feat(weibo): 增加账号分配与客服数据隔离

This commit is contained in:
yml2213
2026-11-08 18:30:51 +08:00
parent c8b62e5267
commit 6b2285913f
16 changed files with 1320 additions and 55 deletions
@@ -0,0 +1,59 @@
"""增加微博账号归属客服。"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "20261108_0074"
down_revision: str | None = "20261108_0073"
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_accounts"):
return
columns = {column["name"] for column in inspector.get_columns("weibo_accounts")}
if "assigned_to" not in columns:
op.add_column(
"weibo_accounts",
sa.Column("assigned_to", sa.Integer(), nullable=True),
)
op.create_foreign_key(
"fk_weibo_accounts_assigned_to_users",
"weibo_accounts",
"users",
["assigned_to"],
["id"],
ondelete="SET NULL",
)
indexes = {index["name"] for index in inspector.get_indexes("weibo_accounts")}
if "ix_weibo_accounts_assigned_to" not in indexes:
op.create_index(
"ix_weibo_accounts_assigned_to",
"weibo_accounts",
["assigned_to"],
)
def downgrade() -> None:
inspector = sa.inspect(op.get_bind())
if not inspector.has_table("weibo_accounts"):
return
foreign_keys = {fk["name"] for fk in inspector.get_foreign_keys("weibo_accounts")}
if "fk_weibo_accounts_assigned_to_users" in foreign_keys:
op.drop_constraint(
"fk_weibo_accounts_assigned_to_users",
"weibo_accounts",
type_="foreignkey",
)
indexes = {index["name"] for index in inspector.get_indexes("weibo_accounts")}
if "ix_weibo_accounts_assigned_to" in indexes:
op.drop_index("ix_weibo_accounts_assigned_to", table_name="weibo_accounts")
columns = {column["name"] for column in inspector.get_columns("weibo_accounts")}
if "assigned_to" in columns:
op.drop_column("weibo_accounts", "assigned_to")
+10
View File
@@ -934,6 +934,11 @@ class WeiboAccount(Base):
String(256), nullable=False, default="", server_default=""
)
activity: Mapped[str] = mapped_column(String(64), default="")
assigned_to: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
is_enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True, server_default="1", index=True
@@ -960,6 +965,11 @@ class WeiboAccount(Base):
UTCDateTime, default=_utcnow, onupdate=_utcnow
)
assigned_user: Mapped[User | None] = relationship(
"User",
foreign_keys=[assigned_to],
)
class WeiboWorkbenchAccount(Base):
"""用户个人微博工作台中的账号选择。"""
+2
View File
@@ -44,6 +44,7 @@ PERMISSIONS = {
# 微博微豆
"weibo:view": "查看微博账号",
"weibo:workbench": "使用个人微博工作台",
"weibo:assign": "查看全部微博账号并分配客服",
"weibo:import": "导入/刷新微博 CK",
"weibo:manage": "编辑、停用和启用微博账号",
"weibo:account:ck_export": "复制微博外部 CK 账号的 CK(写剪贴板)",
@@ -116,6 +117,7 @@ ROLE_PERMISSIONS = {
"yyb:history",
"weibo:view",
"weibo:workbench",
"weibo:assign",
"weibo:import",
"weibo:manage",
"weibo:account:ck_export",
+243 -29
View File
@@ -6,9 +6,9 @@ import os
from fastapi import APIRouter, Depends, HTTPException, Query
from loguru import logger
from sqlalchemy import or_
from sqlalchemy import func, or_
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session, defer
from sqlalchemy.orm import Session, defer, joinedload
from core.weibo import WeiboAuthError, WeiboError, WeiboExchangeUncertainError
@@ -22,6 +22,7 @@ from ..schemas import (
WeiboAccountDisableRequest,
WeiboAccountOut,
WeiboAccountUpdate,
WeiboBatchAssignRequest,
WeiboBindQrOut,
WeiboBindSchemeOut,
WeiboCookieImport,
@@ -80,15 +81,38 @@ def _int_or_none(value: object) -> int | None:
return None
def _get_account(db: Session, account_id: int) -> WeiboAccount:
account = db.query(WeiboAccount).filter(WeiboAccount.id == account_id).first()
def _can_view_all_weibo_accounts(user: User) -> bool:
return user_has_permission(user, "weibo:assign")
def _visible_weibo_accounts_query(query, current: User):
if _can_view_all_weibo_accounts(current):
return query
return query.filter(WeiboAccount.assigned_to == current.id)
def _get_account(
db: Session,
account_id: int,
current: User | None = None,
) -> WeiboAccount:
query = db.query(WeiboAccount).filter(WeiboAccount.id == account_id)
if current is not None:
query = _visible_weibo_accounts_query(query, current)
account = query.first()
if account is None:
raise HTTPException(status_code=404, detail="微博账号不存在")
return account
def _account_out(account: WeiboAccount) -> WeiboAccountOut:
return WeiboAccountOut.model_validate(account)
return WeiboAccountOut.model_validate(account).model_copy(
update={
"assigned_username": (
account.assigned_user.username if account.assigned_user else ""
)
}
)
def _apply_account_filters(
@@ -98,6 +122,8 @@ def _apply_account_filters(
tag: str,
bind_status: str,
enabled: bool | None,
assign_status: str = "",
assigned_user_id: int | None = None,
):
search_text = _query_str(search)
if search_text:
@@ -114,6 +140,14 @@ def _apply_account_filters(
tag_value = _query_str(tag)
if tag_value:
query = query.filter(WeiboAccount.tag == tag_value)
assign_value = _query_str(assign_status)
if assign_value == "unassigned":
query = query.filter(WeiboAccount.assigned_to.is_(None))
elif assign_value == "assigned":
query = query.filter(WeiboAccount.assigned_to.isnot(None))
target_user_id = _int_or_none(assigned_user_id)
if target_user_id is not None:
query = query.filter(WeiboAccount.assigned_to == target_user_id)
if isinstance(enabled, bool):
query = query.filter(WeiboAccount.is_enabled.is_(enabled))
bind_value = _query_str(bind_status)
@@ -132,7 +166,10 @@ def _apply_account_filters(
def _paginated_accounts(query, *, page: int, page_size: int) -> dict:
total = query.order_by(None).count()
rows = (
query.options(defer(WeiboAccount.cookie))
query.options(
joinedload(WeiboAccount.assigned_user),
defer(WeiboAccount.cookie),
)
.order_by(WeiboAccount.id.desc())
.offset((page - 1) * page_size)
.limit(page_size)
@@ -200,6 +237,8 @@ def list_accounts(
tag: str = Query(""),
bind_status: str = Query(""),
enabled: bool | None = Query(None),
assign_status: str = Query(""),
assigned_user_id: int | None = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=200),
db: Session = Depends(get_db),
@@ -209,11 +248,13 @@ def list_accounts(
_require_weibo_perm(current, "weibo:view")
query = _apply_account_filters(
db.query(WeiboAccount),
_visible_weibo_accounts_query(db.query(WeiboAccount), current),
search=search,
tag=tag,
bind_status=bind_status,
enabled=enabled,
assign_status=assign_status,
assigned_user_id=assigned_user_id,
)
return _paginated_accounts(query, page=page, page_size=page_size)
@@ -240,6 +281,8 @@ def list_workbench_accounts(
)
.filter(WeiboWorkbenchAccount.user_id == current.id)
)
if not _can_view_all_weibo_accounts(current):
query = query.filter(WeiboAccount.assigned_to == current.id)
query = _apply_account_filters(
query,
search=search,
@@ -263,8 +306,11 @@ def list_workbench_pool(
_require_weibo_perm(current, "weibo:workbench")
occupied = db.query(WeiboWorkbenchAccount.account_id)
pool_query = db.query(WeiboAccount).filter(WeiboAccount.id.not_in(occupied))
if not _can_view_all_weibo_accounts(current):
pool_query = pool_query.filter(WeiboAccount.assigned_to == current.id)
query = _apply_account_filters(
db.query(WeiboAccount).filter(WeiboAccount.id.not_in(occupied)),
pool_query,
search=search,
tag=tag,
bind_status="",
@@ -281,7 +327,7 @@ def list_workbench_tags(
"""返回当前用户工作台中已有的标签。"""
_require_weibo_perm(current, "weibo:workbench")
rows = (
tag_query = (
db.query(WeiboAccount.tag)
.join(
WeiboWorkbenchAccount,
@@ -291,10 +337,10 @@ def list_workbench_tags(
WeiboWorkbenchAccount.user_id == current.id,
WeiboAccount.tag != "",
)
.distinct()
.order_by(WeiboAccount.tag.asc())
.all()
)
if not _can_view_all_weibo_accounts(current):
tag_query = tag_query.filter(WeiboAccount.assigned_to == current.id)
rows = tag_query.distinct().order_by(WeiboAccount.tag.asc()).all()
return {"tags": [tag for (tag,) in rows if tag]}
@@ -321,6 +367,24 @@ def add_workbench_accounts(
missing = sorted(set(account_ids) - existing_ids)
if missing:
raise HTTPException(status_code=404, detail=f"账号不存在: {missing}")
if not _can_view_all_weibo_accounts(current):
unavailable = {
account_id
for (account_id,) in db.query(WeiboAccount.id)
.filter(
WeiboAccount.id.in_(account_ids),
or_(
WeiboAccount.assigned_to.is_(None),
WeiboAccount.assigned_to != current.id,
),
)
.all()
}
if unavailable:
raise HTTPException(
status_code=403,
detail="只能使用分配给自己的微博账号",
)
taken = {
account_id
for (account_id,) in db.query(WeiboWorkbenchAccount.account_id)
@@ -410,7 +474,11 @@ def batch_tag_accounts(
account_ids = sorted(set(req.ids))
if not account_ids:
raise HTTPException(status_code=400, detail="请先选择账号")
accounts = db.query(WeiboAccount).filter(WeiboAccount.id.in_(account_ids)).all()
accounts = (
_visible_weibo_accounts_query(db.query(WeiboAccount), current)
.filter(WeiboAccount.id.in_(account_ids))
.all()
)
actual_ids = {account.id for account in accounts}
missing = sorted(set(account_ids) - actual_ids)
if missing:
@@ -434,6 +502,109 @@ def batch_tag_accounts(
return {"updated": len(accounts), "tag": tag, "success": True}
@router.post("/accounts/batch-assign")
def batch_assign_accounts(
req: WeiboBatchAssignRequest,
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""批量分配微博账号给客服,或取消分配。"""
_require_weibo_perm(current, "weibo:assign")
account_ids = sorted(set(req.account_ids))
if not account_ids:
raise HTTPException(status_code=400, detail="请先选择账号")
accounts = db.query(WeiboAccount).filter(WeiboAccount.id.in_(account_ids)).all()
actual_ids = {account.id for account in accounts}
missing = sorted(set(account_ids) - actual_ids)
if missing:
raise HTTPException(status_code=404, detail=f"账号不存在: {missing}")
target = None
if req.assigned_to is not None:
target = (
db.query(User)
.filter(
User.id == req.assigned_to,
User.role == "support",
User.is_active.is_(True),
)
.first()
)
if target is None:
raise HTTPException(status_code=404, detail="目标客服不存在或已停用")
previous_assignments = {account.id: account.assigned_to for account in accounts}
if target is None:
db.query(WeiboWorkbenchAccount).filter(
WeiboWorkbenchAccount.account_id.in_(actual_ids)
).delete(synchronize_session=False)
else:
db.query(WeiboWorkbenchAccount).filter(
WeiboWorkbenchAccount.account_id.in_(actual_ids),
WeiboWorkbenchAccount.user_id != target.id,
).delete(synchronize_session=False)
for account in accounts:
account.assigned_to = target.id if target else None
db.commit()
target_name = target.username if target else ""
_record_audit_safely(
db,
current,
action="weibo:account:assign",
target=f"weibo_accounts:{len(account_ids)}",
detail={
"account_ids": account_ids,
"assigned_to": target.id if target else None,
"assigned_username": target_name,
"previous_assignments": previous_assignments,
},
)
return {
"updated": len(accounts),
"assigned_to": target.id if target else None,
"assigned_username": target_name,
"success": True,
}
@router.get("/assignments/summary")
def assignments_summary(
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""微博账号分配概览。"""
_require_weibo_perm(current, "weibo:assign")
rows = (
db.query(User.id, User.username, func.count(WeiboAccount.id))
.outerjoin(WeiboAccount, WeiboAccount.assigned_to == User.id)
.filter(
User.role == "support",
User.is_active.is_(True),
)
.group_by(User.id, User.username)
.order_by(User.username.asc())
.all()
)
users = [
{
"id": user_id,
"username": username,
"assigned_count": assigned_count,
}
for user_id, username, assigned_count in rows
]
total_count = db.query(WeiboAccount).count()
unassigned_count = (
db.query(WeiboAccount).filter(WeiboAccount.assigned_to.is_(None)).count()
)
return {
"total_count": total_count,
"assigned_count": total_count - unassigned_count,
"unassigned_count": unassigned_count,
"support_users": users,
}
@router.get("/accounts/tags")
def list_account_tags(
db: Session = Depends(get_db),
@@ -442,9 +613,9 @@ def list_account_tags(
"""返回全局账号池已有的标签。"""
_require_weibo_perm(current, "weibo:view")
tag_query = _visible_weibo_accounts_query(db.query(WeiboAccount.tag), current)
rows = (
db.query(WeiboAccount.tag)
.filter(WeiboAccount.tag != "")
tag_query.filter(WeiboAccount.tag != "")
.distinct()
.order_by(WeiboAccount.tag.asc())
.all()
@@ -452,6 +623,38 @@ def list_account_tags(
return {"tags": [tag for (tag,) in rows if tag]}
@router.get("/accounts/ids")
def list_account_ids(
search: str = Query(""),
tag: str = Query(""),
bind_status: str = Query(""),
enabled: bool | None = Query(None),
assign_status: str = Query(""),
assigned_user_id: int | None = Query(None),
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""返回当前用户可见且符合筛选条件的全部微博账号 ID。"""
_require_weibo_perm(current, "weibo:view")
query = _apply_account_filters(
_visible_weibo_accounts_query(db.query(WeiboAccount), current),
search=search,
tag=tag,
bind_status=bind_status,
enabled=enabled,
assign_status=assign_status,
assigned_user_id=assigned_user_id,
)
account_ids = [
account_id
for (account_id,) in query.with_entities(WeiboAccount.id)
.order_by(WeiboAccount.id.desc())
.all()
]
return {"account_ids": account_ids, "total": len(account_ids)}
@router.get("/accounts/{account_id}", response_model=WeiboAccountOut)
def get_account(
account_id: int,
@@ -461,7 +664,7 @@ def get_account(
"""查看一个微博账号的本地资料。"""
_require_weibo_perm(current, "weibo:view")
return _account_out(_get_account(db, account_id))
return _account_out(_get_account(db, account_id, current))
@router.put("/accounts/{account_id}", response_model=WeiboAccountOut)
@@ -474,7 +677,7 @@ def update_account(
"""更新账号的人工标签和备注。"""
_require_weibo_perm(current, "weibo:manage")
account = _get_account(db, account_id)
account = _get_account(db, account_id, current)
payload = req.model_dump(exclude_unset=True)
changes = update_weibo_account_metadata(
db,
@@ -503,7 +706,7 @@ def disable_account(
"""停用账号,保留 CK 和历史资料。"""
_require_weibo_perm(current, "weibo:manage")
account = _get_account(db, account_id)
account = _get_account(db, account_id, current)
disable_weibo_account(db, account, reason=req.reason)
_record_audit_safely(
db,
@@ -528,7 +731,7 @@ def enable_account(
"""重新检测 CK 与支付风险,通过后启用账号。"""
_require_weibo_perm(current, "weibo:manage")
account = _get_account(db, account_id)
account = _get_account(db, account_id, current)
_refresh_or_http(account, db)
enable_weibo_account(db, account)
_record_audit_safely(
@@ -557,10 +760,21 @@ def import_cookies(
WEIBO_DEFAULT_ACTIVITY,
req.update_existing,
current.id,
can_update_all=_can_view_all_weibo_accounts(current),
)
typed_errors = [
WeiboImportError(line=item.line, message=item.message) for item in errors
]
if accounts and not _can_view_all_weibo_accounts(current):
changed = False
for account in accounts:
if account.assigned_to != current.id:
account.assigned_to = current.id
changed = True
if changed:
db.commit()
for account in accounts:
db.refresh(account)
if accounts:
_record_audit_safely(
db,
@@ -598,7 +812,7 @@ def refresh_account(
"""立即刷新一个微博账号的 CK 状态和资料。"""
_require_weibo_perm(current, "weibo:view")
account = _get_account(db, account_id)
account = _get_account(db, account_id, current)
_ensure_enabled(account)
_refresh_or_http(account, db)
_record_audit_safely(
@@ -620,7 +834,7 @@ def copy_account_ck(
"""复制已导入的微博 CK 明文;只走单独权限并保留审计。"""
_require_weibo_perm(current, "weibo:account:ck_export")
account = _get_account(db, account_id)
account = _get_account(db, account_id, current)
ck_text = (account.cookie or "").strip()
if not ck_text:
raise HTTPException(status_code=409, detail="该账号没有保存 CK")
@@ -649,7 +863,7 @@ def create_bind_qrcodes(
_require_weibo_perm(current, "weibo:bind")
selected_channel = channel.strip() if isinstance(channel, str) else ""
account = _get_account(db, account_id)
account = _get_account(db, account_id, current)
_ensure_enabled(account)
snapshot = _refresh_or_http(account, db)
if snapshot.is_bound and not snapshot.can_rebind:
@@ -726,7 +940,7 @@ def list_mall_goods(
"""手动查询微豆商城商品列表。"""
_require_weibo_perm(current, "weibo:view")
account = _get_account(db, account_id)
account = _get_account(db, account_id, current)
_ensure_enabled(account)
try:
data = query_weibo_mall(
@@ -779,7 +993,7 @@ def exchange_goods(
_require_weibo_perm(current, "weibo:exchange")
if not req.confirm:
raise HTTPException(status_code=400, detail="请先确认兑换商品")
account = _get_account(db, account_id)
account = _get_account(db, account_id, current)
_ensure_enabled(account)
try:
result = exchange_weibo_goods(
@@ -837,7 +1051,7 @@ def list_exchange_records(
"""手动查询微豆兑换记录。"""
_require_weibo_perm(current, "weibo:view")
account = _get_account(db, account_id)
account = _get_account(db, account_id, current)
_ensure_enabled(account)
try:
data = query_weibo_exchange_records(
@@ -886,7 +1100,7 @@ def list_point_history(
"""手动查询微豆获取和充值明细。"""
_require_weibo_perm(current, "weibo:view")
account = _get_account(db, account_id)
account = _get_account(db, account_id, current)
_ensure_enabled(account)
try:
data = query_weibo_point_history(
@@ -936,7 +1150,7 @@ def create_recharge_order(
"""手动创建助威充值订单并返回支付二维码。"""
_require_weibo_perm(current, "weibo:recharge")
account = _get_account(db, account_id)
account = _get_account(db, account_id, current)
_ensure_enabled(account)
try:
order = create_weibo_recharge_order(
@@ -984,7 +1198,7 @@ def get_recharge_status(
"""手动查询支付状态;支付动作仍由用户在微信/支付宝完成。"""
_require_weibo_perm(current, "weibo:recharge")
account = _get_account(db, account_id)
account = _get_account(db, account_id, current)
_ensure_enabled(account)
try:
status = query_weibo_payment_status(db, account, pay_id=req.pay_id)
@@ -1007,7 +1221,7 @@ def delete_account(
"""删除一个微博账号。"""
_require_weibo_perm(current, "weibo:delete")
account = _get_account(db, account_id)
account = _get_account(db, account_id, current)
uid = account.uid
db.delete(account)
db.commit()
+7
View File
@@ -483,6 +483,8 @@ class WeiboAccountOut(BaseModel):
tag: str = ""
remark: str = ""
activity: str = ""
assigned_to: int | None = None
assigned_username: str = ""
is_enabled: bool = True
disabled_at: datetime | None = None
disabled_reason: str = ""
@@ -515,6 +517,11 @@ class WeiboWorkbenchAccountsRequest(BaseModel):
account_ids: list[int] = Field(default_factory=list)
class WeiboBatchAssignRequest(BaseModel):
account_ids: list[int] = Field(min_length=1)
assigned_to: int | None = None
class WeiboAccountDisableRequest(BaseModel):
reason: str = Field("", max_length=256)
+13
View File
@@ -63,6 +63,7 @@ def import_weibo_cookies(
activity: str = "",
update_existing: bool = False,
current_user_id: int | None = None,
can_update_all: bool = False,
) -> tuple[list[WeiboAccount], list[WeiboImportIssue], int]:
"""逐行验证并导入微博 CK。
@@ -98,6 +99,18 @@ def import_weibo_cookies(
db.query(WeiboAccount).filter(WeiboAccount.uid == snapshot.uid).first()
)
if account is not None and update_existing and current_user_id is not None:
if (
not can_update_all
and account.assigned_to is not None
and account.assigned_to != current_user_id
):
errors.append(
WeiboImportIssue(
line=line_no,
message="该账号已分配给其他客服,不能更新 CK",
)
)
continue
owner_id = (
db.query(WeiboWorkbenchAccount.user_id)
.filter(WeiboWorkbenchAccount.account_id == account.id)
+2
View File
@@ -32,6 +32,7 @@ const HuyaElitePage = lazy(() => import('./pages/HuyaElitePage'));
const HuyaCollabPage = lazy(() => import('./pages/HuyaCollabPage'));
const HuyaBrowserExchangePage = lazy(() => import('./pages/HuyaBrowserExchangePage'));
const WeiboAccountsPage = lazy(() => import('./pages/WeiboAccountsPage'));
const WeiboAssignmentsPage = lazy(() => import('./pages/WeiboAssignmentsPage'));
const WeiboCollabPage = lazy(() => import('./pages/WeiboCollabPage'));
const YybRechargePage = lazy(() => import('./pages/YybRechargePage'));
const AuditLogsPage = lazy(() => import('./pages/AuditLogsPage'));
@@ -108,6 +109,7 @@ function AppContent() {
<Route path="huya/collab" element={lazyRoute(<HuyaCollabPage />)} />
<Route path="huya/browser-exchange" element={lazyRoute(<HuyaBrowserExchangePage />)} />
<Route path="weibo/accounts" element={lazyRoute(<WeiboAccountsPage mode="management" />)} />
<Route path="weibo/assignments" element={lazyRoute(<WeiboAssignmentsPage />)} />
<Route path="weibo/workbench" element={lazyRoute(<WeiboAccountsPage mode="workbench" />)} />
<Route path="weibo/collab" element={lazyRoute(<WeiboCollabPage />)} />
<Route path="yyb/recharge" element={lazyRoute(<YybRechargePage />)} />
+2
View File
@@ -754,6 +754,8 @@ export interface WeiboAccountItem {
nickname: string;
tag: string;
remark: string;
assigned_to: number | null;
assigned_username: string;
is_enabled: boolean;
disabled_at: string | null;
disabled_reason: string;
+22
View File
@@ -1,5 +1,6 @@
import api from './client';
import type {
AssignmentsSummary,
MessageResponse,
PaginatedResponse,
WeiboAccountItem,
@@ -22,6 +23,8 @@ export const weiboApi = {
tag?: string;
bind_status?: string;
enabled?: boolean;
assign_status?: string;
assigned_user_id?: number;
page: number;
page_size: number;
}) =>
@@ -29,6 +32,18 @@ export const weiboApi = {
'/weibo/accounts',
{ params },
),
listAccountIds: (params: {
search?: string;
tag?: string;
bind_status?: string;
enabled?: boolean;
assign_status?: string;
assigned_user_id?: number;
}) =>
api.get<{ account_ids: number[]; total: number }, { account_ids: number[]; total: number }>(
'/weibo/accounts/ids',
{ params },
),
getAccount: (accountId: number) =>
api.get<WeiboAccountItem, WeiboAccountItem>(`/weibo/accounts/${accountId}`),
importCookies: (text: string, tag = '', updateExisting = false) =>
@@ -69,6 +84,13 @@ export const weiboApi = {
'/weibo/accounts/batch-tag',
{ ids, tag },
),
batchAssign: (accountIds: number[], assignedTo: number | null) =>
api.post<MessageResponse, MessageResponse>('/weibo/accounts/batch-assign', {
account_ids: accountIds,
assigned_to: assignedTo,
}),
assignmentsSummary: () =>
api.get<AssignmentsSummary, AssignmentsSummary>('/weibo/assignments/summary'),
accountTags: () =>
api.get<{ tags: string[] }, { tags: string[] }>('/weibo/accounts/tags'),
workbenchTags: () =>
+3
View File
@@ -128,6 +128,9 @@ export default function MainLayout({ onLogout }: { onLogout?: () => void }) {
if (can('weibo:view')) {
weiboItems.push({ key: '/weibo/accounts', label: '账号管理', icon: <UserOutlined /> });
}
if (can('weibo:assign')) {
weiboItems.push({ key: '/weibo/assignments', label: '分配管理', icon: <SwapOutlined /> });
}
if (can('weibo:workbench')) {
weiboItems.push({ key: '/weibo/workbench', label: '微博工作台', icon: <DesktopOutlined /> });
}
+42 -8
View File
@@ -132,6 +132,7 @@ export default function WeiboAccountsPage({
const canRecharge = can('weibo:recharge');
const canExchange = can('weibo:exchange');
const canDelete = can('weibo:delete');
const canAssign = can('weibo:assign');
const [items, setItems] = useState<WeiboAccountItem[]>([]);
const [total, setTotal] = useState(0);
@@ -860,6 +861,23 @@ export default function WeiboAccountsPage({
<Text type="secondary">-</Text>
),
},
...(canAssign
? [
{
title: '归属客服',
dataIndex: 'assigned_username',
width: 120,
align: 'center' as const,
onHeaderCell: centerHeader,
render: (value: string) =>
value ? (
<Tag color="cyan">{value}</Tag>
) : (
<Text type="secondary">未分配</Text>
),
},
]
: []),
{
title: 'CK',
key: 'ck',
@@ -1237,7 +1255,9 @@ export default function WeiboAccountsPage({
<Text type="secondary">
{isWorkbench
? '当前账号独立使用的微博账号、角色绑定、充值与兑换'
: '全局账号池、账号生命周期、批量操作、微豆资料和角色绑定'}
: canAssign
? '全局账号池、归属分配、账号生命周期、批量操作和角色绑定'
: '仅显示分配给你的微博账号,可进行绑定、充值与兑换'}
</Text>
</div>
<Space wrap>
@@ -1484,16 +1504,30 @@ export default function WeiboAccountsPage({
>
{featureAccount ? (
<Space orientation="vertical" size={12} style={{ width: '100%' }}>
<div>
<Text strong style={{ fontSize: 16 }}>
{featureAccount.nickname || featureAccount.uid}
</Text>
<Space
align="start"
style={{ width: '100%', justifyContent: 'space-between' }}
>
<div>
<Text type="secondary" style={{ fontSize: 12 }}>
UID {featureAccount.uid}
<Text strong style={{ fontSize: 16 }}>
{featureAccount.nickname || featureAccount.uid}
</Text>
<div>
<Text type="secondary" style={{ fontSize: 12 }}>
UID {featureAccount.uid}
</Text>
</div>
</div>
</div>
<Button
size="small"
icon={<ReloadOutlined />}
loading={refreshingId === featureAccount.id}
disabled={!featureAccount.is_enabled}
onClick={() => void handleRefresh(featureAccount)}
>
刷新
</Button>
</Space>
<div
style={{
width: '100%',
@@ -0,0 +1,625 @@
import { useCallback, useEffect, useState } from 'react';
import {
Badge,
Button,
Card,
Col,
Input,
Popconfirm,
Row,
Select,
Space,
Statistic,
Table,
Tabs,
Tag,
Tooltip,
Typography,
theme,
} from 'antd';
import type { TableProps } from 'antd';
import {
CheckCircleOutlined,
ClearOutlined,
ReloadOutlined,
SwapOutlined,
TeamOutlined,
UserOutlined,
UsergroupAddOutlined,
} from '@ant-design/icons';
import {
weiboApi,
type AssignmentsSummary,
type PaginatedResponse,
type SupportUserItem,
type WeiboAccountItem,
} from '../api/modules';
import { getErrorMessage } from '../utils/error';
import { message } from '../utils/antdMessage';
import { formatTime } from '../utils/time';
const { Text } = Typography;
const STORAGE_KEY_SELECTED_USER = 'weibo_assignments_selected_user_id';
const STORAGE_KEY_PAGE_SIZE = 'weibo_assignment_page_size';
const CK_STATUS: Record<string, { label: string; color: string }> = {
valid: { label: '有效', color: 'success' },
invalid: { label: '失效', color: 'error' },
error: { label: '查询失败', color: 'warning' },
};
function renderBindStatus(account: WeiboAccountItem) {
if (account.is_bound === true && account.can_rebind === true) {
return <Tag color="processing">可换绑</Tag>;
}
if (account.is_bound === true) {
const days = account.rebind_remain_days ?? 0;
return <Tag color="success">{days > 0 ? `${days} 天后可换绑` : '已绑定'}</Tag>;
}
if (account.is_bound === false) return <Tag color="warning">未绑定</Tag>;
return <Tag>未查询</Tag>;
}
export default function WeiboAssignmentsPage() {
const { token } = theme.useToken();
const [summary, setSummary] = useState<AssignmentsSummary | null>(null);
const [supportUsers, setSupportUsers] = useState<SupportUserItem[]>([]);
const [selectedUser, setSelectedUser] = useState<SupportUserItem | null>(null);
const [accounts, setAccounts] = useState<WeiboAccountItem[]>([]);
const [totalCount, setTotalCount] = useState(0);
const [tableLoading, setTableLoading] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [assigning, setAssigning] = useState(false);
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);
const [knownTags, setKnownTags] = useState<string[]>([]);
const [searchText, setSearchText] = useState('');
const [filterTag, setFilterTag] = useState<string | undefined>();
const [enabledFilter, setEnabledFilter] = useState<string>('');
const [activeTab, setActiveTab] = useState<'unassigned' | 'assigned'>('unassigned');
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(() => {
const saved = localStorage.getItem(STORAGE_KEY_PAGE_SIZE);
return saved ? Number(saved) || 15 : 15;
});
const loadSummary = useCallback(async (silent = false) => {
try {
const data = await weiboApi.assignmentsSummary();
setSummary(data);
setSupportUsers(data.support_users);
return data.support_users;
} catch (error) {
if (!silent) message.error(getErrorMessage(error));
return [];
}
}, []);
const loadTags = useCallback(async () => {
try {
const result = await weiboApi.accountTags();
setKnownTags(result.tags);
} catch {
// 标签字典失败不影响分配列表。
}
}, []);
const loadAccounts = useCallback(
async (silent = false) => {
if (activeTab === 'assigned' && !selectedUser) return;
if (!silent) setTableLoading(true);
try {
const data: PaginatedResponse<WeiboAccountItem> = await weiboApi.listAccounts({
page: currentPage,
page_size: pageSize,
search: searchText.trim() || undefined,
tag: filterTag,
enabled:
enabledFilter === 'enabled'
? true
: enabledFilter === 'disabled'
? false
: undefined,
assign_status: activeTab === 'unassigned' ? 'unassigned' : undefined,
assigned_user_id:
activeTab === 'assigned' ? selectedUser?.id : undefined,
});
setAccounts(data.items);
setTotalCount(data.total);
} catch (error) {
if (!silent) message.error(getErrorMessage(error));
} finally {
if (!silent) setTableLoading(false);
}
},
[
activeTab,
currentPage,
enabledFilter,
filterTag,
pageSize,
searchText,
selectedUser,
],
);
useEffect(() => {
const initialize = async () => {
void loadTags();
const users = await loadSummary();
if (users.length > 0) {
const savedId = localStorage.getItem(STORAGE_KEY_SELECTED_USER);
const savedUser = savedId
? users.find((user) => user.id === Number(savedId))
: undefined;
setSelectedUser(savedUser || users[0]);
}
};
void initialize();
}, [loadSummary, loadTags]);
useEffect(() => {
void loadAccounts();
}, [loadAccounts]);
const refreshData = async (silent = false) => {
await Promise.all([loadSummary(silent), loadAccounts(silent)]);
};
const handleRefresh = async () => {
setRefreshing(true);
try {
await refreshData();
} finally {
setRefreshing(false);
}
};
const handleSelectUser = (user: SupportUserItem) => {
setSelectedUser(user);
setActiveTab('unassigned');
setSelectedRowKeys([]);
setCurrentPage(1);
localStorage.setItem(STORAGE_KEY_SELECTED_USER, String(user.id));
};
const handlePageChange = (page: number, size: number) => {
setCurrentPage(page);
if (size !== pageSize) {
setPageSize(size);
localStorage.setItem(STORAGE_KEY_PAGE_SIZE, String(size));
setCurrentPage(1);
}
};
const handleBatchAssign = async () => {
if (!selectedUser || selectedRowKeys.length === 0) {
message.warning('请先选择客服和微博账号');
return;
}
setAssigning(true);
try {
const result = await weiboApi.batchAssign(selectedRowKeys, selectedUser.id);
message.success(
result.message || `已将 ${selectedRowKeys.length} 个账号分配给 ${selectedUser.username}`,
);
setSelectedRowKeys([]);
await refreshData();
} catch (error) {
message.error(getErrorMessage(error));
} finally {
setAssigning(false);
}
};
const handleAssignAllFiltered = async () => {
if (!selectedUser) {
message.warning('请先选择客服');
return;
}
setAssigning(true);
try {
const result = await weiboApi.listAccountIds({
search: searchText.trim() || undefined,
tag: filterTag,
enabled:
enabledFilter === 'enabled'
? true
: enabledFilter === 'disabled'
? false
: undefined,
assign_status: 'unassigned',
});
if (result.account_ids.length === 0) {
message.info('当前筛选下没有未分配账号');
return;
}
await weiboApi.batchAssign(result.account_ids, selectedUser.id);
message.success(
`已将 ${result.account_ids.length} 个账号分配给 ${selectedUser.username}`,
);
setSelectedRowKeys([]);
await refreshData();
} catch (error) {
message.error(getErrorMessage(error));
} finally {
setAssigning(false);
}
};
const handleBatchUnassign = async () => {
if (selectedRowKeys.length === 0) {
message.warning('请先选择微博账号');
return;
}
setAssigning(true);
try {
await weiboApi.batchAssign(selectedRowKeys, null);
message.success(`已取消 ${selectedRowKeys.length} 个账号的分配`);
setSelectedRowKeys([]);
await refreshData();
} catch (error) {
message.error(getErrorMessage(error));
} finally {
setAssigning(false);
}
};
const selectedUserCount =
supportUsers.find((user) => user.id === selectedUser?.id)?.assigned_count ??
selectedUser?.assigned_count ??
0;
const unassignedCount = summary?.unassigned_count ?? 0;
const assignedCount = summary?.assigned_count ?? 0;
const accountCount = summary?.total_count ?? assignedCount + unassignedCount;
const columns: TableProps<WeiboAccountItem>['columns'] = [
{
title: '序号',
key: 'index',
width: 58,
align: 'center',
render: (_, __, index) => (currentPage - 1) * pageSize + index + 1,
},
{
title: '微博账号',
key: 'account',
width: 190,
align: 'center',
render: (_, account) => (
<Space orientation="vertical" size={0}>
<Text strong>{account.nickname || account.uid}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
UID {account.uid}
</Text>
</Space>
),
},
{
title: '标签',
dataIndex: 'tag',
width: 100,
align: 'center',
render: (tag: string) =>
tag ? <Tag color="blue">{tag}</Tag> : <Text type="secondary">-</Text>,
},
{
title: 'CK 状态',
dataIndex: 'ck_check_status',
width: 100,
align: 'center',
render: (status: string) => {
const state = CK_STATUS[status] || { label: '未检测', color: 'default' };
return <Tag color={state.color}>{state.label}</Tag>;
},
},
{
title: '角色绑定',
key: 'bind',
width: 120,
align: 'center',
render: (_, account) => renderBindStatus(account),
},
{
title: '微豆',
dataIndex: 'weidou_balance',
width: 90,
align: 'center',
render: (value: number | null) =>
value == null ? <Text type="secondary">-</Text> : value.toLocaleString(),
},
{
title: '生命周期',
dataIndex: 'is_enabled',
width: 90,
align: 'center',
render: (enabled: boolean) => (
<Tag color={enabled ? 'success' : 'default'}>
{enabled ? '已启用' : '已停用'}
</Tag>
),
},
{
title: '更新时间',
dataIndex: 'updated_at',
width: 155,
align: 'center',
render: (value: string | null) =>
value ? formatTime(value) : <Text type="secondary">-</Text>,
},
];
return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<h2 style={{ margin: 0 }}>微博分配管理</h2>
<Text type="secondary">管理员和运营查看全部账号;客服只能看到分配给自己的账号。</Text>
</div>
<Button icon={<ReloadOutlined />} loading={refreshing} onClick={handleRefresh}>
刷新
</Button>
</div>
<Row gutter={12} style={{ flexShrink: 0 }}>
<Col xs={12} lg={6}>
<Card size="small">
<Statistic title="微博账号" value={accountCount} prefix={<TeamOutlined />} />
</Card>
</Col>
<Col xs={12} lg={6}>
<Card size="small">
<Statistic
title="已分配"
value={assignedCount}
styles={{ content: { color: token.colorSuccess } }}
prefix={<CheckCircleOutlined />}
/>
</Card>
</Col>
<Col xs={12} lg={6}>
<Card size="small">
<Statistic
title="未分配"
value={unassignedCount}
styles={{ content: { color: token.colorError } }}
prefix={<SwapOutlined />}
/>
</Card>
</Col>
<Col xs={12} lg={6}>
<Card size="small">
<Statistic
title="客服人数"
value={supportUsers.length}
prefix={<UsergroupAddOutlined />}
/>
</Card>
</Col>
</Row>
<Row gutter={12} style={{ flex: 1, minHeight: 0 }}>
<Col xs={24} lg={8} style={{ height: '100%', display: 'flex', minHeight: 0 }}>
<Card
title="客服列表"
size="small"
style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}
styles={{ body: { flex: 1, overflow: 'auto', padding: 8 } }}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{supportUsers.map((user) => {
const isSelected = selectedUser?.id === user.id;
return (
<div
key={user.id}
onClick={() => handleSelectUser(user)}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '10px 14px',
borderRadius: 6,
cursor: 'pointer',
border: `1px solid ${isSelected ? token.colorPrimary : token.colorBorderSecondary}`,
background: isSelected ? token.colorPrimaryBg : token.colorBgContainer,
}}
>
<Space>
<UserOutlined style={{ color: token.colorPrimary }} />
<Text strong>{user.username}</Text>
</Space>
<Tooltip title={`已分配 ${user.assigned_count} 个账号`}>
<Badge
count={user.assigned_count}
overflowCount={999999}
showZero
style={{ backgroundColor: token.colorPrimary }}
/>
</Tooltip>
</div>
);
})}
{supportUsers.length === 0 && (
<Text
type="secondary"
style={{ textAlign: 'center', padding: 20, display: 'block' }}
>
暂无客服用户
</Text>
)}
</div>
</Card>
</Col>
<Col xs={24} lg={16} style={{ height: '100%', display: 'flex', minHeight: 0 }}>
<Card
size="small"
title={
selectedUser ? (
<Space wrap>
<span>已选中客服:</span>
<Tag color="blue" style={{ fontSize: 14, padding: '2px 10px' }}>
{selectedUser.username}(已分配 {selectedUserCount} 个)
</Tag>
</Space>
) : (
<Text type="secondary">请选择左侧客服进行操作</Text>
)
}
style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}
styles={{
body: {
flex: 1,
display: 'flex',
flexDirection: 'column',
minHeight: 0,
padding: 0,
},
}}
>
<div
style={{
padding: '8px 16px',
borderBottom: `1px solid ${token.colorBorderSecondary}`,
display: 'flex',
flexWrap: 'wrap',
gap: 8,
alignItems: 'center',
flexShrink: 0,
}}
>
<Input.Search
allowClear
placeholder="搜索 UID、昵称、角色或备注"
value={searchText}
onChange={(event) => {
setSearchText(event.target.value);
setCurrentPage(1);
}}
style={{ width: 230 }}
size="small"
/>
<Select
allowClear
placeholder="按标签筛选"
value={filterTag}
onChange={(value) => {
setFilterTag(value);
setCurrentPage(1);
}}
style={{ width: 140 }}
size="small"
options={knownTags.map((tag) => ({ value: tag, label: tag }))}
/>
<Select
value={enabledFilter}
onChange={(value) => {
setEnabledFilter(value);
setCurrentPage(1);
}}
style={{ width: 120 }}
size="small"
options={[
{ value: '', label: '全部状态' },
{ value: 'enabled', label: '已启用' },
{ value: 'disabled', label: '已停用' },
]}
/>
</div>
<Tabs
size="small"
activeKey={activeTab}
onChange={(key) => {
setActiveTab(key as 'unassigned' | 'assigned');
setSelectedRowKeys([]);
setCurrentPage(1);
}}
style={{ padding: '0 16px', flexShrink: 0, marginBottom: 0 }}
items={[
{ key: 'unassigned', label: `未分配 (${unassignedCount})` },
{
key: 'assigned',
label: selectedUser
? `已分配给 ${selectedUser.username} (${selectedUserCount})`
: '已分配账号',
disabled: !selectedUser,
},
]}
/>
<div
style={{
padding: '0 16px 8px',
display: 'flex',
gap: 8,
flexWrap: 'wrap',
flexShrink: 0,
}}
>
{activeTab === 'unassigned' && selectedUser && (
<>
<Button
type="primary"
size="small"
icon={<SwapOutlined />}
loading={assigning}
disabled={selectedRowKeys.length === 0}
onClick={handleBatchAssign}
>
分配选中 ({selectedRowKeys.length}) 给 {selectedUser.username}
</Button>
<Popconfirm
title={`把当前筛选下 ${totalCount} 个未分配账号全部分给 ${selectedUser.username}?`}
okText="确认分配"
cancelText="取消"
disabled={totalCount === 0}
onConfirm={handleAssignAllFiltered}
>
<Button size="small" loading={assigning} disabled={totalCount === 0}>
分配当前筛选全部
</Button>
</Popconfirm>
</>
)}
{activeTab === 'assigned' && selectedUser && (
<Button
danger
size="small"
icon={<ClearOutlined />}
loading={assigning}
disabled={selectedRowKeys.length === 0}
onClick={handleBatchUnassign}
>
取消分配选中 ({selectedRowKeys.length})
</Button>
)}
</div>
<Table<WeiboAccountItem>
columns={columns}
dataSource={accounts}
rowKey="id"
size="small"
loading={tableLoading}
pagination={{
current: currentPage,
pageSize,
total: totalCount,
size: 'small',
showSizeChanger: true,
showTotal: (total) => `共 ${total} 条`,
onChange: handlePageChange,
}}
rowSelection={{
selectedRowKeys,
preserveSelectedRowKeys: true,
onChange: (keys) => setSelectedRowKeys(keys.map(Number)),
}}
scroll={{ x: 940, y: 'calc(100vh - 500px)' }}
/>
</Card>
</Col>
</Row>
</div>
);
}