273 lines
14 KiB
HTML
273 lines
14 KiB
HTML
|
|
{% extends "base.html" %}
|
|||
|
|
{% block content %}
|
|||
|
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
|||
|
|
<h4 class="mb-0">抢购任务</h4>
|
|||
|
|
<div>
|
|||
|
|
<button class="btn btn-outline-success btn-sm me-1 rounded-pill" data-bs-toggle="modal" data-bs-target="#syncCartModal">
|
|||
|
|
<i class="bi bi-cart-check"></i> 同步购物车
|
|||
|
|
</button>
|
|||
|
|
<button class="btn btn-outline-warning btn-sm me-1 rounded-pill" onclick="startAll()">
|
|||
|
|
<i class="bi bi-play-circle"></i> 全部启动
|
|||
|
|
</button>
|
|||
|
|
<button class="btn btn-outline-primary btn-sm rounded-pill" data-bs-toggle="modal" data-bs-target="#addTaskModal">
|
|||
|
|
<i class="bi bi-plus-lg"></i> 手动新建
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{% if not grouped %}
|
|||
|
|
<div class="card p-5 text-center text-muted">
|
|||
|
|
<i class="bi bi-inbox" style="font-size:2.5rem"></i>
|
|||
|
|
<p class="mt-2 mb-0">暂无任务,点击"同步购物车"自动添加预售商品</p>
|
|||
|
|
</div>
|
|||
|
|
{% else %}
|
|||
|
|
{% for account_id, group in grouped.items() %}
|
|||
|
|
<div class="card mb-3">
|
|||
|
|
<div class="card-header d-flex justify-content-between align-items-center"
|
|||
|
|
style="background:linear-gradient(135deg,#f8f9ff,#eef1fb);border-radius:14px 14px 0 0;border:none">
|
|||
|
|
<div>
|
|||
|
|
<i class="bi bi-person-circle text-primary"></i>
|
|||
|
|
<span class="fw-bold">{{ group.name }}</span>
|
|||
|
|
<span class="badge bg-light text-muted ms-1">{{ group.tasks|length }} 个任务</span>
|
|||
|
|
</div>
|
|||
|
|
<button class="btn btn-success btn-sm rounded-pill"
|
|||
|
|
onclick="startAccount({{ account_id }})">
|
|||
|
|
<i class="bi bi-play-fill"></i> 启动全部
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
<div class="card-body p-2">
|
|||
|
|
{% for t in group.tasks %}
|
|||
|
|
<div class="task-row d-flex align-items-start gap-3 p-2 {% if not loop.last %}border-bottom{% endif %}">
|
|||
|
|
<div class="countdown-box text-center flex-shrink-0"
|
|||
|
|
data-snatch-time="{{ t.snatch_time }}" data-status="{{ t.status }}">
|
|||
|
|
<div class="cd-time font-monospace">--:--:--</div>
|
|||
|
|
<div class="cd-label text-muted" style="font-size:.7rem">距开售</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="flex-grow-1 min-w-0">
|
|||
|
|
<div class="d-flex align-items-center gap-2 mb-1">
|
|||
|
|
<span class="status-badge status-{{ t.status }}">{{ t.status }}</span>
|
|||
|
|
{% if t.price %}
|
|||
|
|
<span class="text-danger fw-bold" style="font-size:.85rem">¥{{ t.price }}</span>
|
|||
|
|
{% endif %}
|
|||
|
|
</div>
|
|||
|
|
<div class="item-name mb-1">
|
|||
|
|
{% if t.item_name %}
|
|||
|
|
<a href="{{ t.target_url }}" target="_blank" class="text-decoration-none text-dark"
|
|||
|
|
title="{{ t.target_url }}">{{ t.item_name[:50] }}{% if t.item_name|length > 50 %}...{% endif %}</a>
|
|||
|
|
{% elif t.target_url %}
|
|||
|
|
<a href="{{ t.target_url }}" target="_blank" class="text-decoration-none text-muted"
|
|||
|
|
style="font-size:.85rem">{{ t.target_url[:60] }}...</a>
|
|||
|
|
{% else %}<span class="text-muted">-</span>{% endif %}
|
|||
|
|
</div>
|
|||
|
|
<div style="font-size:.78rem" class="text-muted">
|
|||
|
|
<i class="bi bi-clock"></i> {{ t.snatch_time }}
|
|||
|
|
{% if t.result %}
|
|||
|
|
<span class="ms-2 text-truncate d-inline-block" style="max-width:250px;vertical-align:bottom"
|
|||
|
|
title="{{ t.result }}">· {{ t.result[:50] }}</span>
|
|||
|
|
{% endif %}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="flex-shrink-0 d-flex gap-1">
|
|||
|
|
{% if t.status == 'pending' %}
|
|||
|
|
<button class="btn btn-success btn-sm rounded-pill" onclick="startTask({{ t.id }})"><i class="bi bi-play-fill"></i></button>
|
|||
|
|
{% elif t.id in running_ids %}
|
|||
|
|
<button class="btn btn-warning btn-sm rounded-pill" onclick="stopTask({{ t.id }})"><i class="bi bi-stop-fill"></i></button>
|
|||
|
|
{% endif %}
|
|||
|
|
<button class="btn btn-outline-danger btn-sm rounded-pill" onclick="deleteTask({{ t.id }})"><i class="bi bi-trash"></i></button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
{% endfor %}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
{% endfor %}
|
|||
|
|
{% endif %}
|
|||
|
|
|
|||
|
|
<!-- 同步购物车弹窗 -->
|
|||
|
|
<div class="modal fade" id="syncCartModal" tabindex="-1">
|
|||
|
|
<div class="modal-dialog"><div class="modal-content rounded-4">
|
|||
|
|
<div class="modal-header border-0 pb-0">
|
|||
|
|
<h5 class="modal-title">同步购物车预售商品</h5>
|
|||
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|||
|
|
</div>
|
|||
|
|
<div class="modal-body">
|
|||
|
|
<p class="text-muted small">获取购物车中的预售商品并自动创建抢购任务</p>
|
|||
|
|
<div class="mb-3">
|
|||
|
|
<label class="form-label">选择账号</label>
|
|||
|
|
<select class="form-select rounded-3" id="syncAccountId">
|
|||
|
|
<option value="">请选择</option>
|
|||
|
|
{% for a in accounts %}
|
|||
|
|
{% if a.is_logged_in %}
|
|||
|
|
<option value="{{ a.id }}">{{ a.name }}</option>
|
|||
|
|
{% else %}
|
|||
|
|
<option value="{{ a.id }}" disabled>{{ a.name }}(未登录)</option>
|
|||
|
|
{% endif %}
|
|||
|
|
{% endfor %}
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
<div id="syncResult" class="d-none">
|
|||
|
|
<div class="alert rounded-3" role="alert"></div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="modal-footer border-0 pt-0">
|
|||
|
|
<button class="btn btn-outline-secondary btn-sm rounded-pill" data-bs-dismiss="modal">取消</button>
|
|||
|
|
<button class="btn btn-success btn-sm rounded-pill" id="syncBtn" onclick="syncCart()">
|
|||
|
|
<i class="bi bi-cart-check"></i> 同步选中
|
|||
|
|
</button>
|
|||
|
|
<button class="btn btn-primary btn-sm rounded-pill" id="syncAllBtn" onclick="syncAllCarts()">
|
|||
|
|
<i class="bi bi-arrow-repeat"></i> 同步全部
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div></div>
|
|||
|
|
</div>
|
|||
|
|
<!-- 手动新建任务弹窗 -->
|
|||
|
|
<div class="modal fade" id="addTaskModal" tabindex="-1">
|
|||
|
|
<div class="modal-dialog"><div class="modal-content rounded-4">
|
|||
|
|
<div class="modal-header border-0 pb-0">
|
|||
|
|
<h5 class="modal-title">手动新建抢购任务</h5>
|
|||
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|||
|
|
</div>
|
|||
|
|
<div class="modal-body">
|
|||
|
|
<form id="taskForm">
|
|||
|
|
<div class="mb-3">
|
|||
|
|
<label class="form-label">选择账号</label>
|
|||
|
|
<select class="form-select rounded-3" name="account_id" required>
|
|||
|
|
<option value="">请选择</option>
|
|||
|
|
{% for a in accounts %}
|
|||
|
|
<option value="{{ a.id }}">{{ a.name }}</option>
|
|||
|
|
{% endfor %}
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
<div class="mb-3">
|
|||
|
|
<label class="form-label">商品名称(可选)</label>
|
|||
|
|
<input type="text" class="form-control rounded-3" name="item_name" placeholder="备注商品名">
|
|||
|
|
</div>
|
|||
|
|
<div class="mb-3">
|
|||
|
|
<label class="form-label">商品链接</label>
|
|||
|
|
<input type="url" class="form-control rounded-3" name="target_url" required placeholder="https://weidian.com/...">
|
|||
|
|
</div>
|
|||
|
|
<div class="mb-3">
|
|||
|
|
<label class="form-label">抢购时间</label>
|
|||
|
|
<input type="text" class="form-control rounded-3" name="snatch_time" required placeholder="2026-03-20 10:00:00">
|
|||
|
|
</div>
|
|||
|
|
<div class="row">
|
|||
|
|
<div class="col-6 mb-3">
|
|||
|
|
<label class="form-label">商品ID(可选)</label>
|
|||
|
|
<input type="text" class="form-control rounded-3" name="item_id">
|
|||
|
|
</div>
|
|||
|
|
<div class="col-6 mb-3">
|
|||
|
|
<label class="form-label">SKU ID(可选)</label>
|
|||
|
|
<input type="text" class="form-control rounded-3" name="sku_id">
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="mb-3">
|
|||
|
|
<label class="form-label">价格(可选)</label>
|
|||
|
|
<input type="text" class="form-control rounded-3" name="price">
|
|||
|
|
</div>
|
|||
|
|
</form>
|
|||
|
|
</div>
|
|||
|
|
<div class="modal-footer border-0 pt-0">
|
|||
|
|
<button class="btn btn-outline-secondary btn-sm rounded-pill" data-bs-dismiss="modal">取消</button>
|
|||
|
|
<button class="btn btn-primary btn-sm rounded-pill" onclick="addTask()">创建</button>
|
|||
|
|
</div>
|
|||
|
|
</div></div>
|
|||
|
|
</div>
|
|||
|
|
{% endblock %}
|
|||
|
|
|
|||
|
|
{% block scripts %}
|
|||
|
|
<style>
|
|||
|
|
.task-row { transition: background .15s; }
|
|||
|
|
.task-row:hover { background: #f8f9ff; }
|
|||
|
|
.countdown-box {
|
|||
|
|
width: 84px; padding: 8px 4px;
|
|||
|
|
background: linear-gradient(135deg, #f0f4ff, #e8ecf8);
|
|||
|
|
border-radius: 10px;
|
|||
|
|
}
|
|||
|
|
.countdown-box.cd-active { background: linear-gradient(135deg, #fff3e0, #ffe0b2); }
|
|||
|
|
.countdown-box.cd-passed { background: linear-gradient(135deg, #e8f5e9, #c8e6c9); }
|
|||
|
|
.countdown-box.cd-done { background: #f5f5f5; }
|
|||
|
|
.cd-time { font-size: .95rem; font-weight: 700; color: #333; line-height: 1.2; }
|
|||
|
|
.cd-active .cd-time { color: #e65100; }
|
|||
|
|
.cd-passed .cd-time { color: #2e7d32; font-size: .82rem; }
|
|||
|
|
.cd-done .cd-time { color: #999; font-size: .82rem; }
|
|||
|
|
.cd-label { line-height: 1; }
|
|||
|
|
.status-badge {
|
|||
|
|
display: inline-block; padding: 2px 10px; border-radius: 20px;
|
|||
|
|
font-size: .72rem; font-weight: 600; text-transform: uppercase; letter-spacing: .3px;
|
|||
|
|
}
|
|||
|
|
.status-pending { background: #e3e8f0; color: #5a6a85; }
|
|||
|
|
.status-running { background: #fff3cd; color: #856404; }
|
|||
|
|
.status-completed { background: #d4edda; color: #155724; }
|
|||
|
|
.status-failed { background: #f8d7da; color: #721c24; }
|
|||
|
|
.status-cancelled { background: #e2e3e5; color: #383d41; }
|
|||
|
|
.item-name { font-size: .92rem; font-weight: 500; }
|
|||
|
|
.card-header { font-size: .9rem; }
|
|||
|
|
</style>
|
|||
|
|
<script>
|
|||
|
|
function updateCountdowns() {
|
|||
|
|
var now = new Date();
|
|||
|
|
document.querySelectorAll('.countdown-box').forEach(function(box) {
|
|||
|
|
var ts = box.dataset.snatchTime, st = box.dataset.status;
|
|||
|
|
var ct = box.querySelector('.cd-time'), cl = box.querySelector('.cd-label');
|
|||
|
|
if (['completed','cancelled'].includes(st)) {
|
|||
|
|
box.className = 'countdown-box text-center flex-shrink-0 cd-done';
|
|||
|
|
ct.textContent = st === 'completed' ? '已完成' : '已取消'; cl.textContent = ''; return;
|
|||
|
|
}
|
|||
|
|
if (st === 'failed') {
|
|||
|
|
box.className = 'countdown-box text-center flex-shrink-0 cd-done';
|
|||
|
|
ct.textContent = '失败'; cl.textContent = ''; return;
|
|||
|
|
}
|
|||
|
|
var target = new Date(ts.replace(/-/g, '/'));
|
|||
|
|
if (isNaN(target.getTime())) { ct.textContent = ts || '--'; cl.textContent = ''; return; }
|
|||
|
|
var diff = target - now;
|
|||
|
|
if (diff <= 0) {
|
|||
|
|
box.className = 'countdown-box text-center flex-shrink-0 cd-passed';
|
|||
|
|
ct.textContent = '已开售'; cl.textContent = st === 'running' ? '抢购中' : ''; return;
|
|||
|
|
}
|
|||
|
|
box.className = 'countdown-box text-center flex-shrink-0 cd-active';
|
|||
|
|
var h = Math.floor(diff/3600000), m = Math.floor((diff%3600000)/60000), s = Math.floor((diff%60000)/1000);
|
|||
|
|
if (h > 24) { var d = Math.floor(h/24); ct.textContent = d + '天' + (h%24) + '时'; }
|
|||
|
|
else { ct.textContent = pad(h)+':'+pad(m)+':'+pad(s); }
|
|||
|
|
cl.textContent = '距开售';
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
function pad(n) { return n < 10 ? '0'+n : ''+n; }
|
|||
|
|
updateCountdowns(); setInterval(updateCountdowns, 1000);
|
|||
|
|
|
|||
|
|
function syncCart() {
|
|||
|
|
var id = document.getElementById('syncAccountId').value;
|
|||
|
|
if (!id) { alert('请选择账号'); return; }
|
|||
|
|
var btn = document.getElementById('syncBtn');
|
|||
|
|
btn.disabled = true; btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> 同步中...';
|
|||
|
|
var rd = document.getElementById('syncResult'); rd.classList.add('d-none');
|
|||
|
|
fetch('/tasks/sync_cart/' + id, { method: 'POST' }).then(r=>r.json()).then(d => {
|
|||
|
|
rd.classList.remove('d-none');
|
|||
|
|
var a = rd.querySelector('.alert');
|
|||
|
|
a.className = 'alert rounded-3 ' + (d.success ? 'alert-success' : 'alert-danger');
|
|||
|
|
a.textContent = d.msg; btn.disabled = false; btn.innerHTML = '<i class="bi bi-cart-check"></i> 同步选中';
|
|||
|
|
if (d.success && d.count > 0) setTimeout(() => location.reload(), 1500);
|
|||
|
|
}).catch(() => { btn.disabled = false; btn.innerHTML = '<i class="bi bi-cart-check"></i> 同步选中'; });
|
|||
|
|
}
|
|||
|
|
function syncAllCarts() {
|
|||
|
|
var btn = document.getElementById('syncAllBtn');
|
|||
|
|
btn.disabled = true; btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> 同步中...';
|
|||
|
|
var rd = document.getElementById('syncResult'); rd.classList.add('d-none');
|
|||
|
|
fetch('/tasks/sync_all', { method: 'POST' }).then(r=>r.json()).then(d => {
|
|||
|
|
rd.classList.remove('d-none');
|
|||
|
|
var a = rd.querySelector('.alert');
|
|||
|
|
a.className = 'alert rounded-3 ' + (d.success ? 'alert-success' : 'alert-danger');
|
|||
|
|
a.textContent = d.msg; btn.disabled = false; btn.innerHTML = '<i class="bi bi-arrow-repeat"></i> 同步全部';
|
|||
|
|
if (d.success && d.count > 0) setTimeout(() => location.reload(), 1500);
|
|||
|
|
}).catch(() => { btn.disabled = false; btn.innerHTML = '<i class="bi bi-arrow-repeat"></i> 同步全部'; });
|
|||
|
|
}
|
|||
|
|
function addTask() {
|
|||
|
|
var data = new FormData(document.getElementById('taskForm'));
|
|||
|
|
fetch('/tasks/add', { method: 'POST', body: data }).then(r=>r.json()).then(d => { if (d.success) location.reload(); else alert(d.msg); });
|
|||
|
|
}
|
|||
|
|
function startTask(id) { fetch('/tasks/start/'+id, { method:'POST' }).then(()=>location.reload()); }
|
|||
|
|
function stopTask(id) { fetch('/tasks/stop/'+id, { method:'POST' }).then(()=>location.reload()); }
|
|||
|
|
function deleteTask(id) { if (!confirm('确定删除?')) return; fetch('/tasks/delete/'+id, { method:'POST' }).then(r=>r.json()).then(d => { if (d.success) location.reload(); }); }
|
|||
|
|
function startAll() { if (!confirm('启动所有账号的待执行任务?')) return; fetch('/tasks/start_all', { method:'POST' }).then(r=>r.json()).then(d => { alert(d.msg); location.reload(); }); }
|
|||
|
|
function startAccount(aid) { fetch('/tasks/start_account/'+aid, { method:'POST' }).then(r=>r.json()).then(d => { alert(d.msg); location.reload(); }); }
|
|||
|
|
</script>
|
|||
|
|
{% endblock %}
|