From bef0a4fbde118e2ebc2cd7680181ef749b746e42 Mon Sep 17 00:00:00 2001 From: butubb <1422726308@qq.com> Date: Wed, 7 Oct 2026 16:40:24 +0800 Subject: [PATCH] =?UTF-8?q?fix(creator):=20=E6=89=AB=E7=A0=81=E6=88=90?= =?UTF-8?q?=E5=8A=9F=E5=8D=B4=E4=B8=80=E7=9B=B4=E5=81=9C=E5=9C=A8=E4=BA=8C?= =?UTF-8?q?=E7=BB=B4=E7=A0=81=E4=B8=8A=20+=20=E8=BF=90=E8=90=A5=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E5=B7=A6=E5=8F=B3=E5=B8=83=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 【扫码不完成】根因是判据本身。原先读页面里的 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 例,含「游客会话永不完成」「接口不打满每次轮询」 「临时上下文用完必须关掉」。 --- api/creator/login.py | 84 ++-- api/routers/creator.py | 6 +- tests/test_creator_login.py | 160 +++++++ .../components/creator/AddAccountDialog.tsx | 68 ++- .../src/components/creator/OperationView.tsx | 407 +++++++++--------- 5 files changed, 466 insertions(+), 259 deletions(-) create mode 100644 tests/test_creator_login.py diff --git a/api/creator/login.py b/api/creator/login.py index 913c775..503f7c9 100644 --- a/api/creator/login.py +++ b/api/creator/login.py @@ -31,7 +31,6 @@ """ import asyncio -import json import time from typing import Any, Dict, Optional @@ -39,6 +38,7 @@ from playwright.async_api import async_playwright from tools import utils from ..monitor.platforms import PLATFORM_XHS +from .client import CreatorApiError, CreatorClient QR_TTL_SECONDS = 300 @@ -52,32 +52,26 @@ LOGIN_URL = "https://www.xiaohongshu.com" QR_SELECTOR = "xpath=//img[@class='qrcode-img']" LOGIN_BUTTON_SELECTOR = "xpath=//*[@id='app']/div[1]/div[2]/div[1]/ul/div[1]/button" -# 与 api/monitor/qrlogin.py 同一套判据:主站在 window.__INITIAL_STATE__ 里报告登录态, -# 而 user.loggedIn 是 Vue 的响应式引用,要 .value 解包才是布尔值。 -LOGIN_STATE_PROBE = """ -() => { - try { - const user = (window.__INITIAL_STATE__ || {}).user; - if (!user) return JSON.stringify({ known: false }); - let loggedIn = user.loggedIn; - if (loggedIn && typeof loggedIn === 'object' && 'value' in loggedIn) loggedIn = loggedIn.value; - let info = null; - try { info = user.userInfo || null; } catch (e) { info = null; } - 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) }); - } -} -""" +# 判据不读页面状态,而是拿 cookie 直接问创作者后台"我是谁"。 +# +# **为什么不用页面状态**:`window.__INITIAL_STATE__` 是**页面加载那一刻的快照**。 +# 监控那边的同一个探针能用,是因为那台浏览器的页面加载时就已经登录了,快照里 +# loggedIn 就是 true。而扫码是"页面加载之后才登录的"—— SPA 内部确实登进去了, +# 但那个初始快照不会翻转,于是检测永远等不到,界面就一直停在二维码上。 +# +# `user/info` 则是权威的:实测**游客也会拿到 a1**(所以签名算得出来),但接口直接 +# 回 401「无登录信息」;只有真正登录了才返回 user_id。所以"有 a1"什么都证明不了, +# "后台认这份身份"才是。 +LOGIN_CHECK_INTERVAL_SECONDS = 5.0 _lock = asyncio.Lock() _current: Optional["AccountLoginSession"] = None +# 完成后的快照。会话一旦被取走 cookie 就会拆掉,而前端可能还有一个在飞的轮询—— +# 那个请求若看到 _current 为空就会回报 idle,把已经显示出来的成功状态又擦掉。 +# 把结果留在这里,重复轮询就稳定得多。 +_last_result: Optional[Dict[str, Any]] = None + _playwright: Any = None @@ -132,6 +126,7 @@ class AccountLoginSession: self.platform = PLATFORM_XHS self._context = context self._page = page + self._last_login_check = 0.0 @property def elapsed(self) -> float: @@ -145,23 +140,36 @@ class AccountLoginSession: self.message = "二维码已超时,请重新获取" return + # 前端每 2 秒问一次,但没必要每次都去打后台接口 —— 一次真实的网络往返 + # 去确认一个通常还没发生的事件是浪费。 + now = time.time() + if now - self._last_login_check < LOGIN_CHECK_INTERVAL_SECONDS: + return + self._last_login_check = now + try: - raw = await self._page.evaluate(LOGIN_STATE_PROBE) - state = json.loads(raw) if isinstance(raw, str) else {} + cookies = await self._context.cookies() except Exception: self.status = STATUS_ERROR - self.message = "二维码所在页面已被关闭,请重新获取" + self.message = "登录窗口已被关闭,请重新获取" 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 - # 登录成功:从**这个临时上下文**取 cookie。取完上下文就丢弃, + if not info.get("user_id"): + return + + # 登录成功:cookie 取自**这个临时上下文**,取完上下文就丢弃, # 所以不会残留、也不会影响别的账号。 - cookies = await self._context.cookies() - self.cookie = "; ".join(f"{c['name']}={c['value']}" for c in cookies) + self.cookie = cookie + self.account = info self.status = STATUS_SUCCESS - self.message = "已获取登录态,正在识别账号…" + self.message = f"登录成功:{info.get('nickname') or info['user_id']}" def snapshot(self) -> Dict[str, Any]: return { @@ -191,9 +199,10 @@ async def _teardown_locked() -> None: async def start() -> Dict[str, Any]: """开一个临时上下文,打开登录页,取回二维码。""" - global _current + global _current, _last_result async with _lock: + _last_result = None await _teardown_locked() try: @@ -234,6 +243,8 @@ async def start() -> Dict[str, Any]: async def status() -> Dict[str, Any]: async with _lock: if _current is None: + if _last_result is not None: + return _last_result return { "status": STATUS_IDLE, "message": "", @@ -246,6 +257,13 @@ async def status() -> Dict[str, Any]: 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]: """取走已登录的 cookie 并结束会话。 @@ -262,7 +280,9 @@ async def take_cookie() -> Optional[str]: async def cancel() -> Dict[str, Any]: + global _last_result async with _lock: + _last_result = None await _teardown_locked() return { "status": STATUS_IDLE, diff --git a/api/routers/creator.py b/api/routers/creator.py index fd0aa1c..5247a6f 100644 --- a/api/routers/creator.py +++ b/api/routers/creator.py @@ -125,7 +125,9 @@ async def poll_login(): """轮询扫码结果;一旦成功就把账号落库并返回它。""" 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() if cookie: try: @@ -134,9 +136,11 @@ async def poll_login(): except ValueError as exc: snapshot["status"] = creator_login.STATUS_ERROR snapshot["message"] = f"扫码成功但保存账号失败:{exc}" + await creator_login.remember_result(snapshot) return snapshot snapshot["account"] = account snapshot["message"] = f"已添加账号:{account['nickname']}" + await creator_login.remember_result(snapshot) return snapshot diff --git a/tests/test_creator_login.py b/tests/test_creator_login.py new file mode 100644 index 0000000..3dece24 --- /dev/null +++ b/tests/test_creator_login.py @@ -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 diff --git a/webui/src/components/creator/AddAccountDialog.tsx b/webui/src/components/creator/AddAccountDialog.tsx index 408f6e3..f1ad649 100644 --- a/webui/src/components/creator/AddAccountDialog.tsx +++ b/webui/src/components/creator/AddAccountDialog.tsx @@ -42,19 +42,23 @@ export function AddAccountDialog({ if (!open) return if (status === 'waiting' || status === 'idle') return setPolling(false) - if (status === 'success') { - queryClient.invalidateQueries({ queryKey: ['creatorAccounts'] }) - } - }, [status, open, queryClient]) + if (status !== 'success') return - // 关掉弹窗时把后端那个临时上下文也收掉,别让它挂在浏览器里。 - const handleOpenChange = (next: boolean) => { - if (!next) { - setPolling(false) - if (status === 'waiting') cancel.mutate() - } - onOpenChange(next) - } + queryClient.invalidateQueries({ queryKey: ['creatorAccounts'] }) + // 自动关掉:停在二维码上会让人以为没成功,而账号其实已经进了列表。 + const timer = setTimeout(() => onOpenChange(false), 1600) + return () => clearTimeout(timer) + }, [status, open, queryClient, onOpenChange]) + + // 关闭时统一收尾:拆掉后端那个临时上下文(别让它挂在操作者的 Chrome 里), + // 并清掉后端记住的结果 —— 否则下次打开弹窗会立刻显示上一次的成功状态。 + useEffect(() => { + if (open) return + setPolling(false) + cancel.mutate() + // 只在开关变化时跑,cancel/依赖本身不该触发它。 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]) const begin = () => { setPolling(true) @@ -64,7 +68,7 @@ export function AddAccountDialog({ const busy = start.isPending || cancel.isPending return ( - + 新增运营账号 @@ -108,10 +112,40 @@ export function AddAccountDialog({ )} {status === 'success' && ( -

- - {state?.message || '账号已添加'} -

+
+

+ + {state?.message || '账号已添加'} +

+ {state?.account && ( +
+ {state.account.avatar && ( + + )} +
+
+ {state.account.nickname} +
+
+ 小红书号 {state.account.red_id || '—'} +
+
+
+ )} + {state?.account?.permission_tip && ( +

+ {state.account.permission_tip} +

+ )} + +
)} {(status === 'error' || status === 'expired') && ( diff --git a/webui/src/components/creator/OperationView.tsx b/webui/src/components/creator/OperationView.tsx index 015718c..7a87ba8 100644 --- a/webui/src/components/creator/OperationView.tsx +++ b/webui/src/components/creator/OperationView.tsx @@ -1,7 +1,6 @@ import { useState } from 'react' import { AlertTriangle, - ArrowLeft, Briefcase, Clock, Plus, @@ -19,9 +18,9 @@ import { useDeleteCreatorAccount, useSyncCreatorAccount, } from '@/hooks/useCreator' -import { formatCount, formatDateTime, formatRelative } from '@/lib/monitorFormat' 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' @@ -44,7 +43,6 @@ const SUMMARY_TILES: Array<{ key: keyof CreatorNote; label: string }> = [ { key: 'new_followers', label: '涨粉' }, ] -/** 比率字段用百分号展示;其余用紧凑计数。 */ function formatRate(value: number | null): string { 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)} 秒` } -function AccountList({ - selectedId, +/** 左栏里的一行账号。选中项高亮,与监控的任务卡片同一套语言。 */ +function AccountRow({ + account, + selected, onSelect, - onAdd, }: { - selectedId: number | null - onSelect: (id: number) => void - onAdd: () => void + account: CreatorAccount + selected: boolean + onSelect: () => void }) { - // 有同步在后端跑时,列表要自己刷新,否则状态永远是旧的。 - const { data: accounts, isLoading } = useCreatorAccounts(true) - - if (isLoading) { - return

加载中…

- } - - if (!accounts || accounts.length === 0) { - return ( -
- -

- 还没有运营账号,扫码添加一个 -

- -
- ) - } + const permission = PERMISSION_LABEL[account.permission_status] return ( -
- {accounts.map((account) => { - const permission = PERMISSION_LABEL[account.permission_status] - return ( - - ) - })} -
+
+
+ + {account.nickname || '未命名账号'} + + {account.status === 'expired' && ( + + 失效 + + )} +
+
+ {permission.text} + 作品 {account.note_count} +
+
+ ) } -function AccountDetail({ accountId, onBack }: { accountId: number; onBack: () => void }) { +/** 右栏:所选账号的数据。 */ +function AccountPanel({ accountId }: { accountId: number }) { const { data, isLoading } = useCreatorAccount(accountId) const sync = useSyncCreatorAccount() const check = useCheckCreatorAccount() @@ -154,18 +121,14 @@ function AccountDetail({ accountId, onBack }: { accountId: number; onBack: () => const permission = PERMISSION_LABEL[account.permission_status] return ( -
-
- +
+
{account.avatar && ( )} @@ -174,6 +137,9 @@ function AccountDetail({ accountId, onBack }: { accountId: number; onBack: () => 小红书号 {account.red_id || '—'} + + 上次同步 {account.last_synced_at ? formatRelative(account.last_synced_at) : '从未'} +
-
- {/* 权限状态必须显眼:它解释了"为什么没有数据"。后台的原话照抄,不改写。 */} - {account.permission_status !== 'active' && ( -
- -
- {permission.text} - {account.permission_tip && ( - {account.permission_tip} - )} -
- 数据权限由创作者后台按天开通。在此之前同步会成功但返回 0 条 —— 那是正常的, - 不是采集失败。 +
+ {/* 权限状态必须显眼:它解释了"为什么没有数据"。后台的原话照抄,不改写。 */} + {account.permission_status !== 'active' && ( +
+ +
+ {permission.text} + {account.permission_tip && ( + {account.permission_tip} + )} +
+ 数据权限由创作者后台按天开通。在此之前同步会成功但返回 0 条 —— 那是正常的, + 不是采集失败。 +
+ )} + + {account.last_error && ( +

+ + {account.last_error} +

+ )} + +
+ {SUMMARY_TILES.map((tile) => ( +
+
{tile.label}
+
+ {formatCount(summary[tile.key as keyof typeof summary] ?? 0)} +
+
+ ))}
- )} - {account.last_error && ( -

- - {account.last_error} -

- )} - -
- {SUMMARY_TILES.map((tile) => ( -
-
{tile.label}
-
- {formatCount(summary[tile.key as keyof typeof summary] ?? 0)} -
-
- ))} -
- - {notes.length === 0 ? ( -

- {account.permission_status === 'active' - ? '这个账号在所选时间范围内没有作品,或还没同步过 —— 点「同步数据」试试' - : '数据权限生效后即可看到作品数据'} -

- ) : ( -
- - - - - {SUMMARY_TILES.map((tile) => ( - - ))} - - - - - - - {notes.map((note) => ( - - + {notes.length === 0 ? ( +

+ {account.permission_status === 'active' + ? '这个账号在所选时间范围内没有作品,或还没同步过 —— 点「同步数据」试试' + : '数据权限生效后即可看到作品数据'} +

+ ) : ( +
+
作品 - {tile.label} - 点击率均看时长完播率
-
- {note.title || note.note_id} -
-
- {note.publish_time ? formatDateTime(note.publish_time) : '—'} -
-
+ + + {SUMMARY_TILES.map((tile) => ( - + ))} - - - + + + - ))} - -
作品 - {formatCount(note[tile.key] as number | null)} - + {tile.label} + - {formatRate(note.cover_ctr)} - - {formatSeconds(note.avg_watch_seconds)} - - {formatRate(note.completion_rate)} - 点击率均看时长完播率
-
- )} + + + {notes.map((note) => ( + + +
+ {note.title || note.note_id} +
+
+ {note.publish_time ? formatDateTime(note.publish_time) : '—'} +
+ + {SUMMARY_TILES.map((tile) => ( + + {formatCount(note[tile.key] as number | null)} + + ))} + + {formatRate(note.cover_ctr)} + + + {formatSeconds(note.avg_watch_seconds)} + + + {formatRate(note.completion_rate)} + + + ))} + + +
+ )} +
) } @@ -300,14 +261,16 @@ function AccountDetail({ accountId, onBack }: { accountId: number; onBack: () => /** * 运营:管理自己的小红书账号。 * - * 与「监控」并列而非其子视图。监控抓的是公开数据(点赞/收藏/评论/分享),这里是 - * 创作者后台的运营指标(曝光/观看/完播率/涨粉)。两者数据形状不同、凭据不同、 - * 采集方式也不同(那边要浏览器登录态,这边是纯请求)。 + * 与「监控」并列而非其子视图,布局也照搬它:左边账号列表、右边数据面板。 + * 两者关注的东西不同 —— 监控抓公开数据(点赞/收藏/评论/分享)的每轮差分, + * 这里是创作者后台的运营指标(曝光/观看/完播率/涨粉),凭据与采集方式都不同。 */ export function OperationView() { const { platform, capability } = useCurrentPlatform() const [selectedId, setSelectedId] = useState(null) const [addOpen, setAddOpen] = useState(false) + // 有同步在后端跑时列表要自己刷新,否则状态永远是旧的。 + const { data: accounts, isLoading } = useCreatorAccounts(true) // 运营只对小红书成立:它读的是小红书创作者后台。不挡住的话,切到抖音时这里照样 // 列出小红书的账号 —— 看起来像是"抖音账号",其实平台维度根本没参与。 @@ -321,9 +284,7 @@ export function OperationView() {
-

- {label} 没有运营模块 -

+

{label} 没有运营模块

运营读的是创作者后台的数据,目前只接入了小红书

@@ -338,35 +299,63 @@ export function OperationView() { ) } + // 没选过就默认第一个,和监控一样 —— 右栏不该一开始是空的。 + const selected = + accounts?.find((account) => account.id === selectedId) ?? + (accounts && accounts.length > 0 ? accounts[0] : null) + return ( -
-
-
-
- 运营账号 - - {selectedId === null - ? '管理自己的小红书账号,读取创作者后台的曝光/观看/完播等运营数据' - : '点「返回」回到账号列表'} - -
- {selectedId === null && ( +
+
+ {/* 左:账号列表 */} +
+
+ 运营账号 - )} +
+ +
+ {isLoading ? ( +

+ 加载中… +

+ ) : !accounts || accounts.length === 0 ? ( +
+ +

+ 还没有运营账号 +
+ 扫码添加一个 +

+
+ ) : ( + accounts.map((account) => ( + setSelectedId(account.id)} + /> + )) + )} +
- {selectedId === null ? ( - setAddOpen(true)} - /> - ) : ( - setSelectedId(null)} /> - )} + {/* 右:所选账号的数据 */} +
+ {selected ? ( + + ) : ( +
+

+ 从左边选一个账号,或先扫码添加 +

+
+ )} +