feat(发布计划): 改按名字发(文件名 名字_日期_编号)+ 账号管理整页下线 + 自动切号一轮发完

发布计划不再绑设备、不再依赖账号台账:上传文件名 `名字_日期_编号`(标题行
`标题 || 正文_名字_日期_编号`),运行时读本机当前抖音昵称取计划;靠循环块
「一轮把本机待发的号跑完」自动切号,一直发到发完。

· 解析/配对换键:右锚定认名字(不许含下划线、不许纯数字 —— 不合法直接拒收并说清原因),
  删掉三处"手机号必须命中台账"的校验,加索引 ix_video_plan_name_date(启动时补建)
· 新步骤三个:读本机账号名字 / 切换账号(**按名字点控件**、切完回读校验、遇登录页停本设备)/
  循环本机待发的号(试过不回头、跑一轮没进展就换号、停止·超时·上限三个干净出口)
· 发布骨架改 18 步:读名字 → 校验账号(cmp_source=account_target)→ 推送(**推送前重读昵称核对**)
  → 抖音流程 → 标记结果;push_release 的 account 默认 name
· 账号管理整页下线:删 core/ledger.py(serial_of 迁到 core/device_pool.py)、6 个 /api/ledger*、
  「账号」页与 ledger.js、设备池「账号」列、设备端账号块;发布计划升为一级页签;
  device_account 表与 42 行历史数据保留、仍在备份清单内(**停用不等于删数据**)
· 人工入口「按名字找设备」:新增 core/account_probe.py(只读探测、跳过在跑任务的设备)
  + POST /api/video_plan/push_by_account,没设备登着的号逐条报出来;
  推送时把"实际是谁发的"写进 posted_serial / posted_device_name
· 通知:新增 task.account.switch_failed / task.account.need_login
· schema v12(posted_*)/ v13(device.cur_account_name / cur_name_at)
· 文档同步:TASK_DEV / API / DATA_MODEL / ARCHITECTURE / NOTIFY / DEVICE_AGENT / DEPLOY /
  DEVELOPMENT / README / .env.example(两个抖音控件选择器抓一次填 .env)
· 新增 scripts/tests/:6 套用例 + run_all.py(临时库 + 假设备,不碰真实数据、不连真机)
This commit is contained in:
2026-10-12 09:02:40 +08:00
parent 75bee21ffb
commit beda0f3779
37 changed files with 2615 additions and 1747 deletions
+3 -55
View File
@@ -17,9 +17,7 @@
管理端接口在文件后半段(`/api/agent-store/*`,走正常登录 + 权限)。
"""
import base64
import hmac
import json
import os
import secrets
import time
@@ -27,7 +25,7 @@ import time
from flask import Blueprint, jsonify, request, send_file
from config import APK_DIR
from core import device_pool, ledger
from core import device_pool
from core.db_config import meta_get, meta_set
from core.logger import get_logger
from web.auth import PERM_APKS, PERM_DEVICES, perm_required
@@ -319,32 +317,9 @@ def api_agent_show_info():
"--es", "server", server]
if ip:
args += ["--es", "ip", ip]
# 本机账号台账(可选块):身份页顺带显示"这台登的是哪几个号"。
# **中文必须 base64**(§5.1:adb shell 直传中文/特殊字符会变形);
# `_adb` 是 argv 形式、不过 shell,base64 里的 +/= 安全 —— 将来谁把
# 这里改成 shell=True 就会静默坏掉,别改。
# 台账里没有这台设备时**整个 extra 不发**(设备端"没有这个 extra"=
# 本来就没登记,跟"发了但解不开"能区分开),原因随响应体的 warn 回给操作者。
# 账号台账块(`accounts_b64`)随账号管理一起下线了:身份页只显示
# 名称/地址/平台地址这些设备信息。老版本 Agent 收到"没有这个 extra"照常显示。
warn = ""
try:
acc = ledger.agent_accounts(serial, device_name=name,
include_phone=bool(data.get("include_phone")))
if acc.get("accounts"):
b64, acc = _encode_accounts(acc)
if len(b64) + sum(len(a) for a in args) < _ARGS_CMD_MAX:
args += ["--es", "accounts_b64", b64]
_log.info("身份页附账号:%s 共 %s 个号(accounts_b64 %d 字节)",
serial, acc.get("shown"), len(b64))
else:
warn = "账号台账过大,未随身份页下发"
_log.warning("身份页未附账号:%s %s(%d 字节)", serial, warn, len(b64))
else:
warn = f"这台设备在账号台账里没有账号({acc.get('note') or '未匹配到设备名'})"
_log.warning("身份页未附账号:%s %s", serial, warn)
except Exception as e:
# 台账坏了不能把"显示身份"这个动作弄坏:跳过该 extra,只回告警
warn = f"账号台账读取失败:{e}"
_log.warning("身份页附账号失败 %s: %s", serial, e)
# 先点亮屏幕、解除锁屏:设备息屏时 am start 会把大字页开在锁屏**后面**,
# 人看到的还是一片黑(实测踩过)——这正是原来"定位"按钮里的"点亮屏幕"那一步
@@ -373,33 +348,6 @@ def api_agent_show_info():
return jsonify(resp)
# accounts_b64 的预算:44 条 × ~90 字节 ≈ 4KB,base64 后 ≈ 5.3KB,余量充足。
# 再加一道**命令行总长**闸(Windows CreateProcess 上限 ~32767)。
ACCOUNTS_PAYLOAD_MAX = 20000
_ARGS_CMD_MAX = 28000
def _encode_accounts(acc):
"""把账号块压成 base64(超预算**按整条丢**,永不按字节切)。
按字节切会产出破损 JSON —— 设备端解析失败只能整块丢掉,
还不如少几条但能解开(`truncated`/`shown` 如实反映)。
返回 (base64 文本, 修正后的块)。
"""
acc = dict(acc or {})
rows = list(acc.get("accounts") or [])
total = acc.get("total", len(rows))
while True:
block = dict(acc, accounts=rows, shown=len(rows),
truncated=len(rows) < total)
b64 = base64.b64encode(
json.dumps(block, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
).decode("ascii")
if len(b64) <= ACCOUNTS_PAYLOAD_MAX or not rows:
return b64, block
rows = rows[:-1]
@bp.route("/api/agent-store/config", methods=["GET", "POST"])
@perm_required(PERM_APKS)
def api_agent_store_config():
+1 -105
View File
@@ -4,7 +4,7 @@ from concurrent.futures import ThreadPoolExecutor, as_completed
from flask import Blueprint, jsonify, request
from core import device_battery, device_pool, device_discovery, ledger
from core import device_battery, device_pool, device_discovery
from core.adb_helper import adb_connect, adb_connect_light
from core.logger import get_logger
from web.auth import perm_required, PERM_DEVICES
@@ -445,110 +445,6 @@ def api_devices_battery_scan():
return jsonify({"ok": True, "msg": "采集已启动(约 1-3 秒)"})
# ================== API:账号台账(「账号」页) ==================
#
# 数据在 device_account 表(见 core/ledger.py);任务侧怎么用它取号见
# tasks/generic/task.py 的 `_resolve_cmp_value`(if_el 的 cmp_source)。
# ⚠ 台账的抖音号只做「条件判断」的比对值,**不做去重身份**(格式不同会静默失效)。
@bp.route("/api/ledger")
@perm_required(PERM_DEVICES)
def api_ledger_list():
"""账号列表 + 统计(?device=设备号 &q=搜索 &limit=)。"""
device_name = (request.args.get("device") or "").strip()
q = (request.args.get("q") or "").strip()
try:
limit = int(request.args.get("limit") or 500)
except ValueError:
limit = 500
try:
rows, stats = ledger.list_accounts(device_name, q, limit)
names = ledger.device_names()
except Exception as e:
_log.warning(f"读取账号台账失败: {e}")
return jsonify({"ok": False, "error": str(e)[:120]}), 503
return jsonify({"ok": True, "accounts": rows, "stats": stats, "device_names": names})
@bp.route("/api/ledger/by_device")
@perm_required(PERM_DEVICES)
def api_ledger_by_device():
"""按设备分组(设备池显示"这台几个号" + 点开看明细都走它,省一次请求)。"""
devices = ledger.by_device()
counts = {k: v["count"] for k, v in devices.items()}
return jsonify({"ok": True, "devices": devices, "counts": counts})
@bp.route("/api/ledger", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_ledger_add():
"""新增一条账号。"""
row, err = ledger.add(request.json or {})
if err:
return jsonify({"ok": False, "error": err}), 400
return jsonify({"ok": True, "msg": "已新增", "account": row})
@bp.route("/api/ledger/<acc_id>", methods=["PUT"])
@perm_required(PERM_DEVICES)
def api_ledger_update(acc_id):
"""改一条账号。"""
row, err = ledger.update(acc_id, request.json or {})
if err:
code = 404 if "不存在" in err else 400
return jsonify({"ok": False, "error": err}), code
return jsonify({"ok": True, "msg": "已保存", "account": row})
@bp.route("/api/ledger/<acc_id>", methods=["DELETE"])
@perm_required(PERM_DEVICES)
def api_ledger_delete(acc_id):
"""删一条账号(删掉之后任务就取不到这个号了)。"""
if not ledger.delete(acc_id):
return jsonify({"ok": False, "error": "记录不存在或删除失败"}), 404
return jsonify({"ok": True, "msg": "已删除"})
@bp.route("/api/ledger/import", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_ledger_import():
"""从表格粘贴文本批量导入。
请求:{"text": "设备号\\t手机号\\t…\\nA01\\t…", "delimiter": "\\t",
"mode": "skip|overwrite", "dry_run": true}
dry_run=true 只解析+判重、**一个字节都不写** —— 界面默认先预览再导入。
返回逐行结果(line/action/device_name/douyin_id/nickname/error/warns)+ 汇总。
"""
data = request.json or {}
text = data.get("text") or ""
if not text.strip():
return jsonify({"ok": False, "error": "粘贴的内容是空的"}), 400
delim = data.get("delimiter") or "\t"
if delim == "tab":
delim = "\t"
mode = data.get("mode") or "skip"
dry_run = bool(data.get("dry_run"))
try:
rows, errors = ledger.parse_paste(text, delim)
except Exception as e:
_log.warning(f"账号台账解析失败: {e}")
return jsonify({"ok": False, "error": f"解析失败: {str(e)[:120]}"}), 400
if not rows:
return jsonify({"ok": True, "counts": {"total": 0, "added": 0, "updated": 0,
"skipped": 0, "failed": 0},
"rows": [], "errors": errors, "mode": mode, "dry_run": dry_run,
"msg": "没有可导入的行"})
try:
result = ledger.import_rows(rows, mode=mode, dry_run=dry_run)
except Exception as e:
_log.warning(f"账号台账导入失败: {e}")
return jsonify({"ok": False, "error": str(e)[:120]}), 503
result["errors"] = errors
result["msg"] = ("预览完成(未写入)" if dry_run else "导入完成")
return jsonify({"ok": True, **result})
# ================== API:Tailscale 管理(仅管理员) ==================
# 通过 Tailscale 官方 API v2 管理 tailnet 设备(列表/改名/授权/密钥不过期/删除/生成 auth key)。
# 设备 IP 由 tailnet 自动分配,API 无法修改,列表只读展示。
+132 -85
View File
@@ -1,4 +1,4 @@
"""视频发布计划 API(「账号 → 发布计划」页)。
"""视频发布计划 API(「发布计划」页)。
数据在 `video_plan` 表、服务层 `core/video_plan.py`;任务侧怎么自动发布见
`tasks/generic/task.py` 的 `_exec_publish_video`(本文件的「立即发布」调的是同一段逻辑)。
@@ -24,7 +24,7 @@ bp = Blueprint("video_plan", __name__)
def api_vp_upload_video():
"""上传单个视频(前端**逐文件**发,才能给每个文件一条结果与进度)。
文件名规则 `手机号_日期_编号`(编号可省略);解析出的手机号必须**恰好**命中台账一个账号。
文件名规则 `名字_日期_编号`(编号可省略,名字 = 抖音昵称,不查台账)。
"""
f = request.files.get("file")
if not f or not f.filename:
@@ -42,8 +42,8 @@ def api_vp_upload_video():
def api_vp_upload_titles():
"""上传标题:`{text, dry_run}`;也接受 multipart 的 txt 文件(字段名 file)。
每行 `标题内容_手机号_日期_编号` 或 `手机号_日期_编号_标题`(都认;编号可省略、标题里可以有下划线);
`#` 开头的行**只有认不出手机号/日期时才当注释**(`#话题` 开头的标题照收)。
每行 `标题 || 正文_名字_日期_编号`(`||` 后面是正文、可省略;编号可省略、标题里可以有下划线);
`#` 开头的行**只有认不出名字/日期时才当注释**(`#话题` 开头的标题照收)。
"""
if request.files.get("file"):
raw = request.files["file"].stream.read()
@@ -65,7 +65,7 @@ def api_vp_upload_titles():
@bp.route("/api/video_plan/timeline")
@perm_required(PERM_DEVICES)
def api_vp_timeline():
"""时间线:?range=today|week|all &from=&to=&status=&account_id=&q=&limit="""
"""时间线:?range=today|week|all &from=&to=&status=&q=&limit="""
rng = (request.args.get("range") or "").strip()
today = vp._today()
date_from = (request.args.get("from") or "").strip()
@@ -78,7 +78,6 @@ def api_vp_timeline():
date_to = _t.strftime("%Y-%m-%d", _t.localtime(_t.time() + 7 * 86400))
data = vp.timeline(date_from, date_to,
status=(request.args.get("status") or "").strip(),
account_id=(request.args.get("account_id") or "").strip(),
q=(request.args.get("q") or "").strip(),
limit=int(request.args.get("limit") or 500))
return jsonify({"ok": True, **data})
@@ -105,11 +104,13 @@ def api_vp_one(pid):
return jsonify({"ok": False, "error": "计划不存在"}), 404
return jsonify({"ok": True, "msg": "已删除(素材文件一并删除)"})
data = request.json or {}
if "title" in data or "body" in data:
row, err = vp.set_title(pid, data.get("title") or "", body=data.get("body"))
if "title" in data or "body" in data or "nickname" in data:
row, err = vp.set_title(pid, data.get("title") or "", body=data.get("body"),
nickname=data.get("nickname") if "nickname" in data else None)
if err:
return jsonify({"ok": False, "error": err}), 400 if "太长" in err else 404
return jsonify({"ok": True, "msg": "标题/正文已改", "plan": row})
bad = "太长" in err or "名字不对" in err
return jsonify({"ok": False, "error": err}), 400 if bad else 404
return jsonify({"ok": True, "msg": "标题/正文/名字已改", "plan": row})
return jsonify({"ok": False, "error": "没有要改的字段"}), 400
@@ -128,94 +129,142 @@ def api_vp_video(pid):
return _send(path, conditional=True)
def _push_by_name(rows, serials=None):
"""**按名字找设备**:把一批计划推到"现在登着这个名字的那台设备"上。
① 计划按 `nickname` 分组;
② **实时探测**在线设备当前登的号(`core.account_probe`,跳过正在跑任务的设备);
③ 名字 → 设备;找到就**每台设备一个线程、设备内串行**推(同一个手机同时推两个文件
会互相干扰:两条 adb push 抢带宽 + 两条 `content call scan_file` 抢相册索引);
④ 没有在线设备登着某个名字 → 进 `unmatched`,**如实报出来**(人要先去那台设备上切号)。
返回 `(matched, unmatched)`,推送本身在后台线程里跑(接口立刻返回)。
"""
from core import account_probe, device_pool
from tasks.generic.publish_flow import push_one
by_name = {}
for row in rows:
by_name.setdefault(row.get("nickname") or "", []).append(row)
dev_names = {}
try:
for d in device_pool.list_devices():
dev_names[d.get("serial") or ""] = d.get("name") or ""
except Exception:
pass
probes = account_probe.probe_online(serials)
account_probe.cache_names(probes) # 顺手存进 device 表(人工入口的设备建议用)
name2serial = {}
for serial, r in probes.items():
nm = (r or {}).get("name") or ""
if nm and nm not in name2serial:
name2serial[nm] = serial # 同名多机:取第一台(下面的 dup 里说明)
by_serial, matched, unmatched = {}, [], []
for name, plans in by_name.items():
serial = name2serial.get(name)
if not serial:
who = "、".join(f"{s}({(v or {}).get('name')})"
for s, v in sorted(probes.items()) if (v or {}).get("name")) \
or "没有在线设备"
unmatched.append({"name": name, "count": len(plans),
"reason": f"没有在线设备登着『{name}』——先到设备上切到它"
f"(当前探测到:{who})"})
continue
by_serial.setdefault(serial, []).extend(plans)
matched.append({"name": name, "count": len(plans), "serial": serial,
"device_name": dev_names.get(serial, "")})
def _push_device(serial, plans, dname):
for row in plans:
try:
push_one(serial, row, {"device_name": dname})
except Exception as e:
_log.warning(f"按名字推送 {row.get('id')} 异常: {e}")
if rows:
import threading
for serial, plans in by_serial.items():
threading.Thread(target=_push_device,
args=(serial, plans, dev_names.get(serial, "")),
daemon=True).start()
return matched, unmatched
@bp.route("/api/video_plan/<pid>/push", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_vp_push(pid):
"""把这一条的素材**推到手机**(只推送,不在手机上发布)。
发布动作由你写在任务里(推送步骤后面接你自己的步骤),这里只是"人工先推一条"。
计划不绑设备了,所以这里**按名字找设备**:实时探测哪台在线设备现在登着这条计划的
名字,推给它;没有设备登着 → 400 说清"先去切号"。
"""
import threading
row = vp.get_plan(pid)
if not row:
return jsonify({"ok": False, "error": "计划不存在"}), 404
# 地址现查(设备名 → 当前地址):**不能用计划行里的快照** —— 设备换 IP 后快照就是错的,
# 而且台账是粘贴导入的老记录可能压根没存上快照(实测踩过:设备池里明明有 A01,
# 却因为快照为空点不动按钮)。快照只作为兜底。
from core import ledger
serial = ledger.serial_of(row.get("device_name") or "") or (row.get("serial") or "")
if not serial:
return jsonify({"ok": False,
"error": f"找不到设备『{row.get('device_name') or '?'}』的地址 ——"
f"确认「工具 → 设备池」里有这台设备且已连接"}), 400
if row.get("device_name") and not row.get("serial"):
vp.set_serial(pid, serial) # 顺手把空快照补上(自愈,免得下次又踩)
if row["status"] in (vp.ST_PUSHING, vp.ST_PUBLISHING):
return jsonify({"ok": False, "error": "这条已经推到手机上了,别重复推"}), 409
if row["status"] == vp.ST_UNKNOWN:
return jsonify({"ok": False,
"error": "这条上次结果未知(可能已经发出去了)—— 先去抖音确认,"
"再用「已发出 / 未发出」裁决"}), 409
from tasks.generic.publish_flow import run_push
t = threading.Thread(target=run_push, args=(serial, pid), daemon=True)
t.start()
_log.info(f"推送素材到手机 video_plan {pid}({row['phone']} {row['release_date']})")
return jsonify({"ok": True,
"msg": "已开始推送到手机(推完状态会变成「已推送到手机」,"
"之后你在手机上发;发完点「标记已发布」)"})
matched, unmatched = _push_by_name([row])
if unmatched:
return jsonify({"ok": False, "error": unmatched[0]["reason"]}), 400
m = matched[0]
_log.info(f"推送素材到手机 video_plan {pid}({row.get('nickname')} "
f"{row['release_date']})→ {m['device_name'] or m['serial']}")
return jsonify({"ok": True, "device_name": m["device_name"],
"msg": f"已开始推送到『{m['device_name'] or m['serial']}』"
f"(推完状态变「已推送到手机」,之后你在手机上发;"
f"发完点「标记已发布」)"})
def _serial_for_plan(row):
"""这条计划要推给哪台设备:**设备名现查**(设备换 IP 后快照是错的),快照兜底。"""
from core import ledger
return (ledger.serial_of(row.get("device_name") or "") or (row.get("serial") or "")).strip()
def _push_plans_by_account():
"""`push_by_account` / `push_all` 两个路由的公共实现(避免"调用另一个视图")。"""
data = request.json or {}
date = (data.get("date") or "").strip() or vp._today()
rows = vp.pushable_plans(date, retry_failed=bool(data.get("retry_failed", True)))
if not rows:
return jsonify({"ok": True, "count": 0, "devices": 0, "matched": [],
"unmatched": [],
"msg": f"{date} 没有待推送的计划(有标题的才推)"})
matched, unmatched = _push_by_name(rows, data.get("serials") or None)
if not matched:
return jsonify({"ok": False, "count": 0, "devices": 0, "matched": [],
"unmatched": unmatched,
"error": "没有在线设备登着这些号:" +
"、".join(f"{u['name']}({u['count']}条)" for u in unmatched) +
" —— 先到设备上切号,或到「工具 → 设备池」确认设备在线"}), 400
_log.info(f"一键推送(按名字){date}:{len(rows)} 条 → {len(matched)} 个名字"
f"(没设备登着的 {len(unmatched)} 个名字已跳过)")
return jsonify({"ok": True, "count": sum(m["count"] for m in matched),
"devices": len({m["serial"] for m in matched}),
"matched": matched, "unmatched": unmatched,
"msg": f"已开始推送 {sum(m['count'] for m in matched)} 条"
f"({len(matched)} 个号)"
+ (f";{len(unmatched)} 个名字没有在线设备登着,见明细"
if unmatched else "")})
@bp.route("/api/video_plan/push_by_account", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_vp_push_by_account():
"""**按名字找设备**的批量推送(「一键推送」):某天所有待推的计划,谁登着谁推。
立刻返回受理条数(推送在后台跑)——进度与结果看时间线的状态徽章 /
「已推送·相册可见」标记。**没设备登着的名字**在 `unmatched` 里逐条说明。
"""
return _push_plans_by_account()
@bp.route("/api/video_plan/push_all", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_vp_push_all():
"""**一键推送**:把某天(默认今天)所有「待发布/失败」的计划推到各自的手机上。
并发口径:**每台设备一个线程、设备内串行**(同一个手机同时推两个文件会互相干扰:
两条 adb push 抢带宽 + 两条 `content call scan_file` 抢索引),设备之间并行。
立刻返回受理条数(推送是后台跑的)——进度与结果看时间线的状态徽章/
「已推送·相册可见」标记。
"""
import threading
from core import ledger
data = request.json or {}
date = (data.get("date") or "").strip() or vp._today()
rows = vp.pushable_plans(date, retry_failed=bool(data.get("retry_failed", True)))
if not rows:
return jsonify({"ok": True, "count": 0, "devices": 0,
"msg": f"{date} 没有待推送的计划(有标题的才推)"})
by_dev, no_dev = {}, []
for row in rows:
serial = (ledger.serial_of(row.get("device_name") or "") or (row.get("serial") or "")).strip()
(by_dev.setdefault(serial, []) if serial else no_dev).append(row)
by_dev.pop("", None) # 兜底:空 key 不该出现
if not by_dev:
return jsonify({"ok": False, "error": f"{len(rows)} 条计划都没绑上设备地址 ——"
f"到「工具 → 设备池」确认设备在线"}), 400
from tasks.generic.publish_flow import push_one
def _push_device(serial, plans):
for row in plans:
try:
push_one(serial, row)
except Exception as e:
_log.warning(f"一键推送 {row.get('id')} 异常: {e}")
for serial, plans in by_dev.items():
threading.Thread(target=_push_device, args=(serial, plans), daemon=True).start()
_log.info(f"一键推送 {date}:{len(rows)} 条 → {len(by_dev)} 台设备"
f"(没绑设备的 {len(no_dev)} 条已跳过)")
return jsonify({"ok": True, "count": len(rows), "devices": len(by_dev),
"no_device": len(no_dev),
"msg": f"已开始推送 {len(rows)} 条({len(by_dev)} 台设备,每台串行)——"
f"推完状态会变成「已推送·相册可见/相册未见」,刷新看结果"})
"""老入口(前端旧版本还可能在调):语义与 `push_by_account` 一致 —— 都按名字找设备。"""
return _push_plans_by_account()
@bp.route("/api/video_plan/<pid>/mark", methods=["POST"])
@@ -419,14 +468,14 @@ def api_vp_task_adopt(job_id):
return jsonify({"ok": False, "error": f"保存失败: {str(e)[:120]}"}), 500
_log.info(f"给任务 {job.name}({job_id}) 插上平台发布步骤")
return jsonify({"ok": True, "msg": "已插入「推送发布视频」(最前)与「标记发布结果」(最后)——"
"你原来的步骤没动;到「账号 → 发布计划」里核对一下顺序",
"你原来的步骤没动;到「发布计划」里核对一下顺序",
"task": _job_brief(job, context.mgr)})
@bp.route("/api/video_plan/tasks/<job_id>", methods=["GET", "PUT"])
@perm_required(PERM_TASKS)
def api_vp_task_one(job_id):
"""就地编辑发布任务(「账号 → 发布计划」页里,不用跳「任务」页)。
"""就地编辑发布任务(「发布计划」页里,不用跳「任务」页)。
只放开这一页用得上的字段:`name` / `time`(HH:MM) / `target` / `steps` / `enabled`。
**没传的字段原样保留**(深合并:`params` 里还有去重有效期、抢占等通用参数,
@@ -488,17 +537,15 @@ def api_vp_links():
"""已发布作品的分享链接(界面展示 / `format=csv` 导出,给后续铺评论用)。"""
rows = vp.links(date_from=(request.args.get("from") or "").strip(),
date_to=(request.args.get("to") or "").strip(),
device=(request.args.get("device") or "").strip(),
phone=(request.args.get("phone") or "").strip())
name=(request.args.get("name") or "").strip())
if (request.args.get("format") or "") == "csv":
buf = io.StringIO()
w = csv.writer(buf)
w.writerow(["发布日期", "设备号", "手机号", "抖音号",
"编号", "标题", "正文", "分享链接", "发布时间"])
# 按**名字**导出(手机号/抖音号随台账停用一起下线;由谁发的看 posted_device_name)
w.writerow(["发布日期", "名字", "编号", "标题", "正文", "分享链接", "发布时间"])
for r in rows:
w.writerow([r["release_date"], r["device_name"], r["phone"],
r["douyin_id"], r["seq"], r["title"], r.get("body", ""),
r["share_url"], r["published_at"]])
w.writerow([r["release_date"], r["nickname"], r["seq"], r["title"],
r.get("body", ""), r["share_url"], r["published_at"]])
data = ("" + buf.getvalue()).encode("utf-8") # BOM:Excel 打开中文不乱码
return send_file(io.BytesIO(data), mimetype="text/csv", as_attachment=True,
download_name=f"video_links_{vp._today()}.csv")