From 1dd59f7caa161275ab06a28ffa2b4025adcbad0f Mon Sep 17 00:00:00 2001 From: yumoqing Date: Wed, 5 Aug 2026 15:36:44 +0800 Subject: [PATCH] feat(tags): multi-select tag list + dedup create + tag_sync endpoint - tag_form.dspy: rewrite as multi-select checkbox list (clickable rows with toggle) + input field for new tag with + button (calls /api/tag/create with dedup) + submit button calls /api/tag/sync to sync media_tags + dynamically add new tag row to list after creation - media_cards.dspy: read tags from media_tags table instead of metadata - init.py: tag_create_handler adds dedup (check existing by kb_id+name) - init.py: new tag_sync_handler (full sync: delete old + insert selected) - config.json: add /api/tag/sync route --- rag/__pycache__/init.cpython-310.pyc | Bin 32142 -> 31889 bytes rag/init.py | 45 ++++ wwwroot/knowledge_bases_list/media_cards.dspy | 27 ++- wwwroot/knowledge_bases_list/tag_form.dspy | 198 +++++++++++++----- 4 files changed, 213 insertions(+), 57 deletions(-) diff --git a/rag/__pycache__/init.cpython-310.pyc b/rag/__pycache__/init.cpython-310.pyc index 3d783108cac6a8247a6171dec72f76c25d0221e0..b667d003162f3509af3ce612d392bfdb1519fbbb 100644 GIT binary patch delta 2749 zcmb7FYfw|^89whxNMeGLgam{b5V=S|1a!T?!iWV$E0+bAiwiXzIRU~o`4X-k7FXDg z><(6WYikt{S8Jzs*^VBab#~f1?d-Jv>C8^2+fLK&*gw6^blPs6=}y~Ow%>aWu9x;t zlgX3!eBbjv@8x`#v;T(k|AY-g>(^Ta{(0vf>pin@Yr_VkJ+)9S93Kc5GO1hpsa|7M zW?T{{t;hKO6m@EAEp#ZxWxHZg%vZ(3f((a*Nqf|xnC0YYancbrD@NHiExsV=bC-OR z@k;!%Mc$|+C?>_MIFxmYT~1cw$sT;$mp)}HUQ>uX8U+yOdyM~N zm~<)*c~hTw4%pJfF~!pBl)ZAAoIWQ`CPot#qmszuBupFEa&!JyM#VfMC-=@$oXeuT zS+-sk2SK(@y0jcJ6qk}9Z%JDapGwZf>HD?mOg?Rrvv`X6TN|HFtVI|+U=*U9Ilc(O zq#J~Zy-~NEt+?@fc^QTrc`N=luVCuzvp&bDAor{8!8rYY6h?)pq1Pzq@^+bdI~
jbOlG8jPwi)aM?UA2B{wD2d0U(LlI?P!H;)wA<=1~{j}o=1@G zBKdd%4;P)V6b5CUQJ^puM}HA?lnkb7sW|g;*blzvIrTSvZQ{ugZge;2*0R(^@$*W{nIDv(A8D z9_sXotl)F`ZPoYenhc9PGir9(*O`OT^S7G?Ar+N#oXU@I>{rjVxAA z@RIts>Rpi8!EK*~6;d`0G~UP|W(`Sv!!s$=6Jc&VbT}){&xbu2K3o8P^~&LOU^_%) zt>7V4zqrL)&7XIQf)LrjrNmS8?(DD;HuE>gs?lAu8*`5|*qX^}mV)_k=E$K$YF5l9 zg@S|9K)^pBg;)hXJd&AM&XpcnG^Kbk9$dhNxyF#~74jd|{l8LE7Qc-@*D*$;5&UIM zwylagvJs7iKdjq?nYDkmRng-Cf-hidT?2 z(+gwxux`TrlrIU46HMV?eY&lgB2CltjC!$tQM9Fyol4-ripIV6*U9`c0WE%Z25+^c zB+5bN?_>cf8u0f>gK~d>b>N-GoiK|p`n_u0(Vqakfj@n@2!?U><*o20-fQ)$l})nf zCApX}IvxtJ*GR9Q178^-vP1ZOOQ~s&V!e*W)+$qoOaqU&5xZI)FoR>Q`!KsHP5rQS z>e)>FZ4EHbiTQ8^KWf`0BH%jPCbg*jLl7?jp2|)_dn69-W9IR4_$o@rr#X`Dr^g*| z7Zd#X_-?C1J>uU5T<5ip8IC5Yi{pS)1V6?l$p;tJ2hs}I<|(OKp6|pqbjX=_t@BTC z7U$%)XIkfPE4O`vn3o9V@x{O%_ytA+CGeKI64(!RZE}ptUSq6w&D+irnV+))Tf)oT zh2k>cM=i-%F1hfNZqGA<-ku%s7ksIwSiG*)ibd@n^}QZ9=qz0sW5IV#x+qqJUHF~e zed0Htv?Bp6eP!OS@pu#VsMPHr2*^Rn&#g%5W>!PFT|=oa-TkBxZ4I>3jNZPLzGM*$ z*wB-Rzv}bC4fT`0+eXO5o1qJ^#6#{kule#Qy<-^yL zN!Vv#&qZ?M6tA5xKWh`GM)-ykvT$g`8Ns z`#5xRJIJ_nvcUBLAK>e0f|tj8tzR1W@Z>p2!H`lm^&|q_i0lS+LAze)s$kUw!vv!Q frwL{V{=am{gYwW9w6>}>ND?rAvJ{%JYclsg3irxj delta 2957 zcmb7Gdr(x@8Nc7%%d)V`b6+fP0zLo{Rz)FVP*DpgWB_HOfXm8VbP?F)>=hL6Y8qO{ zM}2-a8i52V%^0g=>#fzaX*<)g(=pRD{U_6zolMO*>1#TfBojM*rD@N1FGiF6)xC3n z`e-*59fxSDxu_KmE9nCnGns1QEXZz;5c!du6-!ssCIvNpQkWKF8T!0kM~9A1 z#Wf3@?U@y0nOx&mGv#bK=cEXN!BsQW3_fR9vQ)dAJ0YGDtb3C0k9D^CrZYQ%O`M**YPv5SqA2qj0G_W>al57CB!{Q_X6k z>Qs~D0yV=e$a!jlTsUQ!6%8f`IlC8`IT z%8E=C@=_cst2q4j7j3rl7R5Y=Ji`gb{}_x3ifKTSD|xdLc_W;vORkzS%MPVL_MSBP zgbuz2Jc@Y0IUgmPM_HzNCIxx9QaB;-)t4st!YHO0Nj575s%HX1eBFJ5nJ<*X2-7hi zmco^;&??HOp_tv}n{8ZLx+^psPv{Q~2BTX`A#Mro4@DJ!cyC-B+oip^qzux>Q!7I< z5h`_7rS`C*0IXRc2&|1y&`YZf|&0(*mUh(+U4I1=P3jIoe{Lsjb_8^1rAjrXb?cJdduj_eC70smlVD5S7# z{HV&kkbS`2#FL|e(V*-f4h#k3NqTC2?vL?4332G>dDy+Ja0hpJYs(#AgM3KkmyKV! z|4lYIOcID-5iKBr^jY$MBnuyb?Xxnx<9!Y)wTxwdFu{x3->W}?JU5kPBgo-#$1RWA z!P2=7lWK;&dg1L<8%1YxBt5S8uQV*6i9&)Rvbt5X)lDsoCyxgElrZy$C)eB9|jpSz&!3dc&k+cQxJ<|Zc(NgMV6J#M< zf1P7tgB$*VH4VpLJN}}f3_isV8rt)Baz1ZgcpvKv`sHw6smNA+?rbIYZ1`82o?D`k zTa@j@{*5PL4L;ns!_!AhCxz`I$wksaERzOgjYfJ)bE0Oi`_TinTrxIHDxEAj>xuFHK zH?PEs<~D0LagLGe<#?>Q(N2wsTLY00Z3_QTAi_4``_0An22l_`v2dRFyiK%$1K2_C zBW&#@`!U1*{N_5_NBM-`%{x+RP1^O5>Pj+hPH0Gs&9?8nK|o2TeHEvI5%i zT8jscqHRmA$PVHAE#vi15Q%-5;F|<`>vW*j#jXCnz~G=???pF7J4U4A+H~u*Xv-xz zk02lLl@2%dv=wHYpz*f|x+wEgfN802{IDYduePN^Kfd0!98Tlfoq1Yv`>()!MrUGb zY8L0%;UJo}F2Xl_`C8*vS%edKy)y?!@m}ZCW+dhbO!95uidNrJIF2!2JzUU!>YMsf zF7Nf{qEjx!*&Pn?qLIMq-h{M177Qps|DM3GJQ!r(#Rog8;U%1I&C{y8-T|1zk3zZd zOI+Nu1}mXg9 z+)pi?XPElWSvuchA#U64g{xFDR(IuSKj%6l zs^J*#@40O@eBxB71HPws#EB^bbufj^13Bi`xWiH_y#ig2v(*>6n^H3xhBpV?kce*% zc;TA%>A+11O7TYc+tM62#y4!)xGPd(Hq3bdw=JGj7Y!C_&e6czd1NPft|;vqd9z|bVd(J6oOad!H=UQ#zK%!=0vY2*W6)* zTd&Ny$!DoaKa8FTqSK%Yv-g!`-XVr)`9hU9-6rXIyfFH#l|mQ~|8@L$)CF^B8LP{Ck+l44O<=DREFt(Yhd7L~Bj_J1 zvel7FPj}hp&n#XUONCs#J$4K3YD4?qf|^QV)f1d1I7@JW;3a}-f|m)d5L_j=MsOF| z_)-|a+3^a;J6yrH(KxSwx7|1n@WJ>Q9w>USF8nwGoowt?DpWr>=n!D^qGtyQVgyGC ajuZTU>5v8G!S_p>Y)7xU3c4|*F8&`k(E@k? diff --git a/rag/init.py b/rag/init.py index 1bb93bd..28a7d8a 100644 --- a/rag/init.py +++ b/rag/init.py @@ -679,6 +679,12 @@ async def tag_create_handler(request, params_kw, *args, **kwargs): return json.dumps({"error": "kb_id and name required"}) userorgid = await env.get_userorgid() async with get_sor_context(env, 'rag') as sor: + existing = await sor.sqlExe( + "SELECT id, color FROM tags WHERE kb_id=${kb_id}$ AND name=${name}$ AND org_id=${org_id}$", + {"kb_id": kb_id, "name": name, "org_id": userorgid}) + if existing: + return json.dumps({"status": "SUCCEEDED", "tag_id": existing[0].id, "name": name, + "color": existing[0].color, "duplicate": True}, ensure_ascii=False) tag_id = uuid.uuid4().hex[:16] await sor.sqlExe( "INSERT INTO tags (id, kb_id, name, color, org_id, created_at) " @@ -865,6 +871,44 @@ async def tag_search_handler(request, params_kw, *args, **kwargs): return json.dumps({"error": str(e)}) +async def tag_sync_handler(request, params_kw, *args, **kwargs): + """全量同步媒体标签关联:删除不在选中列表的,插入新增的""" + env = request._run_ns + try: + kb_id = params_kw.get("kb_id", "") + media_type = params_kw.get("media_type", "") + media_id = params_kw.get("media_id", "") + tag_ids_str = params_kw.get("tag_ids", "") + if not all([kb_id, media_type, media_id]): + return json.dumps({"error": "kb_id, media_type, media_id required"}) + if media_type not in ("document", "face", "voice"): + return json.dumps({"error": "media_type must be document/face/voice"}) + wanted_ids = [t.strip() for t in tag_ids_str.split(",") if t.strip()] + async with get_sor_context(env, 'rag') as sor: + recs = await sor.sqlExe( + "SELECT id, tag_id FROM media_tags WHERE media_type=${type}$ AND media_id=${mid}$", + {"type": media_type, "mid": media_id}) + current = {r.tag_id: r.id for r in recs} + removed = 0 + for tid, mt_id in current.items(): + if tid not in wanted_ids: + await sor.sqlExe("DELETE FROM media_tags WHERE id=${id}$", {"id": mt_id}) + removed += 1 + added = 0 + for tid in wanted_ids: + if tid not in current: + mt_id = uuid.uuid4().hex[:16] + await sor.sqlExe( + "INSERT INTO media_tags (id, kb_id, media_type, media_id, tag_id, created_at) " + "VALUES (${id}$, ${kb_id}$, ${type}$, ${mid}$, ${tid}$, NOW())", + {"id": mt_id, "kb_id": kb_id, "type": media_type, "mid": media_id, "tid": tid}) + added += 1 + return json.dumps({"status": "SUCCEEDED", "added": added, "removed": removed}) + except Exception as e: + exception(f"tag_sync: {e}, {format_exc()}") + return json.dumps({"error": str(e)}) + + def init_rag_module(): env = ServerEnv() rf = RegisterFunction() @@ -884,3 +928,4 @@ def init_rag_module(): rf.register("tag_unassign", tag_unassign_handler) rf.register("tag_media_tags", tag_media_tags_handler) rf.register("tag_search", tag_search_handler) + rf.register("tag_sync", tag_sync_handler) diff --git a/wwwroot/knowledge_bases_list/media_cards.dspy b/wwwroot/knowledge_bases_list/media_cards.dspy index c75c4e1..ef054d6 100644 --- a/wwwroot/knowledge_bases_list/media_cards.dspy +++ b/wwwroot/knowledge_bases_list/media_cards.dspy @@ -32,6 +32,20 @@ try: "metadata": r.metadata or '{}', "created_at": str(r.created_at)[:16] if r.created_at else '' }) + + # 查出入介质标签(从标签表中取出来),按 doc_id 编组 + doc_ids = [r["id"] for r in rows] + doc_tags = {} + if doc_ids: + mt_recs = await sor.sqlExe( + "SELECT mt.media_id, t.name, t.color FROM media_tags mt " + "JOIN tags t ON mt.tag_id=t.id " + "WHERE mt.media_type='document' AND mt.media_id IN (${ids}$)", + {"ids": doc_ids}) + for mt in mt_recs: + doc_tags.setdefault(mt.media_id, []).append({"name": mt.name, "color": mt.color or '#3b82f6'}) + for r in rows: + r["media_tags"] = doc_tags.get(r["id"], []) except Exception: return {"widgettype": "Text", "options": {"text": "加载失败", "cfontsize": 14, "color": "#e74c3c", "halign": "center", "marginTop": "40px"}} @@ -49,14 +63,7 @@ for f in rows: doc_id = f["id"] fname = f["file_name"] media_url = safe_url(f["file_path"]) - meta = {} - try: - meta = json.loads(f["metadata"]) - except Exception: - meta = {} - tags = meta.get('tags', []) - if not isinstance(tags, list): - tags = [] + tags = f.get("media_tags", []) if kind == 'voice': media_widget = {"widgettype": "Html", "options": {"html": "", "padding": "8px 12px"}} @@ -66,7 +73,9 @@ for f in rows: if tags: tag_widgets = [] for t in tags: - tag_widgets.append({"widgettype": "Text", "options": {"text": "#" + str(t), "cfontsize": 10, "color": "#1565c0", "bgcolor": "#e3f2fd", "padding": "1px 8px", "borderRadius": "8px", "margin": "2px 4px 2px 0"}}) + tag_widgets.append({"widgettype": "Text", "options": {"text": "#" + str(t["name"]), "cfontsize": 10, + "color": "#1565c0", "bgcolor": "#e3f2fd", + "padding": "1px 8px", "borderRadius": "8px", "margin": "2px 4px 2px 0"}}) tag_row = {"widgettype": "HBox", "options": {"wrap": True, "padding": "0 12px", "alignItems": "center"}, "subwidgets": tag_widgets} else: tag_row = {"widgettype": "Text", "options": {"text": "暂无标签", "cfontsize": 10, "color": "#bbb", "padding": "0 12px"}} diff --git a/wwwroot/knowledge_bases_list/tag_form.dspy b/wwwroot/knowledge_bases_list/tag_form.dspy index 6e966b3..4ac722e 100644 --- a/wwwroot/knowledge_bases_list/tag_form.dspy +++ b/wwwroot/knowledge_bases_list/tag_form.dspy @@ -4,53 +4,82 @@ ns = params_kw.copy() doc_id = ns.get('doc_id', '') kb_id = ns.get('kb_id', '') kind = ns.get('kind', '') +media_type = 'document' db = DBPools() dbname = get_module_dbname('rag') fname = '' -cur_tags = [] +tag_names_by_id = {} # map tag_id -> {"name":..., "color":...} +cur_tag_ids = [] # ids already on this document +all_tags = [] # all tags for kb: list of (id, name, color) + try: async with db.sqlorContext(dbname) as sor: recs = await sor.sqlExe( - "SELECT file_name, metadata FROM documents WHERE id=${id}$ AND kb_id=${kb_id}$", + "SELECT file_name FROM documents WHERE id=${id}$ AND kb_id=${kb_id}$", {"id": doc_id, "kb_id": kb_id}) if recs: fname = recs[0].file_name or '' - try: - meta = json.loads(recs[0].metadata or '{}') - except Exception: - meta = {} - cur_tags = meta.get('tags', []) - if not isinstance(cur_tags, list): - cur_tags = [] + + # 有哪些标签 + tag_recs = await sor.sqlExe( + "SELECT id, name, color FROM tags WHERE kb_id=${kb_id}$ ORDER BY created_at", + {"kb_id": kb_id}) + for t in tag_recs: + all_tags.append((t.id, t.name, t.color or '#3b82f6')) + tag_names_by_id[t.id] = {"name": t.name, "color": t.color or '#3b82f6'} + + # 当前已关联的 + mt_recs = await sor.sqlExe( + "SELECT tag_id FROM media_tags WHERE media_type=${type}$ AND media_id=${mid}$", + {"type": media_type, "mid": doc_id}) + cur_tag_ids = [r.tag_id for r in mt_recs] except Exception: pass -subwidgets = [] -if not doc_id: - subwidgets.append({"widgettype": "Text", "options": {"text": "缺少 doc_id 参数", "cfontsize": 13, "color": "#e74c3c", "padding": "20px"}}) -else: - subwidgets.append({"widgettype": "Text", "options": {"text": "📄 " + (fname or doc_id), "cfontsize": 13, "fontWeight": "bold", "color": "#333", "padding": "0 0 8px 0"}}) - if cur_tags: - tws = [] - for t in cur_tags: - tws.append({"widgettype": "Text", "options": {"text": "#" + str(t), "cfontsize": 10, "color": "#1565c0", "bgcolor": "#e3f2fd", "padding": "1px 8px", "borderRadius": "8px", "margin": "2px 6px 2px 0"}}) - subwidgets.append({"widgettype": "HBox", "options": {"wrap": True, "alignItems": "center", "marginBottom": "8px"}, "subwidgets": tws}) - else: - subwidgets.append({"widgettype": "Text", "options": {"text": "暂无标签", "cfontsize": 11, "color": "#aaa", "marginBottom": "8px"}}) - subwidgets.append({ - "widgettype": "Form", - "id": "tag_input_form", - "options": { - "cols": 1, - "fields": [ - {"name": "tag", "label": "标签名", "uitype": "str", "required": True} - ], - "buttons": [{"name": "submit", "label": "✓ 添加标签", "icon": "save"}] - } +# 构建 checkbox 行 +tag_rows = [] +for i, (tid, tname, tcolor) in enumerate(all_tags): + checked = tid in cur_tag_ids + tag_rows.append({ + "widgettype": "HBox", + "id": "tagrow_" + tid, + "options": {"data-sel": "1" if checked else "0", "margin": "3px 0", "padding": "4px 8px", + "borderRadius": "6px", "alignItems": "center", + "cursor": "pointer", "width": "100%", + "bgcolor": "#e8f0fe" if checked else "#f8f9fa"}, + "subwidgets": [ + {"widgettype": "Text", "id": "tagchk_" + tid, + "options": {"text": "\u2611" if checked else "\u2610", + "cfontsize": 14, "cwidth": 3, "halign": "center", + "color": "#1565c0" if checked else "#999", + "padding": "0 6px 0 0"}}, + {"widgettype": "Text", + "options": {"text": "#" + tname, "cfontsize": 12, + "color": "#1565c0", "bgcolor": "#e3f2fd", + "padding": "2px 10px", "borderRadius": "10px", + "margin": "0 4px 0 0"}} + ], + "binds": [{ + "wid": "self", "event": "click", + "actiontype": "script", + "script": ( + "var el=this.dom_element;" + "var on=el.getAttribute('data-sel')==='1';el.setAttribute('data-sel',on?'0':'1');" + "var c=el.querySelector('[id^=tagchk_]');if(c)c.textContent=on?'\u2610':'\u2611';" + "el.style.backgroundColor=on?'#f8f9fa':'#e8f0fe';" + "var tc=c?c.nextElementSibling:null;if(tc)tc.style.color=on?'#999':'#1565c0';" + ) + }] }) +if not tag_rows: + tag_rows.append({"widgettype": "Text", + "options": {"text": "该知识库暂无标签,请在下方创建", "cfontsize": 12, + "color": "#aaa", "halign": "center", "padding": "12px"}}) + +# 父面板刷新,js refresh_js = '' if kind: refresh_js = ("var mp=document.querySelector('#media_card_panel');" @@ -58,25 +87,98 @@ if kind: + kb_id + "&kind=" + kind + "').then(function(r){return r.json()}).then(function(d){" "var mw=mp.bricks_widget;mw.clear_widgets();bricks.widgetBuild(d,mw).then(function(nw){if(nw)mw.add_widget(nw)})}).catch(function(){})};") -tag_script = ("var d=event&&event.params?event.params:{};if(!d||!d.tag){alert('请输入标签名');return};" - "var u='/rag/knowledge_bases_list/add_tag.dspy?doc_id=" + doc_id + "&kb_id=" + kb_id + "'+'&tag='+encodeURIComponent(d.tag);" - "fetch(u).then(function(r){return r.text()}).then(function(t){var d2={};try{d2=JSON.parse(t)}catch(e){};" - "if(d2&&d2.status==='SUCCEEDED'){" + refresh_js + - "var pw=null;var w=this;while(w){if(w instanceof bricks.PopupWindow||w instanceof bricks.Popup){pw=w;break};w=w.parent};" - "if(pw){pw.dismiss();pw.destroy()}else{alert('标签已添加:'+d2.added)}}" - "else{alert('添加失败:'+((d2&&d2.error)||t))}}.bind(this)).catch(function(){alert('网络错误')})") +close_popup_js = ( + "var pw=null;var w=this;" + "while(w){if(w instanceof bricks.PopupWindow||w instanceof bricks.Popup){pw=w;break};w=w.parent};" + "if(pw){pw.dismiss();pw.destroy()};" +) + +# 关闭弹窗的快捷函数 +close_js = "function closeP(w){var pw=null;while(w){if(w instanceof bricks.PopupWindow||w instanceof bricks.Popup){pw=w;break};w=w.parent};if(pw){pw.dismiss();pw.destroy()}}" + +# + 按钮脚本:创建新标签 +add_tag_js = ("var inp=document.querySelector('#new_tag_input');" + "var name=(inp.value||'').trim();" + "if(!name){alert('请输入新标签名');return;};" + "fetch('/api/tag/create?kb_id=" + kb_id + "&name='+encodeURIComponent(name)).then(function(r){return r.json()}).then(function(d){" + "if(d&&d.status==='SUCCEEDED'){" + "if(d.duplicate){alert('标签「'+d.name+'」已存在');}" + "var tid=(d.tag_id||'');" + "var tname=(d.name||name);" + "var tcolor=(d.color||'#3b82f6');" + "var panel=document.querySelector('#tag_list_panel');" + "if(panel&&tid){" + "var exist=panel.querySelector('#tagrow_'+tid);" + "if(exist){exist.setAttribute('data-sel','1');exist.style.backgroundColor='#e8f0fe';" + "var ec=exist.querySelector('[id^=tagchk_]');if(ec){ec.textContent='\u2611';ec.style.color='#1565c0';}}" + "else{" + "var row=document.createElement('div');" + "row.setAttribute('data-sel','1');row.id='tagrow_'+tid;" + "row.style.cssText='display:flex;align-items:center;margin:3px 0;padding:4px 8px;border-radius:6px;width:100%;cursor:pointer;background:#e8f0fe';" + "row.onclick=function(){var el=this;var on=el.getAttribute('data-sel')==='1';el.setAttribute('data-sel',on?'0':'1');var c=el.querySelector('[id^=tagchk_]');if(c)c.textContent=on?'\u2610':'\u2611';el.style.backgroundColor=on?'#f8f9fa':'#e8f0fe';var tc=c?c.nextElementSibling:null;if(tc)tc.style.color=on?'#999':'#1565c0';};" + "var chk=document.createElement('span');chk.id='tagchk_'+tid;chk.style.cssText='font-size:14px;width:3em;text-align:center;color:#1565c0;padding-right:6px';chk.textContent='\u2611';" + "var lbl=document.createElement('span');lbl.style.cssText='font-size:12px;color:#1565c0;background:#e3f2fd;padding:2px 10px;border-radius:10px';lbl.textContent='#'+tname;" + "row.appendChild(chk);row.appendChild(lbl);panel.appendChild(row);}}" + "inp.value='';}" + "else{alert('创建失败:'+(d&&d.error||'未知错误'));}" + "}).catch(function(){alert('网络错误')});") + +# 提交按钮脚本 +submit_js = ("var rows=document.querySelectorAll('#tag_list_panel [data-sel]');" + "var ids=[];rows.forEach(function(r){if(r.getAttribute('data-sel')==='1'){ids.push(r.id.replace('tagrow_',''))}});" + "if(ids.length===0){alert('请至少勾选一个标签');return};" + "fetch('/api/tag/sync?kb_id=" + kb_id + "&media_type=document&media_id=" + doc_id + "&tag_ids='+encodeURIComponent(ids.join(',')))" + ".then(function(r){return r.json()}).then(function(d){" + "if(d&&d.status==='SUCCEEDED'){" + refresh_js + "alert('已保存:新增 '+d.added+' 个,移除 '+d.removed+' 个');" + close_popup_js + "}" + "else{alert('保存失败:'+(d&&d.error||'未知错误'));}" + "}).catch(function(){alert('网络错误')});") + +# 组合弹窗内容 +subwidgets = [] +subwidgets.append({"widgettype": "Text", "options": {"text": "\U0001f4c4 " + (fname or doc_id), + "cfontsize": 13, "fontWeight": "bold", "color": "#333", "padding": "0 0 4px 0"}}) +subwidgets.append({"widgettype": "Text", "options": {"text": "勾选要添加的标签(可多选):", + "cfontsize": 11, "color": "#666", "padding": "0 0 4px 0"}}) + +# 标签列表面板 +tag_list_box = {"widgettype": "VBox", "id": "tag_list_panel", + "options": {"css": "filler", "overflow": "auto", "padding": "4px", + "border": "1px solid #e0e0e0", "borderRadius": "8px", + "bgcolor": "#fcfcfc"}, + "subwidgets": tag_rows} +subwidgets.append(tag_list_box) + +# 新标签输入行 +new_tag_row = {"widgettype": "HBox", "options": {"alignItems": "center", "margin": "10px 0", "spacing": "6px"}, + "subwidgets": [ + {"widgettype": "Html", "options": { + "html": "", + "width": "100%"}}, + {"widgettype": "Button", "id": "btn_add_tag", + "options": {"label": "\uff0b", "cfontsize": 14, "fontWeight": "bold", + "bgcolor": "#e3f2fd", "color": "#1565c0", + "padding": "5px 14px", "borderRadius": "6px"}, + "binds": [{"wid": "self", "event": "click", + "actiontype": "script", "script": add_tag_js}]} + ]} +subwidgets.append(new_tag_row) + +# 提交按钮 +subwidgets.append({"widgettype": "HBox", "options": {"alignItems": "center"}, + "subwidgets": [ + {"widgettype": "Text", "options": {"text": "", "css": "filler"}}, + {"widgettype": "Button", "id": "btn_submit", + "options": {"label": "\u2705 \u4fdd\u5b58\u6240\u9009\u6807\u7b7e", + "cfontsize": 13, "fontWeight": "bold", + "bgcolor": "#1565c0", "color": "#fff", + "padding": "7px 20px", "borderRadius": "6px"}, + "binds": [{"wid": "self", "event": "click", + "actiontype": "script", "script": submit_js}]} + ]}) result = { "widgettype": "VBox", - "options": {"width": "100%", "padding": "16px", "spacing": "6px"}, - "subwidgets": subwidgets, - "binds": [ - { - "wid": "tag_input_form", - "event": "submit", - "actiontype": "script", - "script": tag_script - } - ] + "options": {"width": "100%", "padding": "12px", "spacing": "4px"}, + "subwidgets": subwidgets } return result