解决超话签到中选择性签到的逻辑问题

This commit is contained in:
2026-04-08 08:34:59 +08:00
parent 31d862dfa0
commit 9e69d34f81
8 changed files with 167 additions and 32 deletions

View File

@@ -304,7 +304,34 @@ async def list_topics(
return error_response("Cookie 解密失败", "COOKIE_ERROR", status_code=400) return error_response("Cookie 解密失败", "COOKIE_ERROR", status_code=400)
topics = await _get_super_topics(cookie_str, account.weibo_user_id) topics = await _get_super_topics(cookie_str, account.weibo_user_id)
return success_response({"topics": topics, "total": len(topics)}) return success_response({
"topics": topics,
"total": len(topics),
"selected_topics": account.selected_topics,
})
@router.put("/{account_id}/topics")
async def save_selected_topics(
account_id: str,
body: dict = Body(...),
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""保存用户选择的签到超话列表。空列表或 null 表示签到全部。"""
account = await _get_owned_account(account_id, user, db)
selected = body.get("selected_topics")
# null 或空列表都表示全部签到
if selected and isinstance(selected, list) and len(selected) > 0:
account.selected_topics = selected
else:
account.selected_topics = None
await db.commit()
await db.refresh(account)
return success_response(
_account_to_dict(account),
f"已保存 {len(selected) if selected else 0} 个超话" if selected else "已设为签到全部超话",
)
# ---- MANUAL SIGNIN ---- # ---- MANUAL SIGNIN ----

View File

@@ -27,6 +27,7 @@ class AccountResponse(BaseModel):
weibo_user_id: str weibo_user_id: str
remark: Optional[str] remark: Optional[str]
status: str status: str
selected_topics: Optional[list] = None
last_checked_at: Optional[datetime] last_checked_at: Optional[datetime]
created_at: Optional[datetime] created_at: Optional[datetime]

View File

@@ -2,7 +2,7 @@
import uuid import uuid
from sqlalchemy import Column, DateTime, ForeignKey, String, Text from sqlalchemy import Column, DateTime, ForeignKey, JSON, String, Text
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from sqlalchemy.sql import func from sqlalchemy.sql import func
@@ -19,6 +19,7 @@ class Account(Base):
encrypted_cookies = Column(Text, nullable=False) encrypted_cookies = Column(Text, nullable=False)
iv = Column(String(32), nullable=False) iv = Column(String(32), nullable=False)
status = Column(String(20), default="pending") status = Column(String(20), default="pending")
selected_topics = Column(JSON, nullable=True) # 用户选择的签到超话列表null=全部
last_checked_at = Column(DateTime, nullable=True) last_checked_at = Column(DateTime, nullable=True)
created_at = Column(DateTime, server_default=func.now()) created_at = Column(DateTime, server_default=func.now())

View File

@@ -369,6 +369,7 @@ async def _async_do_signin(account_id: str, cron_expr: str = ""):
return {"status": "failed", "reason": "cookie decryption failed"} return {"status": "failed", "reason": "cookie decryption failed"}
acc_id = str(account.id) acc_id = str(account.id)
acc_selected_topics = account.selected_topics # 用户选择的超话列表
except Exception as e: except Exception as e:
await eng.dispose() await eng.dispose()
raise e raise e
@@ -387,6 +388,16 @@ async def _async_do_signin(account_id: str, cron_expr: str = ""):
await session.commit() await session.commit()
return {"status": "completed", "signed": 0, "message": "no topics"} return {"status": "completed", "signed": 0, "message": "no topics"}
# 如果用户选择了特定超话,只签选中的
if acc_selected_topics and isinstance(acc_selected_topics, list):
selected_cids = {t.get("containerid") for t in acc_selected_topics if t.get("containerid")}
if selected_cids:
topics = [t for t in topics if t["containerid"] in selected_cids]
logger.info(f"📌 按用户选择过滤: {len(topics)} 个超话 (共 {len(selected_cids)} 个已选)")
if not topics:
return {"status": "completed", "signed": 0, "message": "no selected topics"}
signed = already = failed = 0 signed = already = failed = 0
log_entries = [] log_entries = []

View File

@@ -1085,7 +1085,9 @@ def account_topics(account_id):
try: try:
resp = api_request('GET', f'{API_BASE_URL}/api/v1/accounts/{account_id}/topics') resp = api_request('GET', f'{API_BASE_URL}/api/v1/accounts/{account_id}/topics')
data = resp.json() data = resp.json()
topics = data.get('data', {}).get('topics', []) if data.get('success') else [] payload = data.get('data', {}) if data.get('success') else {}
topics = payload.get('topics', [])
selected_topics = payload.get('selected_topics') or []
# 获取账号信息 # 获取账号信息
acc_resp = api_request('GET', f'{API_BASE_URL}/api/v1/accounts/{account_id}') acc_resp = api_request('GET', f'{API_BASE_URL}/api/v1/accounts/{account_id}')
@@ -1096,12 +1098,32 @@ def account_topics(account_id):
flash('账号不存在', 'danger') flash('账号不存在', 'danger')
return redirect(url_for('dashboard')) return redirect(url_for('dashboard'))
return render_template('topics.html', account=account, topics=topics, user=session.get('user')) return render_template('topics.html', account=account, topics=topics, selected_topics=selected_topics, user=session.get('user'))
except Exception as e: except Exception as e:
flash(f'获取超话列表失败: {str(e)}', 'danger') flash(f'获取超话列表失败: {str(e)}', 'danger')
return redirect(url_for('account_detail', account_id=account_id)) return redirect(url_for('account_detail', account_id=account_id))
@app.route('/accounts/<account_id>/topics/save', methods=['POST'])
@login_required
def save_topics(account_id):
"""保存用户选择的签到超话"""
try:
body = request.json
resp = api_request(
'PUT',
f'{API_BASE_URL}/api/v1/accounts/{account_id}/topics',
json=body,
)
data = resp.json()
if data.get('success'):
return jsonify({'success': True, 'message': data.get('message', '保存成功')})
else:
return jsonify({'success': False, 'message': data.get('message', '保存失败')}), 400
except Exception as e:
return jsonify({'success': False, 'message': str(e)}), 500
@app.route('/accounts/<account_id>/signin-selected', methods=['POST']) @app.route('/accounts/<account_id>/signin-selected', methods=['POST'])
@login_required @login_required
def signin_selected(account_id): def signin_selected(account_id):

View File

@@ -4,8 +4,8 @@
{% block extra_css %} {% block extra_css %}
<style> <style>
.topics-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; } .topics-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; flex-wrap: wrap; gap: 10px; }
.topics-header h1 { font-size: 24px; font-weight: 700; color: #1e293b; } .topics-header h1 { font-size: 22px; font-weight: 700; color: #1e293b; }
.topic-list { display: flex; flex-direction: column; gap: 0; } .topic-list { display: flex; flex-direction: column; gap: 0; }
.topic-item { .topic-item {
display: flex; align-items: center; gap: 14px; padding: 14px 16px; display: flex; align-items: center; gap: 14px; padding: 14px 16px;
@@ -19,22 +19,25 @@
.select-bar { .select-bar {
display: flex; justify-content: space-between; align-items: center; display: flex; justify-content: space-between; align-items: center;
padding: 16px 0; border-bottom: 1px solid #e2e8f0; margin-bottom: 8px; padding: 16px 0; border-bottom: 1px solid #e2e8f0; margin-bottom: 8px;
flex-wrap: wrap; gap: 8px;
} }
.select-bar label { font-weight: 600; color: #475569; font-size: 14px; cursor: pointer; } .select-bar label { font-weight: 600; color: #475569; font-size: 14px; cursor: pointer; }
.signin-btn { .action-btns { display: flex; gap: 8px; flex-wrap: wrap; }
padding: 14px 32px; border-radius: 16px; border: none; font-size: 16px; .signin-btn, .save-btn {
font-weight: 600; cursor: pointer; color: white; padding: 10px 20px; border-radius: 12px; border: none; font-size: 14px;
background: linear-gradient(135deg, #6366f1, #818cf8); font-weight: 600; cursor: pointer; color: white; transition: all 0.2s;
box-shadow: 0 2px 12px rgba(99,102,241,0.3); transition: all 0.2s;
}
.signin-btn:hover { box-shadow: 0 4px 20px rgba(99,102,241,0.4); transform: translateY(-1px); }
.signin-btn:disabled { opacity: 0.6; cursor: not-allowed; transform: none; }
.result-box {
margin-top: 20px; padding: 16px; border-radius: 14px; display: none;
font-size: 14px; font-weight: 500;
} }
.signin-btn { background: linear-gradient(135deg, #6366f1, #818cf8); box-shadow: 0 2px 8px rgba(99,102,241,0.25); }
.save-btn { background: linear-gradient(135deg, #10b981, #059669); box-shadow: 0 2px 8px rgba(16,185,129,0.25); }
.signin-btn:disabled, .save-btn:disabled { opacity: 0.6; cursor: not-allowed; }
.result-box { margin-top: 16px; padding: 14px; border-radius: 12px; display: none; font-size: 13px; font-weight: 500; }
.result-success { background: #ecfdf5; color: #065f46; border: 1px solid #a7f3d0; } .result-success { background: #ecfdf5; color: #065f46; border: 1px solid #a7f3d0; }
.result-error { background: #fef2f2; color: #991b1b; border: 1px solid #fecaca; } .result-error { background: #fef2f2; color: #991b1b; border: 1px solid #fecaca; }
.tip-box { background: #eff6ff; color: #1e40af; border: 1px solid #bfdbfe; border-radius: 12px; padding: 12px 16px; margin-bottom: 16px; font-size: 13px; }
@media (max-width: 768px) {
.action-btns { width: 100%; }
.signin-btn, .save-btn { flex: 1; text-align: center; }
}
</style> </style>
{% endblock %} {% endblock %}
@@ -42,24 +45,35 @@
<div style="max-width: 720px; margin: 0 auto;"> <div style="max-width: 720px; margin: 0 auto;">
<div class="topics-header"> <div class="topics-header">
<div> <div>
<h1>🔥 选择签到超话</h1> <h1>🔥 超话签到管理</h1>
<div style="color:#94a3b8; font-size:14px; margin-top:4px;"> <div style="color:#94a3b8; font-size:13px; margin-top:4px;">
{{ account.remark or account.weibo_user_id }} · 共 {{ topics|length }} 个超话 {{ account.remark or account.weibo_user_id }} · 共 {{ topics|length }} 个超话
</div> </div>
</div> </div>
<a href="{{ url_for('account_detail', account_id=account.id) }}" class="btn btn-secondary">← 返回</a> <a href="{{ url_for('account_detail', account_id=account.id) }}" class="btn btn-secondary">← 返回</a>
</div> </div>
<div class="tip-box">
💡 勾选要参与定时签到的超话,点击「保存选择」后,定时任务和手动签到都只签选中的超话。不选则签到全部。
</div>
<div class="card"> <div class="card">
{% if topics %} {% if topics %}
<div class="select-bar"> <div class="select-bar">
<label><input type="checkbox" id="selectAll" class="topic-cb" checked onchange="toggleAll()"> 全选 (<span id="selectedCount">{{ topics|length }}</span>/{{ topics|length }})</label> <label><input type="checkbox" id="selectAll" class="topic-cb" onchange="toggleAll()"> 全选 (<span id="selectedCount">0</span>/{{ topics|length }})</label>
<button class="signin-btn" id="signinBtn" onclick="doSignin()">🚀 签到选中超话</button> <div class="action-btns">
<button class="save-btn" id="saveBtn" onclick="saveSelection()">💾 保存选择</button>
<button class="signin-btn" id="signinBtn" onclick="doSignin()">🚀 立即签到选中</button>
</div>
</div> </div>
<div class="topic-list" id="topicList"> <div class="topic-list" id="topicList">
{% for topic in topics %} {% for topic in topics %}
<label class="topic-item"> <label class="topic-item">
<input type="checkbox" class="topic-cb topic-check" data-index="{{ loop.index0 }}" checked onchange="updateCount()"> <input type="checkbox" class="topic-cb topic-check"
data-index="{{ loop.index0 }}"
data-title="{{ topic.title }}"
data-cid="{{ topic.containerid }}"
onchange="updateCount()">
<div> <div>
<div class="topic-name">{{ topic.title }}</div> <div class="topic-name">{{ topic.title }}</div>
<div class="topic-id">{{ topic.containerid }}</div> <div class="topic-id">{{ topic.containerid }}</div>
@@ -78,6 +92,24 @@
</div> </div>
<script> <script>
// 已保存的选中超话
const savedTopics = {{ (selected_topics or [])|tojson }};
const savedCids = new Set(savedTopics.map(t => t.containerid));
// 页面加载时恢复选中状态
document.addEventListener('DOMContentLoaded', function() {
const checks = document.querySelectorAll('.topic-check');
if (savedCids.size > 0) {
checks.forEach(cb => {
cb.checked = savedCids.has(cb.dataset.cid);
});
} else {
// 没有保存过 = 全部选中
checks.forEach(cb => cb.checked = true);
}
updateCount();
});
function toggleAll() { function toggleAll() {
const checked = document.getElementById('selectAll').checked; const checked = document.getElementById('selectAll').checked;
document.querySelectorAll('.topic-check').forEach(cb => cb.checked = checked); document.querySelectorAll('.topic-check').forEach(cb => cb.checked = checked);
@@ -91,6 +123,49 @@ function updateCount() {
document.getElementById('selectAll').checked = (checked === total); document.getElementById('selectAll').checked = (checked === total);
} }
function getSelectedTopics() {
const selected = [];
document.querySelectorAll('.topic-check:checked').forEach(cb => {
selected.push({ title: cb.dataset.title, containerid: cb.dataset.cid });
});
return selected;
}
async function saveSelection() {
const btn = document.getElementById('saveBtn');
const resultBox = document.getElementById('resultBox');
const selected = getSelectedTopics();
const total = document.querySelectorAll('.topic-check').length;
btn.disabled = true; btn.textContent = '⏳ 保存中...';
try {
// 全选时传 null签到全部
const body = (selected.length === total)
? { selected_topics: null }
: { selected_topics: selected };
const resp = await fetch('/accounts/{{ account.id }}/topics/save', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body),
});
const data = await resp.json();
resultBox.style.display = 'block';
if (data.success) {
resultBox.className = 'result-box result-success';
resultBox.textContent = '✅ ' + data.message;
} else {
resultBox.className = 'result-box result-error';
resultBox.textContent = data.message || '保存失败';
}
} catch(e) {
resultBox.className = 'result-box result-error';
resultBox.style.display = 'block';
resultBox.textContent = '请求失败: ' + e.message;
}
btn.disabled = false; btn.textContent = '💾 保存选择';
}
async function doSignin() { async function doSignin() {
const btn = document.getElementById('signinBtn'); const btn = document.getElementById('signinBtn');
const resultBox = document.getElementById('resultBox'); const resultBox = document.getElementById('resultBox');
@@ -98,18 +173,14 @@ async function doSignin() {
document.querySelectorAll('.topic-check:checked').forEach(cb => { document.querySelectorAll('.topic-check:checked').forEach(cb => {
indices.push(parseInt(cb.dataset.index)); indices.push(parseInt(cb.dataset.index));
}); });
if (indices.length === 0) { if (indices.length === 0) {
resultBox.className = 'result-box result-error'; resultBox.className = 'result-box result-error';
resultBox.style.display = 'block'; resultBox.style.display = 'block';
resultBox.textContent = '请至少选择一个超话'; resultBox.textContent = '请至少选择一个超话';
return; return;
} }
btn.disabled = true; btn.textContent = '⏳ 签到中...';
btn.disabled = true;
btn.textContent = '⏳ 签到中...';
resultBox.style.display = 'none'; resultBox.style.display = 'none';
try { try {
const resp = await fetch('{{ url_for("signin_selected", account_id=account.id) }}', { const resp = await fetch('{{ url_for("signin_selected", account_id=account.id) }}', {
method: 'POST', method: 'POST',
@@ -117,7 +188,6 @@ async function doSignin() {
body: JSON.stringify({topic_indices: indices}), body: JSON.stringify({topic_indices: indices}),
}); });
const data = await resp.json(); const data = await resp.json();
resultBox.style.display = 'block'; resultBox.style.display = 'block';
if (data.success) { if (data.success) {
resultBox.className = 'result-box result-success'; resultBox.className = 'result-box result-success';
@@ -126,14 +196,12 @@ async function doSignin() {
resultBox.className = 'result-box result-error'; resultBox.className = 'result-box result-error';
resultBox.textContent = data.message || '签到失败'; resultBox.textContent = data.message || '签到失败';
} }
} catch (e) { } catch(e) {
resultBox.className = 'result-box result-error'; resultBox.className = 'result-box result-error';
resultBox.style.display = 'block'; resultBox.style.display = 'block';
resultBox.textContent = '请求失败: ' + e.message; resultBox.textContent = '请求失败: ' + e.message;
} finally {
btn.disabled = false;
btn.textContent = '🚀 签到选中超话';
} }
btn.disabled = false; btn.textContent = '🚀 立即签到选中';
} }
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -28,6 +28,7 @@ CREATE TABLE IF NOT EXISTS accounts (
encrypted_cookies TEXT NOT NULL, encrypted_cookies TEXT NOT NULL,
iv VARCHAR(32) NOT NULL, iv VARCHAR(32) NOT NULL,
status VARCHAR(20) DEFAULT 'pending', status VARCHAR(20) DEFAULT 'pending',
selected_topics JSON DEFAULT NULL,
last_checked_at TIMESTAMP NULL, last_checked_at TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_accounts_user_id (user_id), INDEX idx_accounts_user_id (user_id),

View File

@@ -0,0 +1,4 @@
-- 给 accounts 表添加 selected_topics 字段
-- 用法: mysql -u weibo -p123456 weibo_hotsign < migrate_add_selected_topics.sql
ALTER TABLE accounts ADD COLUMN selected_topics JSON DEFAULT NULL AFTER status;