fix(creator): 扫码成功却一直停在二维码上 + 运营改为左右布局
【扫码不完成】根因是判据本身。原先读页面里的 window.__INITIAL_STATE__,而那是 页面加载那一刻的快照:监控那边的同一探针能用,是因为那台浏览器的页面加载时就已经 登录了;而扫码是加载之后才登录的 —— SPA 内部确实登进去了,但初始快照不会翻转, 于是检测永远等不到。 改成拿 cookie 直接问创作者后台 /api/galaxy/user/info 我是谁。实测这个判据很干净: 游客也会拿到 a1(所以签名算得出来),但接口直接回 401 无登录信息;只有真正登录了 才返回 user_id。所以「有 a1」什么都证明不了,后台认了才算。 顺带按 5 秒节流 —— 前端每 2 秒问一次,没必要每次都打后台接口。 【弹窗不关】成功后不自动关闭,停在二维码上会让人以为没成功。现在显示账号卡片与原话 提示,1.6 秒后自动关闭并提供一个「完成」按钮。 【已完成结果会残留】take_cookie 取走 cookie 就拆会话,而在飞的轮询会看到 _current 为空 回报 idle,把已显示的成功能擦掉。现在把结果记在模块里重复返回,关闭弹窗时清掉 —— 否则下次打开会立刻显示上次的成功。 【布局】按用户要求改成左右两栏(左账号列表、右数据面板),与监控统一,取消二级菜单。 未选过时默认选中第一个,右栏不会一开始就是空的。 测试:tests/test_creator_login.py 新增 7 例,含「游客会话永不完成」「接口不打满每次轮询」 「临时上下文用完必须关掉」。
This commit is contained in:
+52
-32
@@ -31,7 +31,6 @@
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
|
||||||
import time
|
import time
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
@@ -39,6 +38,7 @@ from playwright.async_api import async_playwright
|
|||||||
from tools import utils
|
from tools import utils
|
||||||
|
|
||||||
from ..monitor.platforms import PLATFORM_XHS
|
from ..monitor.platforms import PLATFORM_XHS
|
||||||
|
from .client import CreatorApiError, CreatorClient
|
||||||
|
|
||||||
QR_TTL_SECONDS = 300
|
QR_TTL_SECONDS = 300
|
||||||
|
|
||||||
@@ -52,32 +52,26 @@ LOGIN_URL = "https://www.xiaohongshu.com"
|
|||||||
QR_SELECTOR = "xpath=//img[@class='qrcode-img']"
|
QR_SELECTOR = "xpath=//img[@class='qrcode-img']"
|
||||||
LOGIN_BUTTON_SELECTOR = "xpath=//*[@id='app']/div[1]/div[2]/div[1]/ul/div[1]/button"
|
LOGIN_BUTTON_SELECTOR = "xpath=//*[@id='app']/div[1]/div[2]/div[1]/ul/div[1]/button"
|
||||||
|
|
||||||
# 与 api/monitor/qrlogin.py 同一套判据:主站在 window.__INITIAL_STATE__ 里报告登录态,
|
# 判据不读页面状态,而是拿 cookie 直接问创作者后台"我是谁"。
|
||||||
# 而 user.loggedIn 是 Vue 的响应式引用,要 .value 解包才是布尔值。
|
#
|
||||||
LOGIN_STATE_PROBE = """
|
# **为什么不用页面状态**:`window.__INITIAL_STATE__` 是**页面加载那一刻的快照**。
|
||||||
() => {
|
# 监控那边的同一个探针能用,是因为那台浏览器的页面加载时就已经登录了,快照里
|
||||||
try {
|
# loggedIn 就是 true。而扫码是"页面加载之后才登录的"—— SPA 内部确实登进去了,
|
||||||
const user = (window.__INITIAL_STATE__ || {}).user;
|
# 但那个初始快照不会翻转,于是检测永远等不到,界面就一直停在二维码上。
|
||||||
if (!user) return JSON.stringify({ known: false });
|
#
|
||||||
let loggedIn = user.loggedIn;
|
# `user/info` 则是权威的:实测**游客也会拿到 a1**(所以签名算得出来),但接口直接
|
||||||
if (loggedIn && typeof loggedIn === 'object' && 'value' in loggedIn) loggedIn = loggedIn.value;
|
# 回 401「无登录信息」;只有真正登录了才返回 user_id。所以"有 a1"什么都证明不了,
|
||||||
let info = null;
|
# "后台认这份身份"才是。
|
||||||
try { info = user.userInfo || null; } catch (e) { info = null; }
|
LOGIN_CHECK_INTERVAL_SECONDS = 5.0
|
||||||
const text = (v) => (v === null || v === undefined ? null : String(v));
|
|
||||||
return JSON.stringify({
|
|
||||||
known: true,
|
|
||||||
loggedIn: Boolean(loggedIn),
|
|
||||||
nickname: info ? text(info.nickname) : null
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
return JSON.stringify({ known: false, error: String(e) });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
_lock = asyncio.Lock()
|
_lock = asyncio.Lock()
|
||||||
_current: Optional["AccountLoginSession"] = None
|
_current: Optional["AccountLoginSession"] = None
|
||||||
|
|
||||||
|
# 完成后的快照。会话一旦被取走 cookie 就会拆掉,而前端可能还有一个在飞的轮询——
|
||||||
|
# 那个请求若看到 _current 为空就会回报 idle,把已经显示出来的成功状态又擦掉。
|
||||||
|
# 把结果留在这里,重复轮询就稳定得多。
|
||||||
|
_last_result: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
_playwright: Any = None
|
_playwright: Any = None
|
||||||
|
|
||||||
|
|
||||||
@@ -132,6 +126,7 @@ class AccountLoginSession:
|
|||||||
self.platform = PLATFORM_XHS
|
self.platform = PLATFORM_XHS
|
||||||
self._context = context
|
self._context = context
|
||||||
self._page = page
|
self._page = page
|
||||||
|
self._last_login_check = 0.0
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def elapsed(self) -> float:
|
def elapsed(self) -> float:
|
||||||
@@ -145,23 +140,36 @@ class AccountLoginSession:
|
|||||||
self.message = "二维码已超时,请重新获取"
|
self.message = "二维码已超时,请重新获取"
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# 前端每 2 秒问一次,但没必要每次都去打后台接口 —— 一次真实的网络往返
|
||||||
|
# 去确认一个通常还没发生的事件是浪费。
|
||||||
|
now = time.time()
|
||||||
|
if now - self._last_login_check < LOGIN_CHECK_INTERVAL_SECONDS:
|
||||||
|
return
|
||||||
|
self._last_login_check = now
|
||||||
|
|
||||||
try:
|
try:
|
||||||
raw = await self._page.evaluate(LOGIN_STATE_PROBE)
|
cookies = await self._context.cookies()
|
||||||
state = json.loads(raw) if isinstance(raw, str) else {}
|
|
||||||
except Exception:
|
except Exception:
|
||||||
self.status = STATUS_ERROR
|
self.status = STATUS_ERROR
|
||||||
self.message = "二维码所在页面已被关闭,请重新获取"
|
self.message = "登录窗口已被关闭,请重新获取"
|
||||||
return
|
return
|
||||||
|
|
||||||
if not state.get("logged_in"):
|
cookie = "; ".join(f"{c['name']}={c['value']}" for c in cookies)
|
||||||
|
try:
|
||||||
|
info = await CreatorClient(cookie).fetch_user_info()
|
||||||
|
except CreatorApiError:
|
||||||
|
# 还没登录(或者刚扫、后端还没认),继续等。
|
||||||
return
|
return
|
||||||
|
|
||||||
# 登录成功:从**这个临时上下文**取 cookie。取完上下文就丢弃,
|
if not info.get("user_id"):
|
||||||
|
return
|
||||||
|
|
||||||
|
# 登录成功:cookie 取自**这个临时上下文**,取完上下文就丢弃,
|
||||||
# 所以不会残留、也不会影响别的账号。
|
# 所以不会残留、也不会影响别的账号。
|
||||||
cookies = await self._context.cookies()
|
self.cookie = cookie
|
||||||
self.cookie = "; ".join(f"{c['name']}={c['value']}" for c in cookies)
|
self.account = info
|
||||||
self.status = STATUS_SUCCESS
|
self.status = STATUS_SUCCESS
|
||||||
self.message = "已获取登录态,正在识别账号…"
|
self.message = f"登录成功:{info.get('nickname') or info['user_id']}"
|
||||||
|
|
||||||
def snapshot(self) -> Dict[str, Any]:
|
def snapshot(self) -> Dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
@@ -191,9 +199,10 @@ async def _teardown_locked() -> None:
|
|||||||
|
|
||||||
async def start() -> Dict[str, Any]:
|
async def start() -> Dict[str, Any]:
|
||||||
"""开一个临时上下文,打开登录页,取回二维码。"""
|
"""开一个临时上下文,打开登录页,取回二维码。"""
|
||||||
global _current
|
global _current, _last_result
|
||||||
|
|
||||||
async with _lock:
|
async with _lock:
|
||||||
|
_last_result = None
|
||||||
await _teardown_locked()
|
await _teardown_locked()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -234,6 +243,8 @@ async def start() -> Dict[str, Any]:
|
|||||||
async def status() -> Dict[str, Any]:
|
async def status() -> Dict[str, Any]:
|
||||||
async with _lock:
|
async with _lock:
|
||||||
if _current is None:
|
if _current is None:
|
||||||
|
if _last_result is not None:
|
||||||
|
return _last_result
|
||||||
return {
|
return {
|
||||||
"status": STATUS_IDLE,
|
"status": STATUS_IDLE,
|
||||||
"message": "",
|
"message": "",
|
||||||
@@ -246,6 +257,13 @@ async def status() -> Dict[str, Any]:
|
|||||||
return _current.snapshot()
|
return _current.snapshot()
|
||||||
|
|
||||||
|
|
||||||
|
async def remember_result(snapshot: Dict[str, Any]) -> None:
|
||||||
|
"""记住已完成的扫码结果,供后续轮询重复返回。"""
|
||||||
|
global _last_result
|
||||||
|
async with _lock:
|
||||||
|
_last_result = snapshot
|
||||||
|
|
||||||
|
|
||||||
async def take_cookie() -> Optional[str]:
|
async def take_cookie() -> Optional[str]:
|
||||||
"""取走已登录的 cookie 并结束会话。
|
"""取走已登录的 cookie 并结束会话。
|
||||||
|
|
||||||
@@ -262,7 +280,9 @@ async def take_cookie() -> Optional[str]:
|
|||||||
|
|
||||||
|
|
||||||
async def cancel() -> Dict[str, Any]:
|
async def cancel() -> Dict[str, Any]:
|
||||||
|
global _last_result
|
||||||
async with _lock:
|
async with _lock:
|
||||||
|
_last_result = None
|
||||||
await _teardown_locked()
|
await _teardown_locked()
|
||||||
return {
|
return {
|
||||||
"status": STATUS_IDLE,
|
"status": STATUS_IDLE,
|
||||||
|
|||||||
@@ -125,7 +125,9 @@ async def poll_login():
|
|||||||
"""轮询扫码结果;一旦成功就把账号落库并返回它。"""
|
"""轮询扫码结果;一旦成功就把账号落库并返回它。"""
|
||||||
snapshot = await creator_login.status()
|
snapshot = await creator_login.status()
|
||||||
|
|
||||||
if snapshot["status"] == creator_login.STATUS_SUCCESS and snapshot.get("account") is None:
|
if snapshot["status"] == creator_login.STATUS_SUCCESS:
|
||||||
|
# take_cookie 只在会话还在时返回 cookie,取走即拆会话;重复轮询拿到 None
|
||||||
|
# 就说明已经保存过了,直接返回上次的结果,不要退回 idle。
|
||||||
cookie = await creator_login.take_cookie()
|
cookie = await creator_login.take_cookie()
|
||||||
if cookie:
|
if cookie:
|
||||||
try:
|
try:
|
||||||
@@ -134,9 +136,11 @@ async def poll_login():
|
|||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
snapshot["status"] = creator_login.STATUS_ERROR
|
snapshot["status"] = creator_login.STATUS_ERROR
|
||||||
snapshot["message"] = f"扫码成功但保存账号失败:{exc}"
|
snapshot["message"] = f"扫码成功但保存账号失败:{exc}"
|
||||||
|
await creator_login.remember_result(snapshot)
|
||||||
return snapshot
|
return snapshot
|
||||||
snapshot["account"] = account
|
snapshot["account"] = account
|
||||||
snapshot["message"] = f"已添加账号:{account['nickname']}"
|
snapshot["message"] = f"已添加账号:{account['nickname']}"
|
||||||
|
await creator_login.remember_result(snapshot)
|
||||||
|
|
||||||
return snapshot
|
return snapshot
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""运营账号扫码登录的完成判据。
|
||||||
|
|
||||||
|
这里守的是一个具体的故障:判据原先读页面里的 `window.__INITIAL_STATE__`,而那是
|
||||||
|
**页面加载那一刻的快照** —— 扫码是加载之后才登录的,快照不会翻转,于是登录明明
|
||||||
|
成功了,界面却永远停在二维码上。
|
||||||
|
|
||||||
|
现在改成拿 cookie 去问创作者后台"我是谁"。重要的是**游客也有 a1**(所以签名算得
|
||||||
|
出来),所以"有 a1"什么都不能证明,只有后台认了才算数。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from api.creator import login as creator_login
|
||||||
|
from api.creator.client import CreatorApiError
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeContext:
|
||||||
|
def __init__(self, cookies):
|
||||||
|
self._cookies = cookies
|
||||||
|
self.closed = False
|
||||||
|
|
||||||
|
async def cookies(self):
|
||||||
|
return self._cookies
|
||||||
|
|
||||||
|
async def close(self):
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
|
|
||||||
|
class _FakePage:
|
||||||
|
def __init__(self):
|
||||||
|
self.closed = False
|
||||||
|
|
||||||
|
async def close(self):
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
|
|
||||||
|
def _session(cookies):
|
||||||
|
context = _FakeContext(cookies)
|
||||||
|
page = _FakePage()
|
||||||
|
session = creator_login.AccountLoginSession(context, page)
|
||||||
|
# 跳过节流,让每次 refresh 都真的去问一次。
|
||||||
|
session._last_login_check = 0.0
|
||||||
|
return session, context, page
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_client(monkeypatch, behaviour):
|
||||||
|
"""behaviour(cookie) -> dict 或抛异常。"""
|
||||||
|
|
||||||
|
class _Client:
|
||||||
|
def __init__(self, cookie, **kwargs):
|
||||||
|
self.cookie = cookie
|
||||||
|
|
||||||
|
async def fetch_user_info(self):
|
||||||
|
return behaviour(self.cookie)
|
||||||
|
|
||||||
|
monkeypatch.setattr(creator_login, "CreatorClient", _Client)
|
||||||
|
|
||||||
|
|
||||||
|
GUEST_COOKIES = [
|
||||||
|
{"name": "a1", "value": "guest-a1"},
|
||||||
|
{"name": "web_session", "value": "guest-session"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_guest_session_never_completes(monkeypatch):
|
||||||
|
"""游客也有 a1,但后台回 401 —— 必须继续等,不能当成登录成功。"""
|
||||||
|
|
||||||
|
def behaviour(_cookie):
|
||||||
|
raise CreatorApiError("登录态无效或已过期", status=401)
|
||||||
|
|
||||||
|
_patch_client(monkeypatch, behaviour)
|
||||||
|
session, _context, _page = _session(GUEST_COOKIES)
|
||||||
|
|
||||||
|
await session.refresh()
|
||||||
|
|
||||||
|
assert session.status == creator_login.STATUS_WAITING
|
||||||
|
assert session.cookie == ""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_recognised_identity_completes_the_login(monkeypatch):
|
||||||
|
_patch_client(
|
||||||
|
monkeypatch,
|
||||||
|
lambda _cookie: {"user_id": "u123", "nickname": "小明", "red_id": "1"},
|
||||||
|
)
|
||||||
|
session, _context, _page = _session(GUEST_COOKIES)
|
||||||
|
|
||||||
|
await session.refresh()
|
||||||
|
|
||||||
|
assert session.status == creator_login.STATUS_SUCCESS
|
||||||
|
assert "小明" in session.message
|
||||||
|
assert "a1=guest-a1" in session.cookie
|
||||||
|
assert session.account["user_id"] == "u123"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_an_empty_identity_does_not_complete(monkeypatch):
|
||||||
|
"""接口返回 200 但没有账号标识 —— 同样不能算成功。"""
|
||||||
|
_patch_client(monkeypatch, lambda _cookie: {"user_id": "", "nickname": ""})
|
||||||
|
session, _context, _page = _session(GUEST_COOKIES)
|
||||||
|
|
||||||
|
await session.refresh()
|
||||||
|
|
||||||
|
assert session.status == creator_login.STATUS_WAITING
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_the_api_is_not_hit_on_every_poll(monkeypatch):
|
||||||
|
"""前端每 2 秒轮询一次,但每次轮询都打一次后台接口是浪费。"""
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def behaviour(cookie):
|
||||||
|
calls.append(cookie)
|
||||||
|
raise CreatorApiError("还没登录", status=401)
|
||||||
|
|
||||||
|
_patch_client(monkeypatch, behaviour)
|
||||||
|
session, _context, _page = _session(GUEST_COOKIES)
|
||||||
|
|
||||||
|
await session.refresh()
|
||||||
|
# 第一次之后 _last_login_check 已经是"现在",后面两次应落在节流窗口内。
|
||||||
|
await session.refresh()
|
||||||
|
await session.refresh()
|
||||||
|
|
||||||
|
assert len(calls) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_expiry_beats_a_successful_scan(monkeypatch):
|
||||||
|
_patch_client(monkeypatch, lambda _cookie: {"user_id": "u1", "nickname": "x"})
|
||||||
|
session, _context, _page = _session(GUEST_COOKIES)
|
||||||
|
session.started_at -= creator_login.QR_TTL_SECONDS + 1
|
||||||
|
|
||||||
|
await session.refresh()
|
||||||
|
|
||||||
|
assert session.status == creator_login.STATUS_EXPIRED
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_closed_window_is_reported(monkeypatch):
|
||||||
|
session, context, _page = _session(GUEST_COOKIES)
|
||||||
|
context.cookies = AsyncMock(side_effect=RuntimeError("Target closed"))
|
||||||
|
|
||||||
|
await session.refresh()
|
||||||
|
|
||||||
|
assert session.status == creator_login.STATUS_ERROR
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_closing_discards_the_temporary_context():
|
||||||
|
"""临时上下文是这个设计的关键 —— 用完必须关掉,否则会挂在操作者的 Chrome 里。"""
|
||||||
|
session, context, page = _session(GUEST_COOKIES)
|
||||||
|
|
||||||
|
await session.close()
|
||||||
|
|
||||||
|
assert context.closed is True
|
||||||
|
assert page.closed is True
|
||||||
@@ -42,19 +42,23 @@ export function AddAccountDialog({
|
|||||||
if (!open) return
|
if (!open) return
|
||||||
if (status === 'waiting' || status === 'idle') return
|
if (status === 'waiting' || status === 'idle') return
|
||||||
setPolling(false)
|
setPolling(false)
|
||||||
if (status === 'success') {
|
if (status !== 'success') return
|
||||||
queryClient.invalidateQueries({ queryKey: ['creatorAccounts'] })
|
|
||||||
}
|
|
||||||
}, [status, open, queryClient])
|
|
||||||
|
|
||||||
// 关掉弹窗时把后端那个临时上下文也收掉,别让它挂在浏览器里。
|
queryClient.invalidateQueries({ queryKey: ['creatorAccounts'] })
|
||||||
const handleOpenChange = (next: boolean) => {
|
// 自动关掉:停在二维码上会让人以为没成功,而账号其实已经进了列表。
|
||||||
if (!next) {
|
const timer = setTimeout(() => onOpenChange(false), 1600)
|
||||||
setPolling(false)
|
return () => clearTimeout(timer)
|
||||||
if (status === 'waiting') cancel.mutate()
|
}, [status, open, queryClient, onOpenChange])
|
||||||
}
|
|
||||||
onOpenChange(next)
|
// 关闭时统一收尾:拆掉后端那个临时上下文(别让它挂在操作者的 Chrome 里),
|
||||||
}
|
// 并清掉后端记住的结果 —— 否则下次打开弹窗会立刻显示上一次的成功状态。
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) return
|
||||||
|
setPolling(false)
|
||||||
|
cancel.mutate()
|
||||||
|
// 只在开关变化时跑,cancel/依赖本身不该触发它。
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open])
|
||||||
|
|
||||||
const begin = () => {
|
const begin = () => {
|
||||||
setPolling(true)
|
setPolling(true)
|
||||||
@@ -64,7 +68,7 @@ export function AddAccountDialog({
|
|||||||
const busy = start.isPending || cancel.isPending
|
const busy = start.isPending || cancel.isPending
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="max-w-md">
|
<DialogContent className="max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="font-mono">新增运营账号</DialogTitle>
|
<DialogTitle className="font-mono">新增运营账号</DialogTitle>
|
||||||
@@ -108,10 +112,40 @@ export function AddAccountDialog({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{status === 'success' && (
|
{status === 'success' && (
|
||||||
<p className="flex items-start gap-2 text-[11px] font-mono text-cyber-neon-green leading-relaxed">
|
<div className="space-y-3">
|
||||||
<CheckCircle2 className="w-3 h-3 mt-0.5 shrink-0" />
|
<p className="flex items-start gap-2 text-[11px] font-mono text-cyber-neon-green leading-relaxed">
|
||||||
{state?.message || '账号已添加'}
|
<CheckCircle2 className="w-3 h-3 mt-0.5 shrink-0" />
|
||||||
</p>
|
{state?.message || '账号已添加'}
|
||||||
|
</p>
|
||||||
|
{state?.account && (
|
||||||
|
<div className="flex items-center gap-2 rounded-md border border-cyber-border-subtle bg-cyber-bg-tertiary/40 px-3 py-2">
|
||||||
|
{state.account.avatar && (
|
||||||
|
<img
|
||||||
|
src={state.account.avatar}
|
||||||
|
alt=""
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
className="w-8 h-8 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate font-mono text-xs text-cyber-text-primary">
|
||||||
|
{state.account.nickname}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] font-mono text-cyber-text-muted">
|
||||||
|
小红书号 {state.account.red_id || '—'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{state?.account?.permission_tip && (
|
||||||
|
<p className="text-[10px] font-mono text-cyber-neon-orange leading-relaxed">
|
||||||
|
{state.account.permission_tip}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Button size="sm" onClick={() => onOpenChange(false)}>
|
||||||
|
完成
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(status === 'error' || status === 'expired') && (
|
{(status === 'error' || status === 'expired') && (
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
ArrowLeft,
|
|
||||||
Briefcase,
|
Briefcase,
|
||||||
Clock,
|
Clock,
|
||||||
Plus,
|
Plus,
|
||||||
@@ -19,9 +18,9 @@ import {
|
|||||||
useDeleteCreatorAccount,
|
useDeleteCreatorAccount,
|
||||||
useSyncCreatorAccount,
|
useSyncCreatorAccount,
|
||||||
} from '@/hooks/useCreator'
|
} from '@/hooks/useCreator'
|
||||||
import { formatCount, formatDateTime, formatRelative } from '@/lib/monitorFormat'
|
|
||||||
import { useCurrentPlatform } from '@/hooks/usePlatform'
|
import { useCurrentPlatform } from '@/hooks/usePlatform'
|
||||||
import type { CreatorNote, CreatorPermissionStatus } from '@/types/creator'
|
import { formatCount, formatDateTime, formatRelative } from '@/lib/monitorFormat'
|
||||||
|
import type { CreatorAccount, CreatorNote, CreatorPermissionStatus } from '@/types/creator'
|
||||||
|
|
||||||
import { AddAccountDialog } from './AddAccountDialog'
|
import { AddAccountDialog } from './AddAccountDialog'
|
||||||
|
|
||||||
@@ -44,7 +43,6 @@ const SUMMARY_TILES: Array<{ key: keyof CreatorNote; label: string }> = [
|
|||||||
{ key: 'new_followers', label: '涨粉' },
|
{ key: 'new_followers', label: '涨粉' },
|
||||||
]
|
]
|
||||||
|
|
||||||
/** 比率字段用百分号展示;其余用紧凑计数。 */
|
|
||||||
function formatRate(value: number | null): string {
|
function formatRate(value: number | null): string {
|
||||||
return value === null ? '—' : `${value.toFixed(1)}%`
|
return value === null ? '—' : `${value.toFixed(1)}%`
|
||||||
}
|
}
|
||||||
@@ -55,92 +53,61 @@ function formatSeconds(value: number | null): string {
|
|||||||
return `${Math.floor(value / 60)} 分 ${Math.round(value % 60)} 秒`
|
return `${Math.floor(value / 60)} 分 ${Math.round(value % 60)} 秒`
|
||||||
}
|
}
|
||||||
|
|
||||||
function AccountList({
|
/** 左栏里的一行账号。选中项高亮,与监控的任务卡片同一套语言。 */
|
||||||
selectedId,
|
function AccountRow({
|
||||||
|
account,
|
||||||
|
selected,
|
||||||
onSelect,
|
onSelect,
|
||||||
onAdd,
|
|
||||||
}: {
|
}: {
|
||||||
selectedId: number | null
|
account: CreatorAccount
|
||||||
onSelect: (id: number) => void
|
selected: boolean
|
||||||
onAdd: () => void
|
onSelect: () => void
|
||||||
}) {
|
}) {
|
||||||
// 有同步在后端跑时,列表要自己刷新,否则状态永远是旧的。
|
const permission = PERMISSION_LABEL[account.permission_status]
|
||||||
const { data: accounts, isLoading } = useCreatorAccounts(true)
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return <p className="py-10 text-center text-[11px] font-mono text-cyber-text-muted">加载中…</p>
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!accounts || accounts.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center gap-3 py-12">
|
|
||||||
<UserRound className="w-8 h-8 text-cyber-text-muted" />
|
|
||||||
<p className="text-[11px] font-mono text-cyber-text-muted">
|
|
||||||
还没有运营账号,扫码添加一个
|
|
||||||
</p>
|
|
||||||
<Button size="sm" onClick={onAdd}>
|
|
||||||
<Plus className="w-3 h-3 mr-1" />
|
|
||||||
新增账号
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<button
|
||||||
{accounts.map((account) => {
|
onClick={onSelect}
|
||||||
const permission = PERMISSION_LABEL[account.permission_status]
|
className={`w-full flex items-center gap-2.5 rounded-lg border px-2.5 py-2 text-left transition-colors ${
|
||||||
return (
|
selected
|
||||||
<button
|
? 'border-cyber-neon-cyan/50 bg-cyber-neon-cyan/10'
|
||||||
key={account.id}
|
: 'border-cyber-border-subtle bg-cyber-bg-tertiary/40 hover:border-cyber-border-DEFAULT'
|
||||||
onClick={() => onSelect(account.id)}
|
}`}
|
||||||
className={`w-full flex items-center gap-3 rounded-lg border px-3 py-2.5 text-left transition-colors ${
|
>
|
||||||
selectedId === account.id
|
{account.avatar ? (
|
||||||
? 'border-cyber-neon-cyan/50 bg-cyber-neon-cyan/5'
|
<img
|
||||||
: 'border-cyber-border-subtle bg-cyber-bg-tertiary/40 hover:border-cyber-border-DEFAULT'
|
src={account.avatar}
|
||||||
}`}
|
alt=""
|
||||||
>
|
// 小红书图床对带外部 Referer 的请求返回 403,头像同理。
|
||||||
{account.avatar ? (
|
referrerPolicy="no-referrer"
|
||||||
<img
|
className="w-8 h-8 rounded-full object-cover flex-shrink-0 bg-cyber-bg-tertiary"
|
||||||
src={account.avatar}
|
/>
|
||||||
alt=""
|
) : (
|
||||||
// 小红书图床对带外部 Referer 的请求返回 403,头像同理。
|
<div className="w-8 h-8 rounded-full bg-cyber-bg-tertiary flex-shrink-0" />
|
||||||
referrerPolicy="no-referrer"
|
)}
|
||||||
className="w-9 h-9 rounded-full object-cover flex-shrink-0 bg-cyber-bg-tertiary"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="w-9 h-9 rounded-full bg-cyber-bg-tertiary flex-shrink-0" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="truncate font-mono text-xs text-cyber-text-primary">
|
<span className="truncate font-mono text-[11px] text-cyber-text-primary">
|
||||||
{account.nickname || '未命名账号'}
|
{account.nickname || '未命名账号'}
|
||||||
</span>
|
</span>
|
||||||
{account.status === 'expired' && (
|
{account.status === 'expired' && (
|
||||||
<Badge variant="warning" className="text-[9px]">
|
<Badge variant="warning" className="text-[9px] px-1 py-0 flex-shrink-0">
|
||||||
登录失效
|
失效
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-0.5 flex flex-wrap items-center gap-x-3 text-[10px] font-mono text-cyber-text-muted">
|
<div className="mt-0.5 flex items-center gap-2 text-[9px] font-mono text-cyber-text-muted">
|
||||||
<span>小红书号 {account.red_id || '—'}</span>
|
<span className={permission.tone}>{permission.text}</span>
|
||||||
<span>作品 {account.note_count}</span>
|
<span>作品 {account.note_count}</span>
|
||||||
<span className={permission.tone}>{permission.text}</span>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
|
|
||||||
<span className="flex-shrink-0 text-[9px] font-mono text-cyber-text-muted">
|
|
||||||
{account.last_synced_at ? formatRelative(account.last_synced_at) : '未同步'}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function AccountDetail({ accountId, onBack }: { accountId: number; onBack: () => void }) {
|
/** 右栏:所选账号的数据。 */
|
||||||
|
function AccountPanel({ accountId }: { accountId: number }) {
|
||||||
const { data, isLoading } = useCreatorAccount(accountId)
|
const { data, isLoading } = useCreatorAccount(accountId)
|
||||||
const sync = useSyncCreatorAccount()
|
const sync = useSyncCreatorAccount()
|
||||||
const check = useCheckCreatorAccount()
|
const check = useCheckCreatorAccount()
|
||||||
@@ -154,18 +121,14 @@ function AccountDetail({ accountId, onBack }: { accountId: number; onBack: () =>
|
|||||||
const permission = PERMISSION_LABEL[account.permission_status]
|
const permission = PERMISSION_LABEL[account.permission_status]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="flex flex-col overflow-hidden h-full">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-2 px-3 py-2.5 border-b border-cyber-border-subtle flex-shrink-0">
|
||||||
<Button variant="ghost" size="sm" onClick={onBack}>
|
|
||||||
<ArrowLeft className="w-3 h-3 mr-1" />
|
|
||||||
返回
|
|
||||||
</Button>
|
|
||||||
{account.avatar && (
|
{account.avatar && (
|
||||||
<img
|
<img
|
||||||
src={account.avatar}
|
src={account.avatar}
|
||||||
alt=""
|
alt=""
|
||||||
referrerPolicy="no-referrer"
|
referrerPolicy="no-referrer"
|
||||||
className="w-8 h-8 rounded-full object-cover"
|
className="w-7 h-7 rounded-full object-cover"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<span className="font-mono text-xs text-cyber-text-primary">
|
<span className="font-mono text-xs text-cyber-text-primary">
|
||||||
@@ -174,6 +137,9 @@ function AccountDetail({ accountId, onBack }: { accountId: number; onBack: () =>
|
|||||||
<span className="font-mono text-[10px] text-cyber-text-muted">
|
<span className="font-mono text-[10px] text-cyber-text-muted">
|
||||||
小红书号 {account.red_id || '—'}
|
小红书号 {account.red_id || '—'}
|
||||||
</span>
|
</span>
|
||||||
|
<span className="ml-1 text-[10px] font-mono text-cyber-text-muted">
|
||||||
|
上次同步 {account.last_synced_at ? formatRelative(account.last_synced_at) : '从未'}
|
||||||
|
</span>
|
||||||
|
|
||||||
<div className="ml-auto flex items-center gap-2">
|
<div className="ml-auto flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
@@ -189,110 +155,105 @@ function AccountDetail({ accountId, onBack }: { accountId: number; onBack: () =>
|
|||||||
<RefreshCw className={`w-3 h-3 mr-1 ${sync.isPending ? 'animate-spin' : ''}`} />
|
<RefreshCw className={`w-3 h-3 mr-1 ${sync.isPending ? 'animate-spin' : ''}`} />
|
||||||
同步数据
|
同步数据
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button variant="ghost" size="sm" onClick={() => remove.mutate(account.id)}>
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
remove.mutate(account.id)
|
|
||||||
onBack()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trash2 className="w-3 h-3" />
|
<Trash2 className="w-3 h-3" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 权限状态必须显眼:它解释了"为什么没有数据"。后台的原话照抄,不改写。 */}
|
<div className="flex-1 overflow-y-auto terminal-scroll px-3 py-3 space-y-3">
|
||||||
{account.permission_status !== 'active' && (
|
{/* 权限状态必须显眼:它解释了"为什么没有数据"。后台的原话照抄,不改写。 */}
|
||||||
<div className="flex items-start gap-2 rounded-md border border-cyber-neon-orange/30 bg-cyber-neon-orange/5 px-3 py-2">
|
{account.permission_status !== 'active' && (
|
||||||
<Clock className="w-3.5 h-3.5 mt-0.5 flex-shrink-0 text-cyber-neon-orange" />
|
<div className="flex items-start gap-2 rounded-md border border-cyber-neon-orange/30 bg-cyber-neon-orange/5 px-3 py-2">
|
||||||
<div className="text-[11px] font-mono leading-relaxed">
|
<Clock className="w-3.5 h-3.5 mt-0.5 flex-shrink-0 text-cyber-neon-orange" />
|
||||||
<span className={permission.tone}>{permission.text}</span>
|
<div className="text-[11px] font-mono leading-relaxed">
|
||||||
{account.permission_tip && (
|
<span className={permission.tone}>{permission.text}</span>
|
||||||
<span className="ml-2 text-cyber-text-secondary">{account.permission_tip}</span>
|
{account.permission_tip && (
|
||||||
)}
|
<span className="ml-2 text-cyber-text-secondary">{account.permission_tip}</span>
|
||||||
<div className="mt-0.5 text-cyber-text-muted">
|
)}
|
||||||
数据权限由创作者后台按天开通。在此之前同步会成功但返回 0 条 —— 那是正常的,
|
<div className="mt-0.5 text-cyber-text-muted">
|
||||||
不是采集失败。
|
数据权限由创作者后台按天开通。在此之前同步会成功但返回 0 条 —— 那是正常的,
|
||||||
|
不是采集失败。
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{account.last_error && (
|
||||||
|
<p className="flex items-start gap-2 text-[11px] font-mono text-cyber-neon-pink leading-relaxed">
|
||||||
|
<AlertTriangle className="w-3 h-3 mt-0.5 shrink-0" />
|
||||||
|
{account.last_error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-4 gap-2 xl:grid-cols-7">
|
||||||
|
{SUMMARY_TILES.map((tile) => (
|
||||||
|
<div
|
||||||
|
key={tile.key}
|
||||||
|
className="rounded-lg border border-cyber-border-subtle bg-cyber-bg-tertiary/40 px-3 py-2"
|
||||||
|
>
|
||||||
|
<div className="text-[10px] font-mono text-cyber-text-muted">{tile.label}</div>
|
||||||
|
<div className="mt-0.5 font-mono text-sm text-cyber-text-primary">
|
||||||
|
{formatCount(summary[tile.key as keyof typeof summary] ?? 0)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{account.last_error && (
|
{notes.length === 0 ? (
|
||||||
<p className="flex items-start gap-2 text-[11px] font-mono text-cyber-neon-pink leading-relaxed">
|
<p className="py-8 text-center text-[11px] font-mono text-cyber-text-muted">
|
||||||
<AlertTriangle className="w-3 h-3 mt-0.5 shrink-0" />
|
{account.permission_status === 'active'
|
||||||
{account.last_error}
|
? '这个账号在所选时间范围内没有作品,或还没同步过 —— 点「同步数据」试试'
|
||||||
</p>
|
: '数据权限生效后即可看到作品数据'}
|
||||||
)}
|
</p>
|
||||||
|
) : (
|
||||||
<div className="grid grid-cols-4 gap-2 lg:grid-cols-7">
|
<div className="overflow-x-auto">
|
||||||
{SUMMARY_TILES.map((tile) => (
|
<table className="w-full text-xs font-mono">
|
||||||
<div
|
<thead className="sticky top-0 bg-cyber-bg-tertiary">
|
||||||
key={tile.key}
|
<tr className="text-[10px] text-cyber-text-secondary">
|
||||||
className="rounded-lg border border-cyber-border-subtle bg-cyber-bg-tertiary/40 px-3 py-2"
|
<th className="px-2 py-2 text-left font-normal">作品</th>
|
||||||
>
|
|
||||||
<div className="text-[10px] font-mono text-cyber-text-muted">{tile.label}</div>
|
|
||||||
<div className="mt-0.5 font-mono text-sm text-cyber-text-primary">
|
|
||||||
{formatCount(summary[tile.key as keyof typeof summary] ?? 0)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{notes.length === 0 ? (
|
|
||||||
<p className="py-8 text-center text-[11px] font-mono text-cyber-text-muted">
|
|
||||||
{account.permission_status === 'active'
|
|
||||||
? '这个账号在所选时间范围内没有作品,或还没同步过 —— 点「同步数据」试试'
|
|
||||||
: '数据权限生效后即可看到作品数据'}
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<div className="overflow-x-auto terminal-scroll">
|
|
||||||
<table className="w-full text-xs font-mono">
|
|
||||||
<thead className="sticky top-0 bg-cyber-bg-tertiary">
|
|
||||||
<tr className="text-[10px] text-cyber-text-secondary">
|
|
||||||
<th className="px-2 py-2 text-left font-normal">作品</th>
|
|
||||||
{SUMMARY_TILES.map((tile) => (
|
|
||||||
<th key={tile.key} className="px-2 py-2 text-right font-normal">
|
|
||||||
{tile.label}
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
<th className="px-2 py-2 text-right font-normal">点击率</th>
|
|
||||||
<th className="px-2 py-2 text-right font-normal">均看时长</th>
|
|
||||||
<th className="px-2 py-2 text-right font-normal">完播率</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{notes.map((note) => (
|
|
||||||
<tr key={note.note_id} className="border-t border-cyber-border-subtle">
|
|
||||||
<td className="max-w-[280px] px-2 py-2">
|
|
||||||
<div className="truncate text-cyber-text-primary" title={note.title}>
|
|
||||||
{note.title || note.note_id}
|
|
||||||
</div>
|
|
||||||
<div className="text-[9px] text-cyber-text-muted">
|
|
||||||
{note.publish_time ? formatDateTime(note.publish_time) : '—'}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
{SUMMARY_TILES.map((tile) => (
|
{SUMMARY_TILES.map((tile) => (
|
||||||
<td key={tile.key} className="px-2 py-2 text-right text-cyber-text-primary">
|
<th key={tile.key} className="px-2 py-2 text-right font-normal">
|
||||||
{formatCount(note[tile.key] as number | null)}
|
{tile.label}
|
||||||
</td>
|
</th>
|
||||||
))}
|
))}
|
||||||
<td className="px-2 py-2 text-right text-cyber-text-secondary">
|
<th className="px-2 py-2 text-right font-normal">点击率</th>
|
||||||
{formatRate(note.cover_ctr)}
|
<th className="px-2 py-2 text-right font-normal">均看时长</th>
|
||||||
</td>
|
<th className="px-2 py-2 text-right font-normal">完播率</th>
|
||||||
<td className="px-2 py-2 text-right text-cyber-text-secondary">
|
|
||||||
{formatSeconds(note.avg_watch_seconds)}
|
|
||||||
</td>
|
|
||||||
<td className="px-2 py-2 text-right text-cyber-text-secondary">
|
|
||||||
{formatRate(note.completion_rate)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
</thead>
|
||||||
</tbody>
|
<tbody>
|
||||||
</table>
|
{notes.map((note) => (
|
||||||
</div>
|
<tr key={note.note_id} className="border-t border-cyber-border-subtle">
|
||||||
)}
|
<td className="max-w-[280px] px-2 py-2">
|
||||||
|
<div className="truncate text-cyber-text-primary" title={note.title}>
|
||||||
|
{note.title || note.note_id}
|
||||||
|
</div>
|
||||||
|
<div className="text-[9px] text-cyber-text-muted">
|
||||||
|
{note.publish_time ? formatDateTime(note.publish_time) : '—'}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
{SUMMARY_TILES.map((tile) => (
|
||||||
|
<td key={tile.key} className="px-2 py-2 text-right text-cyber-text-primary">
|
||||||
|
{formatCount(note[tile.key] as number | null)}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
<td className="px-2 py-2 text-right text-cyber-text-secondary">
|
||||||
|
{formatRate(note.cover_ctr)}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2 text-right text-cyber-text-secondary">
|
||||||
|
{formatSeconds(note.avg_watch_seconds)}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2 text-right text-cyber-text-secondary">
|
||||||
|
{formatRate(note.completion_rate)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -300,14 +261,16 @@ function AccountDetail({ accountId, onBack }: { accountId: number; onBack: () =>
|
|||||||
/**
|
/**
|
||||||
* 运营:管理自己的小红书账号。
|
* 运营:管理自己的小红书账号。
|
||||||
*
|
*
|
||||||
* 与「监控」并列而非其子视图。监控抓的是公开数据(点赞/收藏/评论/分享),这里是
|
* 与「监控」并列而非其子视图,布局也照搬它:左边账号列表、右边数据面板。
|
||||||
* 创作者后台的运营指标(曝光/观看/完播率/涨粉)。两者数据形状不同、凭据不同、
|
* 两者关注的东西不同 —— 监控抓公开数据(点赞/收藏/评论/分享)的每轮差分,
|
||||||
* 采集方式也不同(那边要浏览器登录态,这边是纯请求)。
|
* 这里是创作者后台的运营指标(曝光/观看/完播率/涨粉),凭据与采集方式都不同。
|
||||||
*/
|
*/
|
||||||
export function OperationView() {
|
export function OperationView() {
|
||||||
const { platform, capability } = useCurrentPlatform()
|
const { platform, capability } = useCurrentPlatform()
|
||||||
const [selectedId, setSelectedId] = useState<number | null>(null)
|
const [selectedId, setSelectedId] = useState<number | null>(null)
|
||||||
const [addOpen, setAddOpen] = useState(false)
|
const [addOpen, setAddOpen] = useState(false)
|
||||||
|
// 有同步在后端跑时列表要自己刷新,否则状态永远是旧的。
|
||||||
|
const { data: accounts, isLoading } = useCreatorAccounts(true)
|
||||||
|
|
||||||
// 运营只对小红书成立:它读的是小红书创作者后台。不挡住的话,切到抖音时这里照样
|
// 运营只对小红书成立:它读的是小红书创作者后台。不挡住的话,切到抖音时这里照样
|
||||||
// 列出小红书的账号 —— 看起来像是"抖音账号",其实平台维度根本没参与。
|
// 列出小红书的账号 —— 看起来像是"抖音账号",其实平台维度根本没参与。
|
||||||
@@ -321,9 +284,7 @@ export function OperationView() {
|
|||||||
<Briefcase className="w-5 h-5 text-cyber-neon-orange" />
|
<Briefcase className="w-5 h-5 text-cyber-neon-orange" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="font-mono text-sm text-cyber-text-primary">
|
<h2 className="font-mono text-sm text-cyber-text-primary">{label} 没有运营模块</h2>
|
||||||
{label} 没有运营模块
|
|
||||||
</h2>
|
|
||||||
<p className="text-[11px] font-mono text-cyber-text-muted">
|
<p className="text-[11px] font-mono text-cyber-text-muted">
|
||||||
运营读的是创作者后台的数据,目前只接入了小红书
|
运营读的是创作者后台的数据,目前只接入了小红书
|
||||||
</p>
|
</p>
|
||||||
@@ -338,35 +299,63 @@ export function OperationView() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 没选过就默认第一个,和监控一样 —— 右栏不该一开始是空的。
|
||||||
|
const selected =
|
||||||
|
accounts?.find((account) => account.id === selectedId) ??
|
||||||
|
(accounts && accounts.length > 0 ? accounts[0] : null)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 overflow-y-auto terminal-scroll pr-1">
|
<div className="flex-1 flex flex-col gap-3 overflow-hidden min-h-0 relative z-10">
|
||||||
<div className="max-w-5xl space-y-3 pb-6">
|
<div className="flex-1 flex gap-3 overflow-hidden min-h-0">
|
||||||
<div className="flex items-center justify-between gap-3">
|
{/* 左:账号列表 */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="w-[300px] flex-shrink-0 flex flex-col gap-3 overflow-hidden">
|
||||||
<span className="font-mono text-sm text-cyber-text-primary">运营账号</span>
|
<div className="flex items-center justify-between flex-shrink-0">
|
||||||
<span className="text-[10px] font-mono text-cyber-text-muted">
|
<span className="font-mono text-xs text-cyber-text-primary">运营账号</span>
|
||||||
{selectedId === null
|
|
||||||
? '管理自己的小红书账号,读取创作者后台的曝光/观看/完播等运营数据'
|
|
||||||
: '点「返回」回到账号列表'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{selectedId === null && (
|
|
||||||
<Button size="sm" onClick={() => setAddOpen(true)}>
|
<Button size="sm" onClick={() => setAddOpen(true)}>
|
||||||
<Plus className="w-3 h-3 mr-1" />
|
<Plus className="w-3 h-3 mr-1" />
|
||||||
新增账号
|
新增
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto terminal-scroll space-y-2 pr-1">
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="py-8 text-center text-[11px] font-mono text-cyber-text-muted">
|
||||||
|
加载中…
|
||||||
|
</p>
|
||||||
|
) : !accounts || accounts.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center gap-2 py-10">
|
||||||
|
<UserRound className="w-7 h-7 text-cyber-text-muted" />
|
||||||
|
<p className="text-center text-[11px] font-mono text-cyber-text-muted">
|
||||||
|
还没有运营账号
|
||||||
|
<br />
|
||||||
|
扫码添加一个
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
accounts.map((account) => (
|
||||||
|
<AccountRow
|
||||||
|
key={account.id}
|
||||||
|
account={account}
|
||||||
|
selected={selected?.id === account.id}
|
||||||
|
onSelect={() => setSelectedId(account.id)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedId === null ? (
|
{/* 右:所选账号的数据 */}
|
||||||
<AccountList
|
<div className="flex-1 flex flex-col overflow-hidden min-w-0 rounded-lg glass-panel float-panel">
|
||||||
selectedId={selectedId}
|
{selected ? (
|
||||||
onSelect={setSelectedId}
|
<AccountPanel accountId={selected.id} />
|
||||||
onAdd={() => setAddOpen(true)}
|
) : (
|
||||||
/>
|
<div className="flex-1 flex items-center justify-center">
|
||||||
) : (
|
<p className="text-[11px] font-mono text-cyber-text-muted">
|
||||||
<AccountDetail accountId={selectedId} onBack={() => setSelectedId(null)} />
|
从左边选一个账号,或先扫码添加
|
||||||
)}
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AddAccountDialog open={addOpen} onOpenChange={setAddOpen} />
|
<AddAccountDialog open={addOpen} onOpenChange={setAddOpen} />
|
||||||
|
|||||||
Reference in New Issue
Block a user