diff --git a/.gitignore b/.gitignore index 11bd8e8..9368ade 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ slice.jpg # 前端 web/frontend/node_modules/ web/frontend/dist/ +data/web.db diff --git a/data/web.db b/data/web.db index e4be754..4308872 100644 Binary files a/data/web.db and b/data/web.db differ diff --git a/web/backend/__pycache__/database.cpython-312.pyc b/web/backend/__pycache__/database.cpython-312.pyc index 1a8a8a2..537e6ee 100644 Binary files a/web/backend/__pycache__/database.cpython-312.pyc and b/web/backend/__pycache__/database.cpython-312.pyc differ diff --git a/web/backend/__pycache__/models.cpython-312.pyc b/web/backend/__pycache__/models.cpython-312.pyc index 81d6f89..90d7e0b 100644 Binary files a/web/backend/__pycache__/models.cpython-312.pyc and b/web/backend/__pycache__/models.cpython-312.pyc differ diff --git a/web/backend/__pycache__/schemas.cpython-312.pyc b/web/backend/__pycache__/schemas.cpython-312.pyc index 4318646..6a6c628 100644 Binary files a/web/backend/__pycache__/schemas.cpython-312.pyc and b/web/backend/__pycache__/schemas.cpython-312.pyc differ diff --git a/web/backend/database.py b/web/backend/database.py index 0c68d32..2cdaa23 100644 --- a/web/backend/database.py +++ b/web/backend/database.py @@ -29,9 +29,22 @@ def get_db(): def init_db(): """建表 + 写入初始数据。""" Base.metadata.create_all(bind=engine) + _migrate() _seed() +def _migrate(): + """数据库迁移:为已有表添加新列。""" + from sqlalchemy import text + with engine.connect() as conn: + # 检查 accounts.tag 列是否存在 + result = conn.execute(text("PRAGMA table_info(accounts)")) + columns = [row[1] for row in result] + if 'tag' not in columns: + conn.execute(text("ALTER TABLE accounts ADD COLUMN tag VARCHAR(64) DEFAULT ''")) + conn.commit() + + def _seed(): """写入默认超管账号和角色。""" from .models import User diff --git a/web/backend/models.py b/web/backend/models.py index d3e0be6..95ee8ef 100644 --- a/web/backend/models.py +++ b/web/backend/models.py @@ -37,6 +37,7 @@ class Account(Base): email_imap_server = Column(String(128), default="") email_imap_port = Column(Integer, default=993) assigned_to = Column(Integer, ForeignKey("users.id"), nullable=True, index=True) + tag = Column(String(64), default="") remark = Column(String(256), default="") created_at = Column(DateTime, default=datetime.utcnow) diff --git a/web/backend/routers/__pycache__/accounts.cpython-312.pyc b/web/backend/routers/__pycache__/accounts.cpython-312.pyc index 02e77bc..5489a62 100644 Binary files a/web/backend/routers/__pycache__/accounts.cpython-312.pyc and b/web/backend/routers/__pycache__/accounts.cpython-312.pyc differ diff --git a/web/backend/routers/accounts.py b/web/backend/routers/accounts.py index 9295eee..8339581 100644 --- a/web/backend/routers/accounts.py +++ b/web/backend/routers/accounts.py @@ -7,7 +7,7 @@ from sqlalchemy.orm import Session from ..database import get_db from ..models import User, Account, AuditLog -from ..schemas import AccountImport, AccountAssign, AccountOut +from ..schemas import AccountImport, AccountAssign, AccountTag, AccountOut from ..deps import get_current_user, require_permission from ..permissions import has_permission @@ -29,6 +29,7 @@ def _split_account_line(line: str) -> list[str]: @router.get("", response_model=list[AccountOut]) def list_accounts( assigned_only: bool = Query(False), + tag: str = Query(None), db: Session = Depends(get_db), current: User = Depends(get_current_user), ): @@ -45,11 +46,15 @@ def list_accounts( if assigned_only and has_permission(current.role, "account:view_all"): query = query.filter(Account.assigned_to.isnot(None)) + if tag: + query = query.filter(Account.tag == tag) + accounts = query.order_by(Account.id).all() result = [] for acc in accounts: item = AccountOut( id=acc.id, username=acc.username, remark=acc.remark or "", + tag=acc.tag or "", assigned_to=acc.assigned_to, assigned_username=acc.assigned_user.username if acc.assigned_user else None, created_at=acc.created_at, @@ -69,7 +74,7 @@ def import_accounts( db: Session = Depends(get_db), current: User = Depends(require_permission("account:import")), ): - """批量导入账号。格式:用户名|密码|邮箱|邮箱密码""" + """批量导入账号。格式:用户名|密码|邮箱|邮箱密码|标签(可选)""" from core.douyu.email_verifier import get_email_config_for_account accounts = [] @@ -79,11 +84,12 @@ def import_accounts( if not line or line.startswith('#'): continue parts = _split_account_line(line) - if len(parts) != 4: + if len(parts) < 4: skipped += 1 continue - username, password, email, email_password = [p.strip() for p in parts] + username, password, email, email_password = [p.strip() for p in parts[:4]] + tag = parts[4].strip() if len(parts) > 4 else "" if not all([username, password, email, email_password]): skipped += 1 continue @@ -99,6 +105,7 @@ def import_accounts( email_password=email_password, email_imap_server=email_cfg['server'], email_imap_port=email_cfg.get('port', 993), + tag=tag, )) if accounts: @@ -135,6 +142,49 @@ def assign_account( return {"message": "已分配", "success": True} +@router.put("/{account_id}/tag") +def set_account_tag( + account_id: int, + req: AccountTag, + db: Session = Depends(get_db), + current: User = Depends(require_permission("account:import")), +): + """设置单个账号标签。""" + acc = db.query(Account).filter(Account.id == account_id).first() + if not acc: + raise HTTPException(status_code=404, detail="账号不存在") + acc.tag = (req.tag or "").strip() + db.commit() + return {"message": "标签已更新", "success": True} + + +@router.put("/batch-tag") +def batch_tag( + req: AccountTag, + db: Session = Depends(get_db), + current: User = Depends(require_permission("account:import")), +): + """批量设置账号标签。""" + if not req.account_ids: + raise HTTPException(status_code=400, detail="请选择账号") + tag = (req.tag or "").strip() + count = db.query(Account).filter(Account.id.in_(req.account_ids)).update( + {Account.tag: tag}, synchronize_session=False + ) + db.commit() + return {"message": f"已为 {count} 个账号设置标签", "success": True} + + +@router.get("/tags/list") +def list_tags( + db: Session = Depends(get_db), + current: User = Depends(get_current_user), +): + """获取所有标签列表。""" + tags = db.query(Account.tag).filter(Account.tag != "", Account.tag.isnot(None)).distinct().all() + return [t[0] for t in tags if t[0]] + + @router.delete("/{account_id}") def delete_account( account_id: int, diff --git a/web/backend/schemas.py b/web/backend/schemas.py index e47e8d6..7494c13 100644 --- a/web/backend/schemas.py +++ b/web/backend/schemas.py @@ -57,6 +57,11 @@ class AccountAssign(BaseModel): assigned_to: Optional[int] = None +class AccountTag(BaseModel): + tag: Optional[str] = None + account_ids: Optional[list[int]] = None + + class AccountOut(BaseModel): id: int username: str @@ -64,6 +69,7 @@ class AccountOut(BaseModel): password: Optional[str] = None email: Optional[str] = None email_password: Optional[str] = None + tag: str = "" assigned_to: Optional[int] = None assigned_username: Optional[str] = None remark: str = "" diff --git a/web/frontend/src/api/modules.ts b/web/frontend/src/api/modules.ts index bf3d354..b219a4f 100644 --- a/web/frontend/src/api/modules.ts +++ b/web/frontend/src/api/modules.ts @@ -36,11 +36,16 @@ export const userApi = { }; export const accountApi = { - list: (assigned_only?: boolean) => - api.get('/accounts', { params: assigned_only ? { assigned_only: true } : {} }), + list: (params?: { assigned_only?: boolean; tag?: string }) => + api.get('/accounts', { params }), import: (text: string) => api.post('/accounts/import', { text }), assign: (id: number, assigned_to: number | null) => api.put(`/accounts/${id}/assign`, { assigned_to }), + setTag: (id: number, tag: string) => + api.put(`/accounts/${id}/tag`, { tag }), + batchTag: (account_ids: number[], tag: string) => + api.put('/accounts/batch-tag', { account_ids, tag }), + listTags: () => api.get('/accounts/tags/list'), delete: (id: number) => api.delete(`/accounts/${id}`), }; diff --git a/web/frontend/src/pages/AccountsPage.tsx b/web/frontend/src/pages/AccountsPage.tsx index 65bf52e..371fc20 100644 --- a/web/frontend/src/pages/AccountsPage.tsx +++ b/web/frontend/src/pages/AccountsPage.tsx @@ -1,21 +1,31 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useState, useMemo } from 'react'; import { - Table, Button, Modal, Input, Select, message, Popconfirm, Typography, + Table, Button, Modal, Input, Select, message, Popconfirm, Typography, Tag, Space, + Row, Col, Card, Statistic, } from 'antd'; -import { ImportOutlined, DeleteOutlined } from '@ant-design/icons'; +import { ImportOutlined, DeleteOutlined, TagOutlined, FilterOutlined } from '@ant-design/icons'; import { accountApi, userApi } from '../api/modules'; import { getUser, hasPerm } from '../store/auth'; const { TextArea } = Input; const { Text } = Typography; +const TAG_COLORS = [ + 'blue', 'green', 'cyan', 'geekblue', 'purple', 'orange', 'magenta', 'volcano', +]; + export default function AccountsPage() { const [accounts, setAccounts] = useState([]); const [users, setUsers] = useState([]); + const [tags, setTags] = useState([]); const [loading, setLoading] = useState(false); const [importOpen, setImportOpen] = useState(false); const [importText, setImportText] = useState(''); const [importing, setImporting] = useState(false); + const [tagFilter, setTagFilter] = useState(''); + const [selectedRowKeys, setSelectedRowKeys] = useState([]); + const [batchTagInput, setBatchTagInput] = useState(''); + const [batchTagVisible, setBatchTagVisible] = useState(false); const user = getUser(); const canViewAll = hasPerm(user, 'account:view_all'); @@ -26,7 +36,9 @@ export default function AccountsPage() { const loadAccounts = async () => { setLoading(true); try { - const data = await accountApi.list(); + const params: any = {}; + if (tagFilter) params.tag = tagFilter; + const data = await accountApi.list(params); setAccounts(data); } catch (e: any) { message.error(e.message); @@ -42,11 +54,31 @@ export default function AccountsPage() { } catch {} }; + const loadTags = async () => { + try { + const data = await accountApi.listTags(); + setTags(data); + } catch {} + }; + useEffect(() => { loadAccounts(); if (canAssign) loadUsers(); + loadTags(); }, []); + useEffect(() => { + loadAccounts(); + }, [tagFilter]); + + const tagColorMap = useMemo(() => { + const map: Record = {}; + tags.forEach((t, i) => { + map[t] = TAG_COLORS[i % TAG_COLORS.length]; + }); + return map; + }, [tags]); + const handleImport = async () => { if (!importText.trim()) { message.warning('请输入账号数据'); @@ -59,6 +91,7 @@ export default function AccountsPage() { setImportOpen(false); setImportText(''); loadAccounts(); + loadTags(); } catch (e: any) { message.error(e.message); } finally { @@ -76,11 +109,42 @@ export default function AccountsPage() { } }; + const handleSetTag = async (accountId: number, tag: string) => { + try { + await accountApi.setTag(accountId, tag); + message.success('标签已更新'); + loadAccounts(); + loadTags(); + } catch (e: any) { + message.error(e.message); + } + }; + + const handleBatchTag = async () => { + if (selectedRowKeys.length === 0) { + message.warning('请先选择账号'); + return; + } + try { + await accountApi.batchTag(selectedRowKeys as number[], batchTagInput); + message.success(`已为 ${selectedRowKeys.length} 个账号设置标签`); + setBatchTagVisible(false); + setBatchTagInput(''); + setSelectedRowKeys([]); + loadAccounts(); + loadTags(); + } catch (e: any) { + message.error(e.message); + } + }; + const handleDelete = async (id: number) => { try { await accountApi.delete(id); message.success('已删除'); + setSelectedRowKeys((prev) => prev.filter((k) => k !== id)); loadAccounts(); + loadTags(); } catch (e: any) { message.error(e.message); } @@ -89,6 +153,48 @@ export default function AccountsPage() { const columns: any[] = [ { title: 'ID', dataIndex: 'id', width: 60 }, { title: '用户名', dataIndex: 'username' }, + { + title: '标签', + dataIndex: 'tag', + width: 120, + render: (tag: string, record: any) => { + if (!tag) { + if (canImport) { + return ( + { + const val = (e.target as HTMLInputElement).value.trim(); + if (val) handleSetTag(record.id, val); + }} + onBlur={(e) => { + const val = e.target.value.trim(); + if (val) handleSetTag(record.id, val); + }} + /> + ); + } + return -; + } + if (canImport) { + return ( + { + e.preventDefault(); + handleSetTag(record.id, ''); + }} + > + {tag} + + ); + } + return {tag}; + }, + }, ]; if (canViewAll) { @@ -135,13 +241,66 @@ export default function AccountsPage() {

账号管理

- {canImport && ( - - )} + +