From 9af92fc2a9d3d98569971c124f03979e9f8a6eee Mon Sep 17 00:00:00 2001 From: yml2213 Date: Mon, 22 Jun 2026 15:09:51 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E4=BA=86=E8=B4=A6=E5=8F=B7?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E7=9A=84=E5=88=86=E7=BB=84=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + data/web.db | Bin 36864 -> 45056 bytes .../__pycache__/database.cpython-312.pyc | Bin 2295 -> 3073 bytes .../__pycache__/models.cpython-312.pyc | Bin 4743 -> 4801 bytes .../__pycache__/schemas.cpython-312.pyc | Bin 5390 -> 5704 bytes web/backend/database.py | 13 ++ web/backend/models.py | 1 + .../__pycache__/accounts.cpython-312.pyc | Bin 7876 -> 10881 bytes web/backend/routers/accounts.py | 58 ++++- web/backend/schemas.py | 6 + web/frontend/src/api/modules.ts | 9 +- web/frontend/src/pages/AccountsPage.tsx | 201 ++++++++++++++++- web/frontend/src/pages/LoginTasksPage.tsx | 203 ++++++++++++++---- 13 files changed, 437 insertions(+), 55 deletions(-) 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 e4be75424a404f9c577b067bc3b85c5b1f6a1f36..4308872c7ff9406a5cc9f7744c5316093893b31e 100644 GIT binary patch delta 5282 zcmeHLS!^4}8D3eIO_7wD3kXujh#Q-56W7AthXSQ0xg4MmtCK^vgh{=ZgB4(>f89>mMuLmM&}mXlRe*<6|-BX1BuW`An`2{ ze{3rojs#NU$*ntfv`O#CT-H@zw;qCe&dh!8-bVD^=lP<&ard@$o-gL^z2_qjyZb&D z)c@dP+Xe!nV(OMC%f*^CJEWVXVr91T{x3Q&yp152FPueTl-vcAyHIp1Ob!v$5RMEo z1dM^_-u7?#N9SVW7kVG@1R$?F1-XCcJMO!G(<_^{5BzN4N$+Rwl-Kn9)AIs=B-|~h z-wTDd?0jg)I%puUW8ssRFJIW(IsIbi^nuRtvzISioIm)>&Vh3b4O?{*AsHB}*CoT4 z-7P7(?4aFrNRuXQ+Z1(Ua7onr`7Q0kaT=rN5AB^l{r>!ihp)WKMwh^~F}nuyJ~Y`Ckc)tOCxuz0f1e(d>MDY2mdw7{)6i#?U08NKUgN zEhE*lIRjB|0&3mjV09D6tU}UUUg)7mmR#(-b?)lx?-mW1Mi>gm4Wn)=*<~QZ&Naxp z^$2T`tAJ)VdLd-n643mq_QL){oum5~j$K3;1RMfcY&2w)ArXc&a<*ZYmtoavL(7(K z^6xh8;Y+Bk?!iQ?4J*&gmHkJq?rUG!d*td*&(S1R(?}XX({{b=NH_ex0g@mlobT*!kdjl!O~fSwb)dBPp_` z8l~!u^k(&vSQYQmZ-!bt`?v33zJK`s=DXti!uKcNAAG;}{l@oeFtPlb+r0Hv zpRm3c>hX3PPq%S*oBnR&>NXp@&4zB%*KO8!o8E4-uD2KJ?*m))_`d9J=<$6y@YJTG z8zOz5_MPf`x^H{m_VfET^tEV@8}j#T9vJ8kK$xtk1c{;qEMeY~YAL#vUCG|m5KIrD z+-n8>YXz>gf{pE0 zkE=f@J>XhO+4+60|0nD&6Lzuxz5&-Ms1G^=xju!?xSn=x-}u*)`;Kou7IAsHC;ru^ z{QZHz%AEDLpQ%6ELN$~rM3bhNhxJ@0MH_TVGetA#Mt|njRmq$7NJR z^-O{mvzRccF`;ma404Spwl+j-k64?{VHD^MBeX;iDy}FhMNLGlkQ2&H$Kn&2nVMc< zF|~$8EG?7N2uGs;RD~kK;c=}h;dwAJ$CFmGU~7z%vZBSJB-)u|kd$b3BvDm@MVu#U zv%C@wlrWCB%CuLcf=N2YA<47_w~sWQ*%EGYNx>NpjT-7mK1WGA{%TK~wGThzBQPMG;*GH>LC{~SG z)1^!(M#kB!&9mKgDMXtbsaz(X*ec4}wO|foG=VEdlj_C=PA6C!WoM#gqmb7P zYAUA0Ev=a`n?z(BR;#h94p!A>^7XM~h7@b5QLQoxL_wI9?M9@=u|>QJh7op3(g}Z7 zp%iVRAr`_ij@2{@5hyyU6iJ%3CUQZ$mJFs?xm|7!dna)&nMLBs7!sdq|K0Jo|J>aE zcy|oP*|5zu8@h}oDzSV~k4FVKlNa!K9>kh8%y>SLi?_vR?|(R+FQDC(VmbnYl^_#Y zkfp^;wNE_z@Z%ht(D`K0%p@ZQ2Qnk1V~CI!91fi}<53VwcDletxkkopzv=8=Yg6p& zwx@d98GiD3GGGG;sBM7!AlXPboS?pG43&XgEgSSEw zMsP+WaS>mfQPg_ZC5@IP5hoNGmQ@u-X_;1KQ6y+p!wHzA7GC+JbLr*z11CC%UPoIi zzU)@jUSv&GW`;0wkfu?ZCce&k-dcPW_^%53s;cb1Luq9&P)=`Al_jjdt4V)FRc4T5 zJ2x&8xqmH@yRH$rLZh&qBPR=Nt(FQhRw+o6GiD(z7kFaKgo8;rC#n%}#}PA>NF)q6 zD&&)3I0>jb(#-M}<23o`G_DI=h6T4XX~I!HWZR=Nun}uE^%)YTxVl*mrKWf#o=n!s zNnLF!Q<+F81Q@)vn!*(l18LS262TNIj3%ZV^u&|`$Ehh%8yhL-vbZV+Vu2B+zD(d8eoF#R%7DPPwMGkqlO-}Hpi?&60QV4yqhWY8gJ&_WOERq7yd#4< zG7cC#o=zbdlLNfOHR9}OE{<|Ah^NUph8{O3ZNUjRT(bN0gs)M!X5vV#1kPNYF-kRg zHY=7Z1=%pF*RNT(9)ePcQ{yb!%#1Y)HP)I0+-*%2BUUIIOHOf9g_sBe(x?V&aZRw% z!Yn*4fx4xQ(I`WW86y$6RHtka1u4#D9lb__PBm7E@$%T?!A1#;fO>)~hKua9Rx=GC zNF()eb4uW%7zyTT6pvB#bn`nEwEVqFDO? delta 204 zcmZp8z|^pSX@az%F#`hwClJE`$3z`tSz`vhE)ia!5C=y(1K$h2Zr(heI|h2N&B#BG zfq&j+L4_&&ya|ll47$?VlIlzhypEIaM}=~6fmQrr;Qs?u@r-|B01pc@GXv+O2_itl ojMz52P20sO0#d}rKbwL79RGd(5B#(F_wug+DsShXJi9*?06MWU8UO$Q diff --git a/web/backend/__pycache__/database.cpython-312.pyc b/web/backend/__pycache__/database.cpython-312.pyc index 1a8a8a285d329282a94a6a69ca6548182089d3ab..537e6ee318519f52302d28cd5a94bcddad2f617a 100644 GIT binary patch delta 1071 zcmaJ=U1$?o6ux)nW+rLVYLW^~jp?jiHx63=6%{E?o9d#8E1`C2bqR51tSR}icP6$e z328;K6}6T@TYnxD1cidQ>qA8s79aL$Us77Ij&%_c-z}gwZRF z8q^Fq<5#r8Y7NJuT{xl}{m9!@PdJv5rTswE0CGaKvKCdRGGh7y}Aam>7t|EPy~qWmGqB{A;PpZ3bDGf!((9@s~dX zGC*M`2nCh$Kg(noKNw+UrVGf-Z#)25Hp42&O)#U(SsMablw}o51|5gk_Z?=g#OU~n z8Ug~ZB)9V96rO$)49yJ4wbQ)JK$0)Z_JRhm9%!(a>^q2u`uf-o&rvl{=D+?{)58gAH{3KuWsBfPChK0xn8_)1OT`nKW+4;;6m`{0cI5IxyDy!UKyz6w;-22Jd>C?4wR6Q6j=LZUOD0)>@)8+WlXu zMDK>FT4$`{&n$D9d^oHm<#5>Kk45yOvFKq;#|YCR8c+E44RkOUYFi4WGg_LyQqfK8 kj(Ac@#ndL;LL9VRv~lB*^}G{7``c<8tb14MgsxJ+C#U2M{Qv*} delta 290 zcmZpa_%6tInwOW00SFcyu*gzloyaG_D6vspl98oSP*Z5L7h?kxW5VRq%!!j7SPU3h zCl|7)PF}zwqbCQ{Aq&LCGC-n%;Ry>zyKkfKbrzY6EHX>fF0ja4VbQ$9!rAIx1X8v6 z0(&|m(@Vz5mYh=s6oCS_IEqt~ON%l~DvOjR-{X|Fxy6>8pPQRmQX~YF)nq9GDJoI| z5yBus8Auef00{*kC=vk)Ny7-`$wpjVj6#$5a*6ZY;wmo8Nz6&kNX@OBe3L6o7vxTe uQjkNzjsa`G#bJ}1pHiBWYFA_llwbtn;>nW}x%+sT8F@bQ2r&wS)dB$L<3j)d diff --git a/web/backend/__pycache__/models.cpython-312.pyc b/web/backend/__pycache__/models.cpython-312.pyc index 81d6f8995af12fe2f3f125ef7b4c166aa86ee2ef..90d7e0b7837572601a1e378868c1dbcb0e6f1982 100644 GIT binary patch delta 307 zcmZoyJ*diinwOW00SK1ewa7ZKk++wXX&U2XJAS>%r&#YY@=Xq46J->fJdaJDTZoAv zl{Hm3RU}Jm@(DIsLopyoZDCE)>w1e2hJj>lSlKVtSDQP~GH4Zjs3j9HxvnCzo;b@pB1G6q~^` zS!zMh6)uei=grKV+ZY+$H}B%&W@I$je2iO^iP3BF9p1HE!9cx5Ai{C-WWIh@5G#42 zpy*~B{*8=Wu^>qh5it3?KpkW31sr{w?KrnGGP-U4!o|(VsK1Fvl!?)EvKZf5t{|WZMIgdq z@>#xq#-Poy{HGYXVnCuG!hf=%U>#%3$?a#%3&XcS zm}TI=sDl$Bb28+GZTuL8cgziDuwP(~Se82`CT8ZI%npuE_LqaRR(@i*hE|ly@YkHCRDke(lmSXRkwVqlp zY&g5o)SlP^V_F@2m&5EMENLbz>ISIF#l@Pu>`9*JhUlDbFt2zuUXY(%bi-9mMNO(| zGS_PI8egSU9XdK9RfkF%FPVlV(>DV+#FjS}f==Y93IlVqenJr7t&$2SahhKj_7xj` zE3TA9r5}DpOcsTC)v4_w$CZCJpLX!OVM|R%Iw>V|U=$YNhk8B~iDD_t*TpX$PpR|{7vYW z@rU4@YRP?+5^jNKJY{`+!=J`MTb?Ey0)M>Ql<~K`L)l%zJ;EqqjGt+QQouEU?$Fw4PBvzd9YWLjrjk_qz|1v23;q8ZEAN^z^0 zT^-yLt;$mJ^gFfi!5r-1wd7UdSK?>k`|__D=&Y=XVO9=h9?!UQkGxC%0~|7p!W$Tg aPg?=jx@I2=*d59`OHz*AVG4HQ?SBDbs>2ij delta 644 zcmX|-&ui2`9K|!u#@+myG~KLhq}!-amlWBH9z^PHi!7ExYgzk4#^ZWG%8+Z3q*oqR33@O)?q_(XjV?z7ymbZfN^uaC@I{h$p_JO87pIQZjMy)Hc z8X2M}R9|ivg`_ws*F=p>>1hM6lzVz)2~8%9LBf^^K1i7G@@h*~+j~RuQ;x{lQbSIM z*2Pf-j@&rQ)*kdl(%sBN+K)Fx8c3gNw(ze?F(YSM2VCHsL^wmrB zn!ed}rwIwb%bt1}y+tP2y^kTcUHww14i(!Uq#qHW4+vITnX zO`N!oY?z-FL*}5*UQmRZ4?X(rEmrbB&##>q<^?gWR%(WjDJEz3j$T&QEt>_q{;?vL KJ8xyJBi*hqR;(1pbLYe{?#X_S}`$R$S=L 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 delta 2350 zcmZuyYm5_B6rMY;PTTEt+fHBmfbP4MXL&!DM_2^~6vY@fXjwY5%am!S+-W6XHw(#P zb_pipB>@8jOf-hDF)A^}=npkMV|);zStrKG4}NT-NfC@c^`5o^i#o}id+&G7cka38 z&N*}J$xqkIU;BI>hCTzIugXm*r{owr&YnsZ)*wa|RJWnz!-L^`WH3@&dyHs4HW)(; z%V@mnRhvf8lJ}G|a1kaei=)7SC+} z%XpA#LZF!nIi9E=m#UCo*p~TP*l{$>sbMXlMl`n?)qKOE8XFg`)c0TpJE*$>aEIcQ z*1kNVwm3Xz9A?#4EijAUHjCdy`P*vzcHpm$sB;{iGgAIst%v#u-t{4=iCI2+sgFl% zJ~|pPbUIvTZ1}i$hOSxs!aVaC!;FfhXgvrTh6sD`6)_hh>Fy9G=32 z8$3}Yl7L5vO-Wb8I%U-LpJSVnS6E7&Bw!Q2E7R;ar&wnL3(Ug08(8ozCgl163%H!r zAm-#3JW~-!+IvRqq;@HheCv&n8Ses3v4w+W4Ijo{ieqqM^Vl>rTO8Jnl7{g@z~_O0 z3$g{>Dwri(GO?=R%)Y}mH)I&t5Bm8a7J!j&$g=d2f~IE57Im?h9Bo<qfOjGda4%|V;<&|g+`A|NJekAMtW1*FFPq8AX{1G3$Y2oCy zibQ_!50JghNpy@%HZKpHXPF9DX>#&5DwFS;Q*K#xLD^Sif&$?VNp(BrKjm@m(P7X` zPm|SwGDlkuc{>mld>6PWnS2viiu|zlHdlmmj2gHgs6o0`6g707dxFWbh`yulLrh93 zZzhif2gt?X@?Kk{*?UgIHeWPxNxwmhCMDL>yov^dSA$sdawv&5k%gg7G@tAWJ&(4K zZ$fP=TdB_WSfwFcvJUB`{Wt*tXJ`ey0%pE}9hO24)1y{F!O>fNWm6s|4Xc}+@BNG~PN4qWOduZNA`9iJ=i`IB+E zz11G3bpYL3L3$&H{Ap(SqsW4~{KOl5Fm`n+@-vH)WHR21bn;fbXKa|N@D3`<(8g}Q z_s-3U7iNyXxcf4TcY*40DpEe2hIy@+mck5jX$!+Two0`Es@D_z$?y^o&w^+IQ7fMe zozXiU0pR|WFUI`QQM-b85(UCwdnbJ^!Mr<;m!_ zA~wr34*K^o^F`gz@F3W6H1{?SU8Pn9cn^U5KGQU6%>~Mfs^H_Yxnj9wb9$jv%OO5U zF$}R?X7F5ytRB6zE^n%3L+i)KDMs58W4({aXMNp#|23wc{NA@`O)!48mn`~8Y5l%! z{&i)+EmsE$PKTmZj*h=d{;EL7s>sN}KR|PvW7u?+1#Bxho$OmU4^_~#J5uFfQnQCi Yg8)-t#Hwr!-9u)Q8M5?&hiljU59_EUSpWb4 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 && ( - - )} + +