feat(weibo): 增加工作台独占与快捷操作
This commit is contained in:
@@ -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",
|
||||
)
|
||||
@@ -983,6 +983,10 @@ class WeiboWorkbenchAccount(Base):
|
||||
"account_id",
|
||||
name="uq_weibo_workbench_account",
|
||||
),
|
||||
UniqueConstraint(
|
||||
"account_id",
|
||||
name="uq_weibo_workbench_account_owner",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<WeiboAccountItem, WeiboAccountItem>(`/weibo/accounts/${accountId}`),
|
||||
importCookies: (text: string, tag = '') =>
|
||||
importCookies: (text: string, tag = '', updateExisting = false) =>
|
||||
api.post<WeiboImportResult, WeiboImportResult>('/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;
|
||||
}) =>
|
||||
|
||||
@@ -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<string[]>([]);
|
||||
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<WeiboImportError[]>([]);
|
||||
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<WeiboAccountItem | null>(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 || <Text type="secondary">-</Text>,
|
||||
render: (value: string) =>
|
||||
value ? (
|
||||
<Tag color="blue" style={{ marginInlineEnd: 0 }}>
|
||||
{value}
|
||||
</Tag>
|
||||
) : (
|
||||
<Text type="secondary">-</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
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 }}
|
||||
/>
|
||||
<Select
|
||||
allowClear
|
||||
value={tagFilter || undefined}
|
||||
onChange={(value) => {
|
||||
setTagFilter(value || '');
|
||||
setPage(1);
|
||||
}}
|
||||
placeholder="全部标签"
|
||||
style={{ width: 130 }}
|
||||
options={knownTags.map((tag) => ({ value: tag, label: tag }))}
|
||||
/>
|
||||
<Select
|
||||
value={bindFilter}
|
||||
onChange={(value) => {
|
||||
@@ -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 ? <Tag color="blue">{value}</Tag> : '-',
|
||||
},
|
||||
{
|
||||
title: 'CK',
|
||||
@@ -1787,6 +1837,11 @@ export default function WeiboAccountsPage({
|
||||
<Form.Item name="tag" label="标签">
|
||||
<Input placeholder="可选" maxLength={64} />
|
||||
</Form.Item>
|
||||
<Form.Item name="update_existing" valuePropName="checked">
|
||||
<Checkbox>
|
||||
重复账号时更新 CK(仍只有一条全局账号,不新建)
|
||||
</Checkbox>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
{importErrors.length > 0 && (
|
||||
<Alert
|
||||
|
||||
Reference in New Issue
Block a user