From c8b62e526767aa0e76497fc7792d41b04fa734af Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 8 Nov 2026 16:39:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(weibo):=20=E5=A2=9E=E5=8A=A0=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E5=8F=B0=E7=8B=AC=E5=8D=A0=E4=B8=8E=E5=BF=AB=E6=8D=B7?= =?UTF-8?q?=E6=93=8D=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../微博/微博账号池与个人工作台-2026-11-08.md | 18 +++- tests/test_migrations.py | 2 +- tests/test_weibo_accounts.py | 94 ++++++++++++++++++- ...20261108_0073_weibo_workbench_exclusive.py | 54 +++++++++++ web/backend/models.py | 4 + web/backend/routers/weibo.py | 89 ++++++++++++++++-- web/backend/schemas.py | 1 + web/backend/services/weibo_service.py | 21 ++++- web/frontend/src/api/weibo.ts | 10 +- web/frontend/src/pages/WeiboAccountsPage.tsx | 77 ++++++++++++--- 10 files changed, 341 insertions(+), 29 deletions(-) create mode 100644 web/backend/migrations/versions/20261108_0073_weibo_workbench_exclusive.py diff --git a/docs/微博/微博账号池与个人工作台-2026-11-08.md b/docs/微博/微博账号池与个人工作台-2026-11-08.md index a7a3ff1..17df392 100644 --- a/docs/微博/微博账号池与个人工作台-2026-11-08.md +++ b/docs/微博/微博账号池与个人工作台-2026-11-08.md @@ -17,7 +17,7 @@ status: implemented - **账号管理**:全局微博账号池,负责 CK 导入、生命周期、标签、批量操作和资料维护。 - **微博工作台**:每个用户自己的账号子集,只展示自己导入的账号,用于绑定、充值、兑换和日常操作。 -工作台采用斗鱼模式:**账号实体仍然全局唯一,工作台只保存用户与账号的映射**。同一个账号可以被多人分别加入自己的工作台,工作台增删互不影响;底层 CK 刷新后,所有引用该账号的工作台看到同一份最新资料。 +工作台采用斗鱼模式:**账号实体仍然全局唯一,工作台只保存用户与账号的映射**。同一个全局账号同一时间只能被一个用户加入工作台;当前使用人移出后,其他人才可以加入。 ## 2. 数据模型 @@ -33,6 +33,7 @@ weibo_workbench_accounts created_at UNIQUE(user_id, account_id) +UNIQUE(account_id) -- 同一账号同时只能属于一个工作台 ``` 删除账号或用户时,对应工作台映射级联删除,不保留孤儿记录。 @@ -57,7 +58,7 @@ UNIQUE(user_id, account_id) | GET | `/api/weibo/accounts` | `weibo:view` | 全局账号池分页列表 | | GET | `/api/weibo/workbench/accounts` | `weibo:workbench` | 当前用户工作台账号分页列表 | | GET | `/api/weibo/workbench/pool` | `weibo:workbench` | 可加入当前工作台且尚未加入的账号 | -| POST | `/api/weibo/workbench/accounts` | `weibo:workbench` | 批量加入工作台,幂等 | +| POST | `/api/weibo/workbench/accounts` | `weibo:workbench` | 批量加入工作台;已被其他用户占用时返回 409 | | POST | `/api/weibo/workbench/accounts/remove` | `weibo:workbench` | 批量移出工作台,不影响账号池和其他用户 | | POST | `/api/weibo/accounts/batch-tag` | `weibo:manage` | 账号管理页批量设置标签 | @@ -86,12 +87,23 @@ UNIQUE(user_id, account_id) - 复用账号能力,但列表只显示当前用户工作台映射。 - 「导入账号」从账号池选择账号加入。 - 「导入外部 CK」会先写入全局账号池,再自动加入当前工作台。 +- 从账号池只显示当前未被任何用户占用的账号。 - 支持单个/批量移出工作台,不删除账号池数据。 - 绑定二维码、充值、兑换、功能查询继续复用现有账号接口。 ## 6. 数据隔离边界 -- 工作台列表、账号池候选和增删接口都按当前登录用户过滤。 +- 工作台列表和增删接口都按当前登录用户过滤。 +- 账号池候选排除所有已被其他用户占用的账号。 - 加入/移出只操作 `weibo_workbench_accounts`,不修改账号归属或 CK。 - 账号池仍由全局权限控制;客服可以看到账号管理入口,但不能删除账号。 - 外部单协作继续引用全局账号 ID,不受个人工作台选择影响。 + +## 7. CK 重复导入 + +同一微博 UID 在全局池中只允许存在一条账号记录。 + +- 默认重复导入:不覆盖现有 CK,返回 `skipped=1`。 +- 勾选「重复账号时更新 CK」:覆盖现有账号 CK 和最新资料,但**不新建账号**。 +- 若账号已被其他人的工作台占用,其他用户不能更新 CK,必须由当前使用人操作。 +- 无论是否更新,全局账号记录始终只有一条。 diff --git a/tests/test_migrations.py b/tests/test_migrations.py index e31c44b..eb6a16a 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -26,7 +26,7 @@ TESTS_DIR = Path(__file__).resolve().parent PROJECT_ROOT = TESTS_DIR.parent VERSIONS_DIR = PROJECT_ROOT / "web" / "backend" / "migrations" / "versions" -HEAD_REVISION = "20261108_0072" +HEAD_REVISION = "20261108_0073" # 迁移新增、但不声明在模型里的复合查询索引。 EXTRA_INDEXES = ( diff --git a/tests/test_weibo_accounts.py b/tests/test_weibo_accounts.py index 6850d4f..ddcb906 100644 --- a/tests/test_weibo_accounts.py +++ b/tests/test_weibo_accounts.py @@ -30,9 +30,11 @@ from web.backend.routers.weibo import ( enable_account, get_account, import_cookies, + list_account_tags, list_accounts, list_workbench_accounts, list_workbench_pool, + list_workbench_tags, refresh_account, remove_workbench_accounts, update_account, @@ -138,23 +140,42 @@ def stub_client(monkeypatch): monkeypatch.setattr(weibo_service, "WeiboClient", StubWeiboClient) -def test_import_creates_encrypted_account_and_is_idempotent(db, current_user): +def test_import_creates_once_and_updates_existing_only_when_requested(db, current_user): first = import_cookies( WeiboCookieImport(text="SCF=a; SUB=b", tag="运营"), db=db, current=current_user, ) - second = import_cookies( + duplicate = import_cookies( WeiboCookieImport(text="SCF=c; SUB=d", tag="新标签"), db=db, current=current_user, ) assert first.count == 1 - assert second.count == 1 + assert duplicate.count == 0 + assert duplicate.skipped == 1 + assert duplicate.accounts == [] assert db.query(WeiboAccount).count() == 1 account = db.query(WeiboAccount).one() assert account.uid == "4014000001" + assert account.cookie == "SCF=a; SUB=b" + assert account.tag == "运营" + + updated = import_cookies( + WeiboCookieImport( + text="SCF=c; SUB=d", + tag="新标签", + update_existing=True, + ), + db=db, + current=current_user, + ) + + assert updated.count == 1 + assert updated.skipped == 0 + assert db.query(WeiboAccount).count() == 1 + account = db.query(WeiboAccount).one() assert account.cookie == "SCF=c; SUB=d" assert account.tag == "新标签" assert account.ck_check_status == "valid" @@ -162,7 +183,7 @@ def test_import_creates_encrypted_account_and_is_idempotent(db, current_user): assert raw_cookie.startswith("enc:v1:") assert "SUB=d" not in raw_cookie - dumped = second.accounts[0].model_dump() + dumped = updated.accounts[0].model_dump() assert dumped["uid"] == "4014000001" assert dumped["is_bound"] is False assert dumped["weidou_balance"] == 100 @@ -198,6 +219,50 @@ def test_import_reports_payment_access_failure(db, current_user): assert db.query(WeiboAccount).count() == 0 +def test_occupied_workbench_account_cannot_be_updated_by_another_user(db, current_user): + imported_out = import_cookies( + WeiboCookieImport(text="SCF=a; SUB=owner"), + db=db, + current=current_user, + ).accounts[0] + imported = db.query(WeiboAccount).filter(WeiboAccount.id == imported_out.id).one() + original_cookie = imported.cookie + owner = User( + username="weibo-owner", + password_hash="hash", + role="support", + is_active=True, + ) + other = User( + username="weibo-other", + password_hash="hash", + role="support", + is_active=True, + ) + db.add_all([owner, other]) + db.commit() + add_workbench_accounts( + WeiboWorkbenchAccountsRequest(account_ids=[imported.id]), + db=db, + current=owner, + ) + + result = import_cookies( + WeiboCookieImport( + text="SCF=z; SUB=other", + update_existing=True, + ), + db=db, + current=other, + ) + + assert result.count == 0 + assert len(result.errors) == 1 + assert "其他人的工作台占用" in result.errors[0].message + db.refresh(imported) + assert imported.cookie == original_cookie + + def test_refresh_marks_payment_access_failure(db, current_user): account_out = import_cookies( WeiboCookieImport(text="SCF=a; SUB=b", tag=""), @@ -347,6 +412,15 @@ def test_weibo_workbench_is_isolated_per_user(db, current_user): db=db, current=user_a, ) + pool_before = list_workbench_pool(page=1, page_size=20, db=db, current=user_a) + assert [item.uid for item in pool_before["items"]] == [second.uid] + with pytest.raises(HTTPException) as exc_info: + add_workbench_accounts( + WeiboWorkbenchAccountsRequest(account_ids=[first.id]), + db=db, + current=user_b, + ) + assert exc_info.value.status_code == 409 add_workbench_accounts( WeiboWorkbenchAccountsRequest(account_ids=[second.id]), db=db, @@ -357,10 +431,18 @@ def test_weibo_workbench_is_isolated_per_user(db, current_user): page_b = list_workbench_accounts(page=1, page_size=20, db=db, current=user_b) assert [item.uid for item in page_a["items"]] == [first.uid] assert [item.uid for item in page_b["items"]] == [second.uid] + assert ( + list_workbench_accounts(tag="A", page=1, page_size=20, db=db, current=user_a)[ + "total" + ] + == 1 + ) + assert list_workbench_tags(db=db, current=user_a)["tags"] == ["A"] + assert list_account_tags(db=db, current=current_user)["tags"] == ["A", "B"] assert db.query(WeiboWorkbenchAccount).count() == 2 pool_a = list_workbench_pool(page=1, page_size=20, db=db, current=user_a) - assert [item.uid for item in pool_a["items"]] == [second.uid] + assert pool_a["items"] == [] remove_workbench_accounts( WeiboWorkbenchAccountsRequest(account_ids=[first.id]), @@ -375,6 +457,8 @@ def test_weibo_workbench_is_isolated_per_user(db, current_user): list_workbench_accounts(page=1, page_size=20, db=db, current=user_b)["total"] == 1 ) + pool_b = list_workbench_pool(page=1, page_size=20, db=db, current=user_b) + assert [item.uid for item in pool_b["items"]] == [first.uid] def test_batch_tag_updates_selected_accounts(db, current_user): diff --git a/web/backend/migrations/versions/20261108_0073_weibo_workbench_exclusive.py b/web/backend/migrations/versions/20261108_0073_weibo_workbench_exclusive.py new file mode 100644 index 0000000..3e6679c --- /dev/null +++ b/web/backend/migrations/versions/20261108_0073_weibo_workbench_exclusive.py @@ -0,0 +1,54 @@ +"""限制微博账号同时只能属于一个个人工作台。""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "20261108_0073" +down_revision: str | None = "20261108_0072" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + inspector = sa.inspect(op.get_bind()) + if not inspector.has_table("weibo_workbench_accounts"): + return + + # 历史若已有重复占用,保留最早加入的一条,其余释放。 + op.execute( + sa.text( + "DELETE newer FROM weibo_workbench_accounts AS newer " + "JOIN weibo_workbench_accounts AS older " + "ON newer.account_id = older.account_id AND newer.id > older.id" + ) + ) + constraints = { + item["name"] + for item in inspector.get_unique_constraints("weibo_workbench_accounts") + } + if "uq_weibo_workbench_account_owner" not in constraints: + op.create_unique_constraint( + "uq_weibo_workbench_account_owner", + "weibo_workbench_accounts", + ["account_id"], + ) + + +def downgrade() -> None: + inspector = sa.inspect(op.get_bind()) + if not inspector.has_table("weibo_workbench_accounts"): + return + constraints = { + item["name"] + for item in inspector.get_unique_constraints("weibo_workbench_accounts") + } + if "uq_weibo_workbench_account_owner" in constraints: + op.drop_constraint( + "uq_weibo_workbench_account_owner", + "weibo_workbench_accounts", + type_="unique", + ) diff --git a/web/backend/models.py b/web/backend/models.py index 593562b..dadd82e 100644 --- a/web/backend/models.py +++ b/web/backend/models.py @@ -983,6 +983,10 @@ class WeiboWorkbenchAccount(Base): "account_id", name="uq_weibo_workbench_account", ), + UniqueConstraint( + "account_id", + name="uq_weibo_workbench_account_owner", + ), ) diff --git a/web/backend/routers/weibo.py b/web/backend/routers/weibo.py index 85259ed..9308074 100644 --- a/web/backend/routers/weibo.py +++ b/web/backend/routers/weibo.py @@ -7,6 +7,7 @@ import os from fastapi import APIRouter, Depends, HTTPException, Query from loguru import logger from sqlalchemy import or_ +from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session, defer from core.weibo import WeiboAuthError, WeiboError, WeiboExchangeUncertainError @@ -94,6 +95,7 @@ def _apply_account_filters( query, *, search: str, + tag: str, bind_status: str, enabled: bool | None, ): @@ -109,6 +111,9 @@ def _apply_account_filters( WeiboAccount.remark.ilike(pattern), ) ) + tag_value = _query_str(tag) + if tag_value: + query = query.filter(WeiboAccount.tag == tag_value) if isinstance(enabled, bool): query = query.filter(WeiboAccount.is_enabled.is_(enabled)) bind_value = _query_str(bind_status) @@ -192,6 +197,7 @@ def _record_audit_safely( @router.get("/accounts") def list_accounts( search: str = Query(""), + tag: str = Query(""), bind_status: str = Query(""), enabled: bool | None = Query(None), page: int = Query(1, ge=1), @@ -205,6 +211,7 @@ def list_accounts( query = _apply_account_filters( db.query(WeiboAccount), search=search, + tag=tag, bind_status=bind_status, enabled=enabled, ) @@ -214,6 +221,7 @@ def list_accounts( @router.get("/workbench/accounts") def list_workbench_accounts( search: str = Query(""), + tag: str = Query(""), bind_status: str = Query(""), enabled: bool | None = Query(None), page: int = Query(1, ge=1), @@ -235,6 +243,7 @@ def list_workbench_accounts( query = _apply_account_filters( query, search=search, + tag=tag, bind_status=bind_status, enabled=enabled, ) @@ -244,6 +253,7 @@ def list_workbench_accounts( @router.get("/workbench/pool") def list_workbench_pool( search: str = Query(""), + tag: str = Query(""), page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=200), db: Session = Depends(get_db), @@ -252,18 +262,42 @@ def list_workbench_pool( """查看可加入当前用户工作台、且尚未加入的账号池账号。""" _require_weibo_perm(current, "weibo:workbench") - selected = db.query(WeiboWorkbenchAccount.account_id).filter( - WeiboWorkbenchAccount.user_id == current.id - ) + occupied = db.query(WeiboWorkbenchAccount.account_id) query = _apply_account_filters( - db.query(WeiboAccount).filter(WeiboAccount.id.not_in(selected)), + db.query(WeiboAccount).filter(WeiboAccount.id.not_in(occupied)), search=search, + tag=tag, bind_status="", enabled=True, ) return _paginated_accounts(query, page=page, page_size=page_size) +@router.get("/workbench/tags") +def list_workbench_tags( + db: Session = Depends(get_db), + current: User = Depends(get_current_user), +): + """返回当前用户工作台中已有的标签。""" + + _require_weibo_perm(current, "weibo:workbench") + rows = ( + db.query(WeiboAccount.tag) + .join( + WeiboWorkbenchAccount, + WeiboWorkbenchAccount.account_id == WeiboAccount.id, + ) + .filter( + WeiboWorkbenchAccount.user_id == current.id, + WeiboAccount.tag != "", + ) + .distinct() + .order_by(WeiboAccount.tag.asc()) + .all() + ) + return {"tags": [tag for (tag,) in rows if tag]} + + @router.post("/workbench/accounts") def add_workbench_accounts( req: WeiboWorkbenchAccountsRequest, @@ -287,6 +321,20 @@ def add_workbench_accounts( missing = sorted(set(account_ids) - existing_ids) if missing: raise HTTPException(status_code=404, detail=f"账号不存在: {missing}") + taken = { + account_id + for (account_id,) in db.query(WeiboWorkbenchAccount.account_id) + .filter( + WeiboWorkbenchAccount.user_id != current.id, + WeiboWorkbenchAccount.account_id.in_(account_ids), + ) + .all() + } + if taken: + raise HTTPException( + status_code=409, + detail="部分账号已在其他人的工作台中使用,请先让原使用人移出", + ) selected_ids = { account_id for (account_id,) in db.query(WeiboWorkbenchAccount.account_id) @@ -303,7 +351,14 @@ def add_workbench_accounts( if account_id not in selected_ids ] ) - db.commit() + try: + db.commit() + except IntegrityError as exc: + db.rollback() + raise HTTPException( + status_code=409, + detail="账号已被其他工作台占用,请刷新后重试", + ) from exc _record_audit_safely( db, current, @@ -379,6 +434,24 @@ def batch_tag_accounts( return {"updated": len(accounts), "tag": tag, "success": True} +@router.get("/accounts/tags") +def list_account_tags( + db: Session = Depends(get_db), + current: User = Depends(get_current_user), +): + """返回全局账号池已有的标签。""" + + _require_weibo_perm(current, "weibo:view") + rows = ( + db.query(WeiboAccount.tag) + .filter(WeiboAccount.tag != "") + .distinct() + .order_by(WeiboAccount.tag.asc()) + .all() + ) + return {"tags": [tag for (tag,) in rows if tag]} + + @router.get("/accounts/{account_id}", response_model=WeiboAccountOut) def get_account( account_id: int, @@ -482,6 +555,8 @@ def import_cookies( req.text, req.tag, WEIBO_DEFAULT_ACTIVITY, + req.update_existing, + current.id, ) typed_errors = [ WeiboImportError(line=item.line, message=item.message) for item in errors @@ -499,13 +574,13 @@ def import_cookies( "errors": len(errors), }, ) - message = f"成功导入/更新 {len(accounts)} 个微博账号" + message = f"成功导入 {len(accounts)} 个微博账号" if errors: message += f",失败 {len(errors)} 条" if skipped: message += f",跳过 {skipped} 条重复" return WeiboImportResult( - success=bool(accounts), + success=bool(accounts) or (skipped > 0 and not errors), message=message, count=len(accounts), skipped=skipped, diff --git a/web/backend/schemas.py b/web/backend/schemas.py index 2e0c13b..a0d7a1d 100644 --- a/web/backend/schemas.py +++ b/web/backend/schemas.py @@ -468,6 +468,7 @@ class WeiboCookieImport(BaseModel): text: str = Field(..., min_length=1) tag: str = Field("", max_length=64) + update_existing: bool = False class WeiboImportError(BaseModel): diff --git a/web/backend/services/weibo_service.py b/web/backend/services/weibo_service.py index 0317c64..c5f8f0a 100644 --- a/web/backend/services/weibo_service.py +++ b/web/backend/services/weibo_service.py @@ -18,7 +18,7 @@ from core.weibo import ( normalize_weibo_cookie, ) -from ..models import WeiboAccount +from ..models import WeiboAccount, WeiboWorkbenchAccount @dataclass(frozen=True, slots=True) @@ -61,6 +61,8 @@ def import_weibo_cookies( text: str, tag: str = "", activity: str = "", + update_existing: bool = False, + current_user_id: int | None = None, ) -> tuple[list[WeiboAccount], list[WeiboImportIssue], int]: """逐行验证并导入微博 CK。 @@ -95,6 +97,23 @@ def import_weibo_cookies( account = ( db.query(WeiboAccount).filter(WeiboAccount.uid == snapshot.uid).first() ) + if account is not None and update_existing and current_user_id is not None: + owner_id = ( + db.query(WeiboWorkbenchAccount.user_id) + .filter(WeiboWorkbenchAccount.account_id == account.id) + .scalar() + ) + if owner_id is not None and owner_id != current_user_id: + errors.append( + WeiboImportIssue( + line=line_no, + message="该账号已被其他人的工作台占用,不能更新 CK", + ) + ) + continue + if account is not None and not update_existing: + skipped += 1 + continue if account is None: account = WeiboAccount(uid=snapshot.uid, cookie=cookie) db.add(account) diff --git a/web/frontend/src/api/weibo.ts b/web/frontend/src/api/weibo.ts index 5a334bc..8a46e1c 100644 --- a/web/frontend/src/api/weibo.ts +++ b/web/frontend/src/api/weibo.ts @@ -19,6 +19,7 @@ import type { export const weiboApi = { listAccounts: (params: { search?: string; + tag?: string; bind_status?: string; enabled?: boolean; page: number; @@ -30,10 +31,11 @@ export const weiboApi = { ), getAccount: (accountId: number) => api.get(`/weibo/accounts/${accountId}`), - importCookies: (text: string, tag = '') => + importCookies: (text: string, tag = '', updateExisting = false) => api.post('/weibo/accounts/import-cookies', { text, tag, + update_existing: updateExisting, }), updateAccount: ( accountId: number, @@ -67,8 +69,13 @@ export const weiboApi = { '/weibo/accounts/batch-tag', { ids, tag }, ), + accountTags: () => + api.get<{ tags: string[] }, { tags: string[] }>('/weibo/accounts/tags'), + workbenchTags: () => + api.get<{ tags: string[] }, { tags: string[] }>('/weibo/workbench/tags'), listWorkbenchAccounts: (params: { search?: string; + tag?: string; bind_status?: string; enabled?: boolean; page: number; @@ -80,6 +87,7 @@ export const weiboApi = { ), listWorkbenchPool: (params: { search?: string; + tag?: string; page: number; page_size: number; }) => diff --git a/web/frontend/src/pages/WeiboAccountsPage.tsx b/web/frontend/src/pages/WeiboAccountsPage.tsx index 746eda7..fbc6822 100644 --- a/web/frontend/src/pages/WeiboAccountsPage.tsx +++ b/web/frontend/src/pages/WeiboAccountsPage.tsx @@ -3,6 +3,7 @@ import { Alert, AutoComplete, Button, + Checkbox, Descriptions, Drawer, Empty, @@ -137,6 +138,8 @@ export default function WeiboAccountsPage({ const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(20); const [search, setSearch] = useState(''); + const [tagFilter, setTagFilter] = useState(''); + const [knownTags, setKnownTags] = useState([]); const [bindFilter, setBindFilter] = useState(''); const [enabledFilter, setEnabledFilter] = useState(''); const [loading, setLoading] = useState(false); @@ -161,7 +164,11 @@ export default function WeiboAccountsPage({ const [importOpen, setImportOpen] = useState(false); const [importing, setImporting] = useState(false); const [importErrors, setImportErrors] = useState([]); - const [form] = Form.useForm<{ text: string; tag?: string }>(); + const [form] = Form.useForm<{ + text: string; + tag?: string; + update_existing?: boolean; + }>(); const [editForm] = Form.useForm<{ tag?: string; remark?: string }>(); const [editOpen, setEditOpen] = useState(false); const [editAccount, setEditAccount] = useState(null); @@ -199,6 +206,7 @@ export default function WeiboAccountsPage({ try { const params = { search: search.trim() || undefined, + tag: tagFilter || undefined, bind_status: bindFilter || undefined, enabled: enabledFilter === 'enabled' @@ -222,17 +230,36 @@ export default function WeiboAccountsPage({ } finally { setLoading(false); } - }, [bindFilter, enabledFilter, isWorkbench, page, pageSize, search]); + }, [bindFilter, enabledFilter, isWorkbench, page, pageSize, search, tagFilter]); + + const loadKnownTags = useCallback(async () => { + try { + const result = isWorkbench + ? await weiboApi.workbenchTags() + : await weiboApi.accountTags(); + setKnownTags(result.tags); + } catch { + // 标签字典失败不影响账号列表;筛选选项退化为空。 + } + }, [isWorkbench]); useEffect(() => { void loadAccounts(); }, [loadAccounts]); + useEffect(() => { + void loadKnownTags(); + }, [loadKnownTags]); + const handleImport = async () => { const values = await form.validateFields(); setImporting(true); try { - const result = await weiboApi.importCookies(values.text, values.tag || ''); + const result = await weiboApi.importCookies( + values.text, + values.tag || '', + Boolean(values.update_existing), + ); setImportErrors(result.errors); let workbenchError = ''; if (isWorkbench && result.accounts.length > 0) { @@ -253,11 +280,15 @@ export default function WeiboAccountsPage({ message.success(result.message); } await loadAccounts(); + await loadKnownTags(); } if (result.errors.length === 0 && result.count > 0) { setImportOpen(false); form.resetFields(); - } else if (result.count === 0) { + } else if (result.skipped > 0) { + message.warning(result.message); + await loadAccounts(); + } else { message.error(result.message); } } catch (error) { @@ -351,6 +382,7 @@ export default function WeiboAccountsPage({ setBatchTagValue(''); setSelectedIds([]); await loadAccounts(); + await loadKnownTags(); message.success(`已为 ${result.updated} 个账号设置标签`); } catch (error) { message.error(getErrorMessage(error)); @@ -819,7 +851,14 @@ export default function WeiboAccountsPage({ width: isWorkbench ? 90 : 120, align: 'center', onHeaderCell: centerHeader, - render: (value: string) => value || -, + render: (value: string) => + value ? ( + + {value} + + ) : ( + - + ), }, { title: 'CK', @@ -1176,11 +1215,8 @@ export default function WeiboAccountsPage({ workbenchSelectedGoods.price > (mallResult?.weidou_balance ?? featureAccount?.weidou_balance ?? 0); const tagOptions = useMemo( - () => - [...new Set(items.map((item) => item.tag.trim()).filter(Boolean))].map( - (tag) => ({ value: tag }), - ), - [items], + () => knownTags.map((tag) => ({ value: tag })), + [knownTags], ); return ( @@ -1216,6 +1252,17 @@ export default function WeiboAccountsPage({ }} style={{ width: 240 }} /> + { @@ -1257,6 +1304,8 @@ export default function WeiboAccountsPage({ disabled={!canImport} onClick={() => { setImportErrors([]); + form.resetFields(); + form.setFieldsValue({ update_existing: false }); setImportOpen(true); }} > @@ -1736,7 +1785,8 @@ export default function WeiboAccountsPage({ title: '标签', dataIndex: 'tag', width: 120, - render: (value: string) => value || '-', + render: (value: string) => + value ? {value} : '-', }, { title: 'CK', @@ -1787,6 +1837,11 @@ export default function WeiboAccountsPage({ + + + 重复账号时更新 CK(仍只有一条全局账号,不新建) + + {importErrors.length > 0 && (