app.js 297 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585358635873588358935903591359235933594359535963597359835993600360136023603360436053606360736083609361036113612361336143615361636173618361936203621362236233624362536263627362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651365236533654365536563657365836593660366136623663366436653666366736683669367036713672367336743675367636773678367936803681368236833684368536863687368836893690369136923693369436953696369736983699370037013702370337043705370637073708370937103711371237133714371537163717371837193720372137223723372437253726372737283729373037313732373337343735373637373738373937403741374237433744374537463747374837493750375137523753375437553756375737583759376037613762376337643765376637673768376937703771377237733774377537763777377837793780378137823783378437853786378737883789379037913792379337943795379637973798379938003801380238033804380538063807380838093810381138123813381438153816381738183819382038213822382338243825382638273828382938303831383238333834383538363837383838393840384138423843384438453846384738483849385038513852385338543855385638573858385938603861386238633864386538663867386838693870387138723873387438753876387738783879388038813882388338843885388638873888388938903891389238933894389538963897389838993900390139023903390439053906390739083909391039113912391339143915391639173918391939203921392239233924392539263927392839293930393139323933393439353936393739383939394039413942394339443945394639473948394939503951395239533954395539563957395839593960396139623963396439653966396739683969397039713972397339743975397639773978397939803981398239833984398539863987398839893990399139923993399439953996399739983999400040014002400340044005400640074008400940104011401240134014401540164017401840194020402140224023402440254026402740284029403040314032403340344035403640374038403940404041404240434044404540464047404840494050405140524053405440554056405740584059406040614062406340644065406640674068406940704071407240734074407540764077407840794080408140824083408440854086408740884089409040914092409340944095409640974098409941004101410241034104410541064107410841094110411141124113411441154116411741184119412041214122412341244125412641274128412941304131413241334134413541364137413841394140414141424143414441454146414741484149415041514152415341544155415641574158415941604161416241634164416541664167416841694170417141724173417441754176417741784179418041814182418341844185418641874188418941904191419241934194419541964197419841994200420142024203420442054206420742084209421042114212421342144215421642174218421942204221422242234224422542264227422842294230423142324233423442354236423742384239424042414242424342444245424642474248424942504251425242534254425542564257425842594260426142624263426442654266426742684269427042714272427342744275427642774278427942804281428242834284428542864287428842894290429142924293429442954296429742984299430043014302430343044305430643074308430943104311431243134314431543164317431843194320432143224323432443254326432743284329433043314332433343344335433643374338433943404341434243434344434543464347434843494350435143524353435443554356435743584359436043614362436343644365436643674368436943704371437243734374437543764377437843794380438143824383438443854386438743884389439043914392439343944395439643974398439944004401440244034404440544064407440844094410441144124413441444154416441744184419442044214422442344244425442644274428442944304431443244334434443544364437443844394440444144424443444444454446444744484449445044514452445344544455445644574458445944604461446244634464446544664467446844694470447144724473447444754476447744784479448044814482448344844485448644874488448944904491449244934494449544964497449844994500450145024503450445054506450745084509451045114512451345144515451645174518451945204521452245234524452545264527452845294530453145324533453445354536453745384539454045414542454345444545454645474548454945504551455245534554455545564557455845594560456145624563456445654566456745684569457045714572457345744575457645774578457945804581458245834584458545864587458845894590459145924593459445954596459745984599460046014602460346044605460646074608460946104611461246134614461546164617461846194620462146224623462446254626462746284629463046314632463346344635463646374638463946404641464246434644464546464647464846494650465146524653465446554656465746584659466046614662466346644665466646674668466946704671467246734674467546764677467846794680468146824683468446854686468746884689469046914692469346944695469646974698469947004701470247034704470547064707470847094710471147124713471447154716471747184719472047214722472347244725472647274728472947304731473247334734473547364737473847394740474147424743474447454746474747484749475047514752475347544755475647574758475947604761476247634764476547664767476847694770477147724773477447754776477747784779478047814782478347844785478647874788478947904791479247934794479547964797479847994800480148024803480448054806480748084809481048114812481348144815481648174818481948204821482248234824482548264827482848294830483148324833483448354836483748384839484048414842484348444845484648474848484948504851485248534854485548564857485848594860486148624863486448654866486748684869487048714872487348744875487648774878487948804881488248834884488548864887488848894890489148924893489448954896489748984899490049014902490349044905490649074908490949104911491249134914491549164917491849194920492149224923492449254926492749284929493049314932493349344935493649374938493949404941494249434944494549464947494849494950495149524953495449554956495749584959496049614962496349644965496649674968496949704971497249734974497549764977497849794980498149824983498449854986498749884989499049914992499349944995499649974998499950005001500250035004500550065007500850095010501150125013501450155016501750185019502050215022502350245025502650275028502950305031503250335034503550365037503850395040504150425043504450455046504750485049505050515052505350545055505650575058505950605061506250635064506550665067506850695070507150725073507450755076507750785079508050815082508350845085508650875088508950905091509250935094509550965097509850995100510151025103510451055106510751085109511051115112511351145115511651175118511951205121512251235124512551265127512851295130513151325133513451355136513751385139514051415142514351445145514651475148514951505151515251535154515551565157515851595160516151625163516451655166516751685169517051715172517351745175517651775178517951805181518251835184518551865187518851895190519151925193519451955196519751985199520052015202520352045205520652075208520952105211521252135214521552165217521852195220522152225223522452255226522752285229523052315232523352345235523652375238523952405241524252435244524552465247524852495250525152525253525452555256525752585259526052615262526352645265526652675268526952705271527252735274527552765277527852795280528152825283528452855286528752885289529052915292529352945295529652975298529953005301530253035304530553065307530853095310531153125313531453155316531753185319532053215322532353245325532653275328532953305331533253335334533553365337533853395340534153425343534453455346534753485349535053515352535353545355535653575358535953605361536253635364536553665367536853695370537153725373537453755376537753785379538053815382538353845385538653875388538953905391539253935394539553965397539853995400540154025403540454055406540754085409541054115412541354145415541654175418541954205421542254235424542554265427542854295430543154325433543454355436543754385439544054415442544354445445544654475448544954505451545254535454545554565457545854595460546154625463546454655466546754685469547054715472547354745475547654775478547954805481548254835484548554865487548854895490549154925493549454955496549754985499550055015502550355045505550655075508550955105511551255135514551555165517551855195520552155225523552455255526552755285529553055315532553355345535553655375538553955405541554255435544554555465547
  1. (function () {
  2. 'use strict';
  3. const API_BASE = '';
  4. const ACCOUNTS_KEY = 'qiwei-accounts';
  5. const CURRENT_ACCOUNT_KEY = 'qiwei-current-account';
  6. const CO_FILTERS_KEY = 'qiwei-co-filters';
  7. const PT_FILTERS_KEY = 'qiwei-pt-filters';
  8. const TF_FILTERS_KEY = 'qiwei-tf-filters';
  9. const GROUP_FILTERS_KEY = 'qiwei-group-filters';
  10. function loadFilters(key, defaults = {}) {
  11. try {
  12. return { ...defaults, ...JSON.parse(localStorage.getItem(key) || '{}') };
  13. } catch {
  14. return defaults;
  15. }
  16. }
  17. function saveFilters(key, filters) {
  18. localStorage.setItem(key, JSON.stringify(filters));
  19. }
  20. const state = {
  21. page: 'agent',
  22. accounts: JSON.parse(localStorage.getItem(ACCOUNTS_KEY) || '[]'),
  23. currentAccountId: localStorage.getItem(CURRENT_ACCOUNT_KEY) || null,
  24. status: null,
  25. groups: [],
  26. groupSync: {},
  27. groupFilters: loadFilters(GROUP_FILTERS_KEY, { keyword: '', status: '', showCustomerOnly: false }),
  28. groupKeywords: null,
  29. jobs: {},
  30. renderToken: 0,
  31. toastId: 0,
  32. modal: null,
  33. loginTimer: null,
  34. loginPhase: 'idle',
  35. loginUid: null,
  36. autoRecoverAttempted: false,
  37. preRecoveryPage: null,
  38. selectedGroupIds: new Set(),
  39. customerOps: { customers: [], defaultGreeting: '您好 {{name}},我是您的专属顾问,方便加您企业微信沟通。', rateLimit: 12, maxAttempts: 2, lastResult: null, master: { hub: null, selectedId: null, keyword: '', loading: false }, ...loadFilters(CO_FILTERS_KEY, { tablePage: 1, keyword: '', status: '', portrait: '', tag: '' }) },
  40. portraits: { lastPortrait: null, lastTags: [], personalLabels: [], lastTransferPreview: null, ...loadFilters(PT_FILTERS_KEY, { tablePage: 1, keyword: '', source: '', tag: '' }) },
  41. agent: { status: null, conversations: [], responseMonitor: null, selectedId: null, timer: null, loading: false, signature: null },
  42. skills: { registry: null, selectedPackageId: null, selectedSkillId: null, keyword: '' },
  43. knowledge: { tree: null, selectedNodeId: null, file: null, properties: null, selectedProperty: null, expandedFolderIds: new Set(), foldersInitialized: false, filters: { q: '', district: '', layout: '', decoration: '', maxPrice: '' }, meetings: { hub: null, selectedId: null, loading: false }, documents: { hub: null, selectedId: null, loading: false }, todos: { hub: null, selectedId: null, loading: false, userResults: [] }, tasks: { hub: null, loading: false, filters: { q: '', source: '', status: 'active' } } },
  44. transfers: { lastTransferPreview: null, ...loadFilters(TF_FILTERS_KEY, { tablePage: 1, status: '', user: '' }) }
  45. };
  46. const els = {
  47. content: document.getElementById('content'),
  48. pageTitle: document.getElementById('page-title'),
  49. nav: document.getElementById('nav'),
  50. sidebar: document.getElementById('sidebar'),
  51. menuBtn: document.getElementById('menu-btn'),
  52. toastContainer: document.getElementById('toast-container'),
  53. accountBtn: document.getElementById('account-btn'),
  54. accountDropdown: document.getElementById('account-dropdown'),
  55. accountName: document.getElementById('account-name'),
  56. accountDot: document.getElementById('account-dot'),
  57. accountList: document.getElementById('account-list')
  58. };
  59. function api(method, path, body, timeoutMs = 0) {
  60. const opts = { method, headers: {} };
  61. const controller = timeoutMs > 0 ? new AbortController() : null;
  62. const timeout = controller ? setTimeout(() => controller.abort(), timeoutMs) : null;
  63. if (controller) opts.signal = controller.signal;
  64. if (body !== undefined) {
  65. opts.headers['Content-Type'] = 'application/json; charset=utf-8';
  66. opts.body = JSON.stringify(body);
  67. }
  68. return fetch(`${API_BASE}${path}`, opts).then(async res => {
  69. const data = await res.json().catch(() => ({}));
  70. if (!res.ok) return Promise.reject(data);
  71. return data;
  72. }).catch(error => {
  73. if (error?.name === 'AbortError') throw new Error('状态检查超时,请稍后重试');
  74. throw error;
  75. }).finally(() => {
  76. if (timeout) clearTimeout(timeout);
  77. });
  78. }
  79. function currentAccount() {
  80. return state.accounts.find(a => a.id === state.currentAccountId) || null;
  81. }
  82. function getGuid() {
  83. const acc = currentAccount();
  84. return acc ? ((acc.guid && acc.guid !== 'server-managed' ? acc.guid : '') || acc.userId || '') : '';
  85. }
  86. function saveAccounts() {
  87. localStorage.setItem(ACCOUNTS_KEY, JSON.stringify(state.accounts));
  88. localStorage.setItem(CURRENT_ACCOUNT_KEY, state.currentAccountId || '');
  89. }
  90. function isGroupSynced(roomId) {
  91. return !!(state.groupSync[roomId] && state.groupSync[roomId].lastSyncAt);
  92. }
  93. function addAccount(account) {
  94. const existing = state.accounts.find(a =>
  95. (account.uid && a.uid === account.uid) ||
  96. ((account.guid || account.userId) && a.guid === account.guid && a.userId === account.userId)
  97. );
  98. if (existing) {
  99. Object.assign(existing, account, { id: existing.id });
  100. state.currentAccountId = existing.id;
  101. } else {
  102. account.id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
  103. state.accounts.push(account);
  104. state.currentAccountId = account.id;
  105. }
  106. saveAccounts();
  107. updateAccountSwitcher();
  108. }
  109. async function switchAccount(id, options = {}) {
  110. const account = state.accounts.find(item => item.id === id);
  111. if (!account) return false;
  112. if (!account.uid) {
  113. toast('该账号缺少 Fmode 设备绑定,请重新扫码登录后再切换', 'error');
  114. return false;
  115. }
  116. try {
  117. const result = await api('POST', '/api/accounts/switch', {
  118. uid: account.uid,
  119. guid: account.guid === 'server-managed' ? '' : account.guid,
  120. userId: account.userId,
  121. nickname: account.nickname,
  122. corpName: account.corpName,
  123. });
  124. const active = result.data?.account || {};
  125. state.currentAccountId = id;
  126. account.uid = active.uid || account.uid;
  127. account.guid = active.guid || account.guid;
  128. account.userId = active.userId || account.userId;
  129. account.nickname = account.nickname || active.nickname || account.userId;
  130. account.corpName = account.corpName || active.corpName || '';
  131. account.online = Boolean(active.online);
  132. state.agent.status = null;
  133. state.agent.conversations = [];
  134. state.agent.selectedId = null;
  135. state.agent.signature = null;
  136. saveAccounts();
  137. updateAccountSwitcher();
  138. renderPage();
  139. if (!options.silent) toast(result.assistantMessage || `已切换账号:${account.nickname}`);
  140. return true;
  141. } catch (error) {
  142. toast(error.message || '账号切换失败', 'error');
  143. return false;
  144. }
  145. }
  146. function removeAccount(id) {
  147. state.accounts = state.accounts.filter(a => a.id !== id);
  148. if (state.currentAccountId === id) {
  149. state.currentAccountId = state.accounts.length ? state.accounts[0].id : null;
  150. }
  151. saveAccounts();
  152. updateAccountSwitcher();
  153. renderPage();
  154. }
  155. function updateAccountSwitcher() {
  156. const acc = currentAccount();
  157. if (acc) {
  158. els.accountName.textContent = acc.nickname || acc.userId || '已登录账号';
  159. els.accountDot.className = 'account-dot ' + (acc.online ? 'online' : 'offline');
  160. } else {
  161. els.accountName.textContent = '未登录';
  162. els.accountDot.className = 'account-dot offline';
  163. }
  164. els.accountList.innerHTML = state.accounts.map(a => `
  165. <button class="account-option ${a.id === state.currentAccountId ? 'active' : ''}" data-id="${escapeHtml(a.id)}">
  166. <span class="status-dot ${a.online ? 'online' : 'offline'}"></span>
  167. <span class="account-info">
  168. <strong>${escapeHtml(a.nickname || a.userId || a.guid || '未知账号')}</strong>
  169. <span>${escapeHtml(a.corpName || '企业微信账号')}</span>
  170. </span>
  171. </button>
  172. `).join('');
  173. els.accountList.querySelectorAll('.account-option').forEach(btn => {
  174. btn.addEventListener('click', async () => {
  175. const switched = await switchAccount(btn.dataset.id);
  176. if (switched) els.accountDropdown.classList.remove('open');
  177. });
  178. });
  179. }
  180. async function refreshAgentAccountHeader() {
  181. if (currentAccount()) return;
  182. try {
  183. const result = await api('GET', '/api/agent/status');
  184. if (currentAccount() || !['skills', 'knowledge', 'customer-ops'].includes(state.page)) return;
  185. const account = result.data?.account || {};
  186. if (!account.configured) return;
  187. els.accountName.textContent = account.nickname || '企微账号';
  188. els.accountDot.className = `account-dot ${account.online ? 'online' : 'offline'}`;
  189. } catch {}
  190. }
  191. function toast(message, type = 'success') {
  192. const id = ++state.toastId;
  193. const el = document.createElement('div');
  194. el.className = `toast ${type}`;
  195. el.innerHTML = `<span style="flex:1;min-width:0;word-break:break-word">${escapeHtml(message)}</span>
  196. <button class="toast-close" aria-label="关闭" title="关闭">×</button>`;
  197. const closeBtn = el.querySelector('.toast-close');
  198. let timer = null;
  199. const remove = () => {
  200. if (timer) clearTimeout(timer);
  201. el.style.opacity = '0';
  202. el.style.transform = 'translateX(100%)';
  203. setTimeout(() => el.remove(), 300);
  204. };
  205. closeBtn.addEventListener('click', remove);
  206. els.toastContainer.appendChild(el);
  207. timer = setTimeout(remove, 4000);
  208. }
  209. function escapeHtml(str) {
  210. return String(str || '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
  211. }
  212. function formatNumber(n) {
  213. return Number(n || 0).toLocaleString();
  214. }
  215. function badge(status) {
  216. const map = {
  217. 'AUTO_CONFIRMED': ['badge-success', '自动确认'],
  218. 'CONFIRMED': ['badge-success', '已确认'],
  219. 'SUGGESTED': ['badge-warning', '建议确认'],
  220. 'IMPORTED': ['badge-default', '普通群'],
  221. 'REJECTED': ['badge-error', '已忽略']
  222. };
  223. const [cls, text] = map[status] || ['badge-default', status || '未知'];
  224. return `<span class="badge ${cls}">${escapeHtml(text)}</span>`;
  225. }
  226. const modalStack = [];
  227. function openModal(title, bodyHtml, actionsHtml, modalClass = '') {
  228. closeModal();
  229. const previousFocus = document.activeElement;
  230. const overlay = document.createElement('div');
  231. overlay.className = 'modal-overlay';
  232. overlay.innerHTML = `<div class="modal ${escapeHtml(modalClass)}"><div class="modal-title">${escapeHtml(title)}</div>${bodyHtml}<div class="modal-actions">${actionsHtml}</div></div>`;
  233. document.body.appendChild(overlay);
  234. modalStack.push(overlay);
  235. state.modal = overlay;
  236. const onKeydown = event => {
  237. if (event.key === 'Escape') closeModal();
  238. };
  239. document.addEventListener('keydown', onKeydown);
  240. overlay.__modalCleanup = () => document.removeEventListener('keydown', onKeydown);
  241. overlay.__previousFocus = previousFocus;
  242. overlay.addEventListener('click', e => {
  243. if (e.target === overlay) closeModal();
  244. });
  245. }
  246. function removeModalElement(overlay) {
  247. if (!overlay) return;
  248. overlay.__modalCleanup?.();
  249. overlay.remove();
  250. if (state.modal === overlay) state.modal = null;
  251. if (overlay.__previousFocus?.isConnected) overlay.__previousFocus.focus();
  252. }
  253. function closeModal() {
  254. const overlay = modalStack.pop() || state.modal;
  255. if (!overlay) return;
  256. if (typeof overlay.__cancelModal === 'function') {
  257. overlay.__cancelModal();
  258. return;
  259. }
  260. removeModalElement(overlay);
  261. }
  262. function closeAllModals() {
  263. while (modalStack.length) closeModal();
  264. state.modal = null;
  265. }
  266. window.dashboardCloseModal = closeModal;
  267. function businessModalField(field, index) {
  268. const id = `business-modal-field-${Date.now()}-${index}`;
  269. const value = field.value ?? '';
  270. const required = field.required ? 'required' : '';
  271. const readonly = field.readonly ? 'readonly' : '';
  272. const placeholder = field.placeholder ? `placeholder="${escapeHtml(field.placeholder)}"` : '';
  273. const autocomplete = field.autocomplete ? `autocomplete="${escapeHtml(field.autocomplete)}"` : 'autocomplete="off"';
  274. const step = field.step ? `step="${escapeHtml(field.step)}"` : '';
  275. let control;
  276. if (field.type === 'textarea') {
  277. control = `<textarea id="${id}" name="${escapeHtml(field.name)}" ${required} ${readonly} ${placeholder}>${escapeHtml(value)}</textarea>`;
  278. } else if (field.type === 'select') {
  279. control = `<select id="${id}" name="${escapeHtml(field.name)}" ${required}>${(field.options || []).map(option => `<option value="${escapeHtml(option.value)}" ${String(option.value) === String(value) ? 'selected' : ''}>${escapeHtml(option.label)}</option>`).join('')}</select>`;
  280. } else {
  281. const type = ['text', 'datetime-local', 'date', 'number', 'email'].includes(field.type) ? field.type : 'text';
  282. control = `<input id="${id}" name="${escapeHtml(field.name)}" type="${type}" value="${escapeHtml(value)}" ${required} ${readonly} ${placeholder} ${autocomplete} ${step} />`;
  283. }
  284. return `<label class="business-modal-field ${field.readonly ? 'readonly' : ''}" data-business-field="${escapeHtml(field.name)}"><span>${escapeHtml(field.label)}${field.required ? '<em>*</em>' : ''}</span>${control}${field.help ? `<small>${escapeHtml(field.help)}</small>` : ''}</label>`;
  285. }
  286. function showBusinessModal(options = {}) {
  287. closeModal();
  288. const previousFocus = document.activeElement;
  289. const overlay = document.createElement('div');
  290. const titleId = `business-modal-title-${Date.now()}`;
  291. const fields = Array.isArray(options.fields) ? options.fields : [];
  292. const shortcuts = Array.isArray(options.shortcuts) ? options.shortcuts : [];
  293. overlay.className = 'business-modal-backdrop';
  294. overlay.innerHTML = `<section class="business-modal" role="dialog" aria-modal="true" aria-labelledby="${titleId}">
  295. <header class="business-modal-header"><div>${options.eyebrow ? `<span>${escapeHtml(options.eyebrow)}</span>` : ''}<h3 id="${titleId}">${escapeHtml(options.title || '确认操作')}</h3>${options.description ? `<p>${escapeHtml(options.description)}</p>` : ''}</div><button type="button" class="business-modal-close" data-business-cancel aria-label="关闭">×</button></header>
  296. <form class="business-modal-form" novalidate>
  297. <div class="business-modal-fields">${fields.map(businessModalField).join('')}</div>
  298. ${shortcuts.length ? `<div class="business-modal-shortcuts"><span>${escapeHtml(options.shortcutLabel || '快捷选择')}</span><div>${shortcuts.map((shortcut, index) => `<button type="button" data-business-shortcut="${index}">${escapeHtml(shortcut.label)}</button>`).join('')}</div></div>` : ''}
  299. ${options.warning ? `<div class="business-modal-live-warning"><i>!</i><div><strong>${escapeHtml(options.warningTitle || '真实业务操作')}</strong><span>${escapeHtml(options.warning)}</span></div></div>` : ''}
  300. <p class="business-modal-error" data-business-error role="alert" hidden></p>
  301. <footer class="business-modal-actions"><button type="button" class="btn btn-secondary" data-business-cancel>${escapeHtml(options.cancelText || '取消')}</button><button type="submit" class="btn business-modal-confirm">${escapeHtml(options.confirmText || '确认')}</button></footer>
  302. </form>
  303. </section>`;
  304. document.body.appendChild(overlay);
  305. document.body.classList.add('business-modal-open');
  306. state.modal = overlay;
  307. overlay.__previousFocus = previousFocus;
  308. return new Promise(resolve => {
  309. let settled = false;
  310. const form = overlay.querySelector('.business-modal-form');
  311. const errorBox = overlay.querySelector('[data-business-error]');
  312. const focusableSelector = 'button:not([disabled]), input:not([disabled]):not([readonly]), textarea:not([disabled]):not([readonly]), select:not([disabled])';
  313. const finish = value => {
  314. if (settled) return;
  315. settled = true;
  316. document.body.classList.remove('business-modal-open');
  317. removeModalElement(overlay);
  318. resolve(value);
  319. };
  320. overlay.__cancelModal = () => finish(null);
  321. const showError = validation => {
  322. const detail = typeof validation === 'string' ? { message: validation } : validation || {};
  323. overlay.querySelectorAll('.business-modal-field.invalid').forEach(field => field.classList.remove('invalid'));
  324. errorBox.textContent = detail.message || '请检查填写内容';
  325. errorBox.hidden = false;
  326. if (detail.field) {
  327. const wrapper = overlay.querySelector(`[data-business-field="${detail.field}"]`);
  328. wrapper?.classList.add('invalid');
  329. wrapper?.querySelector('input, textarea, select')?.focus();
  330. }
  331. };
  332. overlay.querySelectorAll('[data-business-cancel]').forEach(button => button.addEventListener('click', () => finish(null)));
  333. overlay.addEventListener('click', event => {
  334. if (event.target === overlay) finish(null);
  335. });
  336. overlay.querySelectorAll('[data-business-shortcut]').forEach(button => button.addEventListener('click', () => {
  337. const shortcut = shortcuts[Number(button.dataset.businessShortcut)];
  338. const input = form.elements.namedItem(shortcut?.field || 'dueAt');
  339. if (!input || !shortcut) return;
  340. input.value = String(typeof shortcut.value === 'function' ? shortcut.value() : shortcut.value || '');
  341. input.dispatchEvent(new Event('input', { bubbles: true }));
  342. input.focus();
  343. }));
  344. form.addEventListener('input', event => {
  345. event.target.closest('.business-modal-field')?.classList.remove('invalid');
  346. errorBox.hidden = true;
  347. });
  348. form.addEventListener('submit', event => {
  349. event.preventDefault();
  350. const values = Object.fromEntries(new FormData(form).entries());
  351. const missing = fields.find(field => field.required && !String(values[field.name] || '').trim());
  352. if (missing) {
  353. showError({ field: missing.name, message: `请填写${missing.label}` });
  354. return;
  355. }
  356. const validation = typeof options.validate === 'function' ? options.validate(values) : null;
  357. if (validation) {
  358. showError(validation);
  359. return;
  360. }
  361. finish(values);
  362. });
  363. const onKeydown = event => {
  364. if (event.key === 'Escape') {
  365. event.preventDefault();
  366. finish(null);
  367. return;
  368. }
  369. if (event.key !== 'Tab') return;
  370. const focusable = [...overlay.querySelectorAll(focusableSelector)].filter(item => item.offsetParent !== null);
  371. if (!focusable.length) return;
  372. const first = focusable[0];
  373. const last = focusable[focusable.length - 1];
  374. if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
  375. else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
  376. };
  377. document.addEventListener('keydown', onKeydown);
  378. overlay.__modalCleanup = () => document.removeEventListener('keydown', onKeydown);
  379. requestAnimationFrame(() => (overlay.querySelector('.business-modal-field input:not([readonly]), .business-modal-field textarea:not([readonly]), .business-modal-field select') || overlay.querySelector(focusableSelector))?.focus());
  380. });
  381. }
  382. function localDeadlineValue(dayOffset, hour = 18, minute = 0) {
  383. const date = new Date();
  384. date.setDate(date.getDate() + dayOffset);
  385. date.setHours(hour, minute, 0, 0);
  386. return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}T${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
  387. }
  388. function deadlineInputValue(value) {
  389. const text = String(value || '').trim();
  390. const direct = text.match(/^(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})[ T](\d{1,2}):(\d{1,2})(?::\d{1,2})?$/);
  391. if (direct) return `${direct[1]}-${String(direct[2]).padStart(2, '0')}-${String(direct[3]).padStart(2, '0')}T${String(direct[4]).padStart(2, '0')}:${String(direct[5]).padStart(2, '0')}`;
  392. const date = new Date(text);
  393. if (Number.isNaN(date.getTime())) return '';
  394. return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}T${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
  395. }
  396. async function showOfficialTodoSyncModal(task = {}) {
  397. const currentOwner = task.owner && task.owner !== '待分配' ? task.owner : '';
  398. const values = await showBusinessModal({
  399. eyebrow: 'OFFICIAL TODO · LIVE',
  400. title: '同步至企微待办',
  401. description: '确认负责人和截止时间后,将本地跟进任务同步到企业微信。',
  402. fields: [
  403. { name: 'title', label: '待办内容', type: 'text', value: task.title || '客户跟进', readonly: true },
  404. { name: 'owner', label: '企微内部负责人', type: 'text', value: currentOwner, placeholder: '输入姓名或别名', required: true, help: '系统会自动匹配唯一的企微成员' },
  405. { name: 'dueAt', label: '截止时间', type: 'datetime-local', value: deadlineInputValue(task.dueAt) || localDeadlineValue(1), required: true, step: '60', help: '直接选择或使用下方快捷时间,无需手写格式' },
  406. ],
  407. shortcutLabel: '常用截止时间',
  408. shortcuts: [
  409. { label: '今天 18:00', field: 'dueAt', value: () => localDeadlineValue(0) },
  410. { label: '明天 18:00', field: 'dueAt', value: () => localDeadlineValue(1) },
  411. { label: '三天后', field: 'dueAt', value: () => localDeadlineValue(3) },
  412. { label: '一周后', field: 'dueAt', value: () => localDeadlineValue(7) },
  413. ],
  414. warningTitle: '将写入真实企业微信',
  415. warning: '点击确认后会创建真实企微待办,并与当前本地任务建立关联。',
  416. confirmText: '创建企微待办',
  417. validate: values => String(values.owner || '').trim() ? null : { field: 'owner', message: '请填写企微内部负责人' },
  418. });
  419. if (!values) return null;
  420. return { owner: String(values.owner).trim(), dueAt: String(values.dueAt).replace('T', ' ').slice(0, 16) };
  421. }
  422. async function copyText(value) {
  423. const text = String(value || '');
  424. try {
  425. await navigator.clipboard.writeText(text);
  426. } catch {
  427. const textarea = document.createElement('textarea');
  428. textarea.value = text;
  429. textarea.style.position = 'fixed';
  430. textarea.style.opacity = '0';
  431. document.body.appendChild(textarea);
  432. textarea.select();
  433. document.execCommand('copy');
  434. textarea.remove();
  435. }
  436. }
  437. function openAgentSessionGuide(guide = {}) {
  438. const ready = Boolean(guide.ready);
  439. openModal(
  440. '客户 Claude Code 会话',
  441. `<div class="agent-session-modal">
  442. <div class="agent-session-modal-status ${ready ? 'ready' : ''}"><strong>${ready ? '已检测到客户专属 Session' : 'Session 等待首次运行'}</strong><span>${escapeHtml(guide.guidance || '')}</span></div>
  443. <dl><div><dt>会话名称</dt><dd>${escapeHtml(guide.displayName || '首次运行后生成')}</dd></div><div><dt>打开位置</dt><dd>${escapeHtml(guide.openLocation || 'Fmode Studio → 当前企微项目 → 终端')}</dd></div></dl>
  444. <label>安全打开命令</label>
  445. <pre>${escapeHtml(guide.openCommand || 'npm run agent:session:list')}</pre>
  446. <p>打开时会自动 fork 审阅副本。你可以查看历史、模型思考和工具记录,也可以继续追问;生产客户 Session 不会被修改。</p>
  447. </div>`,
  448. `<button class="btn btn-secondary" data-session-close>关闭</button><button class="btn" data-session-copy ${guide.openCommand ? '' : 'disabled'}>复制打开命令</button>`
  449. );
  450. state.modal.querySelector('[data-session-close]')?.addEventListener('click', closeModal);
  451. state.modal.querySelector('[data-session-copy]')?.addEventListener('click', async () => {
  452. await copyText(guide.openCommand || '');
  453. toast('打开命令已复制');
  454. });
  455. }
  456. function startJob(name, path, body, onDone, trackerContainerId) {
  457. const btn = document.querySelector(`[data-job="${name}"]`);
  458. if (btn) {
  459. btn.disabled = true;
  460. btn.dataset.original = btn.innerHTML;
  461. btn.innerHTML = `<span class="spinner"></span> 执行中…`;
  462. }
  463. api('POST', path, body)
  464. .then(res => {
  465. const id = res.data?.jobId;
  466. if (!id) {
  467. if (btn) {
  468. btn.disabled = false;
  469. btn.innerHTML = btn.dataset.original;
  470. }
  471. return;
  472. }
  473. if (trackerContainerId) {
  474. const container = document.getElementById(trackerContainerId);
  475. if (container) container.innerHTML = renderJobTracker(name, id);
  476. }
  477. state.jobs[id] = { name, onDone };
  478. pollJob(id);
  479. })
  480. .catch(err => {
  481. if (btn) {
  482. btn.disabled = false;
  483. btn.innerHTML = btn.dataset.original;
  484. }
  485. toast(err.message || '启动任务失败', 'error');
  486. });
  487. }
  488. function pollJob(id) {
  489. const interval = setInterval(() => {
  490. api('GET', `/api/jobs/${id}`)
  491. .then(res => {
  492. const job = res.data;
  493. updateJobUI(id, job);
  494. if (job.status === 'done' || job.status === 'error') {
  495. clearInterval(interval);
  496. finishJob(id, job);
  497. }
  498. })
  499. .catch(() => {
  500. clearInterval(interval);
  501. });
  502. }, 1500);
  503. }
  504. function updateJobUI(id, job) {
  505. const wrap = document.getElementById(`job-${id}`);
  506. if (!wrap) return;
  507. const fill = wrap.querySelector('.progress-bar-fill');
  508. if (fill) fill.style.width = `${job.progress}%`;
  509. const text = wrap.querySelector('.job-status-text');
  510. if (text) text.textContent = job.status === 'running' ? '执行中…' : (job.status === 'done' ? '已完成' : '执行失败');
  511. }
  512. function finishJob(id, job) {
  513. const record = state.jobs[id];
  514. if (record) {
  515. const btn = document.querySelector(`[data-job="${record.name}"]`);
  516. if (btn) {
  517. btn.disabled = false;
  518. btn.innerHTML = btn.dataset.original;
  519. }
  520. const isError = job.status === 'error' || job.result?.status === 'error';
  521. if (job.status === 'done' && !isError) {
  522. toast(job.result?.assistantMessage || '任务完成');
  523. if (record.onDone) record.onDone(job.result);
  524. } else {
  525. toast(job.error || job.result?.assistantMessage || '任务失败', 'error');
  526. }
  527. delete state.jobs[id];
  528. // 清理该任务对应的进度条
  529. setTimeout(() => {
  530. const tracker = document.getElementById(`job-${id}`);
  531. if (tracker) tracker.remove();
  532. }, 2000);
  533. }
  534. }
  535. function renderJobTracker(name, id, title) {
  536. const label = title || (name === 'batch-portraits' ? '正在调用 LLM 生成画像…' : '执行中…');
  537. return `<div class="progress-wrap" id="job-${id}">
  538. <div style="font-size:13px;color:var(--text-muted);margin-bottom:6px">${escapeHtml(label)}</div>
  539. <div class="progress-bar"><div class="progress-bar-fill" style="width:0%"></div></div>
  540. <div class="job-status-text" style="font-size:12px;color:var(--text-muted);margin-top:6px">执行中…</div>
  541. </div>`;
  542. }
  543. // ==================== Dashboard 辅助函数 ====================
  544. const dashboardCharts = new Map();
  545. let dashboardRefreshTimer = null;
  546. function disposeDashboard() {
  547. if (dashboardRefreshTimer) {
  548. clearInterval(dashboardRefreshTimer);
  549. dashboardRefreshTimer = null;
  550. }
  551. for (const chart of dashboardCharts.values()) {
  552. try { chart.dispose(); } catch {}
  553. }
  554. dashboardCharts.clear();
  555. }
  556. function showLoading(container, type = 'overlay') {
  557. if (!container) return;
  558. container.classList.add('loading-overlay');
  559. if (type === 'overlay') {
  560. container.innerHTML = '<div class="loading-spinner"></div>';
  561. } else if (type === 'list') {
  562. container.innerHTML = `
  563. <div class="skeleton-list">
  564. <div class="skeleton skeleton-line" style="width:100%"></div>
  565. <div class="skeleton skeleton-line" style="width:92%"></div>
  566. <div class="skeleton skeleton-line" style="width:96%"></div>
  567. <div class="skeleton skeleton-line" style="width:88%"></div>
  568. </div>`;
  569. } else if (type === 'table') {
  570. container.innerHTML = `
  571. <div class="table-skeleton">
  572. <div class="skeleton skeleton-line"></div>
  573. <div class="skeleton skeleton-line" style="opacity:0.85"></div>
  574. <div class="skeleton skeleton-line" style="opacity:0.7"></div>
  575. <div class="skeleton skeleton-line" style="opacity:0.55"></div>
  576. <div class="skeleton skeleton-line" style="opacity:0.4"></div>
  577. </div>`;
  578. }
  579. }
  580. function hideLoading(container) {
  581. if (!container) return;
  582. container.classList.remove('loading-overlay');
  583. }
  584. function showError(container, message, onRetry) {
  585. if (!container) return;
  586. container.innerHTML = `
  587. <div class="error-state">
  588. <svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"/></svg>
  589. <div class="error-state-title">加载失败</div>
  590. <div class="error-state-desc">${escapeHtml(message || '请稍后重试')}</div>
  591. ${onRetry ? '<button class="btn btn-sm" id="btn-retry-loading">重试</button>' : ''}
  592. </div>`;
  593. if (onRetry) {
  594. container.querySelector('#btn-retry-loading').addEventListener('click', onRetry);
  595. }
  596. }
  597. function renderEmptyChart(container, title = '暂无数据') {
  598. if (!container) return;
  599. container.innerHTML = `
  600. <div class="empty-chart">
  601. <svg viewBox="0 0 24 24" style="width:48px;height:48px;fill:var(--text-muted);opacity:0.5;margin-bottom:10px">
  602. <path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V5h14v14zM7 10h2v7H7zm4-3h2v10h-2zm4 6h2v4h-2z"/>
  603. </svg>
  604. <div>${escapeHtml(title)}</div>
  605. </div>`;
  606. }
  607. function isEmptyChartData(option) {
  608. if (!option || !option.series) return true;
  609. const series = Array.isArray(option.series) ? option.series : [option.series];
  610. if (!series.length) return true;
  611. return series.every(s => {
  612. if (!s.data || !s.data.length) return true;
  613. return s.data.every(item => {
  614. const value = typeof item === 'object' ? (item.value ?? 0) : (item ?? 0);
  615. return Number(value) === 0;
  616. });
  617. });
  618. }
  619. function initEChart(containerId, option) {
  620. const container = document.getElementById(containerId);
  621. if (!container) return null;
  622. if (typeof echarts === 'undefined') {
  623. renderEmptyChart(container, '图表库加载失败,请检查网络');
  624. return null;
  625. }
  626. if (isEmptyChartData(option)) {
  627. renderEmptyChart(container, '暂无数据');
  628. return null;
  629. }
  630. if (dashboardCharts.has(containerId)) {
  631. try { dashboardCharts.get(containerId).dispose(); } catch {}
  632. }
  633. const chart = echarts.init(container);
  634. chart.setOption(option, true);
  635. requestAnimationFrame(() => {
  636. try { chart.resize(); } catch {}
  637. });
  638. dashboardCharts.set(containerId, chart);
  639. return chart;
  640. }
  641. function getChartColors() {
  642. if (!getChartColors.cache) {
  643. const root = getComputedStyle(document.documentElement);
  644. const read = (name, fallback) => root.getPropertyValue(name).trim() || fallback;
  645. getChartColors.cache = {
  646. brand: read('--brand', '#fa8c16'),
  647. success: read('--success', '#52c41a'),
  648. error: read('--error', '#ff4d4f'),
  649. warning: read('--warning', '#faad14'),
  650. info: read('--info', '#1890ff'),
  651. muted: read('--text-muted', '#8c8c8c'),
  652. textPrimary: read('--text-primary', '#1f1f1f'),
  653. purple: '#722ed1',
  654. cyan: '#13c2c2'
  655. };
  656. }
  657. return getChartColors.cache;
  658. }
  659. function hexToRgba(hex, alpha) {
  660. const clean = hex.replace('#', '');
  661. if (clean.length === 3) {
  662. const r = parseInt(clean[0] + clean[0], 16);
  663. const g = parseInt(clean[1] + clean[1], 16);
  664. const b = parseInt(clean[2] + clean[2], 16);
  665. return `rgba(${r}, ${g}, ${b}, ${alpha})`;
  666. }
  667. const r = parseInt(clean.slice(0, 2), 16);
  668. const g = parseInt(clean.slice(2, 4), 16);
  669. const b = parseInt(clean.slice(4, 6), 16);
  670. return `rgba(${r}, ${g}, ${b}, ${alpha})`;
  671. }
  672. function resizeDashboardCharts() {
  673. for (const chart of dashboardCharts.values()) {
  674. try { chart.resize(); } catch {}
  675. }
  676. }
  677. window.addEventListener('resize', resizeDashboardCharts);
  678. document.addEventListener('keydown', (e) => {
  679. if (e.key === 'Escape' && modalStack.length) {
  680. closeModal();
  681. }
  682. });
  683. function renderKpiRow(items) {
  684. return items.map((item, i) => `
  685. <div class="kpi-card" style="animation:fadeInUp 0.4s ease ${i * 0.05}s both">
  686. <div class="kpi-label">${escapeHtml(item.label)}</div>
  687. <div class="kpi-value">${escapeHtml(String(item.value))}</div>
  688. ${item.sub ? `<div class="kpi-sub">${escapeHtml(item.sub)}</div>` : ''}
  689. ${item.trend ? `<div class="kpi-sub kpi-trend ${item.trend > 0 ? 'up' : item.trend < 0 ? 'down' : ''}">${item.trend > 0 ? '↑' : item.trend < 0 ? '↓' : ''} ${Math.abs(item.trend)}%</div>` : ''}
  690. </div>
  691. `).join('');
  692. }
  693. function renderChartCard(id, title, subtitle, heightClass = '') {
  694. return `<div class="chart-card ${heightClass}">
  695. <h3 class="chart-title">
  696. <span>${escapeHtml(title)}</span>
  697. ${subtitle ? `<span class="chart-subtitle">${escapeHtml(subtitle)}</span>` : ''}
  698. </h3>
  699. <div class="chart-container ${heightClass}" id="${id}"></div>
  700. </div>`;
  701. }
  702. function renderOperationToolbar(leftHtml, rightHtml) {
  703. return `<div class="operation-toolbar">
  704. <div class="toolbar-left">${leftHtml}</div>
  705. <div class="toolbar-right">${rightHtml}</div>
  706. </div>`;
  707. }
  708. function renderEmptyTableRow(colspan, title = '暂无数据', desc = '') {
  709. return `
  710. <tr>
  711. <td colspan="${colspan}" style="padding:0;border-bottom:none">
  712. <div class="empty-state" style="padding:32px 20px">
  713. <svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm-1-13h2v6h-2zm0 8h2v2h-2z"/></svg>
  714. <div class="empty-state-title">${escapeHtml(title)}</div>
  715. ${desc ? `<div class="empty-state-desc">${escapeHtml(desc)}</div>` : ''}
  716. </div>
  717. </td>
  718. </tr>`;
  719. }
  720. function renderDataTableCard(title, subtitle, tableHtml, metaHtml = '') {
  721. return `<div class="data-table-card">
  722. <div class="card-header">
  723. <div>
  724. <h3 class="card-title">${escapeHtml(title)}</h3>
  725. ${subtitle ? `<div class="card-subtitle">${escapeHtml(subtitle)}</div>` : ''}
  726. </div>
  727. </div>
  728. ${metaHtml ? `<div class="table-meta">${metaHtml}</div>` : ''}
  729. ${tableHtml}
  730. </div>`;
  731. }
  732. function renderPagination(current, total, pageSize, onPageChange) {
  733. const totalPages = Math.max(1, Math.ceil(total / pageSize));
  734. return `<div class="pagination">
  735. <span>共 ${formatNumber(total)} 条,第 ${current} / ${totalPages} 页</span>
  736. <button ${current <= 1 ? 'disabled' : ''} data-page="${current - 1}">上一页</button>
  737. <button ${current >= totalPages ? 'disabled' : ''} data-page="${current + 1}">下一页</button>
  738. </div>`;
  739. }
  740. async function openCustomerSelector(options = {}) {
  741. const { multiple = true, selectedIds = [], title = '选择客户', confirmText = '确定' } = options;
  742. const selected = new Set(selectedIds.filter(Boolean));
  743. const loadedCustomers = new Map();
  744. let query = '';
  745. let portraitFilter = '';
  746. let sortBy = 'default';
  747. let currentPage = 1;
  748. const pageSize = 20;
  749. let total = 0;
  750. let totalPages = 1;
  751. let isLoading = false;
  752. let searchTimer = null;
  753. async function loadCustomers(resetPage = false) {
  754. if (isLoading) return;
  755. isLoading = true;
  756. if (resetPage) currentPage = 1;
  757. const offset = (currentPage - 1) * pageSize;
  758. const listEl = document.getElementById('customer-selector-list');
  759. if (listEl) listEl.innerHTML = '<div class="customer-selector-empty">加载中…</div>';
  760. try {
  761. const params = new URLSearchParams();
  762. params.set('keyword', query);
  763. params.set('limit', String(pageSize));
  764. params.set('offset', String(offset));
  765. params.set('sortBy', sortBy);
  766. if (portraitFilter) params.set('hasPortrait', portraitFilter === 'true' ? 'true' : 'false');
  767. const res = await api('GET', `/api/dashboard/customers/search?${params.toString()}`);
  768. const data = res.data || {};
  769. const items = data.customers || [];
  770. total = data.total || 0;
  771. totalPages = Math.max(1, Math.ceil(total / pageSize));
  772. if (currentPage > totalPages) currentPage = totalPages || 1;
  773. loadedCustomers.clear();
  774. for (const c of items) {
  775. if (c.externalUserId) loadedCustomers.set(c.externalUserId, c);
  776. }
  777. renderList();
  778. renderPagination();
  779. } catch (err) {
  780. toast('加载客户列表失败', 'error');
  781. } finally {
  782. isLoading = false;
  783. }
  784. }
  785. function toggleSelected(id) {
  786. if (multiple) {
  787. if (selected.has(id)) selected.delete(id); else selected.add(id);
  788. } else {
  789. selected.clear();
  790. selected.add(id);
  791. }
  792. renderList();
  793. }
  794. function renderList() {
  795. const listEl = document.getElementById('customer-selector-list');
  796. const countEl = document.getElementById('customer-selector-count');
  797. if (!listEl) return;
  798. if (countEl) countEl.textContent = `已选 ${selected.size} / 共 ${total} 位客户`;
  799. const items = Array.from(loadedCustomers.values());
  800. if (!items.length) {
  801. listEl.innerHTML = '<div class="customer-selector-empty">无匹配客户</div>';
  802. return;
  803. }
  804. listEl.innerHTML = items.map(c => {
  805. const isSelected = selected.has(c.externalUserId);
  806. const portraitBadge = c.hasPortrait ? '<span class="badge badge-info" style="margin-left:6px">已画像</span>' : '';
  807. return `
  808. <div class="customer-selector-item ${isSelected ? 'selected' : ''}" data-id="${escapeHtml(c.externalUserId || '')}">
  809. <input type="${multiple ? 'checkbox' : 'radio'}" ${isSelected ? 'checked' : ''} />
  810. <div class="customer-selector-info">
  811. <div class="customer-selector-name">${escapeHtml(c.name || '未命名客户')}${portraitBadge}</div>
  812. <div class="customer-selector-phone">${escapeHtml(c.phone || '-')}${c.externalUserId ? ` · ${escapeHtml(c.externalUserId)}` : ''}</div>
  813. </div>
  814. </div>
  815. `;
  816. }).join('');
  817. listEl.querySelectorAll('.customer-selector-item').forEach(item => {
  818. item.addEventListener('click', (e) => {
  819. if (e.target.tagName === 'INPUT') return;
  820. toggleSelected(item.dataset.id);
  821. });
  822. const input = item.querySelector('input');
  823. input.addEventListener('change', () => {
  824. const id = item.dataset.id;
  825. if (multiple) {
  826. if (input.checked) selected.add(id); else selected.delete(id);
  827. } else {
  828. selected.clear();
  829. if (input.checked) selected.add(id);
  830. }
  831. renderList();
  832. });
  833. });
  834. }
  835. function renderPagination() {
  836. const wrap = document.getElementById('customer-selector-pagination');
  837. if (!wrap) return;
  838. if (totalPages <= 1) {
  839. wrap.innerHTML = '';
  840. return;
  841. }
  842. const pages = [];
  843. const maxButtons = 5;
  844. let start = Math.max(1, currentPage - Math.floor(maxButtons / 2));
  845. let end = Math.min(totalPages, start + maxButtons - 1);
  846. if (end - start + 1 < maxButtons) start = Math.max(1, end - maxButtons + 1);
  847. for (let i = start; i <= end; i++) {
  848. pages.push(`<button class="btn btn-sm ${i === currentPage ? 'btn-primary' : 'btn-secondary'}" data-page="${i}">${i}</button>`);
  849. }
  850. wrap.innerHTML = `
  851. <button class="btn btn-sm btn-secondary" data-page="prev" ${currentPage === 1 ? 'disabled' : ''}>上一页</button>
  852. ${pages.join('')}
  853. <button class="btn btn-sm btn-secondary" data-page="next" ${currentPage === totalPages ? 'disabled' : ''}>下一页</button>
  854. <span style="font-size:12px;color:var(--text-muted);margin-left:8px">${currentPage} / ${totalPages} 页</span>
  855. `;
  856. wrap.querySelectorAll('button').forEach(btn => {
  857. btn.addEventListener('click', () => {
  858. let page = btn.dataset.page;
  859. if (page === 'prev') page = currentPage - 1;
  860. else if (page === 'next') page = currentPage + 1;
  861. else page = Number(page);
  862. if (page >= 1 && page <= totalPages && page !== currentPage) {
  863. currentPage = page;
  864. loadCustomers(false);
  865. }
  866. });
  867. });
  868. }
  869. const body = `
  870. <div class="form-group" style="margin-bottom:0">
  871. <div class="customer-selector-filters">
  872. <input id="customer-selector-search" class="customer-selector-search" type="text" placeholder="搜索姓名、手机号或 externalUserId" />
  873. <select id="customer-selector-portrait" class="customer-selector-filter">
  874. <option value="">全部画像状态</option>
  875. <option value="true">已生成画像</option>
  876. <option value="false">未生成画像</option>
  877. </select>
  878. <select id="customer-selector-sort" class="customer-selector-filter">
  879. <option value="default">默认排序</option>
  880. <option value="lastActive">最近活跃</option>
  881. <option value="portraitDesc">已画像优先</option>
  882. <option value="portraitAsc">未画像优先</option>
  883. <option value="nameAsc">姓名 A-Z</option>
  884. </select>
  885. </div>
  886. <div class="customer-selector-count" id="customer-selector-count">已选 0 / 共 0 位客户</div>
  887. <div class="customer-selector-list" id="customer-selector-list">
  888. <div class="customer-selector-empty">加载中…</div>
  889. </div>
  890. <div class="customer-selector-pagination" id="customer-selector-pagination"></div>
  891. </div>
  892. `;
  893. return new Promise((resolve) => {
  894. openModal(title, body, `
  895. <button class="btn btn-secondary" onclick="window.dashboardCloseModal()">取消</button>
  896. <button class="btn" id="btn-confirm-customer-selector">${escapeHtml(confirmText)}</button>
  897. `, 'customer-selector-modal');
  898. loadCustomers(true);
  899. const searchEl = document.getElementById('customer-selector-search');
  900. const portraitEl = document.getElementById('customer-selector-portrait');
  901. const sortEl = document.getElementById('customer-selector-sort');
  902. const scheduleReload = () => {
  903. if (searchTimer) clearTimeout(searchTimer);
  904. searchTimer = setTimeout(() => {
  905. query = searchEl.value.trim();
  906. portraitFilter = portraitEl.value;
  907. sortBy = sortEl.value;
  908. loadCustomers(true);
  909. }, 300);
  910. };
  911. searchEl.addEventListener('input', scheduleReload);
  912. portraitEl.addEventListener('change', scheduleReload);
  913. sortEl.addEventListener('change', scheduleReload);
  914. document.getElementById('btn-confirm-customer-selector').addEventListener('click', () => {
  915. const selectedCustomers = Array.from(loadedCustomers.values()).filter(c => selected.has(c.externalUserId));
  916. const externalUserIds = selectedCustomers.map(c => c.externalUserId).filter(Boolean);
  917. closeModal();
  918. resolve({ externalUserIds, customers: selectedCustomers });
  919. });
  920. });
  921. }
  922. function renderCustomerSelectorValue(containerId, customers) {
  923. const container = document.getElementById(containerId);
  924. if (!container) return;
  925. if (!customers.length) {
  926. container.innerHTML = '<span style="color:var(--text-muted);font-size:13px">未选择客户</span>';
  927. return;
  928. }
  929. container.innerHTML = customers.map(c => `
  930. <span class="tag">${escapeHtml(c.name || '未命名')} ${c.phone ? `(${escapeHtml(c.phone)})` : ''}</span>
  931. `).join('');
  932. }
  933. function formatDateTime(iso) {
  934. if (!iso) return '-';
  935. try {
  936. const d = new Date(iso);
  937. return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
  938. } catch {
  939. return iso;
  940. }
  941. }
  942. function formatDate(iso) {
  943. if (!iso) return '-';
  944. try {
  945. const d = new Date(iso);
  946. return `${d.getMonth() + 1}/${d.getDate()}`;
  947. } catch {
  948. return iso;
  949. }
  950. }
  951. function last7Days() {
  952. const days = [];
  953. for (let i = 6; i >= 0; i--) {
  954. const d = new Date();
  955. d.setDate(d.getDate() - i);
  956. days.push(d.toISOString().slice(0, 10));
  957. }
  958. return days;
  959. }
  960. function countByDay(items, dateField) {
  961. const days = last7Days();
  962. const counts = Object.fromEntries(days.map(d => [d, 0]));
  963. for (const item of items) {
  964. const date = item[dateField] ? item[dateField].slice(0, 10) : null;
  965. if (date && counts[date] !== undefined) counts[date]++;
  966. }
  967. return days.map(d => counts[d]);
  968. }
  969. function setActiveNav(page) {
  970. state.page = page;
  971. document.querySelectorAll('.nav-item').forEach(el => el.classList.remove('active'));
  972. const active = document.querySelector(`.nav-item[data-page="${page}"]`);
  973. if (active) active.classList.add('active');
  974. }
  975. function renderPage() {
  976. state.renderToken += 1;
  977. closeAllModals();
  978. const page = location.hash.slice(1) || 'agent';
  979. const loggedIn = !!currentAccount();
  980. els.nav.classList.toggle('nav-locked', !loggedIn);
  981. setActiveNav(page);
  982. updateAccountSwitcher();
  983. els.content.innerHTML = '';
  984. if (state.agent.timer) {
  985. clearInterval(state.agent.timer);
  986. state.agent.timer = null;
  987. }
  988. disposeDashboard();
  989. const acc = currentAccount();
  990. if (!acc && !['agent', 'skills', 'knowledge', 'customer-ops'].includes(page)) {
  991. renderLoginPage();
  992. return;
  993. }
  994. if (page === 'agent') renderAgentPage();
  995. else if (page === 'skills') renderSkillsPage();
  996. else if (page === 'knowledge') renderKnowledgePage();
  997. else if (page === 'status') renderStatusPage();
  998. else if (page === 'customer-ops') renderCustomerOpsPage();
  999. else if (page === 'portraits') renderPortraitsPage();
  1000. else if (page === 'transfers') renderTransfersPage();
  1001. else renderGroupsPage();
  1002. }
  1003. // ==================== Unified Skill Center ====================
  1004. function markdownInline(value) {
  1005. return escapeHtml(value)
  1006. .replace(/`([^`]+)`/g, '<code>$1</code>')
  1007. .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
  1008. .replace(/\*([^*]+)\*/g, '<em>$1</em>');
  1009. }
  1010. function renderMarkdown(content) {
  1011. const source = String(content || '').replace(/^---\s*\r?\n[\s\S]*?\r?\n---\s*/, '').trim();
  1012. const lines = source.split(/\r?\n/);
  1013. const html = [];
  1014. let listType = '';
  1015. let inCode = false;
  1016. let codeLines = [];
  1017. const closeList = () => {
  1018. if (!listType) return;
  1019. html.push(`</${listType}>`);
  1020. listType = '';
  1021. };
  1022. for (const line of lines) {
  1023. if (line.trim().startsWith('```')) {
  1024. closeList();
  1025. if (inCode) {
  1026. html.push(`<pre><code>${escapeHtml(codeLines.join('\n'))}</code></pre>`);
  1027. codeLines = [];
  1028. inCode = false;
  1029. } else {
  1030. inCode = true;
  1031. }
  1032. continue;
  1033. }
  1034. if (inCode) { codeLines.push(line); continue; }
  1035. const heading = line.match(/^(#{1,4})\s+(.+)$/);
  1036. if (heading) {
  1037. closeList();
  1038. const level = Math.min(4, heading[1].length + 1);
  1039. html.push(`<h${level}>${markdownInline(heading[2])}</h${level}>`);
  1040. continue;
  1041. }
  1042. const unordered = line.match(/^\s*[-*]\s+(.+)$/);
  1043. if (unordered) {
  1044. if (listType !== 'ul') { closeList(); listType = 'ul'; html.push('<ul>'); }
  1045. html.push(`<li>${markdownInline(unordered[1])}</li>`);
  1046. continue;
  1047. }
  1048. const ordered = line.match(/^\s*\d+[.)]\s+(.+)$/);
  1049. if (ordered) {
  1050. if (listType !== 'ol') { closeList(); listType = 'ol'; html.push('<ol>'); }
  1051. html.push(`<li>${markdownInline(ordered[1])}</li>`);
  1052. continue;
  1053. }
  1054. closeList();
  1055. if (!line.trim()) continue;
  1056. if (line.startsWith('>')) html.push(`<blockquote>${markdownInline(line.replace(/^>\s?/, ''))}</blockquote>`);
  1057. else if (/^\|.*\|$/.test(line.trim())) html.push(`<div class="md-table-line">${markdownInline(line.trim())}</div>`);
  1058. else html.push(`<p>${markdownInline(line)}</p>`);
  1059. }
  1060. if (inCode) html.push(`<pre><code>${escapeHtml(codeLines.join('\n'))}</code></pre>`);
  1061. closeList();
  1062. return html.join('');
  1063. }
  1064. function skillStatusLabel(status) {
  1065. return ({ running: '主运行包', connected: '已接入', merged: '已合并', integrated: '已集成', 'data-connected': '数据已接入' })[status] || status;
  1066. }
  1067. function selectedSkillPackages() {
  1068. const registry = state.skills.registry;
  1069. if (!registry) return [];
  1070. const keyword = state.skills.keyword.trim().toLowerCase();
  1071. return registry.packages.map(pkg => ({
  1072. ...pkg,
  1073. skills: pkg.skills.filter(skill => !keyword || `${skill.name} ${skill.title} ${skill.description} ${(skill.tags || []).join(' ')}`.toLowerCase().includes(keyword)),
  1074. })).filter(pkg => !keyword || pkg.skills.length);
  1075. }
  1076. function renderSkillsWorkspace(page) {
  1077. const registry = state.skills.registry || { packages: [], summary: {} };
  1078. const packages = selectedSkillPackages();
  1079. const selectedPackage = packages.find(item => item.id === state.skills.selectedPackageId) || packages[0] || null;
  1080. if (selectedPackage) state.skills.selectedPackageId = selectedPackage.id;
  1081. const selectedSkill = selectedPackage?.skills.find(item => item.id === state.skills.selectedSkillId)
  1082. || selectedPackage?.skills[0]
  1083. || packages.flatMap(item => item.skills)[0]
  1084. || null;
  1085. if (selectedSkill) state.skills.selectedSkillId = selectedSkill.id;
  1086. page.innerHTML = `
  1087. <section class="workspace-hero skills-hero">
  1088. <div><span>UNIFIED QIWEI CAPABILITY HUB</span><h2>技能中心</h2><p>把源码版、OpenClaw、Agent Workbench 和花巷房源匹配统一到一个 4320 工作台。</p></div>
  1089. <div class="workspace-stat-row">
  1090. <div><strong>${registry.summary.packageCount || 0}</strong><span>能力来源</span></div>
  1091. <div><strong>${registry.summary.skillCount || 0}</strong><span>技能实例</span></div>
  1092. <div><strong>${registry.summary.uniqueCapabilityCount || 0}</strong><span>去重能力</span></div>
  1093. </div>
  1094. </section>
  1095. <section class="skill-toolbar">
  1096. <div><strong>统一能力目录</strong><span>已有操作页直接跳转;其余 Skill/MCP 能力展示完整说明和调用边界。</span></div>
  1097. <input id="skill-search" type="search" value="${escapeHtml(state.skills.keyword)}" placeholder="搜索技能、能力或触发语" />
  1098. </section>
  1099. <section class="skill-hub-layout">
  1100. <aside class="skill-package-list">
  1101. ${packages.map(pkg => `<button class="skill-package-item ${pkg.id === selectedPackage?.id ? 'active' : ''}" data-skill-package="${escapeHtml(pkg.id)}"><span>${escapeHtml(pkg.label)}</span><em>${pkg.skills.length}</em><small>${escapeHtml(skillStatusLabel(pkg.status))}</small></button>`).join('') || '<div class="library-empty">没有匹配的技能包</div>'}
  1102. </aside>
  1103. <main class="skill-card-panel">
  1104. <header><div><strong>${escapeHtml(selectedPackage?.label || '技能包')}</strong><span>${escapeHtml(selectedPackage?.description || '')}</span></div><code>${escapeHtml(selectedPackage?.root || '')}</code></header>
  1105. <div class="skill-card-grid">
  1106. ${(selectedPackage?.skills || []).map(skill => `<button class="skill-capability-card ${skill.id === selectedSkill?.id ? 'active' : ''}" data-skill-id="${escapeHtml(skill.id)}"><div><span>${escapeHtml(skill.title || skill.name)}</span><em>${escapeHtml(skillStatusLabel(skill.status))}</em></div><p>${escapeHtml(skill.description || '暂无说明')}</p><footer>${(skill.tags || []).map(tag => `<i>${escapeHtml(tag)}</i>`).join('')}</footer></button>`).join('') || '<div class="library-empty">这个包中没有匹配的技能</div>'}
  1107. </div>
  1108. </main>
  1109. <aside class="skill-detail-panel">
  1110. ${selectedSkill ? `<div class="skill-detail-heading"><span>${escapeHtml(selectedPackage?.label || '')}</span><h3>${escapeHtml(selectedSkill.title || selectedSkill.name)}</h3><p>${escapeHtml(selectedSkill.description || '')}</p>${selectedSkill.page ? `<button class="btn" data-open-page="${escapeHtml(selectedSkill.page)}">打开对应功能页</button>` : '<span class="badge badge-default">通过 Skill / MCP 调用</span>'}</div><div class="markdown-view skill-markdown">${renderMarkdown(selectedSkill.content)}</div>` : '<div class="library-empty">选择一个技能查看详情</div>'}
  1111. </aside>
  1112. </section>
  1113. `;
  1114. }
  1115. async function renderSkillsPage() {
  1116. els.pageTitle.textContent = '技能中心';
  1117. void refreshAgentAccountHeader();
  1118. const page = document.createElement('div');
  1119. page.className = 'page skills-page';
  1120. page.innerHTML = '<div class="agent-loading">正在扫描本地技能包…</div>';
  1121. els.content.appendChild(page);
  1122. page.addEventListener('click', event => {
  1123. const packageButton = event.target.closest('[data-skill-package]');
  1124. if (packageButton) {
  1125. state.skills.selectedPackageId = packageButton.dataset.skillPackage;
  1126. state.skills.selectedSkillId = null;
  1127. renderSkillsWorkspace(page);
  1128. return;
  1129. }
  1130. const skillButton = event.target.closest('[data-skill-id]');
  1131. if (skillButton) {
  1132. state.skills.selectedSkillId = skillButton.dataset.skillId;
  1133. renderSkillsWorkspace(page);
  1134. return;
  1135. }
  1136. const openButton = event.target.closest('[data-open-page]');
  1137. if (openButton) location.hash = `#${openButton.dataset.openPage}`;
  1138. });
  1139. page.addEventListener('input', event => {
  1140. if (event.target.id !== 'skill-search') return;
  1141. state.skills.keyword = event.target.value;
  1142. renderSkillsWorkspace(page);
  1143. const input = page.querySelector('#skill-search');
  1144. if (input) { input.focus(); input.setSelectionRange(input.value.length, input.value.length); }
  1145. });
  1146. try {
  1147. const result = await api('GET', '/api/skills');
  1148. state.skills.registry = result.data;
  1149. state.skills.selectedPackageId = state.skills.selectedPackageId || result.data.packages[0]?.id || null;
  1150. renderSkillsWorkspace(page);
  1151. } catch (error) {
  1152. page.innerHTML = `<div class="library-error">${escapeHtml(error.message || '技能包扫描失败')}</div>`;
  1153. }
  1154. }
  1155. // ==================== Knowledge Library ====================
  1156. function flattenKnowledgeNodes(nodes = []) {
  1157. return nodes.flatMap(node => [node, ...(node.children ? flattenKnowledgeNodes(node.children) : [])]);
  1158. }
  1159. function knowledgeIcon(node) {
  1160. if (node.type === 'folder') return '▸';
  1161. return ({ 'property-dataset': '⌂', 'meeting-dashboard': '会', 'doc-dashboard': '文', 'todo-dashboard': '办', 'task-dashboard': '任', markdown: 'M', 'skill-document': 'S', json: '{ }', code: '&lt;/&gt;', csv: '▦' })[node.kind] || '•';
  1162. }
  1163. function renderKnowledgeTreeNode(node, depth = 0) {
  1164. if (node.type === 'folder') {
  1165. const isOpen = state.knowledge.expandedFolderIds.has(node.id);
  1166. return `<details class="knowledge-tree-folder" data-knowledge-folder="${escapeHtml(node.id)}" ${isOpen ? 'open' : ''}><summary style="--tree-depth:${depth}"><span>${knowledgeIcon(node)}</span><strong>${escapeHtml(node.name)}</strong><em>${node.fileCount || 0}</em></summary><div>${(node.children || []).map(child => renderKnowledgeTreeNode(child, depth + 1)).join('')}</div></details>`;
  1167. }
  1168. return `<button class="knowledge-tree-file ${node.id === state.knowledge.selectedNodeId ? 'active' : ''}" style="--tree-depth:${depth}" data-knowledge-node="${escapeHtml(node.id)}"><span>${knowledgeIcon(node)}</span><strong>${escapeHtml(node.name)}</strong><small>${escapeHtml(node.kind)}</small></button>`;
  1169. }
  1170. function formatFileSize(value) {
  1171. const bytes = Number(value || 0);
  1172. if (bytes < 1024) return `${bytes} B`;
  1173. if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
  1174. return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
  1175. }
  1176. function propertyMeta(property) {
  1177. return [property.district, property.layout, property.area ? `${property.area}㎡` : '', property.decoration].filter(Boolean).join(' · ');
  1178. }
  1179. function renderPropertyDetail(property) {
  1180. if (!property) return '<div class="property-detail-empty"><strong>选择一套房源</strong><span>点击左侧房源卡片查看全部字段与数据来源。</span></div>';
  1181. const rows = [
  1182. ['区域', property.district], ['户型', property.layout], ['面积', property.area ? `${property.area}㎡` : ''],
  1183. ['楼层', property.floor], ['朝向', property.orientation], ['装修', property.decoration],
  1184. ['单价', property.unitPrice ? `${property.unitPrice} 元/㎡` : ''], ['学区', property.isSchoolDistrict ? property.schoolName || '是' : '否'],
  1185. ['停车', property.parking], ['房龄', property.buildingAge ? `${property.buildingAge} 年` : ''],
  1186. ['满五唯一', property.isFiveYearOnly ? '是' : '否'], ['议价空间', property.priceDropSpace ? `${property.priceDropSpace} 万` : ''],
  1187. ].filter(([, value]) => value !== '' && value !== null && value !== undefined);
  1188. return `<div class="property-detail"><header><span>${escapeHtml(property.id)}</span><h3>${escapeHtml(property.community)}</h3><strong>${property.totalPrice || '-'}<small>万</small></strong><p>${escapeHtml(propertyMeta(property))}</p></header><div class="property-detail-grid">${rows.map(([label, value]) => `<div><small>${escapeHtml(label)}</small><strong>${escapeHtml(value)}</strong></div>`).join('')}</div>${property.highlights?.length ? `<section><h4>房源亮点</h4><div class="property-tags">${property.highlights.map(tag => `<span>${escapeHtml(tag)}</span>`).join('')}</div></section>` : ''}${property.surrounding ? `<section><h4>周边描述</h4><p>${escapeHtml(property.surrounding)}</p></section>` : ''}${property.ownerSituation ? `<section><h4>业主情况</h4><p>${escapeHtml(property.ownerSituation)}</p></section>` : ''}<section><h4>匹配维度</h4><div class="property-score-list">${Object.entries(property.scores || {}).map(([key, value]) => `<div><span>${escapeHtml(({ community: '小区品质', transport: '交通', surrounding: '配套', priceAdvantage: '价格优势' })[key] || key)}</span><i><b style="width:${Math.min(100, Number(value || 0) * 10)}%"></b></i><em>${value || 0}</em></div>`).join('')}</div></section></div>`;
  1189. }
  1190. function renderPropertiesView() {
  1191. const data = state.knowledge.properties || { items: [], stats: {}, total: 0, page: 1, pageCount: 1 };
  1192. const filters = state.knowledge.filters;
  1193. const selected = state.knowledge.selectedProperty;
  1194. return `<div class="property-library-view">
  1195. <header class="knowledge-view-header"><div><span>PROPERTY DATASET</span><h3>房源数据列表</h3><p>${escapeHtml(data.sourceLabel || '房源数据')} · ${data.total || 0} 条当前结果</p></div><code>${escapeHtml(data.source || '')}</code></header>
  1196. <div class="property-stats"><div><strong>${data.stats.total || 0}</strong><span>总房源</span></div><div><strong>${data.stats.averagePrice || 0}<small>万</small></strong><span>平均总价</span></div><div><strong>${data.stats.districts?.length || 0}</strong><span>区域</span></div><div><strong>${data.stats.schoolDistrictCount || 0}</strong><span>学区房</span></div></div>
  1197. <form class="property-filterbar" id="property-filter-form"><input name="q" value="${escapeHtml(filters.q)}" placeholder="搜索小区、编号、标签" /><select name="district"><option value="">全部区域</option>${(data.stats.districts || []).map(item => `<option ${filters.district === item ? 'selected' : ''}>${escapeHtml(item)}</option>`).join('')}</select><select name="layout"><option value="">全部户型</option>${(data.stats.layouts || []).map(item => `<option ${filters.layout === item ? 'selected' : ''}>${escapeHtml(item)}</option>`).join('')}</select><select name="decoration"><option value="">全部装修</option>${(data.stats.decorations || []).map(item => `<option ${filters.decoration === item ? 'selected' : ''}>${escapeHtml(item)}</option>`).join('')}</select><input name="maxPrice" type="number" value="${escapeHtml(filters.maxPrice)}" placeholder="最高总价/万" /><button class="btn" type="submit">筛选</button></form>
  1198. <div class="property-browser-layout"><div class="property-card-grid">${(data.items || []).map(property => `<button class="property-card ${selected?.id === property.id ? 'active' : ''}" data-property-id="${escapeHtml(property.id)}"><div><span>${escapeHtml(property.id)}</span><em>${property.isSchoolDistrict ? '学区' : property.decoration || '房源'}</em></div><h4>${escapeHtml(property.community)}</h4><p>${escapeHtml(propertyMeta(property))}</p><strong>${property.totalPrice || '-'}<small>万</small></strong><footer>${(property.highlights || []).slice(0, 3).map(tag => `<i>${escapeHtml(tag)}</i>`).join('')}</footer></button>`).join('') || '<div class="library-empty">没有符合条件的房源</div>'}</div><aside class="property-detail-panel">${renderPropertyDetail(selected)}</aside></div>
  1199. </div>`;
  1200. }
  1201. function meetingInputValue(value) {
  1202. return String(value || '').replace(' ', 'T').slice(0, 16);
  1203. }
  1204. function meetingAnalysisLabel(status) {
  1205. return ({ completed: 'AI 已分析', stale: '资料已变化', failed: '分析失败', pending: '待分析' })[status] || '待分析';
  1206. }
  1207. function renderMeetingBullets(items, emptyText) {
  1208. return (items || []).length
  1209. ? `<ul>${items.map(item => `<li>${escapeHtml(item)}</li>`).join('')}</ul>`
  1210. : `<p class="meeting-empty-copy">${escapeHtml(emptyText)}</p>`;
  1211. }
  1212. function renderMeetingDetail(meeting) {
  1213. if (!meeting) {
  1214. return '<div class="library-empty large"><strong>暂无真实会议</strong><span>完成官方 CLI 扫码授权并同步后,会议详情会显示在这里;系统不会生成 Mock 会议。</span></div>';
  1215. }
  1216. const analysis = meeting.analysis || {};
  1217. const actionItems = analysis.actionItems || [];
  1218. const analysisReady = analysis.status === 'completed';
  1219. return `<article class="meeting-detail">
  1220. <header>
  1221. <div><span>OFFICIAL CLI · LIVE DATA</span><h3>${escapeHtml(meeting.title)}</h3><p>${escapeHtml(meeting.startAt || '时间待确认')}${meeting.endAt ? ` — ${escapeHtml(meeting.endAt)}` : ''} · ${escapeHtml(meeting.statusLabel || '未知状态')}</p></div>
  1222. <button class="btn" data-analyze-meeting="${escapeHtml(meeting.id)}" ${state.knowledge.meetings.loading ? 'disabled' : ''}>${analysisReady ? '重新分析' : '生成 AI 分析'}</button>
  1223. </header>
  1224. <div class="meeting-meta-grid">
  1225. <div><small>会议类型</small><strong>${escapeHtml(meeting.meetingTypeLabel || '-')}</strong></div>
  1226. <div><small>会议时长</small><strong>${meeting.durationSeconds ? `${Math.round(meeting.durationSeconds / 60)} 分钟` : '-'}</strong></div>
  1227. <div><small>参会统计</small><strong>${meeting.attendees?.internalCount || 0} 内部 / ${meeting.attendees?.externalCount || 0} 外部</strong></div>
  1228. <div><small>AI 状态</small><strong>${escapeHtml(meetingAnalysisLabel(analysis.status))}</strong></div>
  1229. </div>
  1230. <section class="meeting-source-note"><strong>会议描述(官方资料)</strong><p>${escapeHtml(meeting.description || '官方会议资料未填写描述。')}</p><small>会议 ID:${escapeHtml(meeting.id)} · 同步于 ${escapeHtml(formatAgentTime(meeting.syncedAt))}</small></section>
  1231. ${analysis.status === 'failed' ? `<div class="meeting-analysis-error"><strong>AI 分析未生成</strong><span>${escapeHtml(analysis.error || '模型调用失败,真实会议资料已保留。')}</span></div>` : ''}
  1232. <section class="meeting-analysis-summary"><span>AI SUMMARY · 需人工复核</span><h4>${escapeHtml(analysis.summary || '尚未生成会议摘要')}</h4><p>${escapeHtml(analysis.analysisBasis || '仅基于官方会议元数据和描述,不包含录音、转写或人工纪要。')}</p></section>
  1233. <div class="meeting-analysis-grid">
  1234. <section><h4>关键议题</h4>${renderMeetingBullets(analysis.topics, '资料中暂无可验证的关键议题')}</section>
  1235. <section><h4>明确决策</h4>${renderMeetingBullets(analysis.decisions, '资料中没有可验证的明确决策')}</section>
  1236. <section><h4>风险与待确认</h4>${renderMeetingBullets(analysis.risks, '暂无已识别风险')}</section>
  1237. <section><h4>建议动作(非已确认任务)</h4>${renderMeetingBullets(analysis.suggestedActions, '暂无建议动作')}</section>
  1238. </div>
  1239. <section class="meeting-actions"><header><div><span>ACTION CANDIDATES</span><h4>有依据的待办候选</h4></div><em>${actionItems.length} 条</em></header>${actionItems.length ? actionItems.map(item => `<div class="meeting-action-item"><i class="${escapeHtml(item.priority || 'medium')}"></i><div><strong>${escapeHtml(item.title)}</strong><p>${escapeHtml(item.evidence || '依据待人工确认')}</p><small>${item.owner ? `负责人:${escapeHtml(item.owner)}` : '负责人待确认'}${item.dueDate ? ` · 截止:${escapeHtml(item.dueDate)}` : ' · 截止时间待确认'}</small></div><em>${Math.round(Number(item.confidence || 0) * 100)}%</em></div>`).join('') : '<p class="meeting-empty-copy">没有从现有资料中提取出有明确依据的待办。</p>'}</section>
  1240. <section class="meeting-tags"><strong>知识标签</strong><div>${(analysis.knowledgeTags || []).map(tag => `<span>${escapeHtml(tag)}</span>`).join('') || '<small>暂无标签</small>'}</div></section>
  1241. </article>`;
  1242. }
  1243. function renderMeetingKnowledgeView() {
  1244. const hub = state.knowledge.meetings.hub || { summary: {}, data: { cli: {}, range: {}, meetings: [] } };
  1245. const data = hub.data || {};
  1246. const summary = hub.summary || {};
  1247. const cli = data.cli || {};
  1248. const meetingReady = Boolean(summary.meetingReady);
  1249. const capabilityBlocked = Boolean(cli.ready && !meetingReady);
  1250. const meetings = [...(data.meetings || [])].sort((a, b) => String(b.startAt).localeCompare(String(a.startAt)));
  1251. const selected = meetings.find(item => item.id === state.knowledge.meetings.selectedId) || meetings[0] || null;
  1252. if (selected) state.knowledge.meetings.selectedId = selected.id;
  1253. const range = data.range || {};
  1254. const meetingDetail = selected
  1255. ? renderMeetingDetail(selected)
  1256. : capabilityBlocked
  1257. ? '<div class="library-empty large"><strong>当前企业无法通过官方 CLI 拉取会议</strong><span>扫码授权已经成功;请换用 10 人及以下测试企业演示会议,当前企业可继续演示官方文档和待办能力。</span></div>'
  1258. : renderMeetingDetail(null);
  1259. return `<div class="meeting-workbench">
  1260. <header class="knowledge-view-header"><div><span>WECOM MEETING KNOWLEDGE</span><h3>企微会议知识沉淀</h3><p>官方 CLI 真实同步 · 本地脱敏存储 · AI 分析需人工复核</p></div><em>${meetingReady ? '真实通道已就绪' : capabilityBlocked ? '企业未开放会议' : '等待扫码授权'}</em></header>
  1261. <div class="meeting-stat-row"><div><strong>${summary.meetingCount || 0}</strong><span>累计会议</span></div><div><strong>${summary.analyzedCount || 0}</strong><span>已分析</span></div><div><strong>${summary.actionItemCount || 0}</strong><span>待办候选</span></div><div><strong>${summary.lastSyncedAt ? formatAgentTime(summary.lastSyncedAt) : '-'}</strong><span>最近同步</span></div></div>
  1262. <section class="meeting-cli-banner ${meetingReady ? 'ready' : capabilityBlocked ? 'blocked' : 'waiting'}"><div><i></i><strong>${meetingReady ? '企业微信官方会议 CLI 已就绪' : capabilityBlocked ? '扫码授权成功,但当前企业未开放会议 CLI' : '企业微信官方 CLI 尚未完成扫码授权'}</strong><span>${meetingReady ? `当前版本 ${escapeHtml(cli.version || '-')}` : capabilityBlocked ? escapeHtml(cli.capabilityMessage || '当前企业暂不支持授权机器人的会议权限。') : '授权前无法拉取真实会议,页面不会展示演示或 Mock 会议。'}</span></div>${!cli.ready && cli.initCommand ? `<code>${escapeHtml(cli.initCommand)}</code><button class="btn secondary" data-copy-init="${escapeHtml(cli.initCommand)}">复制初始化命令</button>` : ''}</section>
  1263. <form class="meeting-sync-form" id="meeting-sync-form"><label><span>开始时间</span><input name="beginDatetime" type="datetime-local" value="${escapeHtml(meetingInputValue(range.beginDatetime))}" required /></label><label><span>结束时间</span><input name="endDatetime" type="datetime-local" value="${escapeHtml(meetingInputValue(range.endDatetime))}" required /></label><label class="meeting-limit"><span>最多拉取</span><input name="limit" type="number" min="1" max="100" value="20" /></label><button class="btn" type="submit" ${!meetingReady || state.knowledge.meetings.loading ? 'disabled' : ''}>${state.knowledge.meetings.loading ? '处理中…' : meetingReady ? '同步真实会议' : capabilityBlocked ? '当前企业不可同步' : '等待授权'}</button></form>
  1264. <div class="meeting-browser"><aside><header><strong>会议列表</strong><span>${meetings.length} 场已沉淀</span></header><div class="meeting-list">${meetings.map(meeting => `<button class="meeting-list-item ${selected?.id === meeting.id ? 'active' : ''}" data-meeting-id="${escapeHtml(meeting.id)}"><div><strong>${escapeHtml(meeting.title)}</strong><em>${escapeHtml(meeting.statusLabel || '')}</em></div><p>${escapeHtml(meeting.startAt || '时间待确认')} · ${escapeHtml(meeting.meetingTypeLabel || '')}</p><footer><span>${meeting.attendees?.internalCount || 0} 位内部参会人</span><small>${escapeHtml(meetingAnalysisLabel(meeting.analysis?.status))}</small></footer></button>`).join('') || `<div class="library-empty"><strong>暂无会议</strong><span>${capabilityBlocked ? '当前企业受企微官方会议 CLI 范围限制。' : '授权并确认会议权限后再同步。'}</span></div>`}</div></aside><main>${meetingDetail}</main></div>
  1265. </div>`;
  1266. }
  1267. function officeCapabilityBanner(cli, label) {
  1268. const ready = Boolean(cli?.ready && cli?.available);
  1269. return `<section class="office-cli-banner ${ready ? 'ready' : 'blocked'}"><i></i><div><strong>${ready ? `企业微信官方${label} CLI 已就绪` : `官方${label} CLI 当前不可用`}</strong><span>${escapeHtml(cli?.message || (ready ? `当前版本 ${cli?.version || '-'}` : '请检查扫码授权和企业权限。'))}</span></div></section>`;
  1270. }
  1271. function renderDocumentDetail(document) {
  1272. if (!document) return '<div class="library-empty large"><strong>尚未沉淀企微文档</strong><span>可创建一篇真实企微文档,或粘贴已有文档链接读取到知识库。</span></div>';
  1273. const analysis = document.analysis || {};
  1274. const bullets = (items, empty) => (items || []).length ? `<ul>${items.map(item => `<li>${escapeHtml(item)}</li>`).join('')}</ul>` : `<p>${escapeHtml(empty)}</p>`;
  1275. const link = /^https?:\/\//i.test(document.url || '') ? `<a href="${escapeHtml(document.url)}" target="_blank" rel="noopener">在企微中打开</a>` : '';
  1276. return `<article class="doc-detail"><header><div><span>OFFICIAL DOC · LIVE</span><h3>${escapeHtml(document.title)}</h3><p>${escapeHtml(formatAgentTime(document.syncedAt))} · ${escapeHtml(document.docid || document.id)}</p></div><div>${link}${document.readbackStatus === 'pending-permission' ? `<button class="btn secondary" data-refresh-doc="${escapeHtml(document.id)}" ${state.knowledge.documents.loading ? 'disabled' : ''}>重新读取复核</button>` : ''}<button class="btn" data-analyze-doc="${escapeHtml(document.id)}" ${state.knowledge.documents.loading ? 'disabled' : ''}>${analysis.status === 'completed' ? '重新分析' : 'AI 分析文档'}</button></div></header>
  1277. ${document.readbackStatus === 'pending-permission' ? '<div class="doc-readback-warning"><strong>创建和写入已成功</strong><span>机器人暂缺读取授权,当前原文来自本次已确认写入的 Markdown;授权后可重新读取复核。</span></div>' : ''}
  1278. <section class="doc-analysis"><span>AI KNOWLEDGE · 需人工复核</span><h4>${escapeHtml(analysis.summary || '尚未生成 AI 摘要')}</h4><div><section><strong>关键要点</strong>${bullets(analysis.keyPoints, '暂无')}</section><section><strong>明确决策</strong>${bullets(analysis.decisions, '原文中暂无明确决策')}</section><section><strong>风险</strong>${bullets(analysis.risks, '暂无')}</section></div></section>
  1279. <section class="doc-original"><header><strong>企微文档原文</strong><span>Markdown 只读预览</span></header><article class="markdown-view">${renderMarkdown(document.markdown || '文档为空')}</article></section></article>`;
  1280. }
  1281. function renderDocumentKnowledgeView() {
  1282. const hub = state.knowledge.documents.hub || { summary: {}, data: { cli: {}, documents: [] } };
  1283. const data = hub.data || {};
  1284. const docs = data.documents || [];
  1285. const selected = docs.find(item => item.id === state.knowledge.documents.selectedId) || docs[0] || null;
  1286. if (selected) state.knowledge.documents.selectedId = selected.id;
  1287. return `<div class="office-workbench doc-workbench"><header class="knowledge-view-header"><div><span>WECOM OFFICIAL DOCUMENTS</span><h3>企微文档知识库</h3><p>真实创建/读取 · Markdown 沉淀 · 逐篇 AI 分析</p></div><em>${hub.summary?.docReady ? '官方通道已就绪' : '等待文档权限'}</em></header>
  1288. <div class="office-stat-row"><div><strong>${hub.summary?.documentCount || 0}</strong><span>已沉淀文档</span></div><div><strong>${hub.summary?.analyzedCount || 0}</strong><span>已分析</span></div><div><strong>${hub.summary?.lastSyncedAt ? formatAgentTime(hub.summary.lastSyncedAt) : '-'}</strong><span>最近同步</span></div></div>
  1289. ${officeCapabilityBanner(data.cli, '文档')}
  1290. <div class="office-action-grid"><form id="doc-create-form"><header><strong>创建真实企微文档</strong><span>创建普通文档并写入 Markdown</span></header><input name="title" placeholder="文档标题" required /><textarea name="content" rows="5" placeholder="# 文档正文&#10;&#10;支持 Markdown"></textarea><button class="btn" type="submit" ${!hub.summary?.docReady || state.knowledge.documents.loading ? 'disabled' : ''}>创建并沉淀</button></form><form id="doc-import-form"><header><strong>读取已有企微文档</strong><span>粘贴 doc.weixin.qq.com 链接或 docid</span></header><input name="locator" placeholder="企微文档链接或 docid" required /><input name="title" placeholder="显示标题(可选)" /><button class="btn secondary" type="submit" ${!hub.summary?.docReady || state.knowledge.documents.loading ? 'disabled' : ''}>读取到知识库</button></form></div>
  1291. <div class="office-browser"><aside><header><strong>文档目录</strong><span>${docs.length} 篇</span></header>${docs.map(doc => `<button class="office-list-item ${selected?.id === doc.id ? 'active' : ''}" data-doc-id="${escapeHtml(doc.id)}"><strong>${escapeHtml(doc.title)}</strong><span>${escapeHtml(formatAgentTime(doc.syncedAt))}</span><small>${doc.analysis?.status === 'completed' ? 'AI 已分析' : '待分析'}</small></button>`).join('') || '<div class="library-empty">暂无文档</div>'}</aside><main>${renderDocumentDetail(selected)}</main></div></div>`;
  1292. }
  1293. function renderTodoDetail(todo) {
  1294. if (!todo) return '<div class="library-empty large"><strong>选择一条待办</strong><span>这里只展示当前机器人创建并同步的真实企微待办。</span></div>';
  1295. return `<article class="todo-detail"><span>OFFICIAL TODO · LIVE</span><h3>${escapeHtml(todo.content)}</h3><div class="todo-status ${todo.status === 0 ? 'done' : 'active'}">${escapeHtml(todo.statusLabel)}</div><dl><div><dt>截止时间</dt><dd>${escapeHtml(todo.endTime || '未设置')}</dd></div><div><dt>参与人</dt><dd>${escapeHtml((todo.followers || []).map(item => item.name || '企微成员').join('、') || '未返回')}</dd></div><div><dt>待办 ID</dt><dd>${escapeHtml(todo.id)}</dd></div></dl>${todo.status !== 0 ? `<button class="btn" data-complete-todo="${escapeHtml(todo.id)}" ${state.knowledge.todos.loading ? 'disabled' : ''}>标记为已完成</button>` : ''}<p>官方边界:只能操作由当前机器人创建的待办。</p></article>`;
  1296. }
  1297. function renderTodoKnowledgeView() {
  1298. const hub = state.knowledge.todos.hub || { summary: {}, data: { cli: {}, todos: [] } };
  1299. const data = hub.data || {};
  1300. const todos = data.todos || [];
  1301. const selected = todos.find(item => item.id === state.knowledge.todos.selectedId) || todos[0] || null;
  1302. if (selected) state.knowledge.todos.selectedId = selected.id;
  1303. const users = state.knowledge.todos.userResults || [];
  1304. return `<div class="office-workbench todo-workbench"><header class="knowledge-view-header"><div><span>WECOM OFFICIAL TODO</span><h3>企微官方待办中心</h3><p>成员搜索 · 真实待办同步 · 创建与完成</p></div><em>${hub.summary?.todoReady ? '官方通道已就绪' : '等待待办权限'}</em></header>
  1305. <div class="office-stat-row"><div><strong>${hub.summary?.todoCount || 0}</strong><span>待办总数</span></div><div><strong>${hub.summary?.activeCount || 0}</strong><span>进行中</span></div><div><strong>${hub.summary?.completedCount || 0}</strong><span>已完成</span></div></div>
  1306. ${officeCapabilityBanner(data.cli, '待办')}
  1307. <div class="todo-toolbar"><form id="todo-user-search-form"><input name="keyword" placeholder="按姓名或别名搜索 userid" required /><button class="btn secondary" type="submit">搜索成员</button></form><form id="todo-sync-form"><input name="followerId" value="${escapeHtml(data.followerId || '')}" placeholder="参与人 userid" required /><button class="btn secondary" type="submit" ${!hub.summary?.todoReady || state.knowledge.todos.loading ? 'disabled' : ''}>同步真实待办</button></form></div>
  1308. ${users.length ? `<div class="todo-user-results">${users.map(user => `<button data-use-todo-user="${escapeHtml(user.id)}"><strong>${escapeHtml(user.name || user.id)}</strong><span>${escapeHtml(user.alias || user.id)}</span></button>`).join('')}</div>` : ''}
  1309. <form class="todo-create-form" id="todo-create-form"><input name="content" placeholder="待办内容,例如:整理客户演示材料" required /><input name="followerIds" value="${escapeHtml(data.followerId || '')}" placeholder="参与人 userid,多个用逗号分隔" required /><input name="endTime" type="datetime-local" /><select name="remindType"><option value="1">到期提醒</option><option value="3">提前 15 分钟</option><option value="5">提前 1 小时</option><option value="7">提前 1 天</option><option value="0">不提醒</option></select><button class="btn" type="submit" ${!hub.summary?.todoReady || state.knowledge.todos.loading ? 'disabled' : ''}>创建真实待办</button></form>
  1310. <div class="office-browser"><aside><header><strong>待办列表</strong><span>${todos.length} 条</span></header>${todos.map(todo => `<button class="office-list-item ${selected?.id === todo.id ? 'active' : ''}" data-todo-id="${escapeHtml(todo.id)}"><strong>${escapeHtml(todo.content)}</strong><span>${escapeHtml(todo.endTime || '无截止时间')}</span><small>${escapeHtml(todo.statusLabel)}</small></button>`).join('') || '<div class="library-empty">当前没有已同步待办</div>'}</aside><main>${renderTodoDetail(selected)}</main></div></div>`;
  1311. }
  1312. function unifiedTaskStatusLabel(status) {
  1313. return ({ open: '待处理', in_progress: '处理中', blocked: '已阻塞', done: '已完成', dismissed: '已忽略', suggested: 'AI 候选' })[status] || status || '待处理';
  1314. }
  1315. function unifiedTaskDueLabel(task) {
  1316. if (!task.dueAt) return '未设置截止时间';
  1317. return `${task.overdue ? '已逾期 · ' : ''}${formatAgentTime(task.dueAt)}`;
  1318. }
  1319. function unifiedTaskActions(task) {
  1320. if (task.sourceKind === 'candidate') {
  1321. return task.promotedTo
  1322. ? '<span class="task-promoted">已转为正式任务</span>'
  1323. : `<button class="btn btn-sm" data-task-action="promote" data-task-id="${escapeHtml(task.id)}">确认并转为任务</button>`;
  1324. }
  1325. const active = ['open', 'in_progress', 'blocked'].includes(task.status);
  1326. const buttons = [];
  1327. if (task.source === 'customer') {
  1328. buttons.push(`<button class="btn btn-sm btn-secondary" data-task-open-conversation="${escapeHtml(task.conversationId)}">转到智能会话</button>`);
  1329. if (active && task.actions?.syncOfficial) buttons.push(`<button class="btn btn-sm btn-secondary" data-task-action="sync-official" data-task-id="${escapeHtml(task.id)}">同步企微待办</button>`);
  1330. }
  1331. if (active && task.actions?.dismiss) buttons.push(`<button class="btn btn-sm btn-secondary" data-task-action="dismiss" data-task-id="${escapeHtml(task.id)}">忽略</button>`);
  1332. if (active && task.actions?.complete) buttons.push(`<button class="btn btn-sm" data-task-action="complete" data-task-id="${escapeHtml(task.id)}">完成</button>`);
  1333. return buttons.join('') || `<span>${escapeHtml(unifiedTaskStatusLabel(task.status))}</span>`;
  1334. }
  1335. function renderUnifiedTaskCenter() {
  1336. const hub = state.knowledge.tasks.hub || { summary: {}, data: { tasks: [], policy: {} } };
  1337. const summary = hub.summary || {};
  1338. const data = hub.data || {};
  1339. const filters = state.knowledge.tasks.filters;
  1340. const allTasks = data.tasks || [];
  1341. const q = String(filters.q || '').trim().toLowerCase();
  1342. const tasks = allTasks.filter(task => {
  1343. if (q && ![task.title, task.owner, task.contactName, task.sourceTitle, task.evidence].some(value => String(value || '').toLowerCase().includes(q))) return false;
  1344. if (filters.source && task.source !== filters.source) return false;
  1345. if (filters.status === 'active' && !['open', 'in_progress', 'blocked'].includes(task.status)) return false;
  1346. if (filters.status === 'overdue' && !task.overdue) return false;
  1347. if (filters.status === 'completed' && task.status !== 'done') return false;
  1348. if (filters.status === 'suggested' && task.status !== 'suggested') return false;
  1349. return true;
  1350. });
  1351. const sourceOptions = Object.keys(summary.bySource || {}).map(source => {
  1352. const sample = allTasks.find(task => task.source === source);
  1353. return `<option value="${escapeHtml(source)}" ${filters.source === source ? 'selected' : ''}>${escapeHtml(sample?.sourceLabel || source)}(${summary.bySource[source]})</option>`;
  1354. }).join('');
  1355. return `<div class="unified-task-workbench">
  1356. <header class="knowledge-view-header"><div><span>UNIFIED ACTION CENTER</span><h3>统一任务中心</h3><p>真实企微会话 · 官方待办 · 目标计划 · 文档/会议行动项</p></div><em>内部任务中枢</em></header>
  1357. <div class="task-stat-row"><div><strong>${summary.active || 0}</strong><span>待处理</span></div><div><strong>${summary.overdue || 0}</strong><span>已逾期</span></div><div><strong>${summary.completed || 0}</strong><span>已完成</span></div><div><strong>${summary.candidates || 0}</strong><span>待确认候选</span></div></div>
  1358. <section class="task-policy-banner"><div><strong>客户任务不会被原样发给客户</strong><span>${escapeHtml(data.policy?.externalCustomerSafety || '')}</span></div><div><strong>AI 候选不会自动成为正式任务</strong><span>${escapeHtml(data.policy?.candidateSafety || '')}</span></div></section>
  1359. <form class="task-create-form" id="unified-task-create-form"><div><strong>新建内部任务</strong><span>不依赖 CLI,先在本地形成可管理的执行项</span></div><input name="title" placeholder="任务标题" required /><input name="owner" placeholder="负责人(可选)" /><input name="dueAt" type="datetime-local" /><select name="priority"><option value="medium">普通</option><option value="high">高</option><option value="urgent">紧急</option><option value="low">低</option></select><button class="btn" type="submit" ${state.knowledge.tasks.loading ? 'disabled' : ''}>创建</button></form>
  1360. <form class="task-filterbar" id="unified-task-filter-form"><input name="q" value="${escapeHtml(filters.q)}" placeholder="搜索任务、客户、负责人或证据" /><select name="source"><option value="">全部来源</option>${sourceOptions}</select><select name="status"><option value="active" ${filters.status === 'active' ? 'selected' : ''}>待处理</option><option value="overdue" ${filters.status === 'overdue' ? 'selected' : ''}>已逾期</option><option value="completed" ${filters.status === 'completed' ? 'selected' : ''}>已完成</option><option value="suggested" ${filters.status === 'suggested' ? 'selected' : ''}>AI 候选</option><option value="all" ${filters.status === 'all' ? 'selected' : ''}>全部</option></select><button class="btn secondary" type="submit">筛选</button><span>当前 ${tasks.length} 条</span></form>
  1361. <div class="unified-task-list">${tasks.map(task => `<article class="unified-task-card ${task.overdue ? 'overdue' : ''} ${task.sourceKind === 'candidate' ? 'candidate' : ''}"><i class="task-priority ${escapeHtml(task.priority)}"></i><div class="unified-task-main"><header><div><span class="task-source">${escapeHtml(task.sourceLabel)}</span><span class="task-status ${escapeHtml(task.status)}">${escapeHtml(unifiedTaskStatusLabel(task.status))}</span>${task.audience === 'external' ? '<span class="task-audience">客户跟进 · 内部使用</span>' : ''}</div><small>${escapeHtml(unifiedTaskDueLabel(task))}</small></header><h4>${escapeHtml(task.title)}</h4><p>${escapeHtml(task.evidence || '暂无补充证据')}</p><footer><span>${task.owner ? `负责人:${escapeHtml(task.owner)}` : '负责人待分配'}</span><span>${escapeHtml(task.sourceTitle || '')}</span>${task.confidence !== null ? `<span>可信度 ${Math.round(Number(task.confidence || 0) * 100)}%</span>` : ''}</footer></div><div class="unified-task-actions">${unifiedTaskActions(task)}</div></article>`).join('') || '<div class="library-empty large"><strong>当前筛选下没有任务</strong><span>可以新建本地内部任务,或切换为“全部 / AI 候选”查看其他来源。</span></div>'}</div>
  1362. </div>`;
  1363. }
  1364. function renderKnowledgeFileView(file) {
  1365. if (!file) return '<div class="library-empty large"><strong>选择左侧文件</strong><span>Markdown、JSON、CSV 和代码文件会在这里可视化预览。</span></div>';
  1366. const header = `<header class="knowledge-view-header"><div><span>${escapeHtml(file.library)}</span><h3>${escapeHtml(file.name)}</h3><p>${escapeHtml(file.relativePath)} · ${formatFileSize(file.size)} · ${formatAgentTime(file.modifiedAt)}</p></div><em>${escapeHtml(file.kind)}</em></header>`;
  1367. if (['markdown', 'skill-document'].includes(file.kind)) return `${header}<article class="markdown-view">${renderMarkdown(file.content)}</article>`;
  1368. if (file.kind === 'json') {
  1369. const summary = Array.isArray(file.structured)
  1370. ? `${file.structured.length} 条数组记录`
  1371. : `${Object.keys(file.structured || {}).length} 个顶层字段`;
  1372. return `${header}<div class="json-summary">${escapeHtml(summary)}</div><pre class="code-view"><code>${escapeHtml(JSON.stringify(file.structured ?? file.content, null, 2))}</code></pre>`;
  1373. }
  1374. return `${header}<pre class="code-view"><code>${escapeHtml(file.content)}</code></pre>`;
  1375. }
  1376. function renderKnowledgeWorkspace(page) {
  1377. const tree = state.knowledge.tree || { roots: [], summary: {} };
  1378. const selectedNode = flattenKnowledgeNodes(tree.roots).find(item => item.id === state.knowledge.selectedNodeId);
  1379. const viewer = selectedNode?.kind === 'property-dataset'
  1380. ? renderPropertiesView()
  1381. : selectedNode?.kind === 'meeting-dashboard'
  1382. ? renderMeetingKnowledgeView()
  1383. : selectedNode?.kind === 'doc-dashboard'
  1384. ? renderDocumentKnowledgeView()
  1385. : selectedNode?.kind === 'todo-dashboard'
  1386. ? renderTodoKnowledgeView()
  1387. : selectedNode?.kind === 'task-dashboard'
  1388. ? renderUnifiedTaskCenter()
  1389. : renderKnowledgeFileView(state.knowledge.file);
  1390. page.innerHTML = `
  1391. <section class="workspace-hero knowledge-hero"><div><span>LOCAL KNOWLEDGE & FILE LIBRARY</span><h2>知识库</h2><p>用文件夹方式统一管理规则、技能说明、房源数据和运行输出,点击文件即可可视化查看。</p></div><div class="workspace-stat-row"><div><strong>${tree.summary.libraryCount || 0}</strong><span>目录来源</span></div><div><strong>${tree.summary.fileCount || 0}</strong><span>可预览文件</span></div><div><strong>${tree.summary.propertyCount || 0}</strong><span>房源记录</span></div></div></section>
  1392. <section class="knowledge-layout"><aside class="knowledge-tree-panel"><header><strong>知识库目录</strong><span>本地文件 · 只读预览</span></header><div class="knowledge-tree">${(tree.roots || []).map(root => renderKnowledgeTreeNode(root)).join('')}</div></aside><main class="knowledge-viewer">${viewer}</main></section>
  1393. `;
  1394. }
  1395. async function loadKnowledgeProperties(page) {
  1396. const params = new URLSearchParams({ pageSize: '30' });
  1397. for (const [key, value] of Object.entries(state.knowledge.filters)) if (String(value || '').trim()) params.set(key, value);
  1398. const result = await api('GET', `/api/knowledge/properties?${params}`);
  1399. state.knowledge.properties = result.data;
  1400. if (state.knowledge.selectedProperty) {
  1401. state.knowledge.selectedProperty = result.data.items.find(item => item.id === state.knowledge.selectedProperty.id) || null;
  1402. }
  1403. renderKnowledgeWorkspace(page);
  1404. }
  1405. async function loadMeetingKnowledgeHub(page) {
  1406. const result = await api('GET', '/api/knowledge/meetings');
  1407. state.knowledge.meetings.hub = result;
  1408. const meetings = result.data?.meetings || [];
  1409. if (!meetings.some(item => item.id === state.knowledge.meetings.selectedId)) {
  1410. state.knowledge.meetings.selectedId = [...meetings].sort((a, b) => String(b.startAt).localeCompare(String(a.startAt)))[0]?.id || null;
  1411. }
  1412. renderKnowledgeWorkspace(page);
  1413. }
  1414. async function loadDocumentKnowledgeHub(page) {
  1415. const result = await api('GET', '/api/knowledge/docs');
  1416. state.knowledge.documents.hub = result;
  1417. const documents = result.data?.documents || [];
  1418. if (!documents.some(item => item.id === state.knowledge.documents.selectedId)) state.knowledge.documents.selectedId = documents[0]?.id || null;
  1419. renderKnowledgeWorkspace(page);
  1420. }
  1421. async function loadTodoKnowledgeHub(page) {
  1422. const result = await api('GET', '/api/knowledge/todos');
  1423. state.knowledge.todos.hub = result;
  1424. const todos = result.data?.todos || [];
  1425. if (!todos.some(item => item.id === state.knowledge.todos.selectedId)) state.knowledge.todos.selectedId = todos[0]?.id || null;
  1426. renderKnowledgeWorkspace(page);
  1427. }
  1428. async function loadUnifiedTaskHub(page) {
  1429. const result = await api('GET', '/api/knowledge/tasks');
  1430. state.knowledge.tasks.hub = result;
  1431. renderKnowledgeWorkspace(page);
  1432. }
  1433. async function openKnowledgeNode(page, nodeId) {
  1434. const node = flattenKnowledgeNodes(state.knowledge.tree?.roots || []).find(item => item.id === nodeId);
  1435. if (!node || node.type !== 'file') return;
  1436. state.knowledge.selectedNodeId = nodeId;
  1437. state.knowledge.file = null;
  1438. state.knowledge.selectedProperty = null;
  1439. if (node.kind === 'meeting-dashboard') await loadMeetingKnowledgeHub(page);
  1440. else if (node.kind === 'doc-dashboard') await loadDocumentKnowledgeHub(page);
  1441. else if (node.kind === 'todo-dashboard') await loadTodoKnowledgeHub(page);
  1442. else if (node.kind === 'task-dashboard') await loadUnifiedTaskHub(page);
  1443. else if (node.kind === 'property-dataset') await loadKnowledgeProperties(page);
  1444. else {
  1445. const result = await api('GET', `/api/knowledge/file?id=${encodeURIComponent(nodeId)}`);
  1446. state.knowledge.file = result.data;
  1447. renderKnowledgeWorkspace(page);
  1448. }
  1449. }
  1450. async function renderKnowledgePage() {
  1451. els.pageTitle.textContent = '知识库';
  1452. void refreshAgentAccountHeader();
  1453. const page = document.createElement('div');
  1454. page.className = 'page knowledge-page';
  1455. page.innerHTML = '<div class="agent-loading">正在扫描知识库目录…</div>';
  1456. els.content.appendChild(page);
  1457. page.addEventListener('click', async event => {
  1458. const fileButton = event.target.closest('[data-knowledge-node]');
  1459. if (fileButton) {
  1460. try { await openKnowledgeNode(page, fileButton.dataset.knowledgeNode); }
  1461. catch (error) { toast(error.message || '文件读取失败', 'error'); }
  1462. return;
  1463. }
  1464. const copyInitButton = event.target.closest('[data-copy-init]');
  1465. if (copyInitButton) {
  1466. try {
  1467. await navigator.clipboard.writeText(copyInitButton.dataset.copyInit);
  1468. toast('初始化命令已复制');
  1469. } catch { toast('复制失败,请手动选择命令', 'error'); }
  1470. return;
  1471. }
  1472. const analyzeMeetingButton = event.target.closest('[data-analyze-meeting]');
  1473. if (analyzeMeetingButton) {
  1474. state.knowledge.meetings.loading = true;
  1475. renderKnowledgeWorkspace(page);
  1476. try {
  1477. const result = await api('POST', `/api/knowledge/meetings/${encodeURIComponent(analyzeMeetingButton.dataset.analyzeMeeting)}/analyze`, {});
  1478. await loadMeetingKnowledgeHub(page);
  1479. toast(result.assistantMessage || (result.status === 'ok' ? '会议分析完成' : '会议分析未完成'), result.status === 'ok' ? 'success' : 'error');
  1480. } catch (error) { toast(error.assistantMessage || error.message || '会议分析失败', 'error'); }
  1481. finally {
  1482. state.knowledge.meetings.loading = false;
  1483. renderKnowledgeWorkspace(page);
  1484. }
  1485. return;
  1486. }
  1487. const meetingButton = event.target.closest('[data-meeting-id]');
  1488. if (meetingButton) {
  1489. state.knowledge.meetings.selectedId = meetingButton.dataset.meetingId;
  1490. renderKnowledgeWorkspace(page);
  1491. return;
  1492. }
  1493. const analyzeDocButton = event.target.closest('[data-analyze-doc]');
  1494. if (analyzeDocButton) {
  1495. state.knowledge.documents.loading = true;
  1496. renderKnowledgeWorkspace(page);
  1497. try {
  1498. const result = await api('POST', `/api/knowledge/docs/${encodeURIComponent(analyzeDocButton.dataset.analyzeDoc)}/analyze`, {});
  1499. await loadDocumentKnowledgeHub(page);
  1500. toast(result.assistantMessage || '文档分析完成', result.status === 'ok' ? 'success' : 'error');
  1501. } catch (error) { toast(error.assistantMessage || error.message || '文档分析失败', 'error'); }
  1502. finally { state.knowledge.documents.loading = false; renderKnowledgeWorkspace(page); }
  1503. return;
  1504. }
  1505. const refreshDocButton = event.target.closest('[data-refresh-doc]');
  1506. if (refreshDocButton) {
  1507. state.knowledge.documents.loading = true;
  1508. renderKnowledgeWorkspace(page);
  1509. try {
  1510. const result = await api('POST', `/api/knowledge/docs/${encodeURIComponent(refreshDocButton.dataset.refreshDoc)}/refresh`, {});
  1511. await loadDocumentKnowledgeHub(page);
  1512. toast(result.assistantMessage || '文档重新读取完成', result.status === 'ok' ? 'success' : 'error');
  1513. } catch (error) { toast(error.assistantMessage || error.message || '文档重新读取失败', 'error'); }
  1514. finally { state.knowledge.documents.loading = false; renderKnowledgeWorkspace(page); }
  1515. return;
  1516. }
  1517. const docButton = event.target.closest('[data-doc-id]');
  1518. if (docButton) {
  1519. state.knowledge.documents.selectedId = docButton.dataset.docId;
  1520. renderKnowledgeWorkspace(page);
  1521. return;
  1522. }
  1523. const useTodoUserButton = event.target.closest('[data-use-todo-user]');
  1524. if (useTodoUserButton) {
  1525. const id = useTodoUserButton.dataset.useTodoUser;
  1526. page.querySelectorAll('input[name="followerId"], input[name="followerIds"]').forEach(input => { input.value = id; });
  1527. toast(`已填入成员 userid:${id}`);
  1528. return;
  1529. }
  1530. const completeTodoButton = event.target.closest('[data-complete-todo]');
  1531. if (completeTodoButton) {
  1532. state.knowledge.todos.loading = true;
  1533. renderKnowledgeWorkspace(page);
  1534. try {
  1535. const result = await api('POST', `/api/knowledge/todos/${encodeURIComponent(completeTodoButton.dataset.completeTodo)}/complete`, {});
  1536. await loadTodoKnowledgeHub(page);
  1537. toast(result.assistantMessage || '待办已完成', result.status === 'ok' ? 'success' : 'error');
  1538. } catch (error) { toast(error.assistantMessage || error.message || '待办更新失败', 'error'); }
  1539. finally { state.knowledge.todos.loading = false; renderKnowledgeWorkspace(page); }
  1540. return;
  1541. }
  1542. const todoButton = event.target.closest('[data-todo-id]');
  1543. if (todoButton) {
  1544. state.knowledge.todos.selectedId = todoButton.dataset.todoId;
  1545. renderKnowledgeWorkspace(page);
  1546. return;
  1547. }
  1548. const openTaskConversationButton = event.target.closest('[data-task-open-conversation]');
  1549. if (openTaskConversationButton) {
  1550. state.agent.selectedId = openTaskConversationButton.dataset.taskOpenConversation;
  1551. location.hash = '#agent';
  1552. return;
  1553. }
  1554. const taskActionButton = event.target.closest('[data-task-action]');
  1555. if (taskActionButton) {
  1556. const task = (state.knowledge.tasks.hub?.data?.tasks || []).find(item => item.id === taskActionButton.dataset.taskId);
  1557. if (!task) { toast('任务记录已变化,请刷新后重试', 'error'); return; }
  1558. const action = taskActionButton.dataset.taskAction;
  1559. state.knowledge.tasks.loading = true;
  1560. renderKnowledgeWorkspace(page);
  1561. try {
  1562. let result;
  1563. if (action === 'promote') {
  1564. if (!window.confirm(`确认将以下 AI 候选转为正式内部任务?\n\n${task.title}\n\n依据:${task.evidence || '待补充'}\n\n创建后仍需人工分配负责人并推进。`)) return;
  1565. result = await api('POST', '/api/knowledge/tasks/create', { title: task.title, owner: task.owner, dueAt: task.dueAt, priority: task.priority, evidence: task.evidence, sourceTitle: task.sourceTitle, source: task.source, sourceKey: task.id });
  1566. } else if (action === 'sync-official') {
  1567. const syncInput = await showOfficialTodoSyncModal(task);
  1568. if (!syncInput) return;
  1569. result = await api('POST', `/api/agent/tasks/${encodeURIComponent(task.id)}/sync-official`, syncInput);
  1570. } else {
  1571. const nextStatus = action === 'complete' ? 'done' : 'dismissed';
  1572. const officialWarning = task.source === 'official' || task.officialTodoId ? '\n\n该操作会同步更新真实企微待办。' : '';
  1573. if (!window.confirm(`确认将“${task.title}”标记为${action === 'complete' ? '已完成' : '已忽略'}?${officialWarning}`)) return;
  1574. result = await api('POST', '/api/knowledge/tasks/update', { source: task.source, taskId: task.id, goalId: task.goalId, status: nextStatus });
  1575. }
  1576. if (result && result.status !== 'ok') throw result;
  1577. await loadUnifiedTaskHub(page);
  1578. toast(result?.assistantMessage || '任务已更新');
  1579. } catch (error) { toast(error.assistantMessage || error.message || '任务操作失败', 'error'); }
  1580. finally { state.knowledge.tasks.loading = false; renderKnowledgeWorkspace(page); }
  1581. return;
  1582. }
  1583. const propertyButton = event.target.closest('[data-property-id]');
  1584. if (propertyButton) {
  1585. try {
  1586. const result = await api('GET', `/api/knowledge/properties/${encodeURIComponent(propertyButton.dataset.propertyId)}`);
  1587. state.knowledge.selectedProperty = result.data.property;
  1588. renderKnowledgeWorkspace(page);
  1589. } catch (error) { toast(error.message || '房源读取失败', 'error'); }
  1590. }
  1591. });
  1592. page.addEventListener('submit', async event => {
  1593. if (event.target.id === 'unified-task-filter-form') {
  1594. event.preventDefault();
  1595. const form = new FormData(event.target);
  1596. state.knowledge.tasks.filters.q = String(form.get('q') || '').trim();
  1597. state.knowledge.tasks.filters.source = String(form.get('source') || '').trim();
  1598. state.knowledge.tasks.filters.status = String(form.get('status') || 'active');
  1599. renderKnowledgeWorkspace(page);
  1600. return;
  1601. }
  1602. if (event.target.id === 'unified-task-create-form') {
  1603. event.preventDefault();
  1604. const form = new FormData(event.target);
  1605. state.knowledge.tasks.loading = true;
  1606. renderKnowledgeWorkspace(page);
  1607. try {
  1608. const result = await api('POST', '/api/knowledge/tasks/create', {
  1609. title: String(form.get('title') || '').trim(),
  1610. owner: String(form.get('owner') || '').trim(),
  1611. dueAt: String(form.get('dueAt') || '').trim(),
  1612. priority: String(form.get('priority') || 'medium'),
  1613. sourceTitle: '任务中心人工创建',
  1614. });
  1615. if (result.status !== 'ok') throw result;
  1616. await loadUnifiedTaskHub(page);
  1617. toast(result.assistantMessage || '内部任务已创建');
  1618. } catch (error) { toast(error.assistantMessage || error.message || '内部任务创建失败', 'error'); }
  1619. finally { state.knowledge.tasks.loading = false; renderKnowledgeWorkspace(page); }
  1620. return;
  1621. }
  1622. if (event.target.id === 'doc-create-form' || event.target.id === 'doc-import-form') {
  1623. event.preventDefault();
  1624. const formId = event.target.id;
  1625. const form = new FormData(event.target);
  1626. const body = formId === 'doc-create-form'
  1627. ? { title: String(form.get('title') || '').trim(), content: String(form.get('content') || '') }
  1628. : { ...(String(form.get('locator') || '').trim().startsWith('http') ? { url: String(form.get('locator') || '').trim() } : { docid: String(form.get('locator') || '').trim() }), title: String(form.get('title') || '').trim() };
  1629. state.knowledge.documents.loading = true;
  1630. renderKnowledgeWorkspace(page);
  1631. try {
  1632. const result = await api('POST', formId === 'doc-create-form' ? '/api/knowledge/docs/create' : '/api/knowledge/docs/import', body);
  1633. if (result.status !== 'ok') throw result;
  1634. state.knowledge.documents.selectedId = result.data?.document?.id || null;
  1635. const treeResult = await api('GET', '/api/knowledge/tree');
  1636. state.knowledge.tree = treeResult.data;
  1637. await loadDocumentKnowledgeHub(page);
  1638. toast(result.assistantMessage || '企微文档处理完成');
  1639. } catch (error) { toast(error.assistantMessage || error.message || '企微文档处理失败', 'error'); }
  1640. finally { state.knowledge.documents.loading = false; renderKnowledgeWorkspace(page); }
  1641. return;
  1642. }
  1643. if (event.target.id === 'todo-user-search-form') {
  1644. event.preventDefault();
  1645. const form = new FormData(event.target);
  1646. try {
  1647. const result = await api('POST', '/api/knowledge/todos/search-users', { keyword: String(form.get('keyword') || '').trim() });
  1648. state.knowledge.todos.userResults = result.data?.users || [];
  1649. renderKnowledgeWorkspace(page);
  1650. toast(result.assistantMessage || '成员搜索完成', result.status === 'ok' ? 'success' : 'error');
  1651. } catch (error) { toast(error.assistantMessage || error.message || '成员搜索失败', 'error'); }
  1652. return;
  1653. }
  1654. if (event.target.id === 'todo-sync-form' || event.target.id === 'todo-create-form') {
  1655. event.preventDefault();
  1656. const formId = event.target.id;
  1657. const form = new FormData(event.target);
  1658. const body = formId === 'todo-sync-form'
  1659. ? { followerId: String(form.get('followerId') || '').trim(), limit: 20 }
  1660. : { content: String(form.get('content') || '').trim(), followerIds: String(form.get('followerIds') || '').trim(), endTime: String(form.get('endTime') || '').trim(), remindType: Number(form.get('remindType') || 1) };
  1661. state.knowledge.todos.loading = true;
  1662. renderKnowledgeWorkspace(page);
  1663. try {
  1664. const result = await api('POST', formId === 'todo-sync-form' ? '/api/knowledge/todos/sync' : '/api/knowledge/todos/create', body);
  1665. if (result.status !== 'ok') throw result;
  1666. state.knowledge.todos.selectedId = result.data?.todo?.id || state.knowledge.todos.selectedId;
  1667. const treeResult = await api('GET', '/api/knowledge/tree');
  1668. state.knowledge.tree = treeResult.data;
  1669. await loadTodoKnowledgeHub(page);
  1670. toast(result.assistantMessage || '企微待办处理完成');
  1671. } catch (error) { toast(error.assistantMessage || error.message || '企微待办处理失败', 'error'); }
  1672. finally { state.knowledge.todos.loading = false; renderKnowledgeWorkspace(page); }
  1673. return;
  1674. }
  1675. if (event.target.id === 'meeting-sync-form') {
  1676. event.preventDefault();
  1677. const form = new FormData(event.target);
  1678. state.knowledge.meetings.loading = true;
  1679. renderKnowledgeWorkspace(page);
  1680. try {
  1681. const result = await api('POST', '/api/knowledge/meetings/sync', {
  1682. beginDatetime: String(form.get('beginDatetime') || ''),
  1683. endDatetime: String(form.get('endDatetime') || ''),
  1684. limit: Number(form.get('limit') || 20),
  1685. });
  1686. if (result.status !== 'ok') throw result;
  1687. const treeResult = await api('GET', '/api/knowledge/tree');
  1688. state.knowledge.tree = treeResult.data;
  1689. await loadMeetingKnowledgeHub(page);
  1690. toast(result.assistantMessage || '真实会议同步完成');
  1691. } catch (error) { toast(error.assistantMessage || error.message || '会议同步失败', 'error'); }
  1692. finally {
  1693. state.knowledge.meetings.loading = false;
  1694. renderKnowledgeWorkspace(page);
  1695. }
  1696. return;
  1697. }
  1698. if (event.target.id === 'property-filter-form') {
  1699. event.preventDefault();
  1700. const form = new FormData(event.target);
  1701. for (const key of Object.keys(state.knowledge.filters)) state.knowledge.filters[key] = String(form.get(key) || '').trim();
  1702. try { await loadKnowledgeProperties(page); }
  1703. catch (error) { toast(error.message || '房源筛选失败', 'error'); }
  1704. }
  1705. });
  1706. page.addEventListener('toggle', event => {
  1707. const folder = event.target.closest?.('[data-knowledge-folder]');
  1708. if (!folder) return;
  1709. if (folder.open) state.knowledge.expandedFolderIds.add(folder.dataset.knowledgeFolder);
  1710. else state.knowledge.expandedFolderIds.delete(folder.dataset.knowledgeFolder);
  1711. }, true);
  1712. try {
  1713. const result = await api('GET', '/api/knowledge/tree');
  1714. state.knowledge.tree = result.data;
  1715. if (!state.knowledge.foldersInitialized) {
  1716. for (const root of result.data.roots) {
  1717. if (['agent-knowledge', 'property-data', 'meeting-knowledge', 'doc-knowledge', 'todo-knowledge', 'task-knowledge'].includes(root.libraryId)) {
  1718. state.knowledge.expandedFolderIds.add(root.id);
  1719. }
  1720. }
  1721. state.knowledge.foldersInitialized = true;
  1722. }
  1723. const nodes = flattenKnowledgeNodes(result.data.roots);
  1724. const defaultNode = nodes.find(item => item.kind === 'task-dashboard') || nodes.find(item => item.kind === 'doc-dashboard') || nodes.find(item => item.kind === 'todo-dashboard') || nodes.find(item => item.kind === 'meeting-dashboard') || nodes.find(item => item.kind === 'property-dataset') || nodes.find(item => item.type === 'file');
  1725. if (defaultNode) await openKnowledgeNode(page, state.knowledge.selectedNodeId || defaultNode.id);
  1726. else renderKnowledgeWorkspace(page);
  1727. } catch (error) {
  1728. page.innerHTML = `<div class="library-error">${escapeHtml(error.message || '知识库扫描失败')}</div>`;
  1729. }
  1730. }
  1731. // ==================== Intelligent Conversations ====================
  1732. function formatAgentTime(value) {
  1733. const date = new Date(value || 0);
  1734. if (Number.isNaN(date.getTime())) return '-';
  1735. return date.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
  1736. }
  1737. function agentTaskStatusLabel(status) {
  1738. return ({ open: '待处理', in_progress: '处理中', done: '已完成', dismissed: '已忽略' })[status] || status || '待处理';
  1739. }
  1740. function agentOfficialTodoLabel(task = {}) {
  1741. if (!task.officialTodoId) return '仅本地';
  1742. return task.officialSyncStatus === 'done'
  1743. ? '企微已完成'
  1744. : task.officialSyncStatus === 'completion_pending'
  1745. ? '企微待确认完成'
  1746. : task.officialSyncStatus === 'error'
  1747. ? '企微同步异常'
  1748. : '已同步企微';
  1749. }
  1750. function renderAgentCustomerTask(task = {}) {
  1751. const evidenceCount = Array.isArray(task.evidenceItems) ? task.evidenceItems.length : (task.evidence ? 1 : 0);
  1752. const active = ['open', 'in_progress'].includes(task.status);
  1753. const officialClass = task.officialSyncStatus === 'error' ? 'error' : task.officialTodoId ? 'synced' : 'local';
  1754. const needsOfficialCompletion = task.status === 'done' && task.officialTodoId && task.officialSyncStatus !== 'done';
  1755. const actions = active
  1756. ? `${task.officialTodoId ? '' : `<button class="btn btn-sm btn-secondary" data-agent-action="customer-task-sync-official" data-id="${escapeHtml(task.id)}">同步企微待办</button>`}<button class="btn btn-sm btn-secondary" data-agent-action="customer-task-dismiss" data-id="${escapeHtml(task.id)}">忽略</button><button class="btn btn-sm" data-agent-action="customer-task-done" data-id="${escapeHtml(task.id)}">完成</button>`
  1757. : `${needsOfficialCompletion ? `<button class="btn btn-sm" data-agent-action="customer-task-complete-official" data-id="${escapeHtml(task.id)}">同步完成到企微</button>` : ''}<span>${escapeHtml(agentTaskStatusLabel(task.status))}${task.resolutionReason === 'profile_condition_resolved' ? ' · 画像条件已补齐' : ''}</span>`;
  1758. return `<div class="agent-customer-task ${escapeHtml(task.priority)} ${escapeHtml(task.status)}">
  1759. <div><strong>${escapeHtml(task.title)}</strong><small>${escapeHtml([task.owner, task.dueAt].filter(Boolean).join(' · ') || '待分配')}</small><p>${escapeHtml(task.reason || task.evidence || '')}</p><div class="agent-task-meta"><span>${evidenceCount} 条依据</span><span class="${officialClass}">${escapeHtml(agentOfficialTodoLabel(task))}</span></div></div>
  1760. <div class="agent-intelligence-actions">${actions}</div>
  1761. </div>`;
  1762. }
  1763. function demandItems(demand = {}) {
  1764. const districts = Array.isArray(demand.districts) ? demand.districts.join('/') : (demand.preferredRegion || demand.region || demand.intent_area || '');
  1765. const min = demand.budgetMin ?? demand.budget_min;
  1766. const max = demand.budgetMax ?? demand.budget_max;
  1767. const rooms = demand.rooms || (demand.house_type ? String(demand.house_type).replace(/[^0-9]/g, '') : '');
  1768. const values = [
  1769. ['区域', districts],
  1770. ['预算', demand.budgetWan ? `${demand.budgetWan}万${demand.budgetType && demand.budgetType !== '待确认' ? `(${demand.budgetType})` : ''}` : (min && max ? `${min}-${max}万` : (max ? `${max}万内` : ''))],
  1771. ['户型', demand.layout || (rooms ? `${rooms}室` : (demand.house_type || ''))],
  1772. ['面积', demand.areaMin && demand.areaMax ? `${demand.areaMin}-${demand.areaMax}㎡` : ''],
  1773. ['装修', demand.decoration],
  1774. ['用途', demand.purpose || demand.buyerType || demand.purchase_purpose],
  1775. ['偏好', [demand.floor, demand.orientation].filter(Boolean).join(' / ')],
  1776. ['学区', demand.schoolRequired ? '需要' : ''],
  1777. ['时间', demand.timeline],
  1778. ['紧迫度', demand.urgency],
  1779. ];
  1780. return values.filter(([, value]) => value !== undefined && value !== null && String(value).trim());
  1781. }
  1782. function renderAgentMessage(message) {
  1783. const roleLabel = message.role === 'customer' ? '客户' : message.role === 'human' ? '人工客服' : 'Agent 建议';
  1784. const statusLabel = message.status === 'pending' ? '待确认' : message.status === 'sending' ? '发送中' : message.status === 'sent' ? '已发送' : message.status === 'submitted' ? '已提交企微' : '';
  1785. return `<div class="agent-message ${escapeHtml(message.role)}">
  1786. <div class="agent-message-meta"><span>${roleLabel}</span><span>${formatAgentTime(message.timestamp)}${statusLabel ? ` · ${statusLabel}` : ''}</span></div>
  1787. <div class="agent-message-body">${escapeHtml(message.content).replace(/\n/g, '<br>')}</div>
  1788. </div>`;
  1789. }
  1790. function agentModeLabel(mode) {
  1791. return ({ review: '待审核', auto: '高置信自动', human: '人工接管', paused: '会话暂停' })[mode] || mode || '待审核';
  1792. }
  1793. function formatReplyWaiting(minutes) {
  1794. const value = Math.max(0, Number(minutes) || 0);
  1795. if (value < 60) return `${value} 分钟`;
  1796. if (value < 1440) return `${Math.floor(value / 60)} 小时 ${value % 60} 分钟`;
  1797. return `${Math.floor(value / 1440)} 天 ${Math.floor((value % 1440) / 60)} 小时`;
  1798. }
  1799. function renderResponseMonitor(monitor = {}) {
  1800. const summary = monitor.summary || {};
  1801. const config = monitor.config || {};
  1802. const scope = monitor.scope || { customers: [], groups: [] };
  1803. const reminders = monitor.reminders || [];
  1804. const scopeItems = [
  1805. ...(scope.customers || []).map(item => ({ ...item, typeLabel: '客户' })),
  1806. ...(scope.groups || []).map(item => ({ ...item, typeLabel: '客户群' })),
  1807. ];
  1808. return `
  1809. <section class="response-monitor ${summary.urgent ? 'has-urgent' : ''}">
  1810. <header class="response-monitor-header">
  1811. <div>
  1812. <span>RESPONSE SLA MONITOR</span>
  1813. <strong>客服回复提醒</strong>
  1814. <small>最后一条是客户消息且尚未回复时计时;仅本地提醒,不自动外发</small>
  1815. </div>
  1816. <div class="response-monitor-kpis">
  1817. <span><b>${summary.monitoredCustomers || 0}</b> 个客户</span>
  1818. <span><b>${summary.monitoredGroups || 0}</b> 个客户群</span>
  1819. <span class="${summary.overdue ? 'danger' : ''}"><b>${summary.overdue || 0}</b> 个超时</span>
  1820. </div>
  1821. </header>
  1822. <div class="response-monitor-body">
  1823. <div class="response-monitor-scope">
  1824. <div class="response-monitor-section-title"><strong>当前监听范围</strong><span>${config.warningMinutes || 15} 分钟提醒 · ${config.urgentMinutes || 60} 分钟紧急</span></div>
  1825. <div class="response-scope-list">
  1826. ${scopeItems.length ? scopeItems.map(item => `<span class="response-scope-chip ${escapeHtml(item.status || '')}"><i>${escapeHtml(item.typeLabel)}</i>${escapeHtml(item.name)}<em>${item.status === 'awaiting_sync' ? '待同步' : item.status === 'overdue' ? '待回复' : item.status === 'replied' ? '已回复' : item.status === 'no_reply_needed' ? '无需回复' : '监控中'}</em></span>`).join('') : '<span class="response-monitor-empty">尚未配置监听客户或客户群</span>'}
  1827. </div>
  1828. </div>
  1829. <div class="response-reminder-list">
  1830. <div class="response-monitor-section-title"><strong>需要客服处理</strong><span>${summary.urgent || 0} 个紧急 · ${summary.warning || 0} 个提醒</span></div>
  1831. ${reminders.length ? reminders.slice(0, 8).map(item => `
  1832. <article class="response-reminder ${escapeHtml(item.severity)}">
  1833. <div><i>${item.kind === 'group' ? '群' : '客'}</i><strong>${escapeHtml(item.name)}</strong><em>${item.severity === 'urgent' ? '紧急' : '提醒'}</em></div>
  1834. <p>${escapeHtml(item.lastMessagePreview || '收到新消息')}</p>
  1835. <footer><span>已等待 ${formatReplyWaiting(item.waitingMinutes)}</span><button class="btn btn-sm" data-response-monitor-target="${escapeHtml(item.kind)}" data-id="${escapeHtml(item.conversationId || item.id)}">${item.kind === 'group' ? '查看群聊' : '去回复'}</button></footer>
  1836. </article>
  1837. `).join('') : '<div class="response-monitor-empty success">当前没有超时未回复会话</div>'}
  1838. </div>
  1839. </div>
  1840. </section>`;
  1841. }
  1842. function agentAuditLabel(action) {
  1843. return ({
  1844. message_received: '收到真实企微消息',
  1845. draft_created: 'Agent 生成草稿',
  1846. draft_approved: '人工审核通过',
  1847. draft_rejected: '人工驳回草稿',
  1848. draft_regenerate_requested: '请求重新生成',
  1849. message_sent: '回复已发送',
  1850. manual_message_sent: '人工消息已发送',
  1851. message_send_failed: '发送失败',
  1852. agent_failed: 'Agent 上游失败',
  1853. agent_not_configured: 'Agent 未配置',
  1854. agent_no_reply_needed: 'Agent 判断无需回复',
  1855. agent_skipped_global_paused: '全局暂停,跳过 Agent',
  1856. agent_skipped_human: '人工接管,跳过 Agent',
  1857. agent_skipped_paused: '会话暂停,跳过 Agent',
  1858. message_duplicate_content_skipped: '重复消息已拦截',
  1859. conversation_mode_changed: '会话模式已更新',
  1860. manual_agent_run_requested: '手动请求 Agent',
  1861. conversation_history_synced: '人工补采企微会话',
  1862. customer_intelligence_updated: '客户画像、待办与预警已更新',
  1863. customer_task_updated: '客户待办状态已更新',
  1864. customer_task_synced_to_official_todo: '客户待办已同步企微',
  1865. customer_alert_updated: '客户预警状态已更新',
  1866. })[action] || action;
  1867. }
  1868. function scrollAgentMessagesToLatest(page) {
  1869. requestAnimationFrame(() => {
  1870. const stream = page.querySelector('.agent-message-stream');
  1871. if (stream) stream.scrollTop = stream.scrollHeight;
  1872. });
  1873. }
  1874. function captureAgentInsightScroll(page) {
  1875. const panel = page.querySelector('.agent-insight-panel');
  1876. if (!panel) return null;
  1877. return {
  1878. conversationId: panel.dataset.conversationId || '',
  1879. scrollTop: panel.scrollTop,
  1880. };
  1881. }
  1882. function restoreAgentInsightScroll(page, snapshot, conversationId) {
  1883. if (!snapshot || snapshot.conversationId !== String(conversationId || '')) return;
  1884. requestAnimationFrame(() => {
  1885. const panel = page.querySelector('.agent-insight-panel');
  1886. if (!panel || panel.dataset.conversationId !== snapshot.conversationId) return;
  1887. const maxScrollTop = Math.max(0, panel.scrollHeight - panel.clientHeight);
  1888. panel.scrollTop = Math.min(snapshot.scrollTop, maxScrollTop);
  1889. });
  1890. }
  1891. function renderAgentWorkspace(page) {
  1892. const status = state.agent.status || {};
  1893. const listener = status.listener || {};
  1894. const account = status.account || {};
  1895. const config = status.config || {};
  1896. const accountName = currentAccount()?.nickname || account.nickname || account.userId || '当前企微账号';
  1897. const mode = status.globalMode || 'suggest';
  1898. const aiActive = Boolean(listener.running && mode === 'auto');
  1899. const conversations = state.agent.conversations || [];
  1900. const selected = conversations.find(item => item.id === state.agent.selectedId) || conversations[0] || null;
  1901. if (selected) state.agent.selectedId = selected.id;
  1902. const analysis = selected?.analysis || {};
  1903. const customerIntelligence = selected?.customerIntelligence || {};
  1904. const customerTasks = customerIntelligence.tasks || [];
  1905. const customerAlerts = customerIntelligence.alerts || [];
  1906. const intelligenceSummary = customerIntelligence.summary || {};
  1907. const demands = demandItems(analysis.demand || {});
  1908. const matches = analysis.matches || [];
  1909. const pendingText = selected?.pendingReply?.content || '';
  1910. const isHistorical = selected?.source === 'verified-history';
  1911. const manualActive = Boolean(!aiActive || selected?.mode === 'manual');
  1912. els.accountName.textContent = accountName;
  1913. els.accountDot.className = `account-dot ${account.online ? 'online' : 'offline'}`;
  1914. page.innerHTML = `
  1915. <section class="agent-hero">
  1916. <div>
  1917. <div class="agent-eyebrow">WECOM · AGENTIC SERVICE</div>
  1918. <h2>把企微消息变成可执行的客户线索</h2>
  1919. <p>启动监听后由 AI 自动回复白名单客户;关闭监听后停止 AI,由人工继续回复。</p>
  1920. </div>
  1921. <div class="agent-safety-card">
  1922. <span class="agent-live-dot ${account.online ? 'online' : ''}"></span>
  1923. <div><strong>${escapeHtml(accountName)} · ${account.online ? '在线' : '离线'}</strong><small>${escapeHtml(account.corpName || config.transport || '企微通道')} · ${aiActive ? 'AI 自动回复中' : '当前人工回复'}</small></div>
  1924. <div class="agent-safety-actions">
  1925. <button class="btn ${aiActive ? 'btn-secondary' : ''}" data-agent-action="${aiActive ? 'stop-listener' : 'start-listener'}" ${account.online ? '' : 'disabled'}>${aiActive ? '关闭 AI 监听' : '启动 AI 监听'}</button>
  1926. </div>
  1927. </div>
  1928. </section>
  1929. <section class="agent-modebar">
  1930. <div class="agent-mode-copy"><strong>当前处理方式</strong><span>启停监听即切换 AI / 人工</span></div>
  1931. <div class="agent-mode-switch">
  1932. <span class="agent-mode-btn ${!aiActive ? 'active' : ''}">人工回复</span>
  1933. <span class="agent-mode-btn ${aiActive ? 'active' : ''}">AI 自动回复</span>
  1934. </div>
  1935. <div class="agent-guard"><span>安全边界</span>AI 只回复唯一白名单测试联系人</div>
  1936. </section>
  1937. <section class="agent-workspace">
  1938. <aside class="agent-conversation-list">
  1939. <div class="agent-panel-heading"><div><strong>客户会话</strong><small>${conversations.length} 个已归集会话</small></div><span class="agent-count">${conversations.length}</span></div>
  1940. <div class="agent-list-scroll">
  1941. ${conversations.length ? conversations.map(item => {
  1942. const last = [...(item.messages || [])].reverse().find(message => message.role === 'customer') || item.messages?.[item.messages.length - 1];
  1943. return `<button class="agent-conversation-item ${item.id === selected?.id ? 'active' : ''}" data-conversation-id="${item.id}">
  1944. <span class="agent-avatar">${escapeHtml((item.displayName || '客').slice(0, 1))}</span>
  1945. <span class="agent-conversation-copy"><strong>${escapeHtml(item.displayName)}</strong><small>${escapeHtml(last?.content || '等待新消息')}</small><em>${item.source === 'verified-history' ? '真实链路记录' : !aiActive || item.mode === 'manual' ? '人工回复' : 'AI 自动回复'}</em></span>
  1946. <time>${formatAgentTime(item.lastMessageAt)}</time>
  1947. </button>`;
  1948. }).join('') : `<div class="agent-empty"><strong>等待第一条真实消息</strong><span>启动监听后,用白名单企微发送测试内容。</span></div>`}
  1949. </div>
  1950. </aside>
  1951. <main class="agent-chat-panel">
  1952. ${selected ? `
  1953. <header class="agent-chat-header">
  1954. <div><strong>${escapeHtml(selected.displayName)}</strong><span>${escapeHtml(selected.maskedId)} · ${isHistorical ? '已验证真实收发记录' : '实时企微会话'}</span></div>
  1955. ${aiActive ? `<button class="btn ${selected.mode === 'manual' ? '' : 'btn-secondary'}" data-agent-action="${selected.mode === 'manual' ? 'resume' : 'takeover'}" data-id="${selected.id}">${selected.mode === 'manual' ? '恢复 Agent' : '人工接管'}</button>` : '<span class="badge badge-success">人工回复中</span>'}
  1956. </header>
  1957. <div class="agent-message-stream">${(selected.messages || []).map(renderAgentMessage).join('')}</div>
  1958. <div class="agent-composer ${manualActive ? 'manual' : ''}">
  1959. <div class="agent-composer-label"><strong>${manualActive ? '人工回复' : 'AI 自动回复'}</strong><span>${selected.pendingReply ? '等待处理' : (isHistorical ? '历史记录无待发送内容' : aiActive ? 'AI 将自动发送新回复' : 'AI 已停止')}</span></div>
  1960. <textarea id="agent-reply-editor" placeholder="${manualActive ? '输入人工回复内容…' : 'AI 自动回复运行中…'}">${escapeHtml(pendingText)}</textarea>
  1961. <div class="agent-composer-actions"><span>${manualActive ? '也可以直接在企业微信中人工回复' : '新消息将由 AI 自动回复'}</span><button class="btn" data-agent-action="approve" data-id="${selected.id}" ${pendingText || manualActive ? '' : 'disabled'}>${manualActive ? '人工发送' : '发送建议'}</button></div>
  1962. </div>
  1963. ` : `<div class="agent-empty large"><strong>智能会话准备就绪</strong><span>启动监听,然后从白名单企微发送:“我想在新北区买婚房,预算150万,三室,最好精装”</span></div>`}
  1964. </main>
  1965. <aside class="agent-insight-panel">
  1966. ${selected ? `
  1967. <div class="agent-insight-section">
  1968. <div class="agent-section-title"><span>01</span><div><strong>意图判断</strong><small>Agent 结构化理解</small></div></div>
  1969. <div class="agent-intent-card"><strong>${escapeHtml(analysis.intentLabel || analysis.intent || '待识别')}</strong><span>画像完整度 ${Math.round(analysis.completenessScore || 0)}%</span><div><i style="width:${Math.min(100, analysis.completenessScore || 0)}%"></i></div></div>
  1970. <p class="agent-reasoning">${escapeHtml(analysis.reasoning || '等待新消息进入。')}</p>
  1971. </div>
  1972. <div class="agent-insight-section">
  1973. <div class="agent-section-title"><span>02</span><div><strong>需求画像</strong><small>从多轮会话持续归集</small></div></div>
  1974. <div class="agent-demand-grid">${demands.length ? demands.map(([label, value]) => `<div><small>${escapeHtml(label)}</small><strong>${escapeHtml(value)}</strong></div>`).join('') : '<p class="agent-muted">暂无明确需求</p>'}</div>
  1975. </div>
  1976. <div class="agent-insight-section">
  1977. <div class="agent-section-title"><span>03</span><div><strong>匹配建议</strong><small>本地房源数据与评分模型</small></div></div>
  1978. <div class="agent-match-list">${matches.length ? matches.map((match, index) => `<div class="agent-match-card"><span>${index + 1}</span><div><strong>${escapeHtml(match.community || match.title || '匹配结果')}</strong><small>${escapeHtml([match.price ? `${match.price}万` : '', match.layout, match.area ? `${match.area}㎡` : ''].filter(Boolean).join(' · '))}</small><em>${escapeHtml((match.highlights || []).join(' / ') || match.level || '')}</em></div>${match.score !== null && match.score !== undefined ? `<b>${match.score}</b>` : ''}</div>`).join('') : '<p class="agent-muted">当前消息未触发房源匹配</p>'}</div>
  1979. </div>
  1980. <div class="agent-insight-section knowledge">
  1981. <div class="agent-section-title"><span>04</span><div><strong>依据与边界</strong><small>让建议可解释、可追溯</small></div></div>
  1982. <ul>${(analysis.knowledgeSources || []).map(source => `<li>${escapeHtml(source)}</li>`).join('')}</ul>
  1983. </div>
  1984. ` : '<div class="agent-empty"><strong>暂无分析</strong><span>选择会话后显示 Agent 工作过程。</span></div>'}
  1985. </aside>
  1986. </section>
  1987. `;
  1988. scrollAgentMessagesToLatest(page);
  1989. }
  1990. function renderAgentWorkspaceV2(page) {
  1991. const insightScroll = captureAgentInsightScroll(page);
  1992. const status = state.agent.status || {};
  1993. const listener = status.listener || {};
  1994. const account = status.account || {};
  1995. const config = status.config || {};
  1996. const accountName = currentAccount()?.nickname || account.nickname || account.userId || '当前企微账号';
  1997. const global = status.global || {};
  1998. const globalPaused = global.paused !== undefined ? Boolean(global.paused) : status.globalMode === 'paused';
  1999. const globalMode = global.defaultMode || (status.globalMode === 'auto' ? 'auto' : 'review');
  2000. const listenerRunning = Boolean(listener.running);
  2001. const agent = status.agent || {};
  2002. const knowledge = status.knowledge || {};
  2003. const conversations = state.agent.conversations || [];
  2004. const selected = conversations.find(item => item.id === state.agent.selectedId) || conversations[0] || null;
  2005. if (selected) state.agent.selectedId = selected.id;
  2006. const sessionGuide = selected?.claudeSession || null;
  2007. const analysis = selected?.analysis || {};
  2008. const customerIntelligence = selected?.customerIntelligence || {};
  2009. const customerTasks = customerIntelligence.tasks || [];
  2010. const customerAlerts = customerIntelligence.alerts || [];
  2011. const intelligenceSummary = customerIntelligence.summary || {};
  2012. const demands = demandItems(analysis.demand || {});
  2013. const matches = analysis.matches || [];
  2014. const pending = selected?.pendingReply || null;
  2015. const noReplyNotice = selected?.agentNotice || null;
  2016. const activeDraft = pending;
  2017. const pendingText = pending?.content || '';
  2018. const manualActive = selected?.mode === 'human';
  2019. const conversationPaused = selected?.mode === 'paused';
  2020. const audit = selected?.audit || [];
  2021. const toolTrace = activeDraft?.toolTrace || activeDraft?.tool_trace || [];
  2022. const citations = activeDraft?.citations || [];
  2023. els.accountName.textContent = accountName;
  2024. els.accountDot.className = `account-dot ${account.online ? 'online' : 'offline'}`;
  2025. page.innerHTML = `
  2026. <section class="agent-hero">
  2027. <div>
  2028. <div class="agent-eyebrow">WECOM · REAL AGENT · HUMAN IN THE LOOP</div>
  2029. <h2>真实企微消息,由 Agent 思考,人工可随时监管</h2>
  2030. <p>消息进入后,Agent 自主检索规则库、知识库、画像和房源工具;草稿、依据、置信度和工具轨迹全部留痕。</p>
  2031. </div>
  2032. <div class="agent-safety-card">
  2033. <span class="agent-live-dot ${account.online ? 'online' : ''}"></span>
  2034. <div><strong>${escapeHtml(accountName)} · ${account.online ? '在线' : '离线'}</strong><small>${escapeHtml(account.corpName || config.transport || '企微通道')} · ${listenerRunning ? '真实消息监听中' : '监听已停止'}</small></div>
  2035. <div class="agent-safety-actions">
  2036. <button class="btn btn-secondary" data-agent-action="sync-conversations" ${account.online ? '' : 'disabled'}>补采最近消息</button>
  2037. <button class="btn ${listenerRunning ? 'btn-secondary' : ''}" data-agent-action="${listenerRunning ? 'stop-listener' : 'start-listener'}" ${account.online ? '' : 'disabled'}>${listenerRunning ? '关闭 AI 监听(转人工)' : '启动 AI 监听'}</button>
  2038. </div>
  2039. </div>
  2040. </section>
  2041. <section class="agent-modebar">
  2042. <div class="agent-mode-copy"><strong>全局 Agent 策略</strong><span>监听与 Agent 策略相互独立,暂停时仍可收消息</span></div>
  2043. <div class="agent-mode-switch">
  2044. <button class="agent-mode-btn ${!globalPaused && globalMode === 'review' ? 'active' : ''}" data-agent-mode="review">待审核</button>
  2045. <button class="agent-mode-btn ${!globalPaused && globalMode === 'auto' ? 'active' : ''}" data-agent-mode="auto">高置信自动</button>
  2046. <button class="agent-mode-btn ${globalPaused ? 'active danger' : ''}" data-agent-mode="paused">全局暂停</button>
  2047. </div>
  2048. <div class="agent-guard"><span>安全边界</span>仅处理 1 个白名单联系人 · 演示回复已禁用</div>
  2049. </section>
  2050. ${renderResponseMonitor(state.agent.responseMonitor || {})}
  2051. ${selected?.agentError
  2052. ? `<div class="agent-runtime-alert"><strong>Agent 上游不可用</strong><span>${escapeHtml(selected.agentError.message)}。真实消息已保存,系统没有生成或发送 Mock 回复。</span></div>`
  2053. : noReplyNotice
  2054. ? `<div class="agent-runtime-alert notice"><strong>Agent 已处理</strong><span>${escapeHtml(noReplyNotice.message)},系统没有向客户发送消息。</span></div>`
  2055. : ''}
  2056. <section class="agent-workspace">
  2057. <aside class="agent-conversation-list">
  2058. <div class="agent-panel-heading"><div><strong>真实客户会话</strong><small>${conversations.length} 个白名单会话</small></div><span class="agent-count">${conversations.length}</span></div>
  2059. <div class="agent-list-scroll">
  2060. ${conversations.length ? conversations.map(item => {
  2061. const last = [...(item.messages || [])].reverse().find(message => message.role === 'customer') || item.messages?.[item.messages.length - 1];
  2062. return `<button class="agent-conversation-item ${item.id === selected?.id ? 'active' : ''}" data-conversation-id="${item.id}">
  2063. <span class="agent-avatar">${escapeHtml((item.displayName || '客').slice(0, 1))}</span>
  2064. <span class="agent-conversation-copy"><strong>${escapeHtml(item.displayName)}</strong><small>${escapeHtml(last?.content || '等待新消息')}</small><em>${agentModeLabel(item.mode)}${item.pendingReply ? ' · 有待审草稿' : ''}${item.customerIntelligence?.summary?.openTasks ? ` · ${item.customerIntelligence.summary.openTasks}待办` : ''}${item.customerIntelligence?.summary?.highAlerts ? ` · ${item.customerIntelligence.summary.highAlerts}预警` : ''}</em></span>
  2065. <time>${formatAgentTime(item.lastMessageAt)}</time>
  2066. </button>`;
  2067. }).join('') : `<div class="agent-empty"><strong>等待第一条真实消息</strong><span>开始接收后,用“王刚”的企微发送测试内容。</span></div>`}
  2068. </div>
  2069. </aside>
  2070. <main class="agent-chat-panel">
  2071. ${selected ? `
  2072. <header class="agent-chat-header">
  2073. <div><strong>${escapeHtml(selected.displayName)}</strong><span>${escapeHtml(selected.maskedId)} · 真实企微会话 · ${agentModeLabel(selected.mode)}</span></div>
  2074. <div class="agent-conversation-modes">
  2075. <button class="btn btn-sm ${selected.mode === 'review' ? '' : 'btn-secondary'}" data-agent-conversation-mode="review" data-id="${selected.id}">待审核</button>
  2076. <button class="btn btn-sm ${selected.mode === 'auto' ? '' : 'btn-secondary'}" data-agent-conversation-mode="auto" data-id="${selected.id}">自动</button>
  2077. <button class="btn btn-sm ${selected.mode === 'human' ? '' : 'btn-secondary'}" data-agent-conversation-mode="human" data-id="${selected.id}">人工接管</button>
  2078. <button class="btn btn-sm ${selected.mode === 'paused' ? '' : 'btn-secondary'}" data-agent-conversation-mode="paused" data-id="${selected.id}">暂停</button>
  2079. </div>
  2080. </header>
  2081. <div class="agent-session-strip ${sessionGuide?.ready ? 'ready' : ''}">
  2082. <span class="agent-session-dot"></span>
  2083. <div><strong>${sessionGuide?.ready ? 'Claude Code 客户 Session 已识别' : 'Claude Code 客户 Session 等待首次运行'}</strong><small>${escapeHtml(sessionGuide?.displayName || 'Agent 首次生成草稿后自动创建')} · ${sessionGuide?.ready ? '可安全打开审阅副本' : '不会与其他客户共用上下文'}</small></div>
  2084. <button class="btn btn-sm btn-secondary" data-agent-action="session-guide" data-id="${selected.id}">${sessionGuide?.ready ? '查看会话与打开方式' : '查看说明'}</button>
  2085. </div>
  2086. <div class="agent-message-stream">${(selected.messages || []).map(renderAgentMessage).join('')}</div>
  2087. <div class="agent-composer ${manualActive ? 'manual' : ''}">
  2088. <div class="agent-composer-label"><strong>${pending ? 'Agent 待审核草稿' : manualActive ? '人工回复' : conversationPaused ? '会话已暂停' : noReplyNotice ? 'Agent 已处理' : 'Agent 处理区'}</strong><span>${pending ? `置信度 ${Math.round((pending.confidence || 0) * 100)}% · ${pending.requiresHuman ? '必须人工审核' : '可审核发送'}` : manualActive ? 'Agent 不会生成或发送回复' : conversationPaused ? '消息保留,Agent 不处理' : noReplyNotice ? '最新消息无需回复' : '没有待审核草稿'}</span></div>
  2089. <textarea id="agent-reply-editor" placeholder="${manualActive ? '输入人工回复内容…' : pending ? '可先编辑 Agent 草稿…' : noReplyNotice ? '最新客户消息无需回复' : '点击下方按钮,让 Agent 处理最近一条消息'}" ${conversationPaused ? 'disabled' : ''}>${escapeHtml(pendingText)}</textarea>
  2090. <div class="agent-composer-actions">
  2091. <span>${pending ? escapeHtml(pending.reason || '请核对内容与依据后再发送') : `模型:${escapeHtml(agent.model || '未配置')} · ${agent.configured ? '已配置' : '未配置'}`}</span>
  2092. <div class="agent-review-actions">
  2093. ${pending ? `<button class="btn btn-secondary" data-agent-action="reject" data-id="${selected.id}" data-draft-id="${pending.id}">驳回</button><button class="btn btn-secondary" data-agent-action="regenerate" data-id="${selected.id}" data-draft-id="${pending.id}">重新生成</button><button class="btn" data-agent-action="approve" data-id="${selected.id}" data-draft-id="${pending.id}">批准并发送</button>` : manualActive ? `<button class="btn" data-agent-action="manual-send" data-id="${selected.id}">人工发送</button>` : conversationPaused ? '' : `<button class="btn" data-agent-action="generate" data-id="${selected.id}">${noReplyNotice ? '重新处理' : '让 Agent 处理'}</button>`}
  2094. </div>
  2095. </div>
  2096. </div>
  2097. ` : `<div class="agent-empty large"><strong>真实 Agent 会话准备就绪</strong><span>开始接收,然后从“王刚”的企微发送:“我想在新北区买婚房,预算150万,三室,最好精装”</span></div>`}
  2098. </main>
  2099. <aside class="agent-insight-panel" data-conversation-id="${escapeHtml(selected?.id || '')}">
  2100. ${selected ? `
  2101. <div class="agent-insight-section">
  2102. <div class="agent-section-title"><span>01</span><div><strong>意图判断</strong><small>Agent 结构化理解</small></div></div>
  2103. <div class="agent-intent-card"><strong>${escapeHtml(analysis.intentLabel || analysis.intent || '待识别')}</strong><span>画像完整度 ${Math.round(analysis.completenessScore || 0)}%</span><div><i style="width:${Math.min(100, analysis.completenessScore || 0)}%"></i></div></div>
  2104. <p class="agent-reasoning">${escapeHtml(analysis.reasoning || '等待新消息进入。')}</p>
  2105. </div>
  2106. <div class="agent-insight-section">
  2107. <div class="agent-section-title"><span>02</span><div><strong>需求画像</strong><small>从多轮会话持续归集</small></div></div>
  2108. <div class="agent-demand-grid">${demands.length ? demands.map(([label, value]) => `<div><small>${escapeHtml(label)}</small><strong>${escapeHtml(value)}</strong></div>`).join('') : '<p class="agent-muted">暂无明确需求</p>'}</div>
  2109. </div>
  2110. <div class="agent-insight-section">
  2111. <div class="agent-section-title"><span>03</span><div><strong>客户待办</strong><small>${intelligenceSummary.openTasks || 0} 项待处理 · 仅内部建议</small></div></div>
  2112. <div class="agent-customer-task-list">${customerTasks.length ? customerTasks.slice(0, 8).map(renderAgentCustomerTask).join('') : '<p class="agent-muted">继续对话后,Agent 会从明确需求中生成内部跟进待办</p>'}</div>
  2113. </div>
  2114. <div class="agent-insight-section">
  2115. <div class="agent-section-title"><span>04</span><div><strong>客户预警</strong><small>${intelligenceSummary.openAlerts || 0} 项未处理 · ${intelligenceSummary.highAlerts || 0} 项高优先级</small></div></div>
  2116. <div class="agent-customer-alert-list">${customerAlerts.length ? customerAlerts.slice(0, 6).map(alert => `<div class="agent-customer-alert ${escapeHtml(alert.severity)} ${escapeHtml(alert.status)}"><div class="agent-alert-heading"><strong>${escapeHtml(alert.title)}</strong><em>${escapeHtml(alert.severity)}</em></div><p>${escapeHtml(alert.detail || '')}</p><small>依据:${escapeHtml(alert.evidence || '待补充')}</small><div class="agent-intelligence-actions">${alert.status === 'open' ? `<button class="btn btn-sm btn-secondary" data-agent-action="customer-alert-dismiss" data-id="${alert.id}">忽略</button><button class="btn btn-sm" data-agent-action="customer-alert-ack" data-id="${alert.id}">已知悉</button>` : `<span>${alert.status === 'acknowledged' ? '已知悉' : alert.status === 'resolved' ? '已解决' : '已忽略'}</span>`}</div></div>`).join('') : '<p class="agent-muted">当前没有高意向、投诉、时效或矛盾预警</p>'}</div>
  2117. </div>
  2118. <div class="agent-insight-section">
  2119. <div class="agent-section-title"><span>05</span><div><strong>匹配建议</strong><small>已接入房源工具的返回结果</small></div></div>
  2120. <div class="agent-match-list">${matches.length ? matches.map((match, index) => `<div class="agent-match-card"><span>${index + 1}</span><div><strong>${escapeHtml(match.community || match.title || '匹配结果')}</strong><small>${escapeHtml([match.price ? `${match.price}万` : '', match.layout, match.area ? `${match.area}㎡` : ''].filter(Boolean).join(' · '))}</small><em>${escapeHtml((match.highlights || []).join(' / ') || match.level || '')}</em></div></div>`).join('') : '<p class="agent-muted">当前消息未触发房源工具</p>'}</div>
  2121. </div>
  2122. <div class="agent-insight-section knowledge">
  2123. <div class="agent-section-title"><span>06</span><div><strong>依据与引用</strong><small>${knowledge.knowledgeChunks || 0} 个知识块 · ${knowledge.propertyDataLabel || '房源未接入'}</small></div></div>
  2124. <ul>${(citations.length ? citations.map(item => `${item.heading || item.source} · ${item.source || ''}`) : analysis.knowledgeSources || []).map(source => `<li>${escapeHtml(source)}</li>`).join('')}</ul>
  2125. </div>
  2126. <div class="agent-insight-section">
  2127. <div class="agent-section-title"><span>07</span><div><strong>工具调用轨迹</strong><small>Agent 自主检索过程</small></div></div>
  2128. <div class="agent-tool-trace">${toolTrace.length ? toolTrace.map((item, index) => `<details ${index === 0 ? 'open' : ''}><summary>${escapeHtml(item.tool)}</summary><pre>${escapeHtml(JSON.stringify({ input: item.args || {}, result: item.result || {} }, null, 2))}</pre></details>`).join('') : '<p class="agent-muted">当前没有工具调用记录</p>'}</div>
  2129. </div>
  2130. <div class="agent-insight-section">
  2131. <div class="agent-section-title"><span>08</span><div><strong>审计日志</strong><small>接收、决策、审核和发送全留痕</small></div></div>
  2132. <div class="agent-audit-list">${audit.length ? audit.slice(0, 10).map(item => `<div><span>${escapeHtml(agentAuditLabel(item.action))}</span><time>${formatAgentTime(item.created_at)}</time></div>`).join('') : '<p class="agent-muted">暂无审计记录</p>'}</div>
  2133. </div>
  2134. ` : '<div class="agent-empty"><strong>暂无分析</strong><span>选择会话后显示 Agent 工作过程。</span></div>'}
  2135. </aside>
  2136. </section>
  2137. `;
  2138. restoreAgentInsightScroll(page, insightScroll, selected?.id);
  2139. scrollAgentMessagesToLatest(page);
  2140. }
  2141. async function loadAgentData(page, silent = false) {
  2142. if (state.agent.loading) return;
  2143. state.agent.loading = true;
  2144. try {
  2145. const [statusRes, conversationsRes, responseMonitorRes] = await Promise.all([
  2146. api('GET', '/api/agent/status'),
  2147. api('GET', '/api/agent/conversations'),
  2148. api('GET', '/api/agent/response-monitor'),
  2149. ]);
  2150. const nextStatus = statusRes.data || {};
  2151. const nextConversations = conversationsRes.data?.conversations || [];
  2152. const nextResponseMonitor = responseMonitorRes.data || {};
  2153. const localAccount = currentAccount();
  2154. const activeAccount = nextStatus.account || {};
  2155. if (localAccount && localAccount.uid && activeAccount.uid === localAccount.uid) {
  2156. let accountChanged = false;
  2157. for (const [key, value] of [
  2158. ['guid', activeAccount.guid],
  2159. ['userId', activeAccount.userId],
  2160. ['corpName', activeAccount.corpName],
  2161. ]) {
  2162. if (!localAccount[key] && value) {
  2163. localAccount[key] = value;
  2164. accountChanged = true;
  2165. }
  2166. }
  2167. if (!localAccount.nickname && activeAccount.nickname) {
  2168. localAccount.nickname = activeAccount.nickname;
  2169. accountChanged = true;
  2170. }
  2171. if (localAccount.online !== Boolean(activeAccount.online)) {
  2172. localAccount.online = Boolean(activeAccount.online);
  2173. accountChanged = true;
  2174. }
  2175. if (accountChanged) {
  2176. saveAccounts();
  2177. updateAccountSwitcher();
  2178. }
  2179. }
  2180. const nextSignature = JSON.stringify({
  2181. globalMode: nextStatus.globalMode,
  2182. listenerRunning: Boolean(nextStatus.listener?.running),
  2183. account: nextStatus.account,
  2184. config: nextStatus.config,
  2185. conversations: nextConversations,
  2186. responseMonitor: nextResponseMonitor,
  2187. });
  2188. const changed = nextSignature !== state.agent.signature;
  2189. state.agent.status = nextStatus;
  2190. state.agent.conversations = nextConversations;
  2191. state.agent.responseMonitor = nextResponseMonitor;
  2192. state.agent.signature = nextSignature;
  2193. if (!state.agent.selectedId && state.agent.conversations.length) state.agent.selectedId = state.agent.conversations[0].id;
  2194. const waitingForFirstRender = Boolean(page.querySelector('.agent-loading'));
  2195. if ((changed || waitingForFirstRender) && state.page === 'agent' && page.isConnected) renderAgentWorkspaceV2(page);
  2196. } catch (error) {
  2197. if (!silent) toast(error.message || '智能会话加载失败', 'error');
  2198. } finally {
  2199. state.agent.loading = false;
  2200. }
  2201. }
  2202. function renderAgentPage() {
  2203. els.pageTitle.textContent = '智能会话';
  2204. state.agent.signature = null;
  2205. if (!currentAccount()) {
  2206. els.accountName.textContent = '正在检测账号';
  2207. els.accountDot.className = 'account-dot offline';
  2208. }
  2209. const page = document.createElement('div');
  2210. page.className = 'page agent-page';
  2211. page.innerHTML = '<div class="agent-loading">正在读取企微智能会话…</div>';
  2212. els.content.appendChild(page);
  2213. page.addEventListener('click', async event => {
  2214. const responseMonitorButton = event.target.closest('[data-response-monitor-target]');
  2215. if (responseMonitorButton) {
  2216. if (responseMonitorButton.dataset.responseMonitorTarget === 'customer') {
  2217. state.agent.selectedId = responseMonitorButton.dataset.id;
  2218. renderAgentWorkspaceV2(page);
  2219. } else {
  2220. location.hash = '#groups';
  2221. }
  2222. return;
  2223. }
  2224. const conversationButton = event.target.closest('[data-conversation-id]');
  2225. if (conversationButton) {
  2226. state.agent.selectedId = conversationButton.dataset.conversationId;
  2227. renderAgentWorkspaceV2(page);
  2228. return;
  2229. }
  2230. const modeButton = event.target.closest('[data-agent-mode]');
  2231. if (modeButton) {
  2232. const mode = modeButton.dataset.agentMode;
  2233. if (mode === 'auto' && !window.confirm('高置信自动模式会真实发送回复,仅对白名单联系人“王刚”生效。确认开启吗?')) return;
  2234. try {
  2235. const result = await api('POST', '/api/agent/mode', { mode });
  2236. toast(result.assistantMessage || '模式已更新');
  2237. await loadAgentData(page);
  2238. } catch (error) {
  2239. toast(error.message || '模式切换失败', 'error');
  2240. }
  2241. return;
  2242. }
  2243. const conversationModeButton = event.target.closest('[data-agent-conversation-mode]');
  2244. if (conversationModeButton) {
  2245. const id = conversationModeButton.dataset.id;
  2246. const mode = conversationModeButton.dataset.agentConversationMode;
  2247. if (mode === 'auto' && !window.confirm('该会话的高置信草稿可能自动真实发送。确认开启吗?')) return;
  2248. const action = ({ review: 'resume', auto: 'auto', human: 'takeover', paused: 'pause' })[mode];
  2249. conversationModeButton.disabled = true;
  2250. try {
  2251. const result = await api('POST', `/api/agent/conversations/${id}/${action}`, {});
  2252. toast(result.assistantMessage || '会话模式已更新');
  2253. await loadAgentData(page);
  2254. } catch (error) {
  2255. toast(error.message || '会话模式切换失败', 'error');
  2256. } finally {
  2257. if (conversationModeButton.isConnected) conversationModeButton.disabled = false;
  2258. }
  2259. return;
  2260. }
  2261. const actionButton = event.target.closest('[data-agent-action]');
  2262. if (!actionButton) return;
  2263. const action = actionButton.dataset.agentAction;
  2264. const id = actionButton.dataset.id;
  2265. if (action === 'session-guide') {
  2266. const conversation = (state.agent.conversations || []).find(item => item.id === id);
  2267. openAgentSessionGuide(conversation?.claudeSession || {});
  2268. return;
  2269. }
  2270. actionButton.disabled = true;
  2271. try {
  2272. let result;
  2273. if (action === 'sync-conversations') {
  2274. if (!window.confirm('只补采白名单联系人最近的真实消息,不运行 Agent,也不发送任何回复。确认继续吗?')) return;
  2275. result = await api('POST', '/api/agent/conversations/sync', {});
  2276. const synced = result.data?.conversations || [];
  2277. if (synced.length) {
  2278. state.agent.conversations = synced;
  2279. state.agent.selectedId = synced[0].id;
  2280. }
  2281. } else if (action === 'customer-task-sync-official') {
  2282. const selected = (state.agent.conversations || []).find(item => item.id === state.agent.selectedId) || (state.agent.conversations || [])[0];
  2283. const task = selected?.customerIntelligence?.tasks?.find(item => item.id === id);
  2284. if (!task) throw new Error('待办记录已变化,请刷新后重试');
  2285. const syncInput = await showOfficialTodoSyncModal(task);
  2286. if (!syncInput) return;
  2287. result = await api('POST', `/api/agent/tasks/${encodeURIComponent(id)}/sync-official`, syncInput);
  2288. } else if (action === 'customer-task-complete-official') {
  2289. if (!window.confirm('本地待办已经完成。确认现在将绑定的真实企业微信待办也标记为完成?')) return;
  2290. result = await api('POST', `/api/agent/tasks/${id}`, { status: 'done' });
  2291. } else if (action === 'customer-task-done' || action === 'customer-task-dismiss') {
  2292. const selected = (state.agent.conversations || []).find(item => item.id === state.agent.selectedId) || (state.agent.conversations || [])[0];
  2293. const task = selected?.customerIntelligence?.tasks?.find(item => item.id === id);
  2294. if (action === 'customer-task-done' && task?.officialTodoId && !window.confirm('该待办已绑定企业微信。确认后会同时将真实企微待办标记为完成,是否继续?')) return;
  2295. result = await api('POST', `/api/agent/tasks/${id}`, { status: action === 'customer-task-done' ? 'done' : 'dismissed' });
  2296. } else if (action === 'customer-alert-ack' || action === 'customer-alert-dismiss') {
  2297. result = await api('POST', `/api/agent/alerts/${id}`, { status: action === 'customer-alert-ack' ? 'acknowledged' : 'dismissed' });
  2298. } else if (action === 'start-listener' || action === 'stop-listener') {
  2299. if (action === 'start-listener' && !window.confirm('启动后,白名单客户的新消息会由 AI 处理;高置信回复可能自动真实发送。人工可随时关闭监听或接管会话。确认启动吗?')) return;
  2300. result = await api('POST', `/api/agent/listener/${action === 'start-listener' ? 'start' : 'stop'}`, {});
  2301. } else if (action === 'approve') {
  2302. const content = page.querySelector('#agent-reply-editor')?.value.trim();
  2303. if (!content) throw new Error('请先输入回复内容');
  2304. if (!window.confirm('确认审核通过,并真实发送给白名单联系人“王刚”?')) return;
  2305. result = await api('POST', `/api/agent/drafts/${actionButton.dataset.draftId}/approve`, { content });
  2306. } else if (action === 'reject') {
  2307. const reason = window.prompt('驳回原因(可选)', '需要人工调整');
  2308. if (reason === null) return;
  2309. result = await api('POST', `/api/agent/drafts/${actionButton.dataset.draftId}/reject`, { reason });
  2310. } else if (action === 'regenerate') {
  2311. result = await api('POST', `/api/agent/drafts/${actionButton.dataset.draftId}/regenerate`, {});
  2312. } else if (action === 'generate') {
  2313. result = await api('POST', `/api/agent/conversations/${id}/generate`, {});
  2314. } else if (action === 'manual-send') {
  2315. const content = page.querySelector('#agent-reply-editor')?.value.trim();
  2316. if (!content) throw new Error('请先输入人工回复内容');
  2317. if (!window.confirm('确认由人工真实发送给白名单联系人“王刚”?')) return;
  2318. result = await api('POST', `/api/agent/conversations/${id}/manual-send`, { content });
  2319. }
  2320. if (result) toast(result.assistantMessage || '操作成功');
  2321. await loadAgentData(page);
  2322. } catch (error) {
  2323. toast(error.message || '操作失败', 'error');
  2324. } finally {
  2325. if (actionButton.isConnected) actionButton.disabled = false;
  2326. }
  2327. });
  2328. loadAgentData(page);
  2329. state.agent.timer = setInterval(() => loadAgentData(page, true), 5000);
  2330. }
  2331. // ==================== Login ====================
  2332. async function openSubscribeFlowPage() {
  2333. try {
  2334. const res = await api('POST', '/api/login/start', { flowUi: true });
  2335. const flowUrl = res.data?.flowUrl || res.summary?.flowUrl;
  2336. if (flowUrl) {
  2337. window.open(flowUrl, '_blank');
  2338. return true;
  2339. }
  2340. toast('未获取到订阅流程页地址', 'warning');
  2341. return false;
  2342. } catch (err) {
  2343. toast(err.message || '打开订阅流程页失败', 'error');
  2344. return false;
  2345. }
  2346. }
  2347. function renderLoginPage() {
  2348. els.pageTitle.textContent = '登录企微账号';
  2349. const page = document.createElement('div');
  2350. page.className = 'login-page';
  2351. page.innerHTML = `
  2352. <div class="login-card">
  2353. <div class="brand-icon" style="margin:0 auto 16px;width:48px;height:48px">
  2354. <svg viewBox="0 0 24 24" style="width:28px;height:28px" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 15h-2v-6h2v6zm4 0h-2v-6h2v6zm-2-8c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2z"/></svg>
  2355. </div>
  2356. <h2>请先登录企业微信</h2>
  2357. <p>登录后才能扫描和管理客户群。如需管理多个账号,可重复登录并切换。</p>
  2358. <div id="login-qr-wrap">
  2359. <button class="btn" id="btn-start-login" style="width:100%">生成登录二维码</button>
  2360. </div>
  2361. </div>
  2362. `;
  2363. els.content.appendChild(page);
  2364. page.querySelector('#btn-start-login').addEventListener('click', () => startLoginFlow());
  2365. }
  2366. function createDeviceUid() {
  2367. const id = typeof crypto?.randomUUID === 'function'
  2368. ? crypto.randomUUID()
  2369. : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
  2370. return `qiwei-${id}`;
  2371. }
  2372. async function startLoginFlow() {
  2373. if (!state.loginUid) state.loginUid = currentAccount()?.uid || createDeviceUid();
  2374. const wrap = document.getElementById('login-qr-wrap');
  2375. wrap.innerHTML = `<div class="qr-wrap">
  2376. <div class="skeleton" style="width:220px;height:220px;margin:0 auto"></div>
  2377. </div>
  2378. <div class="qr-status" id="login-status-text">正在生成二维码…</div>`;
  2379. try {
  2380. const res = await api('POST', '/api/login/start', { uid: state.loginUid, persistConfig: false });
  2381. if (res.status === 'needs_subscription') {
  2382. wrap.innerHTML = `<div class="hint-box" style="text-align:left">
  2383. <svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"/></svg>
  2384. <div>${escapeHtml(res.assistantMessage || '需要先开通订阅')}</div>
  2385. </div>
  2386. <button class="btn" id="btn-subscribe-login" style="width:100%;margin-bottom:10px">打开订阅与登录流程页</button>
  2387. <button class="btn btn-secondary" id="btn-retry-login" style="width:100%">重试</button>`;
  2388. document.getElementById('btn-subscribe-login').addEventListener('click', openSubscribeFlowPage);
  2389. document.getElementById('btn-retry-login').addEventListener('click', startLoginFlow);
  2390. return;
  2391. }
  2392. const qrcodeFile = res.data?.qrcodeFile || res.summary?.qrcodeFile;
  2393. const flowUrl = res.data?.flowUrl;
  2394. if (flowUrl && !qrcodeFile) {
  2395. wrap.innerHTML = `<div class="hint-box" style="text-align:left">
  2396. <svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"/></svg>
  2397. <div>已打开登录流程页,请在新窗口中完成扫码。</div>
  2398. </div>
  2399. <a class="btn" href="${escapeHtml(flowUrl)}" target="_blank" style="width:100%;text-decoration:none">打开登录页面</a>`;
  2400. return;
  2401. }
  2402. if (!qrcodeFile) {
  2403. wrap.innerHTML = `<div class="hint-box" style="text-align:left">
  2404. <svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"/></svg>
  2405. <div>未获取到二维码,请重试。</div>
  2406. </div>
  2407. <button class="btn" id="btn-retry-login">重试</button>`;
  2408. document.getElementById('btn-retry-login').addEventListener('click', startLoginFlow);
  2409. return;
  2410. }
  2411. wrap.innerHTML = `<div class="qr-wrap">
  2412. <img src="/api/outputs?path=${encodeURIComponent(qrcodeFile)}" alt="登录二维码" id="login-qr">
  2413. </div>
  2414. <div class="qr-status" id="login-status-text">请用企业微信扫码</div>
  2415. <button class="btn btn-secondary" id="btn-retry-login" style="margin-top:12px">重新生成二维码</button>`;
  2416. document.getElementById('btn-retry-login').addEventListener('click', () => {
  2417. clearInterval(state.loginTimer);
  2418. startLoginFlow();
  2419. });
  2420. state.loginPhase = 'polling';
  2421. pollLoginStatus();
  2422. } catch (err) {
  2423. wrap.innerHTML = `<div class="hint-box" style="text-align:left;background:var(--error-bg);color:var(--error);border-color:var(--error-border)">
  2424. <svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"/></svg>
  2425. <div>${escapeHtml(err.message || '登录启动失败')}</div>
  2426. </div>
  2427. <button class="btn" id="btn-retry-login">重试</button>`;
  2428. document.getElementById('btn-retry-login').addEventListener('click', startLoginFlow);
  2429. }
  2430. }
  2431. function pollLoginStatus() {
  2432. clearInterval(state.loginTimer);
  2433. let checkCount = 0;
  2434. const statusText = document.getElementById('login-status-text');
  2435. state.loginTimer = setInterval(async () => {
  2436. checkCount++;
  2437. try {
  2438. const res = await api('POST', '/api/login/check', { uid: state.loginUid, persistConfig: false });
  2439. const statusCode = res.summary?.statusCode;
  2440. if (statusCode === 2) {
  2441. clearInterval(state.loginTimer);
  2442. if (statusText) statusText.textContent = '登录成功,正在获取账号信息…';
  2443. await finalizeLogin(res.data || {});
  2444. return;
  2445. }
  2446. if (res.status === 'needs_verify_code') {
  2447. clearInterval(state.loginTimer);
  2448. if (statusText) statusText.textContent = '请输入手机端验证码';
  2449. showVerifyCodeInput();
  2450. return;
  2451. }
  2452. if (statusCode === 4) {
  2453. clearInterval(state.loginTimer);
  2454. if (statusText) statusText.textContent = '登录已取消,请重新生成二维码';
  2455. return;
  2456. }
  2457. if (statusText) {
  2458. if (statusCode === 1) statusText.textContent = '已扫码,请在手机上确认';
  2459. else if (statusCode === 0 && checkCount > 3) statusText.textContent = '等待扫码…(可尝试免扫码登录)';
  2460. else statusText.textContent = '请用企业微信扫码';
  2461. }
  2462. } catch (err) {
  2463. if (statusText) statusText.textContent = '状态检测异常,重试中…';
  2464. }
  2465. }, 2500);
  2466. }
  2467. function showVerifyCodeInput() {
  2468. const wrap = document.getElementById('login-qr-wrap');
  2469. wrap.innerHTML = `
  2470. <div class="form-group" style="text-align:left">
  2471. <label>手机端显示的 6 位验证码</label>
  2472. <input id="verify-code" type="text" maxlength="6" placeholder="000000" style="font-size:22px;letter-spacing:12px;text-align:center" />
  2473. </div>
  2474. <button class="btn" id="btn-submit-verify" style="width:100%;margin-top:8px">提交验证码</button>
  2475. <button class="btn btn-secondary" id="btn-back-qr" style="width:100%;margin-top:10px">返回二维码</button>
  2476. `;
  2477. const input = document.getElementById('verify-code');
  2478. input.focus();
  2479. input.addEventListener('input', () => {
  2480. if (/^\d{6}$/.test(input.value.trim())) submitVerifyCode(input.value.trim());
  2481. });
  2482. document.getElementById('btn-submit-verify').addEventListener('click', () => {
  2483. submitVerifyCode(input.value.trim());
  2484. });
  2485. document.getElementById('btn-back-qr').addEventListener('click', () => {
  2486. clearInterval(state.loginTimer);
  2487. startLoginFlow();
  2488. });
  2489. }
  2490. async function submitVerifyCode(code) {
  2491. if (!/^\d{6}$/.test(code)) {
  2492. toast('请输入 6 位数字验证码', 'warning');
  2493. return;
  2494. }
  2495. try {
  2496. await api('POST', '/api/login/verify', { code, uid: state.loginUid });
  2497. toast('验证码已提交,正在确认登录…');
  2498. pollLoginStatus();
  2499. const statusText = document.getElementById('login-status-text');
  2500. if (statusText) statusText.textContent = '验证码已提交,正在确认…';
  2501. } catch (err) {
  2502. toast(err.message || '验证码提交失败', 'error');
  2503. }
  2504. }
  2505. async function finalizeLogin(result = {}) {
  2506. try {
  2507. const loginDetail = result.status || result.detail || {};
  2508. const uid = String(result.uid || state.loginUid || '').trim();
  2509. const guid = String(result.guid || loginDetail.guid || loginDetail.userId || '').trim();
  2510. const account = {
  2511. uid,
  2512. guid,
  2513. userId: loginDetail.userId || '',
  2514. nickname: loginDetail.nickname || loginDetail.userId || '',
  2515. corpName: loginDetail.corpName || '',
  2516. corpId: loginDetail.corpId || '',
  2517. online: true
  2518. };
  2519. if (!account.uid) {
  2520. toast('登录成功,但未获取到 Fmode 设备标识,请重新扫码', 'error');
  2521. return;
  2522. }
  2523. addAccount(account);
  2524. const switched = await switchAccount(state.currentAccountId, { silent: true });
  2525. if (!switched) return;
  2526. state.loginUid = null;
  2527. toast(`登录成功:${account.nickname || account.userId}`);
  2528. if (state.preRecoveryPage) {
  2529. location.hash = `#${state.preRecoveryPage}`;
  2530. state.preRecoveryPage = null;
  2531. }
  2532. renderPage();
  2533. } catch (err) {
  2534. toast('登录成功,但获取账号信息失败', 'error');
  2535. }
  2536. }
  2537. // ==================== Login Recovery ====================
  2538. function renderRecoverButton(container, options = {}) {
  2539. const { loading = false, message = '' } = options;
  2540. if (!container) return;
  2541. container.innerHTML = '';
  2542. if (message) {
  2543. const msg = document.createElement('div');
  2544. msg.className = 'recover-message';
  2545. msg.innerHTML = `${loading ? '<span class="spinner" style="width:14px;height:14px;border-color:rgba(250,140,22,0.3);border-top-color:var(--brand)"></span>' : ''}${escapeHtml(message)}`;
  2546. container.appendChild(msg);
  2547. }
  2548. if (!loading) {
  2549. const btn = document.createElement('button');
  2550. btn.className = 'btn btn-sm';
  2551. btn.id = 'btn-recover-login';
  2552. btn.innerHTML = '恢复登录';
  2553. btn.addEventListener('click', () => recoverLogin());
  2554. container.appendChild(btn);
  2555. }
  2556. }
  2557. function updateRecoverMessage(message, loading = false) {
  2558. const wrap = document.getElementById('status-recover-wrap');
  2559. if (wrap) renderRecoverButton(wrap, { loading, message });
  2560. }
  2561. async function attemptAutoRecover(auto = false) {
  2562. if (state.loginPhase === 'recovering') return false;
  2563. state.loginPhase = 'recovering';
  2564. updateRecoverMessage('正在尝试免扫码恢复登录…', true);
  2565. try {
  2566. const uid = state.loginUid || currentAccount()?.uid || '';
  2567. if (!uid) throw new Error('该账号缺少 Fmode 设备绑定,请重新扫码登录');
  2568. state.loginUid = uid;
  2569. const res = await api('POST', '/api/login/check', { manual: true, uid, persistConfig: false });
  2570. const statusCode = res.summary?.statusCode;
  2571. if (statusCode === 2) {
  2572. await finalizeLogin(res.data || {});
  2573. state.loginPhase = 'idle';
  2574. return true;
  2575. }
  2576. if (res.status === 'needs_verify_code') {
  2577. state.loginPhase = 'verify';
  2578. state.preRecoveryPage = location.hash.slice(1) || 'groups';
  2579. state.currentAccountId = null;
  2580. renderPage();
  2581. setTimeout(() => showVerifyCodeInput(), 100);
  2582. return false;
  2583. }
  2584. state.loginPhase = 'idle';
  2585. if (auto) {
  2586. toast('账号离线,无法自动恢复,请进入「账号状态」手动登录', 'warning');
  2587. } else {
  2588. updateRecoverMessage('无法自动恢复,请手动登录');
  2589. }
  2590. return false;
  2591. } catch (err) {
  2592. state.loginPhase = 'idle';
  2593. if (auto) {
  2594. toast('账号离线,自动恢复失败,请进入「账号状态」手动登录', 'warning');
  2595. } else {
  2596. updateRecoverMessage('自动恢复失败,请手动登录');
  2597. }
  2598. return false;
  2599. }
  2600. }
  2601. async function recoverLogin() {
  2602. state.autoRecoverAttempted = true;
  2603. state.loginUid = currentAccount()?.uid || null;
  2604. const recovered = await attemptAutoRecover(false);
  2605. if (!recovered) {
  2606. state.preRecoveryPage = location.hash.slice(1) || 'groups';
  2607. state.currentAccountId = null;
  2608. renderPage();
  2609. setTimeout(() => startLoginFlow(), 100);
  2610. }
  2611. }
  2612. function syncAccountOnlineStatus(online, detail = {}) {
  2613. const current = currentAccount();
  2614. const currentGuid = current?.guid || detail?.guid || detail?.userId || '';
  2615. const currentUserId = current?.userId || detail?.userId || '';
  2616. let changed = false;
  2617. state.accounts.forEach(a => {
  2618. const match = (currentGuid && (a.guid === currentGuid || a.userId === currentGuid)) ||
  2619. (currentUserId && (a.userId === currentUserId || a.guid === currentUserId));
  2620. if (match) {
  2621. if (a.online !== online) {
  2622. a.online = online;
  2623. changed = true;
  2624. }
  2625. if (detail?.nickname && a.nickname !== detail.nickname) {
  2626. a.nickname = detail.nickname;
  2627. changed = true;
  2628. }
  2629. if (detail?.corpName && a.corpName !== detail.corpName) {
  2630. a.corpName = detail.corpName;
  2631. changed = true;
  2632. }
  2633. }
  2634. });
  2635. if (changed) {
  2636. saveAccounts();
  2637. updateAccountSwitcher();
  2638. }
  2639. }
  2640. // ==================== Status Page ====================
  2641. async function renderStatusPage() {
  2642. els.pageTitle.textContent = '账号状态';
  2643. const page = document.createElement('div');
  2644. page.className = 'page';
  2645. els.content.appendChild(page);
  2646. showLoading(page, 'list');
  2647. try {
  2648. const res = await api('GET', '/api/status', undefined, 8000);
  2649. hideLoading(page);
  2650. const login = res.data?.login || {};
  2651. const sub = res.data?.subscription || {};
  2652. const online = !!login.summary?.online;
  2653. const subscribed = !!sub.summary?.subscribed;
  2654. const acc = currentAccount() || {};
  2655. // 同步已保存账号的在线状态,确保表格显示与当前状态一致
  2656. syncAccountOnlineStatus(online, login.data?.detail);
  2657. const currentAcc = {
  2658. nickname: acc.nickname || login.summary?.nickname || '当前账号',
  2659. corpName: acc.corpName || login.data?.detail?.corpName || '',
  2660. userId: acc.userId || login.data?.detail?.userId || ''
  2661. };
  2662. page.innerHTML = `
  2663. <div class="status-cards">
  2664. <div class="status-card">
  2665. <div class="status-icon ${online ? 'green' : 'red'}">
  2666. <svg viewBox="0 0 24 24"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>
  2667. </div>
  2668. <div class="status-content">
  2669. <h3>${escapeHtml(currentAcc.nickname)}</h3>
  2670. <p>${escapeHtml(currentAcc.corpName || '企业微信账号')}</p>
  2671. <div class="status-detail">
  2672. <span class="status-dot ${online ? 'online' : 'offline'}"></span>
  2673. ${online ? '账号在线,可正常操作' : '账号离线,请重新登录'}
  2674. </div>
  2675. ${!online ? `<div id="status-recover-wrap" style="margin-top:12px"></div>` : ''}
  2676. </div>
  2677. </div>
  2678. <div class="status-card">
  2679. <div class="status-icon ${subscribed ? 'green' : 'orange'}">
  2680. <svg viewBox="0 0 24 24"><path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 10.99h7c-.53 4.12-3.28 7.79-7 8.94V12H5V6.3l7-3.11v8.8z"/></svg>
  2681. </div>
  2682. <div class="status-content">
  2683. <h3>${subscribed ? '订阅有效' : '未开通订阅'}</h3>
  2684. <p>${escapeHtml(sub.summary?.expireAt ? `到期时间:${sub.summary.expireAt}` : '请检查订阅状态')}</p>
  2685. ${sub.summary?.seats ? `<div class="status-detail">席位:${formatNumber(sub.summary.seats)} 个</div>` : ''}
  2686. <button class="btn btn-sm" id="btn-subscribe-action" style="margin-top:10px">${subscribed ? '续费/管理' : '开通订阅'}</button>
  2687. </div>
  2688. </div>
  2689. <div class="status-card">
  2690. <div class="status-icon ${res.summary?.authConfigured ? 'green' : 'red'}">
  2691. <svg viewBox="0 0 24 24"><path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4z"/></svg>
  2692. </div>
  2693. <div class="status-content">
  2694. <h3>${res.summary?.authConfigured ? '鉴权已配置' : '鉴权未配置'}</h3>
  2695. <p>${res.summary?.authConfigured ? '已检测到 Fmode 鉴权 Token' : '请配置 QIWEI_AUTH_TOKEN 或 FMODE_API_KEY'}</p>
  2696. </div>
  2697. </div>
  2698. </div>
  2699. <div class="card">
  2700. <div class="card-header">
  2701. <h3 class="card-title">已保存账号</h3>
  2702. <button class="btn btn-sm" id="btn-add-account-page">添加账号</button>
  2703. </div>
  2704. ${state.accounts.length ? `
  2705. <div class="table-wrap">
  2706. <table>
  2707. <thead><tr><th>账号</th><th>企业</th><th>状态</th><th style="width:120px">操作</th></tr></thead>
  2708. <tbody>
  2709. ${state.accounts.map(a => `
  2710. <tr class="${a.id === state.currentAccountId ? 'active-row' : ''}">
  2711. <td>${escapeHtml(a.nickname || a.userId || a.guid)}</td>
  2712. <td>${escapeHtml(a.corpName || '-')}</td>
  2713. <td><span class="status-dot ${a.online ? 'online' : 'offline'}"></span>${a.online ? '在线' : '离线'}</td>
  2714. <td>
  2715. <div style="display:flex;gap:6px">
  2716. ${a.id !== state.currentAccountId ? `<button class="btn btn-sm btn-secondary" data-action="switch" data-id="${escapeHtml(a.id)}">切换</button>` : ''}
  2717. <button class="btn btn-sm btn-danger" data-action="remove" data-id="${escapeHtml(a.id)}">删除</button>
  2718. </div>
  2719. </td>
  2720. </tr>
  2721. `).join('')}
  2722. </tbody>
  2723. </table>
  2724. </div>
  2725. ` : `
  2726. <div class="empty-state" style="padding:32px">
  2727. <div class="empty-state-title">还没有保存的账号</div>
  2728. <div class="empty-state-desc">点击右上角「添加账号」开始登录</div>
  2729. </div>
  2730. `}
  2731. </div>
  2732. `;
  2733. page.querySelector('#btn-add-account-page').addEventListener('click', () => {
  2734. state.loginUid = createDeviceUid();
  2735. state.currentAccountId = null;
  2736. saveAccounts();
  2737. renderPage();
  2738. });
  2739. const subscribeBtn = page.querySelector('#btn-subscribe-action');
  2740. if (subscribeBtn) {
  2741. subscribeBtn.addEventListener('click', openSubscribeFlowPage);
  2742. }
  2743. page.querySelectorAll('[data-action="switch"]').forEach(btn => {
  2744. btn.addEventListener('click', () => { void switchAccount(btn.dataset.id); });
  2745. });
  2746. page.querySelectorAll('[data-action="remove"]').forEach(btn => {
  2747. btn.addEventListener('click', () => {
  2748. if (confirm('确定删除这个账号吗?')) removeAccount(btn.dataset.id);
  2749. });
  2750. });
  2751. const recoverWrap = page.querySelector('#status-recover-wrap');
  2752. if (recoverWrap) {
  2753. renderRecoverButton(recoverWrap);
  2754. }
  2755. } catch (err) {
  2756. showError(page, err.message || '加载状态失败', () => renderStatusPage());
  2757. }
  2758. }
  2759. // ==================== Groups Page ====================
  2760. async function renderGroupsPage() {
  2761. els.pageTitle.textContent = '客户群管理';
  2762. state.selectedGroupIds = state.selectedGroupIds || new Set();
  2763. const page = document.createElement('div');
  2764. page.className = 'page customer-workspace-page';
  2765. page.innerHTML = `
  2766. <section class="workspace-hero customer-workspace-hero group-hero">
  2767. <div>
  2768. <span>WECOM CUSTOMER GROUP OPERATIONS</span>
  2769. <h2>客户群管理</h2>
  2770. <p>识别真实客户群、同步历史消息,并集中完成人工确认与群运营准备。</p>
  2771. </div>
  2772. ${renderGroupStatsCard(null)}
  2773. </section>
  2774. <section class="group-workspace">
  2775. <aside class="group-sync-panel">${renderGroupSyncCard()}</aside>
  2776. <main class="group-list-panel">
  2777. <div class="operation-note">
  2778. <svg viewBox="0 0 24 24"><path d="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20Zm1 15h-2v-6h2v6Zm0-8h-2V7h2v2Z"/></svg>
  2779. <div><strong>先识别,再同步</strong><span>勾选目标群后可批量确认并保存历史消息;未确认的普通群不会进入客户运营流程。</span></div>
  2780. </div>
  2781. ${renderGroupFilters()}
  2782. <div id="groups-table-card"></div>
  2783. </main>
  2784. </section>
  2785. `;
  2786. els.content.appendChild(page);
  2787. bindGroupSync(page);
  2788. bindGroupFilters(page);
  2789. await loadGroups();
  2790. }
  2791. function renderGroupSyncCard() {
  2792. return `
  2793. <div class="card group-sync-card">
  2794. <div class="card-header">
  2795. <div>
  2796. <h3 class="card-title">
  2797. <svg viewBox="0 0 24 24"><path d="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46C19.54 15.03 20 13.57 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74C4.46 8.97 4 10.43 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z"/></svg>
  2798. 扫描客户群
  2799. </h3>
  2800. <div class="card-subtitle">从企业微信拉取群数据,并自动判断哪些可能是客户群</div>
  2801. </div>
  2802. </div>
  2803. <div class="group-sync-fields">
  2804. <div class="form-group">
  2805. <label>扫描范围</label>
  2806. <select id="sync-scope">
  2807. <option value="all">全部群</option>
  2808. <option value="self">我创建的群</option>
  2809. <option value="session">最近聊天里的群</option>
  2810. <option value="messages">从消息记录里找群</option>
  2811. </select>
  2812. </div>
  2813. <div class="form-group">
  2814. <label>扫描深度</label>
  2815. <input id="sync-max-pages" type="number" value="20" min="1" max="300" />
  2816. <span class="field-help">数值越大扫描越完整,耗时也越长</span>
  2817. </div>
  2818. </div>
  2819. <details class="advanced-settings">
  2820. <summary>识别规则设置</summary>
  2821. <div class="advanced-settings-body">
  2822. <div class="form-group">
  2823. <label>客户群关键词(用逗号隔开)</label>
  2824. <textarea id="sync-keywords" rows="3">客户群,服务群,售后群,咨询群,VIP群,专属群,业主群,项目群,订单群,用户群</textarea>
  2825. </div>
  2826. <div class="form-group">
  2827. <label>高置信度词</label>
  2828. <input id="sync-high-confidence" type="text" value="客户群,服务群" />
  2829. </div>
  2830. </div>
  2831. </details>
  2832. <div class="checkbox-row group-auto-classify">
  2833. <input id="sync-auto-classify" type="checkbox" checked />
  2834. <label for="sync-auto-classify">自动识别客户群</label>
  2835. </div>
  2836. <button class="btn group-primary-action" id="btn-sync-groups" data-job="sync-groups">开始扫描</button>
  2837. <div id="sync-job-tracker"></div>
  2838. </div>
  2839. `;
  2840. }
  2841. function renderGroupStatsCard(summary) {
  2842. const s = summary || {};
  2843. return `
  2844. <div class="workspace-stat-row group-stat-row" id="group-stats">
  2845. <div><strong data-count="${s.total || 0}">${formatNumber(s.total)}</strong><span>已扫描群聊</span></div>
  2846. <div><strong data-count="${(s.autoConfirmed || 0) + (s.confirmed || 0)}">${formatNumber((s.autoConfirmed || 0) + (s.confirmed || 0))}</strong><span>确认客户群</span></div>
  2847. <div><strong data-count="${s.suggested || 0}">${formatNumber(s.suggested)}</strong><span>待人工确认</span></div>
  2848. </div>
  2849. `;
  2850. }
  2851. function renderGroupFilters() {
  2852. return `
  2853. <div class="card group-filter-card">
  2854. <div class="card-header">
  2855. <div><h3 class="card-title">群列表</h3><div class="card-subtitle">识别结果与消息同步状态</div></div>
  2856. <div class="filter-bar">
  2857. <input id="filter-keyword" type="text" placeholder="搜索群名" value="${escapeHtml(state.groupFilters.keyword)}" />
  2858. <select id="filter-status">
  2859. <option value="" ${state.groupFilters.status === '' ? 'selected' : ''}>全部状态</option>
  2860. <option value="AUTO_CONFIRMED" ${state.groupFilters.status === 'AUTO_CONFIRMED' ? 'selected' : ''}>自动确认</option>
  2861. <option value="SUGGESTED" ${state.groupFilters.status === 'SUGGESTED' ? 'selected' : ''}>建议确认</option>
  2862. <option value="CONFIRMED" ${state.groupFilters.status === 'CONFIRMED' ? 'selected' : ''}>已确认</option>
  2863. <option value="REJECTED" ${state.groupFilters.status === 'REJECTED' ? 'selected' : ''}>已忽略</option>
  2864. </select>
  2865. <button class="btn btn-sm btn-secondary ${state.groupFilters.showCustomerOnly ? 'active' : ''}" id="btn-filter-customer-only">${state.groupFilters.showCustomerOnly ? '展示全部状态的群聊' : '只看可能的客户群'}</button>
  2866. <button class="btn btn-sm btn-secondary" id="btn-refresh-groups">刷新</button>
  2867. </div>
  2868. </div>
  2869. </div>
  2870. `;
  2871. }
  2872. function renderGroupTable(groups) {
  2873. const customerStatuses = new Set(['AUTO_CONFIRMED', 'SUGGESTED', 'CONFIRMED', 'REJECTED']);
  2874. const filtered = groups.filter(g => {
  2875. // 过滤无效群记录:无名称且无成员数通常是单聊或系统通知
  2876. if (!g.roomName && !g.memberCount) return false;
  2877. const kw = state.groupFilters.keyword.trim();
  2878. if (kw && !(g.roomName || '').includes(kw) && !(g.roomId || '').includes(kw)) return false;
  2879. if (state.groupFilters.status && g.reviewStatus !== state.groupFilters.status) return false;
  2880. if (state.groupFilters.showCustomerOnly && !customerStatuses.has(g.reviewStatus)) return false;
  2881. return true;
  2882. });
  2883. const card = document.getElementById('groups-table-card');
  2884. if (!filtered.length) {
  2885. card.innerHTML = `
  2886. <div class="card">
  2887. <div class="empty-state">
  2888. <svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm-1-13h2v6h-2zm0 8h2v2h-2z"/></svg>
  2889. <div class="empty-state-title">还没有群数据</div>
  2890. <div class="empty-state-desc">点击上方「开始扫描」从企业微信拉取</div>
  2891. </div>
  2892. </div>
  2893. `;
  2894. return;
  2895. }
  2896. const allSelected = filtered.length > 0 && filtered.every(g => state.selectedGroupIds.has(g.roomId));
  2897. card.innerHTML = `
  2898. <div class="card" style="padding:0;overflow:hidden">
  2899. <div style="padding:14px 16px;border-bottom:1px solid var(--border);background:var(--page-bg);display:flex;align-items:center;gap:12px;flex-wrap:wrap">
  2900. <label class="checkbox-row" style="margin:0">
  2901. <input type="checkbox" id="select-all-groups" ${allSelected ? 'checked' : ''} />
  2902. <span>全选本页</span>
  2903. </label>
  2904. <span style="color:var(--text-muted);font-size:13px">已选 ${formatNumber(state.selectedGroupIds.size)} 个</span>
  2905. <button class="btn btn-sm" id="btn-batch-confirm" ${state.selectedGroupIds.size ? '' : 'disabled'}>
  2906. 确认选中为客户群
  2907. </button>
  2908. <button class="btn btn-sm" id="btn-batch-sync-msg" ${state.selectedGroupIds.size ? '' : 'disabled'}>
  2909. 同步选中群消息
  2910. </button>
  2911. </div>
  2912. <div class="table-wrap">
  2913. <table>
  2914. <thead>
  2915. <tr>
  2916. <th style="width:40px"></th>
  2917. <th>群名</th>
  2918. <th style="width:70px">人数</th>
  2919. <th style="width:100px">识别结果</th>
  2920. <th style="width:100px">置信度</th>
  2921. <th style="width:100px">消息同步</th>
  2922. <th>命中关键词</th>
  2923. <th style="width:120px">操作</th>
  2924. </tr>
  2925. </thead>
  2926. <tbody>
  2927. ${filtered.map((g, i) => {
  2928. const synced = isGroupSynced(g.roomId);
  2929. return `
  2930. <tr style="animation:fadeInUp 0.3s ease ${i * 0.03}s both">
  2931. <td>
  2932. <input type="checkbox" class="group-checkbox" data-room="${escapeHtml(g.roomId)}" ${state.selectedGroupIds.has(g.roomId) ? 'checked' : ''} />
  2933. </td>
  2934. <td>
  2935. <div style="font-weight:600">${escapeHtml(g.roomName || '未命名')}</div>
  2936. <div style="font-size:12px;color:var(--text-muted);margin-top:2px">${escapeHtml(g.roomId || '')}</div>
  2937. </td>
  2938. <td>${formatNumber(g.memberCount)}</td>
  2939. <td>${badge(g.reviewStatus)}</td>
  2940. <td>
  2941. <div style="display:flex;align-items:center;gap:8px">
  2942. <div class="confidence-bar"><div class="confidence-bar-fill" style="width:${Math.round((g.confidence || 0) * 100)}%"></div></div>
  2943. <span style="font-size:12px;color:var(--text-muted)">${Math.round((g.confidence || 0) * 100)}%</span>
  2944. </div>
  2945. </td>
  2946. <td>${synced ? `<span class="badge badge-success">已同步</span>` : `<span class="badge badge-default">未同步</span>`}</td>
  2947. <td>${(g.matchedKeywords || []).map(k => `<span class="tag" style="margin-bottom:4px">${escapeHtml(k)}</span>`).join(' ') || '-'}</td>
  2948. <td>
  2949. <div style="display:flex;gap:6px;flex-wrap:wrap">
  2950. ${!['CONFIRMED', 'AUTO_CONFIRMED'].includes(g.reviewStatus) ? `<button class="btn btn-sm" data-action="confirm" data-room="${escapeHtml(g.roomId)}" data-name="${escapeHtml(g.roomName || '')}">确认</button>` : ''}
  2951. ${g.reviewStatus === 'CONFIRMED' ? `<button class="btn btn-sm" data-action="detail" data-room="${escapeHtml(g.roomId)}" data-name="${escapeHtml(g.roomName || '')}">群详情</button>` : ''}
  2952. ${g.reviewStatus !== 'REJECTED' ? `<button class="btn btn-sm btn-secondary" data-action="reject" data-room="${escapeHtml(g.roomId)}" data-name="${escapeHtml(g.roomName || '')}">忽略</button>` : ''}
  2953. <button class="btn btn-sm btn-secondary" data-action="sync-msg" data-room="${escapeHtml(g.roomId)}">${synced ? '重新同步' : '同步消息'}</button>
  2954. </div>
  2955. </td>
  2956. </tr>`;
  2957. }).join('')}
  2958. </tbody>
  2959. </table>
  2960. </div>
  2961. </div>
  2962. `;
  2963. bindGroupTableActions(card, filtered);
  2964. }
  2965. function bindGroupTableActions(card, filtered) {
  2966. const selectAll = card.querySelector('#select-all-groups');
  2967. if (selectAll) {
  2968. selectAll.addEventListener('change', () => {
  2969. filtered.forEach(g => {
  2970. if (selectAll.checked) state.selectedGroupIds.add(g.roomId);
  2971. else state.selectedGroupIds.delete(g.roomId);
  2972. });
  2973. renderGroupTable(state.groups);
  2974. });
  2975. }
  2976. card.querySelectorAll('.group-checkbox').forEach(cb => {
  2977. cb.addEventListener('change', () => {
  2978. const roomId = cb.dataset.room;
  2979. if (cb.checked) state.selectedGroupIds.add(roomId);
  2980. else state.selectedGroupIds.delete(roomId);
  2981. renderGroupTable(state.groups);
  2982. });
  2983. });
  2984. const batchConfirmBtn = card.querySelector('#btn-batch-confirm');
  2985. if (batchConfirmBtn) {
  2986. batchConfirmBtn.addEventListener('click', () => {
  2987. const roomIds = Array.from(state.selectedGroupIds);
  2988. if (!roomIds.length) return;
  2989. batchConfirmGroups(roomIds);
  2990. });
  2991. }
  2992. const batchSyncBtn = card.querySelector('#btn-batch-sync-msg');
  2993. if (batchSyncBtn) {
  2994. batchSyncBtn.addEventListener('click', () => {
  2995. const roomIds = Array.from(state.selectedGroupIds);
  2996. if (!roomIds.length) return;
  2997. syncGroupMessages(roomIds, batchSyncBtn);
  2998. });
  2999. }
  3000. card.querySelectorAll('[data-action]').forEach(btn => {
  3001. btn.addEventListener('click', () => {
  3002. const roomId = btn.dataset.room;
  3003. const roomName = btn.dataset.name;
  3004. if (btn.dataset.action === 'confirm') confirmGroup(roomId, roomName);
  3005. if (btn.dataset.action === 'reject') openRejectModal(roomId, roomName);
  3006. if (btn.dataset.action === 'sync-msg') syncGroupMessages([roomId], btn);
  3007. if (btn.dataset.action === 'detail') openGroupDetailModal(roomId, roomName);
  3008. });
  3009. });
  3010. }
  3011. async function confirmGroup(roomId, roomName) {
  3012. const guid = getGuid();
  3013. if (!guid) {
  3014. toast('请先登录企业微信账号', 'warning');
  3015. state.currentAccountId = null;
  3016. renderPage();
  3017. return;
  3018. }
  3019. const acc = currentAccount();
  3020. if (!acc || !acc.online) {
  3021. toast('账号当前离线,请先恢复登录后再确认客户群', 'warning');
  3022. location.hash = '#status';
  3023. return;
  3024. }
  3025. try {
  3026. const res = await api('POST', '/api/groups/confirm', { roomId });
  3027. toast(res.assistantMessage || '已确认为客户群');
  3028. loadGroups();
  3029. } catch (err) {
  3030. toast(err.message || '确认失败', 'error');
  3031. }
  3032. }
  3033. async function batchConfirmGroups(roomIds) {
  3034. let success = 0;
  3035. let failed = 0;
  3036. for (const roomId of roomIds) {
  3037. try {
  3038. await api('POST', '/api/groups/confirm', { roomId });
  3039. success++;
  3040. } catch (err) {
  3041. failed++;
  3042. console.error('确认客户群失败', roomId, err);
  3043. }
  3044. }
  3045. toast(`批量确认完成:成功 ${success} 个${failed ? `,失败 ${failed} 个` : ''}`);
  3046. loadGroups();
  3047. }
  3048. async function loadGroups() {
  3049. try {
  3050. const [groupsRes, syncRes] = await Promise.all([
  3051. api('GET', '/api/groups/list'),
  3052. api('GET', '/api/dashboard/groups')
  3053. ]);
  3054. state.groups = groupsRes.data?.groups || [];
  3055. state.groupSync = syncRes.data || {};
  3056. updateGroupStats(groupsRes.summary);
  3057. renderGroupTable(state.groups);
  3058. } catch (err) {
  3059. toast(err.message || '加载群列表失败', 'error');
  3060. }
  3061. }
  3062. function updateGroupStats(summary) {
  3063. if (!summary) {
  3064. const counts = state.groups.reduce((acc, g) => {
  3065. acc[g.reviewStatus] = (acc[g.reviewStatus] || 0) + 1;
  3066. return acc;
  3067. }, {});
  3068. summary = {
  3069. total: state.groups.length,
  3070. autoConfirmed: counts.AUTO_CONFIRMED || 0,
  3071. suggested: counts.SUGGESTED || 0,
  3072. imported: counts.IMPORTED || 0,
  3073. confirmed: counts.CONFIRMED || 0,
  3074. rejected: counts.REJECTED || 0
  3075. };
  3076. }
  3077. const stats = document.getElementById('group-stats');
  3078. if (stats) stats.outerHTML = renderGroupStatsCard(summary);
  3079. }
  3080. function bindGroupSync(page) {
  3081. page.querySelector('#btn-sync-groups').addEventListener('click', () => {
  3082. const guid = getGuid();
  3083. if (!guid) {
  3084. toast('请先登录企业微信账号', 'warning');
  3085. state.currentAccountId = null;
  3086. renderPage();
  3087. return;
  3088. }
  3089. const acc = currentAccount();
  3090. if (!acc || !acc.online) {
  3091. toast('账号当前离线,请先恢复登录后再扫描群聊', 'warning');
  3092. location.hash = '#status';
  3093. return;
  3094. }
  3095. const keywords = document.getElementById('sync-keywords').value.split(/[,,]/).map(s => s.trim()).filter(Boolean);
  3096. const highConfidence = document.getElementById('sync-high-confidence').value.split(/[,,]/).map(s => s.trim()).filter(Boolean);
  3097. const body = {
  3098. guid,
  3099. scope: document.getElementById('sync-scope').value,
  3100. maxPages: Number(document.getElementById('sync-max-pages').value) || 20,
  3101. customerKeywords: keywords,
  3102. highConfidenceTerms: highConfidence,
  3103. autoClassify: document.getElementById('sync-auto-classify').checked,
  3104. scanFromMessages: document.getElementById('sync-scope').value === 'messages'
  3105. };
  3106. const tracker = document.getElementById('sync-job-tracker');
  3107. const id = Date.now().toString(36);
  3108. tracker.innerHTML = renderJobTracker('sync-groups', id);
  3109. startJob('sync-groups', '/api/groups/sync', body, result => {
  3110. tracker.innerHTML = '';
  3111. updateGroupStats(result.summary);
  3112. loadGroups();
  3113. });
  3114. });
  3115. }
  3116. function bindGroupFilters(page) {
  3117. const refresh = () => {
  3118. state.groupFilters.keyword = document.getElementById('filter-keyword').value;
  3119. state.groupFilters.status = document.getElementById('filter-status').value;
  3120. saveFilters(GROUP_FILTERS_KEY, state.groupFilters);
  3121. renderGroupTable(state.groups);
  3122. };
  3123. page.querySelector('#filter-keyword').addEventListener('input', refresh);
  3124. page.querySelector('#filter-status').addEventListener('change', refresh);
  3125. page.querySelector('#btn-refresh-groups').addEventListener('click', loadGroups);
  3126. const customerOnlyBtn = page.querySelector('#btn-filter-customer-only');
  3127. customerOnlyBtn.addEventListener('click', () => {
  3128. state.groupFilters.showCustomerOnly = !state.groupFilters.showCustomerOnly;
  3129. customerOnlyBtn.classList.toggle('active', state.groupFilters.showCustomerOnly);
  3130. customerOnlyBtn.textContent = state.groupFilters.showCustomerOnly ? '展示全部状态的群聊' : '只看可能的客户群';
  3131. saveFilters(GROUP_FILTERS_KEY, state.groupFilters);
  3132. renderGroupTable(state.groups);
  3133. });
  3134. }
  3135. function openGroupDetailModal(roomId, roomName) {
  3136. const group = state.groups.find(g => g.roomId === roomId) || {};
  3137. const members = group.members || [];
  3138. // 自动识别客户:优先选择外部联系人(type === 2)
  3139. let suggestedCustomer = null;
  3140. const externalMembers = members.filter(m => m.type === 2 || String(m.type) === '2');
  3141. if (externalMembers.length) {
  3142. suggestedCustomer = externalMembers[0];
  3143. } else if (members.length) {
  3144. // 没有明确外部标识时,排除常见内部角色与当前账号后取第一个
  3145. const current = currentAccount();
  3146. const currentIds = new Set([current?.userId, current?.guid, current?.nickname].filter(Boolean));
  3147. suggestedCustomer = members.find(m => {
  3148. const name = String(m.userName || '').trim();
  3149. const id = String(m.userId || '').trim();
  3150. if (!name) return false;
  3151. if (currentIds.has(name) || currentIds.has(id)) return false;
  3152. return !/^(企业微信|WeChat|系统|小助手|机器人|客服|顾问|销售|管理员)$/.test(name);
  3153. }) || members[0];
  3154. }
  3155. const defaultCustomerName = group.customerName || (suggestedCustomer?.userName) || '';
  3156. const defaultExternalUserId = group.externalUserId || (suggestedCustomer?.userId) || '';
  3157. const defaultCustomerId = group.customerId || '';
  3158. openModal('群详情 / 客户信息', `
  3159. <p style="color:var(--text-secondary);font-size:13px;margin-bottom:16px">群名:${escapeHtml(roomName || roomId)}</p>
  3160. <div class="form-group" style="margin-bottom:12px">
  3161. <label>客户编号(可选)</label>
  3162. <input id="detail-customer-id" type="text" value="${escapeHtml(defaultCustomerId)}" />
  3163. </div>
  3164. <div class="form-group" style="margin-bottom:12px">
  3165. <label>客户企微 ID(可选)</label>
  3166. <input id="detail-external-user-id" type="text" value="${escapeHtml(defaultExternalUserId)}" />
  3167. </div>
  3168. <div class="form-group" style="margin-bottom:12px">
  3169. <label>客户姓名(可选)</label>
  3170. <input id="detail-customer-name" type="text" value="${escapeHtml(defaultCustomerName)}" />
  3171. </div>
  3172. ${members.length ? `
  3173. <div class="form-group">
  3174. <label>群成员(自动识别参考)</label>
  3175. <div style="max-height:120px;overflow-y:auto;border:1px solid var(--border);border-radius:var(--radius-sm);padding:8px;font-size:13px;color:var(--text-secondary)">
  3176. ${members.map(m => `<div style="padding:4px 0">${escapeHtml(m.userName || m.userId || '未知成员')} ${m.type === 2 ? '<span class="badge badge-info" style="margin-left:6px">外部联系人</span>' : ''}</div>`).join('')}
  3177. </div>
  3178. </div>
  3179. ` : ''}
  3180. `, `
  3181. <button class="btn btn-secondary" onclick="window.dashboardCloseModal()">取消</button>
  3182. <button class="btn" id="btn-save-group-detail">保存</button>
  3183. `);
  3184. document.getElementById('btn-save-group-detail').addEventListener('click', async () => {
  3185. const body = {
  3186. roomId,
  3187. customerId: document.getElementById('detail-customer-id').value.trim() || undefined,
  3188. externalUserId: document.getElementById('detail-external-user-id').value.trim() || undefined,
  3189. customerName: document.getElementById('detail-customer-name').value.trim() || undefined
  3190. };
  3191. try {
  3192. const res = await api('POST', '/api/groups/confirm', body);
  3193. closeModal();
  3194. toast(res.assistantMessage || '已保存');
  3195. loadGroups();
  3196. } catch (err) {
  3197. toast(err.message || '保存失败', 'error');
  3198. }
  3199. });
  3200. }
  3201. function openRejectModal(roomId, roomName) {
  3202. openModal('忽略这个群', `
  3203. <p style="color:var(--text-secondary);font-size:13px;margin-bottom:16px">群名:${escapeHtml(roomName || roomId)}</p>
  3204. <div class="form-group">
  3205. <label>原因(可选)</label>
  3206. <input id="reject-reason" type="text" value="不是客户群" />
  3207. </div>
  3208. `, `
  3209. <button class="btn btn-secondary" onclick="window.dashboardCloseModal()">取消</button>
  3210. <button class="btn btn-danger" id="btn-do-reject">忽略</button>
  3211. `);
  3212. document.getElementById('btn-do-reject').addEventListener('click', async () => {
  3213. const body = { roomId, reason: document.getElementById('reject-reason').value.trim() };
  3214. try {
  3215. const res = await api('POST', '/api/groups/reject', body);
  3216. closeModal();
  3217. toast(res.assistantMessage || '已忽略');
  3218. loadGroups();
  3219. } catch (err) {
  3220. toast(err.message || '忽略失败', 'error');
  3221. }
  3222. });
  3223. }
  3224. async function syncGroupMessages(roomIds, triggerButton = null) {
  3225. const guid = getGuid();
  3226. if (!guid) {
  3227. toast('请先登录企业微信账号', 'warning');
  3228. return;
  3229. }
  3230. const acc = currentAccount();
  3231. if (!acc || !acc.online) {
  3232. toast('账号当前离线,请先恢复登录后再同步消息', 'warning');
  3233. location.hash = '#status';
  3234. return;
  3235. }
  3236. const body = { guid, roomIds, maxPages: 20, maxMessagesPerRoom: 500 };
  3237. const name = 'sync-messages-' + roomIds[0];
  3238. if (triggerButton) {
  3239. triggerButton.dataset.job = name;
  3240. }
  3241. startJob(name, '/api/groups/messages/sync', body, result => {
  3242. toast(`共同步 ${result.summary?.totalNew || 0} 条消息`);
  3243. loadGroups();
  3244. });
  3245. }
  3246. // ==================== Helpers ====================
  3247. function requireOnlineAccount() {
  3248. const acc = currentAccount();
  3249. if (!acc) {
  3250. toast('请先登录企业微信账号', 'warning');
  3251. state.currentAccountId = null;
  3252. renderPage();
  3253. return false;
  3254. }
  3255. const guid = getGuid();
  3256. if (!guid) {
  3257. toast('当前账号缺少 guid,请重新登录', 'warning');
  3258. return false;
  3259. }
  3260. if (!acc.online) {
  3261. toast('账号当前离线,请先恢复登录', 'warning');
  3262. location.hash = '#status';
  3263. return false;
  3264. }
  3265. return true;
  3266. }
  3267. function renderFileLink(filePath, label = '下载文件') {
  3268. if (!filePath) return '';
  3269. return `<a class="btn btn-sm btn-secondary" href="/api/outputs?path=${encodeURIComponent(filePath)}" target="_blank" rel="noopener">${escapeHtml(label)}</a>`;
  3270. }
  3271. function readFileBase64(file) {
  3272. return new Promise((resolve, reject) => {
  3273. const reader = new FileReader();
  3274. reader.onload = () => resolve(String(reader.result).split(',')[1]);
  3275. reader.onerror = reject;
  3276. reader.readAsDataURL(file);
  3277. });
  3278. }
  3279. // ==================== Customer Operations ====================
  3280. function parseCustomerText(text) {
  3281. return text.split(/\n|\r/).map(line => line.trim()).filter(Boolean).map(line => {
  3282. const parts = line.split(/\s+/);
  3283. const phone = parts.find(p => /^1[3-9]\d{9}$/.test(p)) || '';
  3284. const name = parts.find(p => !/^1[3-9]\d{9}$/.test(p)) || '';
  3285. return { phone, name };
  3286. }).filter(c => c.phone);
  3287. }
  3288. function customerProfileInput(profile, ...keys) {
  3289. for (const key of keys) {
  3290. const value = profile?.[key];
  3291. if (value !== undefined && value !== null) return Array.isArray(value) ? value.join('、') : String(value);
  3292. }
  3293. return '';
  3294. }
  3295. function renderCustomerMasterSection() {
  3296. const hub = state.customerOps.master.hub || { summary: {}, data: { customers: [], policy: {} } };
  3297. const summary = hub.summary || {};
  3298. const data = hub.data || {};
  3299. const keyword = String(state.customerOps.master.keyword || '').trim().toLowerCase();
  3300. const customers = (data.customers || []).filter(customer => !keyword || [customer.displayName, customer.maskedId, customer.stage?.label, ...(customer.fields || []).flatMap(field => [field.label, field.displayValue]), ...(customer.tags || [])].some(value => String(value || '').toLowerCase().includes(keyword)));
  3301. const selected = (data.customers || []).find(item => item.id === state.customerOps.master.selectedId) || customers[0] || data.customers?.[0] || null;
  3302. if (selected) state.customerOps.master.selectedId = selected.id;
  3303. const profile = selected?.profile || {};
  3304. const evidenceItems = (selected?.fields || []).filter(field => field.evidence);
  3305. const openTasks = (selected?.tasks || []).filter(task => ['open', 'in_progress'].includes(task.status));
  3306. const openAlerts = (selected?.alerts || []).filter(alert => alert.status === 'open');
  3307. const nextActions = selected?.nextActions || [];
  3308. const recommendations = selected?.recommendations || [];
  3309. const timeline = selected?.timeline || [];
  3310. const intelligenceItems = `${openTasks.map(task => `<div class="customer-master-action-item"><strong>${escapeHtml(task.title)}</strong><p>${escapeHtml(task.evidence || task.reason || '')}</p><small>内部任务 · ${escapeHtml(agentTaskStatusLabel(task.status))}</small></div>`).join('')}${openAlerts.map(alert => `<div class="customer-master-action-item alert"><strong>${escapeHtml(alert.title)}</strong><p>${escapeHtml(alert.evidence || alert.detail || '')}</p><small>客户预警 · ${escapeHtml(alert.severity)}</small></div>`).join('')}`;
  3311. const detail = selected ? `<article class="customer-master-detail">
  3312. <header class="customer-master-detail-head"><div><span>REAL WECOM CUSTOMER</span><h3>${escapeHtml(selected.displayName)}</h3><p>${escapeHtml(selected.maskedId)} · ${escapeHtml(selected.source)} · 最近互动 ${escapeHtml(formatAgentTime(selected.lastMessageAt))}</p></div><div><span class="customer-stage ${escapeHtml(selected.stage?.key)}">${escapeHtml(selected.stage?.label)}</span><button class="btn btn-sm btn-secondary" data-customer-master-open="${escapeHtml(selected.id)}">转到智能会话</button></div></header>
  3313. <div class="customer-master-score-row"><div><strong>${selected.completenessScore}%</strong><span>画像完整度</span></div><div><strong>${selected.evidenceCoverage}%</strong><span>字段证据覆盖</span></div><div><strong>${selected.messageCount}</strong><span>已纳入消息</span></div><div><strong>${selected.openTaskCount}</strong><span>待跟进任务</span></div></div>
  3314. <section class="customer-profile-fields"><header><strong>自动沉淀画像</strong><span>${escapeHtml(formatAgentTime(selected.profileUpdatedAt))} 更新</span></header><div>${(selected.fields || []).map(field => `<div><small>${escapeHtml(field.label)}</small><strong>${escapeHtml(field.displayValue || '-')}</strong><em>${field.evidence ? field.evidence.source === 'human' ? '人工核对' : '有原话依据' : '历史字段 · 待补依据'}</em></div>`).join('') || '<p class="agent-muted">还没有形成结构化画像,后续真实对话会持续补齐。</p>'}</div>${selected.tags?.length ? `<footer>${selected.tags.map(tag => `<span>${escapeHtml(tag)}</span>`).join('')}</footer>` : ''}</section>
  3315. <section class="customer-next-action-section"><header><div><strong>下一步行动工作台</strong><span>规则建议 · 需人工判断 · 不自动外发</span></div><em>${nextActions.length} 项建议</em></header><div>${nextActions.map(action => `<article class="customer-next-action-card ${escapeHtml(action.priority)}"><div><span>${action.confidence === 'rule' ? '规则建议' : 'AI 建议'}</span><strong>${escapeHtml(action.title)}</strong><p>${escapeHtml(action.description)}</p></div><ul>${(action.evidence || []).slice(0, 3).map(item => `<li>${escapeHtml(item)}</li>`).join('') || '<li>当前依据待补充</li>'}</ul><button class="btn btn-sm btn-secondary" data-customer-master-open="${escapeHtml(selected.id)}">去智能会话处理</button></article>`).join('') || '<p class="agent-muted">当前没有新的行动建议。</p>'}</div></section>
  3316. <section class="customer-recommendation-section"><header><div><strong>房源推荐历史</strong><span>保存已推荐内容和客户反馈,避免重复推荐</span></div><em>${recommendations.length} 套 · ${selected.pendingFeedbackCount || 0} 套待反馈</em></header><div>${recommendations.map(item => `<article class="customer-recommendation-card"><header><div><span>${escapeHtml(item.propertyId)}</span><strong>${escapeHtml(item.property?.community || '未命名房源')}</strong></div><em class="${escapeHtml(item.status)}">${escapeHtml(item.statusLabel)}</em></header><p>${[item.property?.district, item.property?.layout, item.property?.area ? `${item.property.area}㎡` : '', item.property?.decoration].filter(Boolean).map(escapeHtml).join(' · ') || '房源快照字段待补充'}</p><div><strong>${item.property?.totalPrice || '-'}<small>万</small></strong><span>累计推荐 ${item.recommendCount || 1} 次</span><span>最近 ${escapeHtml(formatAgentTime(item.lastRecommendedAt))}</span></div>${item.feedbackReason ? `<blockquote>${escapeHtml(item.feedbackReason)}</blockquote>` : ''}<footer><button class="btn btn-sm btn-secondary" data-recommendation-feedback="${escapeHtml(item.id)}" data-feedback-status="interested">感兴趣</button><button class="btn btn-sm btn-secondary" data-recommendation-feedback="${escapeHtml(item.id)}" data-feedback-status="rejected">不合适</button><button class="btn btn-sm btn-secondary" data-recommendation-feedback="${escapeHtml(item.id)}" data-feedback-status="viewing">已约带看</button><button class="btn btn-sm" data-recommendation-feedback="${escapeHtml(item.id)}" data-feedback-status="viewed">已带看</button></footer></article>`).join('') || '<p class="agent-muted">还没有可验证的房源推荐记录。后续房源工具调用或明确发送的房源方案会自动进入这里。</p>'}</div></section>
  3317. <div class="customer-master-intelligence-grid">
  3318. <section><header><strong>字段依据</strong><span>${evidenceItems.length} 条</span></header>${evidenceItems.map(field => `<div class="customer-evidence-item"><strong>${escapeHtml(field.label)}</strong><p>${escapeHtml(field.evidence.text || '人工核对')}</p><small>${field.evidence.source === 'human' ? '人工修正记录' : '真实企微消息'} · ${escapeHtml(formatAgentTime(field.evidence.updatedAt))}</small></div>`).join('') || '<p class="agent-muted">现有历史画像没有逐字段原话定位;新消息产生的字段会自动记录证据。</p>'}</section>
  3319. <section><header><strong>跟进任务与预警</strong><span>${openTasks.length + openAlerts.length} 项</span></header>${intelligenceItems || '<p class="agent-muted">当前没有待处理任务或预警。</p>'}</section>
  3320. </div>
  3321. ${selected.latestMessage ? `<section class="customer-latest-message"><strong>最近纳入主档的消息</strong><p>${escapeHtml(selected.latestMessage.content)}</p><small>${selected.latestMessage.role === 'customer' ? '客户' : '内部回复'} · ${escapeHtml(formatAgentTime(selected.latestMessage.timestamp))}</small></section>` : ''}
  3322. <section class="customer-timeline-section"><header><div><strong>客户时间线</strong><span>消息、画像、任务、预警和房源反馈统一留痕</span></div><em>${timeline.length} 个事件</em></header><div>${timeline.slice(0, 20).map(event => `<article class="customer-timeline-item ${escapeHtml(event.type)}"><i></i><time>${escapeHtml(formatAgentTime(event.occurredAt))}</time><div><strong>${escapeHtml(event.title)}</strong><p>${escapeHtml(event.description || '')}</p><small>${escapeHtml(event.source || '内部主账')}${event.status ? ` · ${escapeHtml(event.status)}` : ''}</small></div></article>`).join('') || '<p class="agent-muted">暂无客户事件。</p>'}</div></section>
  3323. <form class="customer-master-edit-form" id="customer-master-profile-form" data-customer-id="${escapeHtml(selected.id)}"><header><div><strong>人工核对画像</strong><span>只修正主档,不会向客户发送消息</span></div><button class="btn btn-sm" type="submit" ${state.customerOps.master.loading ? 'disabled' : ''}>保存主档</button></header><div><label><span>意向区域</span><input name="preferredRegion" value="${escapeHtml(customerProfileInput(profile, 'preferredRegion', 'region', 'district'))}" /></label><label><span>预算/万</span><input name="budgetWan" type="number" value="${escapeHtml(customerProfileInput(profile, 'budgetWan'))}" /></label><label><span>户型</span><input name="layout" value="${escapeHtml(customerProfileInput(profile, 'layout', 'houseType'))}" /></label><label><span>购置用途</span><input name="intent" value="${escapeHtml(customerProfileInput(profile, 'intent', 'purpose'))}" placeholder="自住 / 投资 / 待确认" /></label><label><span>购置时间</span><input name="timeline" value="${escapeHtml(customerProfileInput(profile, 'timeline'))}" /></label><label><span>标签</span><input name="tags" value="${escapeHtml((selected.tags || []).join(','))}" /></label></div><label class="customer-master-reason"><span>修正依据(可选)</span><input name="reason" placeholder="例如:已电话确认;仅用于审计留痕" /></label></form>
  3324. </article>` : '<div class="library-empty large"><strong>暂无已同步客户</strong><span>从真实企微会话同步消息后,画像会自动沉淀到这里。</span></div>';
  3325. return `<section class="customer-master-shell">
  3326. <header class="customer-master-hero"><div><span>CUSTOMER 360 · CANONICAL PROFILE</span><h2>客户管理</h2><p>真实企微消息持续生成客户主档,画像、标签、任务、预警和证据统一归档。</p></div><button class="btn secondary" id="btn-customer-master-refresh" ${state.customerOps.master.loading ? 'disabled' : ''}>刷新主档</button></header>
  3327. <div class="customer-master-stats"><div><strong>${summary.customerCount || 0}</strong><span>真实会话客户</span></div><div><strong>${summary.profiledCount || 0}</strong><span>已形成画像</span></div><div><strong>${summary.priorityCount || 0}</strong><span>重点跟进</span></div><div><strong>${summary.openTaskCount || 0}</strong><span>待跟进任务</span></div></div>
  3328. <section class="customer-master-policy"><strong>自动沉淀已启用</strong><span>${escapeHtml(data.policy?.autoSync || '')}</span><small>${escapeHtml(data.policy?.evidence || '')}</small></section>
  3329. <div class="customer-master-layout"><aside><header><strong>客户主档</strong><input id="customer-master-search" value="${escapeHtml(state.customerOps.master.keyword)}" placeholder="搜索姓名、画像或标签" /></header><div>${customers.map(customer => `<button class="customer-master-list-item ${selected?.id === customer.id ? 'active' : ''}" data-customer-master-id="${escapeHtml(customer.id)}"><div><strong>${escapeHtml(customer.displayName)}</strong><span class="customer-stage ${escapeHtml(customer.stage?.key)}">${escapeHtml(customer.stage?.label)}</span></div><p>${(customer.fields || []).slice(0, 3).map(field => `${field.label}:${field.displayValue}`).join(' · ') || '画像待完善'}</p><footer><span>完整度 ${customer.completenessScore}%</span><span>${customer.openTaskCount} 待办 / ${customer.openAlertCount} 预警</span></footer></button>`).join('') || '<p class="agent-muted">没有匹配客户</p>'}</div></aside><main>${detail}</main></div>
  3330. </section>`;
  3331. }
  3332. async function renderCustomerOpsPage() {
  3333. const renderToken = state.renderToken;
  3334. els.pageTitle.textContent = '客户管理';
  3335. void refreshAgentAccountHeader();
  3336. try {
  3337. state.customerOps.master.hub = await api('GET', '/api/customers');
  3338. const customers = state.customerOps.master.hub.data?.customers || [];
  3339. if (!customers.some(item => item.id === state.customerOps.master.selectedId)) state.customerOps.master.selectedId = customers[0]?.id || null;
  3340. } catch (error) {
  3341. state.customerOps.master.hub = { status: 'error', summary: {}, data: { customers: [], policy: {} }, assistantMessage: error.message || '客户主档读取失败' };
  3342. }
  3343. if (renderToken !== state.renderToken || state.page !== 'customer-ops') return;
  3344. const page = document.createElement('div');
  3345. page.className = 'page';
  3346. page.innerHTML = `
  3347. <div id="customer-master-root">${renderCustomerMasterSection()}</div>
  3348. <section class="workspace-hero customer-workspace-hero" style="margin-top:20px">
  3349. <div>
  3350. <span>OPTIONAL BATCH OPERATIONS</span>
  3351. <h2>批量客户运营(可选)</h2>
  3352. <p>用于手机号或 externalUserId 导入、批量加好友和自动建群;上方真实企微会话客户无需重复导入。</p>
  3353. </div>
  3354. </section>
  3355. <div class="dashboard-grid" id="co-dashboard">
  3356. <div class="kpi-row" id="co-kpi-row"></div>
  3357. <div class="chart-card">
  3358. <h3 class="chart-title"><span>好友状态分布</span><span class="chart-subtitle">全部客户</span></h3>
  3359. <div class="chart-container" id="co-friend-chart"></div>
  3360. </div>
  3361. <div class="chart-card">
  3362. <h3 class="chart-title"><span>近7日运营趋势</span><span class="chart-subtitle">加好友 / 建群</span></h3>
  3363. <div class="chart-container" id="co-trend-chart"></div>
  3364. </div>
  3365. <div class="operation-toolbar">
  3366. <div class="toolbar-left">
  3367. <button class="btn" id="btn-import-customers">导入客户</button>
  3368. <button class="btn btn-secondary" id="btn-co-refresh">刷新</button>
  3369. <button class="btn btn-secondary" id="btn-co-rebuild">重建索引</button>
  3370. </div>
  3371. <div class="toolbar-right">
  3372. <button class="btn btn-sm" id="btn-batch-add" data-job="batch-add-friends" disabled>批量加好友</button>
  3373. <button class="btn btn-sm btn-secondary" id="btn-check-friends" data-job="check-friend-status" disabled>检查好友状态</button>
  3374. <button class="btn btn-sm btn-secondary" id="btn-auto-group" data-job="auto-create-group" disabled>为通过好友建群</button>
  3375. </div>
  3376. </div>
  3377. <div class="data-table-card">
  3378. <div class="card-header">
  3379. <div>
  3380. <h3 class="card-title">客户列表</h3>
  3381. <div class="card-subtitle">支持按好友状态、画像、标签筛选</div>
  3382. </div>
  3383. </div>
  3384. <div class="filter-bar" style="margin-bottom:16px">
  3385. <input id="co-filter-keyword" type="text" placeholder="搜索手机号/姓名/externalUserId" style="width:220px" value="${escapeHtml(state.customerOps.keyword || '')}" />
  3386. <select id="co-filter-status">
  3387. <option value="">全部好友状态</option>
  3388. <option value="ACCEPTED" ${state.customerOps.status === 'ACCEPTED' ? 'selected' : ''}>已是好友</option>
  3389. <option value="PENDING" ${state.customerOps.status === 'PENDING' ? 'selected' : ''}>待通过</option>
  3390. <option value="NOT_FOUND" ${state.customerOps.status === 'NOT_FOUND' ? 'selected' : ''}>未找到</option>
  3391. <option value="FAILED" ${state.customerOps.status === 'FAILED' ? 'selected' : ''}>失败</option>
  3392. </select>
  3393. <select id="co-filter-portrait">
  3394. <option value="">全部画像状态</option>
  3395. <option value="true" ${state.customerOps.portrait === 'true' ? 'selected' : ''}>已生成画像</option>
  3396. <option value="false" ${state.customerOps.portrait === 'false' ? 'selected' : ''}>未生成画像</option>
  3397. </select>
  3398. <select id="co-filter-tag">
  3399. <option value="">全部标签</option>
  3400. </select>
  3401. <button class="btn btn-sm btn-secondary" id="btn-co-reset-filters">重置筛选</button>
  3402. </div>
  3403. <div id="co-customer-table-wrap"></div>
  3404. <div id="co-pagination"></div>
  3405. </div>
  3406. </div>
  3407. `;
  3408. els.content.appendChild(page);
  3409. bindCustomerOpsPage(page);
  3410. }
  3411. function bindCustomerOpsPage(page) {
  3412. const textArea = page.querySelector('#co-customer-text');
  3413. const fileInput = page.querySelector('#co-customer-file');
  3414. const greetingInput = page.querySelector('#co-default-greeting');
  3415. const previewText = page.querySelector('#co-preview-text');
  3416. const tableWrap = page.querySelector('#co-customer-table-wrap');
  3417. const masterRoot = page.querySelector('#customer-master-root');
  3418. function rerenderCustomerMaster() {
  3419. if (masterRoot) masterRoot.innerHTML = renderCustomerMasterSection();
  3420. }
  3421. async function reloadCustomerMaster(message = '') {
  3422. state.customerOps.master.loading = true;
  3423. rerenderCustomerMaster();
  3424. try {
  3425. state.customerOps.master.hub = await api('GET', '/api/customers');
  3426. const customers = state.customerOps.master.hub.data?.customers || [];
  3427. if (!customers.some(item => item.id === state.customerOps.master.selectedId)) state.customerOps.master.selectedId = customers[0]?.id || null;
  3428. rerenderCustomerMaster();
  3429. if (message) toast(message);
  3430. } catch (error) { toast(error.assistantMessage || error.message || '客户主档读取失败', 'error'); }
  3431. finally { state.customerOps.master.loading = false; rerenderCustomerMaster(); }
  3432. }
  3433. masterRoot?.addEventListener('click', event => {
  3434. const customerButton = event.target.closest('[data-customer-master-id]');
  3435. if (customerButton) {
  3436. state.customerOps.master.selectedId = customerButton.dataset.customerMasterId;
  3437. rerenderCustomerMaster();
  3438. return;
  3439. }
  3440. const openButton = event.target.closest('[data-customer-master-open]');
  3441. if (openButton) {
  3442. state.agent.selectedId = openButton.dataset.customerMasterOpen;
  3443. location.hash = '#agent';
  3444. return;
  3445. }
  3446. if (event.target.closest('#btn-customer-master-refresh')) void reloadCustomerMaster('客户主档已刷新');
  3447. const feedbackButton = event.target.closest('[data-recommendation-feedback]');
  3448. if (feedbackButton) {
  3449. const status = feedbackButton.dataset.feedbackStatus;
  3450. let feedbackReason = '';
  3451. if (status === 'rejected') {
  3452. const answer = window.prompt('请记录不合适原因(价格、区域、楼层、装修或其他)', '');
  3453. if (answer === null) return;
  3454. feedbackReason = answer.trim();
  3455. } else if (status === 'interested') {
  3456. const answer = window.prompt('可选:记录客户感兴趣的原因', '');
  3457. if (answer === null) return;
  3458. feedbackReason = answer.trim();
  3459. }
  3460. state.customerOps.master.loading = true;
  3461. rerenderCustomerMaster();
  3462. api('POST', `/api/customers/${encodeURIComponent(state.customerOps.master.selectedId)}/recommendations/${encodeURIComponent(feedbackButton.dataset.recommendationFeedback)}`, { status, feedbackReason })
  3463. .then(async result => {
  3464. if (result.status !== 'ok') throw result;
  3465. state.customerOps.master.hub = await api('GET', '/api/customers');
  3466. toast(result.assistantMessage || '房源反馈已记录');
  3467. })
  3468. .catch(error => toast(error.assistantMessage || error.message || '房源反馈记录失败', 'error'))
  3469. .finally(() => { state.customerOps.master.loading = false; rerenderCustomerMaster(); });
  3470. }
  3471. });
  3472. masterRoot?.addEventListener('change', event => {
  3473. if (event.target.id !== 'customer-master-search') return;
  3474. state.customerOps.master.keyword = event.target.value;
  3475. rerenderCustomerMaster();
  3476. });
  3477. masterRoot?.addEventListener('submit', async event => {
  3478. if (event.target.id !== 'customer-master-profile-form') return;
  3479. event.preventDefault();
  3480. const form = new FormData(event.target);
  3481. const budgetText = String(form.get('budgetWan') || '').trim();
  3482. const profile = {
  3483. preferredRegion: String(form.get('preferredRegion') || '').trim(),
  3484. budgetWan: budgetText ? Number(budgetText) : '',
  3485. layout: String(form.get('layout') || '').trim(),
  3486. intent: String(form.get('intent') || '').trim(),
  3487. timeline: String(form.get('timeline') || '').trim(),
  3488. };
  3489. state.customerOps.master.loading = true;
  3490. rerenderCustomerMaster();
  3491. try {
  3492. const result = await api('POST', `/api/customers/${encodeURIComponent(event.target.dataset.customerId)}/profile`, {
  3493. profile,
  3494. tags: String(form.get('tags') || '').split(/[,,]/).map(item => item.trim()).filter(Boolean),
  3495. reason: String(form.get('reason') || '').trim(),
  3496. });
  3497. if (result.status !== 'ok') throw result;
  3498. state.customerOps.master.hub = await api('GET', '/api/customers');
  3499. toast(result.assistantMessage || '客户主档已保存');
  3500. } catch (error) { toast(error.assistantMessage || error.message || '客户主档保存失败', 'error'); }
  3501. finally { state.customerOps.master.loading = false; rerenderCustomerMaster(); }
  3502. });
  3503. void loadCustomerOpsDashboard(page);
  3504. dashboardRefreshTimer = setInterval(() => {
  3505. if (state.page === 'customer-ops') loadCustomerOpsDashboard(page, true);
  3506. }, 30000);
  3507. page.querySelector('#btn-co-refresh').addEventListener('click', () => loadCustomerOpsDashboard(page));
  3508. page.querySelector('#btn-co-rebuild').addEventListener('click', async () => {
  3509. try {
  3510. await api('POST', '/api/dashboard/rebuild');
  3511. toast('索引已重建');
  3512. loadCustomerOpsDashboard(page);
  3513. } catch (err) {
  3514. toast(err.message || '重建失败', 'error');
  3515. }
  3516. });
  3517. page.querySelector('#btn-import-customers').addEventListener('click', () => openImportCustomersModal(page));
  3518. page.querySelector('#co-filter-keyword').addEventListener('input', () => loadCustomerOpsTable(page));
  3519. page.querySelector('#co-filter-status').addEventListener('change', () => loadCustomerOpsTable(page));
  3520. page.querySelector('#co-filter-portrait').addEventListener('change', () => loadCustomerOpsTable(page));
  3521. page.querySelector('#co-filter-tag').addEventListener('change', () => loadCustomerOpsTable(page));
  3522. page.querySelector('#btn-co-reset-filters').addEventListener('click', () => resetCustomerOpsFilters(page));
  3523. page.querySelector('#btn-batch-add').addEventListener('click', () => {
  3524. const selected = getSelectedCustomers(page);
  3525. if (!selected.length) return;
  3526. const phoneCustomers = selected.filter(c => c.phone).map(c => ({ phone: c.phone, name: c.name }));
  3527. if (!phoneCustomers.length) {
  3528. toast('选中的客户没有手机号,无法批量加好友', 'warning');
  3529. return;
  3530. }
  3531. const body = { customers: phoneCustomers, guid: getGuid() };
  3532. startJob('batch-add-friends', '/api/customer-ops/batch-add-friends', body, () => loadCustomerOpsDashboard(page));
  3533. });
  3534. page.querySelector('#btn-check-friends').addEventListener('click', () => {
  3535. const selected = getSelectedCustomers(page);
  3536. if (!selected.length) return;
  3537. const body = {
  3538. phones: selected.map(c => c.phone).filter(Boolean),
  3539. externalUserIds: selected.map(c => c.externalUserId).filter(Boolean),
  3540. guid: getGuid()
  3541. };
  3542. startJob('check-friend-status', '/api/customer-ops/check-friend-status', body, () => loadCustomerOpsDashboard(page));
  3543. });
  3544. page.querySelector('#btn-auto-group').addEventListener('click', () => {
  3545. const selected = getSelectedCustomers(page).filter(c => c.friendRequestStatus === 'ACCEPTED' && !['CREATED', 'IN_GROUP'].includes(c.groupStatus));
  3546. if (!selected.length) {
  3547. toast('请选中已是好友且未建群的客户', 'warning');
  3548. return;
  3549. }
  3550. const body = { memberList: selected.map(c => c.externalUserId).filter(Boolean), guid: getGuid() };
  3551. startJob('auto-create-group', '/api/customer-ops/auto-create-group', body, () => loadCustomerOpsDashboard(page));
  3552. });
  3553. }
  3554. async function loadCustomerOpsDashboard(page, silent = false) {
  3555. const tableWrap = page.querySelector('#co-customer-table-wrap');
  3556. if (!silent) showLoading(tableWrap, 'table');
  3557. try {
  3558. const [summaryRes, customersRes, tagsRes] = await Promise.all([
  3559. api('GET', '/api/dashboard/summary'),
  3560. api('GET', '/api/dashboard/customers'),
  3561. api('GET', '/api/dashboard/tags')
  3562. ]);
  3563. const summary = summaryRes.data || {};
  3564. const customers = Object.values(customersRes.data?.customers || {});
  3565. const tags = tagsRes.data?.allTags || [];
  3566. hideLoading(tableWrap);
  3567. renderCustomerOpsKpi(page, summary);
  3568. renderCustomerOpsCharts(page, summary, customers);
  3569. renderCustomerOpsTagFilter(page, tags);
  3570. state.customerOps.allCustomers = customers;
  3571. loadCustomerOpsTable(page);
  3572. } catch (err) {
  3573. if (!silent) showError(tableWrap, err.message || '加载看板失败', () => loadCustomerOpsDashboard(page));
  3574. }
  3575. }
  3576. function renderCustomerOpsKpi(page, summary) {
  3577. const wrap = page.querySelector('#co-kpi-row');
  3578. const c = summary.customers || {};
  3579. const friendStatus = c.friendStatusCounts || {};
  3580. const kpi = [
  3581. { label: '批量导入客户', value: formatNumber(c.total) },
  3582. { label: '已是好友', value: formatNumber(friendStatus.ACCEPTED || 0), sub: '已通过好友申请' },
  3583. { label: '待通过', value: formatNumber(friendStatus.PENDING || 0), sub: '等待对方确认' },
  3584. { label: '自动建群', value: formatNumber(c.autoCreatedGroups || 0), sub: '已创建客户群' },
  3585. { label: '今日新增', value: formatNumber(c.todayNew || 0), sub: '今日通过好友' }
  3586. ];
  3587. wrap.innerHTML = renderKpiRow(kpi);
  3588. }
  3589. function renderCustomerOpsCharts(page, summary, customers) {
  3590. const colors = getChartColors();
  3591. const friendStatus = summary.customers?.friendStatusCounts || {};
  3592. initEChart('co-friend-chart', {
  3593. tooltip: { trigger: 'item' },
  3594. legend: { bottom: 0 },
  3595. color: [colors.success, colors.warning, colors.error, colors.purple, colors.muted],
  3596. series: [{
  3597. type: 'pie',
  3598. radius: ['45%', '70%'],
  3599. center: ['50%', '45%'],
  3600. data: [
  3601. { name: '已是好友', value: friendStatus.ACCEPTED || 0 },
  3602. { name: '待通过', value: friendStatus.PENDING || 0 },
  3603. { name: '未找到', value: friendStatus.NOT_FOUND || 0 },
  3604. { name: '失败', value: friendStatus.FAILED || 0 },
  3605. { name: '未知', value: friendStatus.UNKNOWN || 0 }
  3606. ]
  3607. }]
  3608. });
  3609. const days = last7Days();
  3610. const addCounts = countByDay(customers.filter(c => c.lastAddAttemptAt), 'lastAddAttemptAt');
  3611. const groupCounts = countByDay(customers.filter(c => c.autoCreatedAt), 'autoCreatedAt');
  3612. const newCustomerCounts = countByDay(customers.filter(c => c.createdAt), 'createdAt');
  3613. initEChart('co-trend-chart', {
  3614. tooltip: { trigger: 'axis' },
  3615. legend: { bottom: 0 },
  3616. grid: { left: 8, right: 18, top: 20, bottom: 42, containLabel: true },
  3617. xAxis: { type: 'category', data: days.map(d => formatDate(d)), axisTick: { show: false } },
  3618. yAxis: { type: 'value', minInterval: 1 },
  3619. series: [
  3620. { name: '新增客户', type: 'bar', data: newCustomerCounts, itemStyle: { color: colors.brand, borderRadius: [4, 4, 0, 0] }, barMaxWidth: 28 },
  3621. { name: '加好友', type: 'line', smooth: true, data: addCounts, itemStyle: { color: colors.info } },
  3622. { name: '建群', type: 'line', smooth: true, data: groupCounts, itemStyle: { color: colors.success } }
  3623. ]
  3624. });
  3625. }
  3626. function renderCustomerOpsTagFilter(page, tags) {
  3627. const select = page.querySelector('#co-filter-tag');
  3628. const current = select.value;
  3629. select.innerHTML = '<option value="">全部标签</option>' + tags.map(t => `<option value="${escapeHtml(t)}">${escapeHtml(t)}</option>`).join('');
  3630. select.value = current;
  3631. }
  3632. function resetCustomerOpsFilters(page) {
  3633. state.customerOps.keyword = '';
  3634. state.customerOps.status = '';
  3635. state.customerOps.portrait = '';
  3636. state.customerOps.tag = '';
  3637. state.customerOps.tablePage = 1;
  3638. saveFilters(CO_FILTERS_KEY, {
  3639. tablePage: 1,
  3640. keyword: '',
  3641. status: '',
  3642. portrait: '',
  3643. tag: ''
  3644. });
  3645. page.querySelector('#co-filter-keyword').value = '';
  3646. page.querySelector('#co-filter-status').value = '';
  3647. page.querySelector('#co-filter-portrait').value = '';
  3648. page.querySelector('#co-filter-tag').value = '';
  3649. loadCustomerOpsTable(page);
  3650. toast('筛选已重置');
  3651. }
  3652. function getSelectedCustomers(page) {
  3653. const checkboxes = page.querySelectorAll('#co-customer-table-wrap input[type="checkbox"]:checked');
  3654. return Array.from(checkboxes).map(cb => {
  3655. const idx = Number(cb.dataset.index);
  3656. return state.customerOps.filteredCustomers?.[idx];
  3657. }).filter(Boolean);
  3658. }
  3659. function loadCustomerOpsTable(page) {
  3660. const wrap = page.querySelector('#co-customer-table-wrap');
  3661. const customers = state.customerOps.allCustomers || [];
  3662. const keyword = page.querySelector('#co-filter-keyword').value.trim().toLowerCase();
  3663. const status = page.querySelector('#co-filter-status').value;
  3664. const portrait = page.querySelector('#co-filter-portrait').value;
  3665. const tag = page.querySelector('#co-filter-tag').value;
  3666. state.customerOps.keyword = keyword;
  3667. state.customerOps.status = status;
  3668. state.customerOps.portrait = portrait;
  3669. state.customerOps.tag = tag;
  3670. saveFilters(CO_FILTERS_KEY, {
  3671. tablePage: state.customerOps.tablePage,
  3672. keyword,
  3673. status,
  3674. portrait,
  3675. tag
  3676. });
  3677. let filtered = customers.filter(c => {
  3678. if (keyword) {
  3679. const text = `${c.phone || ''} ${c.name || ''} ${c.externalUserId || ''}`.toLowerCase();
  3680. if (!text.includes(keyword)) return false;
  3681. }
  3682. if (status && c.friendRequestStatus !== status) return false;
  3683. if (portrait === 'true' && !c.hasPortrait) return false;
  3684. if (portrait === 'false' && c.hasPortrait) return false;
  3685. if (tag && !(Array.isArray(c.tags) && c.tags.includes(tag))) return false;
  3686. return true;
  3687. });
  3688. state.customerOps.filteredCustomers = filtered;
  3689. const pageSize = 20;
  3690. const currentPage = state.customerOps.tablePage || 1;
  3691. const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
  3692. if (currentPage > totalPages) {
  3693. state.customerOps.tablePage = totalPages;
  3694. saveFilters(CO_FILTERS_KEY, {
  3695. tablePage: state.customerOps.tablePage,
  3696. keyword: state.customerOps.keyword,
  3697. status: state.customerOps.status,
  3698. portrait: state.customerOps.portrait,
  3699. tag: state.customerOps.tag
  3700. });
  3701. }
  3702. const start = (state.customerOps.tablePage - 1) * pageSize;
  3703. const pageItems = filtered.slice(start, start + pageSize);
  3704. const friendBadge = (status) => {
  3705. const map = {
  3706. ACCEPTED: ['badge-success', '已是好友'],
  3707. PENDING: ['badge-warning', '待通过'],
  3708. NOT_FOUND: ['badge-error', '未找到'],
  3709. FAILED: ['badge-error', '失败']
  3710. };
  3711. const [cls, text] = map[status] || ['badge-default', '未知'];
  3712. return `<span class="badge ${cls}">${escapeHtml(text)}</span>`;
  3713. };
  3714. const groupBadge = (status) => {
  3715. if (status === 'CREATED') return '<span class="badge badge-success">已建群</span>';
  3716. if (status === 'IN_GROUP') return '<span class="badge badge-info">群内客户</span>';
  3717. return '<span class="badge badge-default">未建群</span>';
  3718. };
  3719. wrap.innerHTML = `
  3720. <div class="table-wrap">
  3721. <table>
  3722. <thead>
  3723. <tr>
  3724. <th><input type="checkbox" id="co-select-all"></th>
  3725. <th>手机号</th>
  3726. <th>姓名</th>
  3727. <th>externalUserId</th>
  3728. <th>好友状态</th>
  3729. <th>建群状态</th>
  3730. <th>画像</th>
  3731. <th>标签</th>
  3732. <th>最后操作</th>
  3733. <th>操作</th>
  3734. </tr>
  3735. </thead>
  3736. <tbody>
  3737. ${pageItems.length ? pageItems.map((c, i) => `
  3738. <tr>
  3739. <td><input type="checkbox" data-index="${start + i}"></td>
  3740. <td>${escapeHtml(c.phone || '-')}</td>
  3741. <td>${escapeHtml(c.name || '-')}</td>
  3742. <td style="font-family:monospace;font-size:12px">${escapeHtml(c.externalUserId || '-')}</td>
  3743. <td>${friendBadge(c.friendRequestStatus)}</td>
  3744. <td>${groupBadge(c.groupStatus)}</td>
  3745. <td>${c.hasPortrait ? '<span class="badge badge-info">已生成</span>' : '<span class="badge badge-default">未生成</span>'}</td>
  3746. <td>${Array.isArray(c.tags) && c.tags.length ? c.tags.map(t => `<span class="tag">${escapeHtml(t)}</span>`).join('') : '-'}</td>
  3747. <td>${formatDateTime(c.updatedAt)}</td>
  3748. <td>
  3749. <button class="btn btn-sm btn-secondary" onclick="window.dashboardCustomerDetail('${escapeHtml(c.customerId || c.externalUserId || c.phone || '')}')">详情</button>
  3750. </td>
  3751. </tr>
  3752. `).join('') : renderEmptyTableRow(10, '暂无客户数据', '导入客户或同步群成员后将显示')}
  3753. </tbody>
  3754. </table>
  3755. </div>
  3756. `;
  3757. const pagination = page.querySelector('#co-pagination');
  3758. pagination.innerHTML = renderPagination(state.customerOps.tablePage, filtered.length, pageSize);
  3759. pagination.querySelectorAll('button').forEach(btn => {
  3760. btn.addEventListener('click', () => {
  3761. state.customerOps.tablePage = Number(btn.dataset.page);
  3762. saveFilters(CO_FILTERS_KEY, {
  3763. tablePage: state.customerOps.tablePage,
  3764. keyword: state.customerOps.keyword,
  3765. status: state.customerOps.status,
  3766. portrait: state.customerOps.portrait,
  3767. tag: state.customerOps.tag
  3768. });
  3769. loadCustomerOpsTable(page);
  3770. });
  3771. });
  3772. const selectAll = wrap.querySelector('#co-select-all');
  3773. const rowCheckboxes = wrap.querySelectorAll('tbody input[type="checkbox"]');
  3774. selectAll?.addEventListener('change', () => {
  3775. rowCheckboxes.forEach(cb => cb.checked = selectAll.checked);
  3776. updateCustomerOpsActionButtons(page);
  3777. });
  3778. rowCheckboxes.forEach(cb => cb.addEventListener('change', () => updateCustomerOpsActionButtons(page)));
  3779. updateCustomerOpsActionButtons(page);
  3780. }
  3781. function updateCustomerOpsActionButtons(page) {
  3782. const selected = getSelectedCustomers(page);
  3783. page.querySelector('#btn-batch-add').disabled = !selected.some(c => c.phone);
  3784. page.querySelector('#btn-check-friends').disabled = !selected.length;
  3785. page.querySelector('#btn-auto-group').disabled = !selected.some(c => c.friendRequestStatus === 'ACCEPTED' && !['CREATED', 'IN_GROUP'].includes(c.groupStatus));
  3786. }
  3787. function openImportCustomersModal(page) {
  3788. const body = `
  3789. <div class="form-group" style="margin-bottom:16px">
  3790. <label>粘贴客户列表(每行:手机号 姓名)</label>
  3791. <textarea id="import-customer-text" rows="6" placeholder="13800138000 张三"></textarea>
  3792. </div>
  3793. <div class="form-group" style="margin-bottom:16px">
  3794. <label>或上传 Excel</label>
  3795. <input type="file" id="import-customer-file" accept=".xlsx,.xls" />
  3796. </div>
  3797. <div class="form-group">
  3798. <label>默认验证消息</label>
  3799. <input id="import-greeting" type="text" value="${escapeHtml(state.customerOps.defaultGreeting)}" />
  3800. </div>
  3801. `;
  3802. openModal('导入客户', body, `
  3803. <button class="btn btn-secondary" onclick="window.dashboardCloseModal()">取消</button>
  3804. <button class="btn" id="btn-confirm-import">导入</button>
  3805. `);
  3806. document.getElementById('btn-confirm-import').addEventListener('click', async () => {
  3807. const text = document.getElementById('import-customer-text').value;
  3808. const file = document.getElementById('import-customer-file').files[0];
  3809. const greeting = document.getElementById('import-greeting').value;
  3810. let customers = parseCustomerText(text);
  3811. if (file) {
  3812. const base64 = await readFileBase64(file);
  3813. const upload = await api('POST', '/api/upload', { data: base64, name: file.name });
  3814. const filePath = upload.data?.path;
  3815. if (filePath) {
  3816. const res = await api('POST', '/api/customer-ops/batch-add-friends', { filePath, defaultGreeting: greeting, guid: getGuid() });
  3817. toast(res.assistantMessage || '导入任务已启动');
  3818. closeModal();
  3819. loadCustomerOpsDashboard(page);
  3820. return;
  3821. }
  3822. }
  3823. if (!customers.length) {
  3824. toast('请输入有效客户', 'warning');
  3825. return;
  3826. }
  3827. closeModal();
  3828. startJob('batch-add-friends', '/api/customer-ops/batch-add-friends', { customers, defaultGreeting: greeting, guid: getGuid() }, () => loadCustomerOpsDashboard(page));
  3829. });
  3830. }
  3831. function renderDetailCard(title, iconSvg, contentHtml) {
  3832. return `
  3833. <div class="detail-card">
  3834. <div class="detail-section-title">
  3835. ${iconSvg}
  3836. ${escapeHtml(title)}
  3837. </div>
  3838. ${contentHtml}
  3839. </div>
  3840. `;
  3841. }
  3842. function renderDetailItem(label, value) {
  3843. return `
  3844. <div class="detail-item">
  3845. <div class="detail-item-label">${escapeHtml(label)}</div>
  3846. <div class="detail-item-value">${value || '-'}</div>
  3847. </div>
  3848. `;
  3849. }
  3850. function renderPortraitField(label, value) {
  3851. const displayValue = value === undefined || value === null || value === '' ? '-' : String(value);
  3852. return renderDetailItem(label, escapeHtml(displayValue));
  3853. }
  3854. function customerFriendBadge(status) {
  3855. const map = {
  3856. ACCEPTED: ['badge-success', '已是好友'],
  3857. PENDING: ['badge-warning', '待通过'],
  3858. NOT_FOUND: ['badge-error', '未找到'],
  3859. FAILED: ['badge-error', '失败'],
  3860. UNKNOWN: ['badge-default', '未知']
  3861. };
  3862. const [cls, text] = map[status] || map.UNKNOWN;
  3863. return `<span class="badge ${cls}">${escapeHtml(text)}</span>`;
  3864. }
  3865. function customerGroupBadge(status) {
  3866. if (status === 'CREATED') return '<span class="badge badge-success">已建群</span>';
  3867. if (status === 'IN_GROUP') return '<span class="badge badge-info">群内客户</span>';
  3868. return '<span class="badge badge-default">未建群</span>';
  3869. }
  3870. function renderCustomerOpsSummary(customer) {
  3871. const tags = Array.isArray(customer.tags) ? customer.tags : [];
  3872. const groupNames = Array.isArray(customer.groupNames) ? customer.groupNames.filter(Boolean) : [];
  3873. const sourceRoomIds = Array.isArray(customer.sourceRoomIds) ? customer.sourceRoomIds.filter(Boolean) : [];
  3874. const groupLabels = groupNames.length
  3875. ? groupNames
  3876. : sourceRoomIds;
  3877. const sourceGroupHtml = groupLabels.length
  3878. ? `<div class="detail-list compact">
  3879. ${groupLabels.slice(0, 5).map((name, index) => `
  3880. <div class="detail-list-item">
  3881. <div>
  3882. <div>${escapeHtml(name)}</div>
  3883. ${sourceRoomIds[index] && sourceRoomIds[index] !== name ? `<div class="detail-muted">${escapeHtml(sourceRoomIds[index])}</div>` : ''}
  3884. </div>
  3885. </div>
  3886. `).join('')}
  3887. ${groupLabels.length > 5 ? `<div class="detail-muted">还有 ${formatNumber(groupLabels.length - 5)} 个来源群未展示</div>` : ''}
  3888. </div>`
  3889. : '<div class="detail-empty">暂无来源群记录</div>';
  3890. const basicInfo = `
  3891. <div class="detail-grid compact">
  3892. ${renderDetailItem('姓名', escapeHtml(customer.name || '-'))}
  3893. ${renderDetailItem('手机号', escapeHtml(customer.phone || '-'))}
  3894. ${renderDetailItem('好友状态', customerFriendBadge(customer.friendRequestStatus))}
  3895. ${renderDetailItem('群状态', customerGroupBadge(customer.groupStatus))}
  3896. ${renderDetailItem('画像状态', customer.hasPortrait ? '<span class="badge badge-info">已生成</span>' : '<span class="badge badge-default">未生成</span>')}
  3897. ${renderDetailItem('最近更新', formatDateTime(customer.updatedAt))}
  3898. </div>
  3899. ${tags.length ? `<div class="detail-tags">${tags.map(t => `<span class="tag">${escapeHtml(t)}</span>`).join(' ')}</div>` : ''}
  3900. <div class="detail-id-line">externalUserId:${escapeHtml(customer.externalUserId || '-')}</div>
  3901. `;
  3902. return {
  3903. basicInfo,
  3904. sourceGroupHtml
  3905. };
  3906. }
  3907. function renderRawToggle(rawData) {
  3908. return `
  3909. <div class="raw-toggle" id="raw-toggle">
  3910. <svg viewBox="0 0 24 24"><path d="M7 10l5 5 5-5z"/></svg>
  3911. <span>查看原始数据</span>
  3912. </div>
  3913. <div class="detail-panel raw-panel" id="raw-panel">${escapeHtml(JSON.stringify(rawData || {}, null, 2))}</div>
  3914. `;
  3915. }
  3916. function bindRawToggle() {
  3917. const toggle = document.getElementById('raw-toggle');
  3918. const panel = document.getElementById('raw-panel');
  3919. if (toggle && panel) {
  3920. toggle.addEventListener('click', () => {
  3921. const isOpen = panel.style.display === 'block';
  3922. panel.style.display = isOpen ? 'none' : 'block';
  3923. toggle.classList.toggle('open', !isOpen);
  3924. });
  3925. }
  3926. }
  3927. async function loadCustomerFullProfile(externalUserId) {
  3928. const target = document.getElementById('customer-full-profile');
  3929. const button = document.getElementById('btn-load-customer-full-profile');
  3930. if (!target || !externalUserId) return;
  3931. if (button) {
  3932. button.disabled = true;
  3933. button.textContent = '加载中...';
  3934. }
  3935. target.innerHTML = '<div class="detail-empty">正在加载完整资料...</div>';
  3936. try {
  3937. const res = await api('POST', '/api/customer-ops/customer-profile', { externalUserId, guid: getGuid(), mode: 'full' });
  3938. const data = res.data || {};
  3939. const portrait = data.portrait || {};
  3940. const contactList = data.contactList || [];
  3941. const rooms = data.rooms || [];
  3942. const contactHtml = contactList.length
  3943. ? `<div class="detail-list compact">
  3944. ${contactList.slice(0, 5).map(c => `
  3945. <div class="detail-list-item">
  3946. <div>
  3947. <div>${escapeHtml(c.nickName || c.userName || c.externalUserId || '未知联系人')}</div>
  3948. <div class="detail-muted">${escapeHtml(c.externalUserId || c.userId || '-')}</div>
  3949. </div>
  3950. </div>
  3951. `).join('')}
  3952. </div>`
  3953. : '<div class="detail-empty">暂无匹配联系人</div>';
  3954. const roomsHtml = rooms.length
  3955. ? `<div class="detail-list compact">
  3956. ${rooms.slice(0, 8).map(r => `
  3957. <div class="detail-list-item">
  3958. <div>
  3959. <div>${escapeHtml(r.roomName || '未命名群')}</div>
  3960. <div class="detail-muted">${escapeHtml(r.roomId || '-')}</div>
  3961. </div>
  3962. </div>
  3963. `).join('')}
  3964. </div>`
  3965. : '<div class="detail-empty">暂无相关群</div>';
  3966. const portraitFields = portrait.portrait || {};
  3967. const portraitKeys = Object.keys(portraitFields);
  3968. const portraitHtml = portraitKeys.length
  3969. ? `<div class="detail-grid compact">
  3970. ${portraitKeys.slice(0, 8).map(k => renderPortraitField(k, portraitFields[k])).join('')}
  3971. </div>`
  3972. : '<div class="detail-empty">暂无画像数据</div>';
  3973. const contactsIcon = '<svg viewBox="0 0 24 24"><path d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5z"/></svg>';
  3974. const roomsIcon = '<svg viewBox="0 0 24 24"><path d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5s-3 1.34-3 3 1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z"/></svg>';
  3975. const portraitIcon = '<svg viewBox="0 0 24 24"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>';
  3976. target.innerHTML = `
  3977. ${renderDetailCard(`联系人 (${formatNumber(contactList.length)})`, contactsIcon, contactHtml)}
  3978. ${renderDetailCard(`相关群 (${formatNumber(rooms.length)})`, roomsIcon, roomsHtml)}
  3979. ${renderDetailCard('客户画像', portraitIcon, portraitHtml)}
  3980. ${renderRawToggle(data)}
  3981. `;
  3982. bindRawToggle();
  3983. if (button) button.remove();
  3984. } catch (err) {
  3985. target.innerHTML = '<div class="detail-empty">完整资料加载失败</div>';
  3986. if (button) {
  3987. button.disabled = false;
  3988. button.textContent = '重新加载完整资料';
  3989. }
  3990. toast(err.message || '查询失败', 'error');
  3991. }
  3992. }
  3993. window.dashboardCustomerDetail = (externalUserId) => {
  3994. if (!externalUserId) return;
  3995. const localCustomer = (state.customerOps.allCustomers || []).find(c => c.externalUserId === externalUserId || c.phone === externalUserId) || { externalUserId };
  3996. const { basicInfo, sourceGroupHtml } = renderCustomerOpsSummary(localCustomer);
  3997. const personIcon = '<svg viewBox="0 0 24 24"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>';
  3998. const roomsIcon = '<svg viewBox="0 0 24 24"><path d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5s-3 1.34-3 3 1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z"/></svg>';
  3999. openModal('客户详情', `
  4000. ${renderDetailCard('运营信息', personIcon, basicInfo)}
  4001. ${renderDetailCard('来源群聊', roomsIcon, sourceGroupHtml)}
  4002. <div class="detail-card" style="margin-top:16px">
  4003. <div class="detail-card-header">
  4004. <svg viewBox="0 0 24 24" width="18" height="18"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
  4005. <span>编辑资料</span>
  4006. </div>
  4007. <div class="detail-card-body">
  4008. <div class="form-group" style="margin-bottom:12px">
  4009. <label>姓名</label>
  4010. <input id="customer-edit-name" type="text" value="${escapeHtml(localCustomer.name || '')}" placeholder="客户姓名" />
  4011. </div>
  4012. <div class="form-group" style="margin-bottom:12px">
  4013. <label>手机号</label>
  4014. <input id="customer-edit-phone" type="text" value="${escapeHtml(localCustomer.phone || '')}" placeholder="手机号" />
  4015. </div>
  4016. <div id="customer-edit-message" style="font-size:13px;color:var(--text-muted);min-height:20px"></div>
  4017. </div>
  4018. </div>
  4019. <div class="detail-actions">
  4020. <button class="btn btn-sm btn-secondary" id="btn-load-customer-full-profile">加载完整资料</button>
  4021. <button class="btn btn-sm" id="btn-save-customer-info">保存修改</button>
  4022. </div>
  4023. <div id="customer-full-profile"></div>
  4024. `, `<button class="btn btn-secondary" onclick="window.dashboardCloseModal()">关闭</button>`);
  4025. document.getElementById('btn-load-customer-full-profile')?.addEventListener('click', () => loadCustomerFullProfile(externalUserId));
  4026. document.getElementById('btn-save-customer-info')?.addEventListener('click', async () => {
  4027. const name = document.getElementById('customer-edit-name').value.trim();
  4028. const phone = document.getElementById('customer-edit-phone').value.trim();
  4029. const msgEl = document.getElementById('customer-edit-message');
  4030. try {
  4031. const res = await api('POST', '/api/customer-ops/update', { externalUserId, name, phone });
  4032. if (res.status === 'ok') {
  4033. msgEl.textContent = '已同步更新';
  4034. msgEl.style.color = 'var(--success)';
  4035. // 更新本地客户列表状态
  4036. const customer = (state.customerOps.allCustomers || []).find(c => c.externalUserId === externalUserId);
  4037. if (customer) {
  4038. customer.name = name || customer.name;
  4039. customer.phone = phone || customer.phone;
  4040. customer.updatedAt = new Date().toISOString();
  4041. }
  4042. // 刷新客户运营列表
  4043. const page = document.querySelector('#co-customer-table-wrap')?.closest('.page');
  4044. if (page) loadCustomerOpsTable(page);
  4045. toast('客户资料已同步更新');
  4046. } else {
  4047. msgEl.textContent = res.assistantMessage || '保存失败';
  4048. msgEl.style.color = 'var(--error)';
  4049. }
  4050. } catch (err) {
  4051. msgEl.textContent = err.message || '保存失败';
  4052. msgEl.style.color = 'var(--error)';
  4053. }
  4054. });
  4055. };
  4056. async function renderPortraitsPage() {
  4057. els.pageTitle.textContent = '画像与标签';
  4058. const page = document.createElement('div');
  4059. page.className = 'page customer-workspace-page';
  4060. page.innerHTML = `
  4061. <section class="workspace-hero customer-workspace-hero portrait-dashboard-hero">
  4062. <div>
  4063. <span>CUSTOMER INTELLIGENCE & TAGS</span>
  4064. <h2>画像与标签</h2>
  4065. <p>把客户对话沉淀为可追溯的结构化画像,用统一标签支持筛选、运营和后续跟进。</p>
  4066. </div>
  4067. <div class="hero-feature-list" aria-label="能力说明">
  4068. <span>AI 画像</span><span>证据可追溯</span><span>批量标签</span>
  4069. </div>
  4070. </section>
  4071. <div class="dashboard-grid customer-dashboard-grid" id="pt-dashboard">
  4072. <div class="kpi-row" id="pt-kpi-row"></div>
  4073. <div class="chart-card third">
  4074. <h3 class="chart-title"><span>画像覆盖率</span></h3>
  4075. <div class="chart-container small" id="pt-coverage-chart"></div>
  4076. </div>
  4077. <div class="chart-card third">
  4078. <h3 class="chart-title"><span>画像来源分布</span></h3>
  4079. <div class="chart-container small" id="pt-source-chart"></div>
  4080. </div>
  4081. <div class="chart-card third">
  4082. <h3 class="chart-title"><span>Top 标签</span></h3>
  4083. <div class="chart-container small" id="pt-tags-chart"></div>
  4084. </div>
  4085. <div class="chart-card full">
  4086. <h3 class="chart-title"><span>近7日画像生成趋势</span></h3>
  4087. <div class="chart-container" id="pt-trend-chart"></div>
  4088. </div>
  4089. <div class="operation-toolbar">
  4090. <div class="toolbar-left">
  4091. <button class="btn" id="btn-pt-batch" data-job="batch-portraits">批量生成画像</button>
  4092. <button class="btn btn-secondary" id="btn-pt-tag">为客户打标签</button>
  4093. <button class="btn btn-secondary" id="btn-pt-export">导出全部画像</button>
  4094. </div>
  4095. <div class="toolbar-right">
  4096. <button class="btn btn-sm btn-secondary" id="btn-pt-refresh">刷新</button>
  4097. <button class="btn btn-sm btn-secondary" id="btn-pt-rebuild">重建索引</button>
  4098. </div>
  4099. </div>
  4100. <div id="pt-job-tracker" style="margin:12px 0"></div>
  4101. <div class="data-table-card">
  4102. <div class="card-header">
  4103. <div>
  4104. <h3 class="card-title">客户画像列表</h3>
  4105. <div class="card-subtitle">支持按画像状态、来源、标签筛选</div>
  4106. </div>
  4107. </div>
  4108. <div class="filter-bar" style="margin-bottom:16px">
  4109. <input id="pt-filter-keyword" type="text" placeholder="搜索 externalUserId / 姓名" style="width:220px" value="${escapeHtml(state.portraits.keyword || '')}" />
  4110. <select id="pt-filter-source">
  4111. <option value="">全部来源</option>
  4112. <option value="keyword" ${state.portraits.source === 'keyword' ? 'selected' : ''}>关键词</option>
  4113. <option value="agent" ${state.portraits.source === 'agent' ? 'selected' : ''}>AI</option>
  4114. </select>
  4115. <select id="pt-filter-tag">
  4116. <option value="">全部标签</option>
  4117. </select>
  4118. </div>
  4119. <div id="pt-portrait-table-wrap"></div>
  4120. <div id="pt-pagination"></div>
  4121. </div>
  4122. </div>
  4123. `;
  4124. els.content.appendChild(page);
  4125. await loadPortraitsDashboard(page);
  4126. dashboardRefreshTimer = setInterval(() => {
  4127. if (state.page === 'portraits') loadPortraitsDashboard(page, true);
  4128. }, 30000);
  4129. page.querySelector('#btn-pt-refresh').addEventListener('click', () => loadPortraitsDashboard(page));
  4130. page.querySelector('#btn-pt-rebuild').addEventListener('click', async () => {
  4131. try {
  4132. await api('POST', '/api/dashboard/rebuild');
  4133. toast('索引已重建');
  4134. loadPortraitsDashboard(page);
  4135. } catch (err) {
  4136. toast(err.message || '重建失败', 'error');
  4137. }
  4138. });
  4139. page.querySelector('#pt-filter-keyword').addEventListener('input', () => loadPortraitsTable(page));
  4140. page.querySelector('#pt-filter-source').addEventListener('change', () => loadPortraitsTable(page));
  4141. page.querySelector('#pt-filter-tag').addEventListener('change', () => loadPortraitsTable(page));
  4142. page.querySelector('#btn-pt-batch').addEventListener('click', () => openBatchPortraitSelector(page));
  4143. page.querySelector('#btn-pt-tag').addEventListener('click', () => openTagModal(page));
  4144. page.querySelector('#btn-pt-export').addEventListener('click', () => {
  4145. const portraits = state.portraits?.allPortraits || {};
  4146. if (!Object.keys(portraits).length) {
  4147. toast('当前没有可导出的画像,请先生成画像', 'warning');
  4148. return;
  4149. }
  4150. startJob('export-portraits', '/api/portraits/export', {}, (result) => {
  4151. if (result?.status === 'error') {
  4152. toast(result.assistantMessage || '导出失败', 'error');
  4153. return;
  4154. }
  4155. const filePath = result?.files?.[0];
  4156. if (!filePath) {
  4157. toast('导出未生成文件', 'warning');
  4158. return;
  4159. }
  4160. toast('导出成功,点击下载', 'success');
  4161. window.open(`/api/outputs?path=${encodeURIComponent(filePath)}`, '_blank');
  4162. });
  4163. });
  4164. }
  4165. async function loadPortraitsDashboard(page, silent = false) {
  4166. const tableWrap = page.querySelector('#pt-portrait-table-wrap');
  4167. if (!silent) showLoading(tableWrap, 'table');
  4168. try {
  4169. const [summaryRes, portraitsRes, tagsRes, customersRes, customerMasterRes] = await Promise.all([
  4170. api('GET', '/api/dashboard/summary'),
  4171. api('GET', '/api/dashboard/portraits'),
  4172. api('GET', '/api/dashboard/tags'),
  4173. api('GET', '/api/dashboard/customers'),
  4174. api('GET', '/api/customers')
  4175. ]);
  4176. const summary = summaryRes.data || {};
  4177. const legacyPortraits = portraitsRes.data?.portraits || {};
  4178. const legacyCustomers = customersRes.data?.customers || {};
  4179. const customerMaster = customerMasterRes.data?.customers || [];
  4180. const canonicalPortraits = {};
  4181. const canonicalCustomers = {};
  4182. for (const customer of customerMaster) {
  4183. const hasProfile = customer.profile && Object.keys(customer.profile).length > 0;
  4184. if (!hasProfile) continue;
  4185. canonicalPortraits[customer.id] = {
  4186. externalUserId: customer.id,
  4187. displayId: customer.maskedId,
  4188. fields: customer.fields || [],
  4189. profile: customer.profile || {},
  4190. source: 'agent',
  4191. canonical: true,
  4192. messageCount: customer.messageCount || 0,
  4193. updatedAt: customer.profileUpdatedAt || customer.lastMessageAt,
  4194. };
  4195. canonicalCustomers[customer.id] = {
  4196. externalUserId: customer.id,
  4197. name: customer.displayName,
  4198. phone: customer.maskedId,
  4199. tags: customer.tags || [],
  4200. };
  4201. }
  4202. const portraits = { ...legacyPortraits, ...canonicalPortraits };
  4203. const customers = { ...legacyCustomers, ...canonicalCustomers };
  4204. const tags = [...new Set([
  4205. ...(tagsRes.data?.allTags || []),
  4206. ...customerMaster.flatMap(customer => customer.tags || []),
  4207. ])];
  4208. const portraitItems = Object.values(portraits);
  4209. const customerTotal = Math.max(Number(summary.customers?.total || 0), customerMaster.length);
  4210. const bySource = portraitItems.reduce((counts, portrait) => {
  4211. const source = portrait.source === 'agent' ? 'agent' : portrait.source === 'keyword' ? 'keyword' : 'other';
  4212. counts[source] += 1;
  4213. return counts;
  4214. }, { agent: 0, keyword: 0, other: 0 });
  4215. summary.portraits = {
  4216. ...(summary.portraits || {}),
  4217. total: portraitItems.length,
  4218. coverage: customerTotal ? Math.round((portraitItems.length / customerTotal) * 100) : 0,
  4219. bySource,
  4220. };
  4221. summary.tags = {
  4222. ...(summary.tags || {}),
  4223. total: tags.length,
  4224. avgPerCustomer: customerTotal ? Number((tags.length / customerTotal).toFixed(1)) : 0,
  4225. };
  4226. state.portraits.allPortraits = portraits;
  4227. state.portraits.allCustomers = customers;
  4228. hideLoading(tableWrap);
  4229. renderPortraitsKpi(page, summary);
  4230. renderPortraitsCharts(page, summary, portraits, tags);
  4231. renderPortraitsTagFilter(page, tags);
  4232. loadPortraitsTable(page);
  4233. } catch (err) {
  4234. if (!silent) showError(tableWrap, err.message || '加载看板失败', () => loadPortraitsDashboard(page));
  4235. }
  4236. }
  4237. function renderPortraitsKpi(page, summary) {
  4238. const wrap = page.querySelector('#pt-kpi-row');
  4239. const p = summary.portraits || {};
  4240. const t = summary.tags || {};
  4241. const kpi = [
  4242. { label: '已生成画像', value: formatNumber(p.total) },
  4243. { label: '画像覆盖率', value: (p.coverage || 0) + '%', sub: '有画像客户占比' },
  4244. { label: '本地标签', value: formatNumber(t.total), sub: '去重后标签数' },
  4245. { label: '人均标签', value: t.avgPerCustomer || 0, sub: '每个客户平均标签' },
  4246. { label: 'AI 画像', value: formatNumber(p.bySource?.agent || 0), sub: 'Agent 分析生成' },
  4247. { label: '关键词画像', value: formatNumber(p.bySource?.keyword || 0), sub: '关键词匹配生成' }
  4248. ];
  4249. wrap.innerHTML = renderKpiRow(kpi);
  4250. }
  4251. function renderPortraitsCharts(page, summary, portraits, tags) {
  4252. const colors = getChartColors();
  4253. const coverage = summary.portraits?.coverage || 0;
  4254. initEChart('pt-coverage-chart', {
  4255. series: [{
  4256. type: 'gauge',
  4257. startAngle: 180,
  4258. endAngle: 0,
  4259. min: 0,
  4260. max: 100,
  4261. progress: { show: true, width: 12, itemStyle: { color: colors.brand } },
  4262. axisLine: { lineStyle: { width: 12, color: [[1, 'rgba(0,0,0,0.06)']] } },
  4263. axisTick: { show: false },
  4264. splitLine: { show: false },
  4265. axisLabel: { show: false },
  4266. pointer: { show: false },
  4267. anchor: { show: false },
  4268. title: { show: false },
  4269. detail: {
  4270. valueAnimation: true,
  4271. fontSize: 28,
  4272. fontWeight: 800,
  4273. color: colors.textPrimary,
  4274. formatter: '{value}%',
  4275. offsetCenter: [0, '0%']
  4276. },
  4277. data: [{ value: coverage }]
  4278. }]
  4279. });
  4280. const bySource = summary.portraits?.bySource || {};
  4281. initEChart('pt-source-chart', {
  4282. tooltip: { trigger: 'item' },
  4283. legend: { bottom: 0 },
  4284. color: [colors.brand, colors.info, colors.muted],
  4285. series: [{
  4286. type: 'pie',
  4287. radius: ['40%', '65%'],
  4288. center: ['50%', '45%'],
  4289. data: [
  4290. { name: '关键词', value: bySource.keyword || 0 },
  4291. { name: 'AI', value: bySource.agent || 0 },
  4292. { name: '其他', value: bySource.other || 0 }
  4293. ]
  4294. }]
  4295. });
  4296. const customers = Object.values(state.portraits.allCustomers || {});
  4297. const topTags = tags
  4298. .map(t => ({
  4299. name: t,
  4300. value: customers.filter(c => Array.isArray(c.tags) && c.tags.includes(t)).length
  4301. }))
  4302. .filter(t => t.value > 0)
  4303. .sort((a, b) => b.value - a.value)
  4304. .slice(0, 10);
  4305. initEChart('pt-tags-chart', {
  4306. tooltip: { trigger: 'axis' },
  4307. grid: { left: 8, right: 34, top: 10, bottom: 8, containLabel: true },
  4308. xAxis: {
  4309. type: 'value',
  4310. splitLine: { lineStyle: { color: 'rgba(0,0,0,0.06)' } },
  4311. axisLabel: { color: colors.muted }
  4312. },
  4313. yAxis: {
  4314. type: 'category',
  4315. data: topTags.map(t => t.name).reverse(),
  4316. axisLabel: {
  4317. width: 96,
  4318. overflow: 'truncate',
  4319. interval: 0,
  4320. color: colors.textPrimary
  4321. },
  4322. axisTick: { show: false }
  4323. },
  4324. series: [{
  4325. type: 'bar',
  4326. data: topTags.map(t => t.value).reverse(),
  4327. itemStyle: { color: colors.brand, borderRadius: [0, 5, 5, 0] },
  4328. barWidth: 14,
  4329. label: {
  4330. show: true,
  4331. position: 'right',
  4332. color: colors.textPrimary,
  4333. fontWeight: 600
  4334. }
  4335. }]
  4336. });
  4337. const days = last7Days();
  4338. const portraitItems = Object.values(portraits);
  4339. const counts = countByDay(portraitItems, 'updatedAt');
  4340. initEChart('pt-trend-chart', {
  4341. tooltip: { trigger: 'axis' },
  4342. xAxis: { type: 'category', data: days.map(d => formatDate(d)) },
  4343. yAxis: { type: 'value' },
  4344. series: [{ name: '画像生成', type: 'line', smooth: true, data: counts, itemStyle: { color: colors.brand }, areaStyle: { color: hexToRgba(colors.brand, 0.15) } }]
  4345. });
  4346. }
  4347. function renderPortraitsTagFilter(page, tags) {
  4348. const select = page.querySelector('#pt-filter-tag');
  4349. const current = select.value;
  4350. select.innerHTML = '<option value="">全部标签</option>' + tags.map(t => `<option value="${escapeHtml(t)}">${escapeHtml(t)}</option>`).join('');
  4351. select.value = current;
  4352. }
  4353. function loadPortraitsTable(page) {
  4354. const wrap = page.querySelector('#pt-portrait-table-wrap');
  4355. const portraits = state.portraits.allPortraits || {};
  4356. const customers = state.portraits.allCustomers || {};
  4357. const keyword = page.querySelector('#pt-filter-keyword').value.trim().toLowerCase();
  4358. const source = page.querySelector('#pt-filter-source').value;
  4359. const tag = page.querySelector('#pt-filter-tag').value;
  4360. state.portraits.keyword = keyword;
  4361. state.portraits.source = source;
  4362. state.portraits.tag = tag;
  4363. saveFilters(PT_FILTERS_KEY, {
  4364. tablePage: state.portraits.tablePage,
  4365. keyword,
  4366. source,
  4367. tag
  4368. });
  4369. let items = Object.values(portraits).map(p => {
  4370. const customer = Object.values(customers).find(c => c.externalUserId === p.externalUserId) || {};
  4371. return { ...p, name: customer.name, phone: customer.phone, tags: customer.tags || [] };
  4372. });
  4373. let filtered = items.filter(p => {
  4374. if (keyword) {
  4375. const text = `${p.externalUserId || ''} ${p.name || ''} ${p.phone || ''}`.toLowerCase();
  4376. if (!text.includes(keyword)) return false;
  4377. }
  4378. if (source && p.source !== source) return false;
  4379. if (tag && !p.tags.includes(tag)) return false;
  4380. return true;
  4381. });
  4382. const pageSize = 20;
  4383. const currentPage = state.portraits.tablePage || 1;
  4384. const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
  4385. if (currentPage > totalPages) {
  4386. state.portraits.tablePage = totalPages;
  4387. saveFilters(PT_FILTERS_KEY, {
  4388. tablePage: state.portraits.tablePage,
  4389. keyword: state.portraits.keyword,
  4390. source: state.portraits.source,
  4391. tag: state.portraits.tag
  4392. });
  4393. }
  4394. const start = (state.portraits.tablePage - 1) * pageSize;
  4395. const pageItems = filtered.slice(start, start + pageSize);
  4396. wrap.innerHTML = `
  4397. <div class="table-wrap">
  4398. <table>
  4399. <thead>
  4400. <tr>
  4401. <th>externalUserId</th>
  4402. <th>姓名</th>
  4403. <th>标签</th>
  4404. <th>画像字段</th>
  4405. <th>来源</th>
  4406. <th>消息数</th>
  4407. <th>更新时间</th>
  4408. <th>操作</th>
  4409. </tr>
  4410. </thead>
  4411. <tbody>
  4412. ${pageItems.length ? pageItems.map(p => `
  4413. <tr>
  4414. <td style="font-family:monospace;font-size:12px">${escapeHtml(p.displayId || p.externalUserId || '-')}</td>
  4415. <td>${escapeHtml(p.name || '-')}</td>
  4416. <td>${p.tags.length ? p.tags.map(t => `<span class="tag">${escapeHtml(t)}</span>`).join('') : '-'}</td>
  4417. <td>${formatNumber(p.fields?.length || 0)} 个字段</td>
  4418. <td>${p.source === 'keyword' ? '<span class="badge badge-warning">关键词</span>' : p.source === 'agent' ? '<span class="badge badge-info">AI</span>' : '<span class="badge badge-default">未知</span>'}</td>
  4419. <td>${formatNumber(p.messageCount)}</td>
  4420. <td>${formatDateTime(p.updatedAt)}</td>
  4421. <td>
  4422. <button class="btn btn-sm btn-secondary" onclick="window.dashboardPortraitDetail('${escapeHtml(p.externalUserId)}')">查看</button>
  4423. </td>
  4424. </tr>
  4425. `).join('') : renderEmptyTableRow(8, '暂无画像数据', '选择客户后批量生成画像')}
  4426. </tbody>
  4427. </table>
  4428. </div>
  4429. `;
  4430. const pagination = page.querySelector('#pt-pagination');
  4431. pagination.innerHTML = renderPagination(state.portraits.tablePage, filtered.length, pageSize);
  4432. pagination.querySelectorAll('button').forEach(btn => {
  4433. btn.addEventListener('click', () => {
  4434. state.portraits.tablePage = Number(btn.dataset.page);
  4435. saveFilters(PT_FILTERS_KEY, {
  4436. tablePage: state.portraits.tablePage,
  4437. keyword: state.portraits.keyword,
  4438. source: state.portraits.source,
  4439. tag: state.portraits.tag
  4440. });
  4441. loadPortraitsTable(page);
  4442. });
  4443. });
  4444. }
  4445. async function openBatchPortraitSelector(page) {
  4446. const result = await openCustomerSelector({
  4447. multiple: true,
  4448. title: '选择要生成画像的客户',
  4449. confirmText: '生成画像'
  4450. });
  4451. const ids = result.externalUserIds || [];
  4452. if (!ids.length) {
  4453. toast('未选择客户', 'warning');
  4454. return;
  4455. }
  4456. startJob('batch-portraits', '/api/portraits/batch', { externalUserIds: ids, aiMode: 'ai', guid: getGuid() }, () => loadPortraitsDashboard(page), 'pt-job-tracker');
  4457. }
  4458. function openTagModal(page) {
  4459. const body = `
  4460. <div class="form-group" style="margin-bottom:16px">
  4461. <label>选择客户</label>
  4462. <button type="button" class="customer-selector-trigger" id="btn-select-tag-customer">点击选择客户</button>
  4463. <div class="customer-selector-value" id="tag-customer-selected"><span style="color:var(--text-muted);font-size:13px">未选择客户</span></div>
  4464. </div>
  4465. <div class="form-group" style="margin-bottom:16px">
  4466. <label>标签(逗号分隔)</label>
  4467. <input id="tag-input" type="text" placeholder="高意向,学区房" />
  4468. </div>
  4469. <div class="form-group">
  4470. <label>操作</label>
  4471. <select id="tag-action">
  4472. <option value="add">添加标签</option>
  4473. <option value="remove">移除标签</option>
  4474. </select>
  4475. </div>
  4476. `;
  4477. openModal('为客户打标签', body, `
  4478. <button class="btn btn-secondary" onclick="window.dashboardCloseModal()">取消</button>
  4479. <button class="btn" id="btn-confirm-tag">确定</button>
  4480. `);
  4481. let selectedCustomer = null;
  4482. document.getElementById('btn-select-tag-customer').addEventListener('click', async () => {
  4483. const result = await openCustomerSelector({ multiple: false, selectedIds: selectedCustomer ? [selectedCustomer.externalUserId] : [], title: '选择要打标签的客户' });
  4484. selectedCustomer = result.customers[0] || null;
  4485. renderCustomerSelectorValue('tag-customer-selected', selectedCustomer ? [selectedCustomer] : []);
  4486. });
  4487. document.getElementById('btn-confirm-tag').addEventListener('click', async () => {
  4488. const externalUserId = selectedCustomer ? selectedCustomer.externalUserId : '';
  4489. const tags = document.getElementById('tag-input').value.split(/[,,]/).map(s => s.trim()).filter(Boolean);
  4490. const action = document.getElementById('tag-action').value;
  4491. if (!externalUserId || !tags.length) {
  4492. toast('请选择客户并输入标签', 'warning');
  4493. return;
  4494. }
  4495. try {
  4496. await api('POST', `/api/tags/${action}`, { externalUserId, tags });
  4497. toast('标签已更新');
  4498. closeModal();
  4499. loadPortraitsDashboard(page);
  4500. } catch (err) {
  4501. toast(err.message || '更新失败', 'error');
  4502. }
  4503. });
  4504. }
  4505. function portraitLabel(key) {
  4506. const labels = {
  4507. intent: '意向',
  4508. budgetRange: '预算',
  4509. preferredAreas: '区域',
  4510. houseType: '房型',
  4511. timeline: '时间计划',
  4512. keyConcerns: '关注点',
  4513. urgency: '紧迫度',
  4514. aiSummary: '综合判断',
  4515. personaType: '客户类型',
  4516. fiveW2H: '5W2H',
  4517. priorityMatrix: '优先级',
  4518. confidence: '置信度',
  4519. coreAnxiety: '核心顾虑',
  4520. decisionMaker: '决策人',
  4521. loanCapacity: '贷款能力',
  4522. negotiationStage: '谈判阶段'
  4523. };
  4524. return labels[key] || key;
  4525. }
  4526. function normalizePortraitValue(value) {
  4527. if (value === undefined || value === null || value === '') return { text: '-', chips: [], note: '' };
  4528. if (Array.isArray(value)) {
  4529. return { text: value.map(v => typeof v === 'object' ? JSON.stringify(v) : String(v)).join('、'), chips: value.map(String), note: '' };
  4530. }
  4531. if (typeof value !== 'object') return { text: String(value), chips: [], note: '' };
  4532. const chips = [];
  4533. if (Array.isArray(value.keywords)) chips.push(...value.keywords.map(String));
  4534. if (Array.isArray(value.evidence)) chips.push(...value.evidence.map(String));
  4535. if (Array.isArray(value.values)) chips.push(...value.values.map(String));
  4536. const textCandidates = [
  4537. value.value,
  4538. value.result,
  4539. value.summary,
  4540. value.note,
  4541. value.inference,
  4542. value.description,
  4543. value.label
  4544. ].filter(v => v !== undefined && v !== null && v !== '');
  4545. if (textCandidates.length) {
  4546. return {
  4547. text: textCandidates.map(v => typeof v === 'object' ? JSON.stringify(v) : String(v)).join(';'),
  4548. chips: [...new Set(chips)],
  4549. note: value.inferred ? '根据聊天记录推断' : ''
  4550. };
  4551. }
  4552. return {
  4553. text: JSON.stringify(value, null, 2),
  4554. chips: [...new Set(chips)],
  4555. note: value.inferred ? '根据聊天记录推断' : ''
  4556. };
  4557. }
  4558. function renderPortraitInsightCard(key, value) {
  4559. const normalized = normalizePortraitValue(value);
  4560. const chipHtml = normalized.chips.length
  4561. ? `<div class="portrait-evidence">${normalized.chips.slice(0, 8).map(chip => `<span>${escapeHtml(chip)}</span>`).join('')}</div>`
  4562. : '';
  4563. return `
  4564. <div class="portrait-insight-card">
  4565. <div class="portrait-insight-label">${escapeHtml(portraitLabel(key))}</div>
  4566. <div class="portrait-insight-value">${escapeHtml(normalized.text)}</div>
  4567. ${normalized.note ? `<div class="portrait-insight-note">${escapeHtml(normalized.note)}</div>` : ''}
  4568. ${chipHtml}
  4569. </div>
  4570. `;
  4571. }
  4572. function renderPortraitSection(title, keys, portraitFields) {
  4573. const available = keys.filter(k => portraitFields[k] !== undefined && portraitFields[k] !== null && portraitFields[k] !== '');
  4574. if (!available.length) return '';
  4575. return `
  4576. <section class="portrait-section">
  4577. <div class="portrait-section-head">
  4578. <h3>${escapeHtml(title)}</h3>
  4579. <span>${formatNumber(available.length)} 项</span>
  4580. </div>
  4581. <div class="portrait-insight-grid">
  4582. ${available.map(k => renderPortraitInsightCard(k, portraitFields[k])).join('')}
  4583. </div>
  4584. </section>
  4585. `;
  4586. }
  4587. window.dashboardPortraitDetail = async (externalUserId) => {
  4588. if (!externalUserId) return;
  4589. const cachedPortrait = state.portraits.allPortraits?.[externalUserId];
  4590. if (cachedPortrait?.canonical) {
  4591. const customer = state.portraits.allCustomers?.[externalUserId] || {};
  4592. const fields = cachedPortrait.fields || [];
  4593. openModal('画像详情', `
  4594. <div class="portrait-profile">
  4595. <div class="portrait-hero">
  4596. <div class="portrait-avatar">${escapeHtml((customer.name || '客').slice(0, 1))}</div>
  4597. <div class="portrait-identity">
  4598. <div class="portrait-name">${escapeHtml(customer.name || '未命名客户')}</div>
  4599. <div class="portrait-subtitle">${escapeHtml(cachedPortrait.displayId || '-')} · 真实企微会话画像</div>
  4600. <div class="portrait-chip-row">
  4601. <span class="badge badge-info">Agent 自动沉淀</span>
  4602. <span class="portrait-soft-chip">${formatNumber(cachedPortrait.messageCount || 0)} 条消息</span>
  4603. <span class="portrait-soft-chip">${formatNumber(fields.length)} 个画像字段</span>
  4604. </div>
  4605. </div>
  4606. </div>
  4607. <div class="portrait-highlight-grid">
  4608. ${fields.map(field => `<div class="portrait-insight-card"><span>${escapeHtml(field.label || field.key)}</span><strong>${escapeHtml(field.displayValue ?? '-')}</strong></div>`).join('')}
  4609. </div>
  4610. </div>
  4611. `, `<button class="btn btn-secondary" onclick="window.dashboardCloseModal()">关闭</button>`, 'portrait-modal');
  4612. return;
  4613. }
  4614. try {
  4615. const res = await api('POST', '/api/customer-ops/customer-profile', { externalUserId, guid: getGuid() });
  4616. const data = res.data || {};
  4617. const portrait = data.portrait || {};
  4618. const portraitFields = portrait.portrait || {};
  4619. const localCustomer = Object.values(state.portraits.allCustomers || {}).find(c => c.externalUserId === externalUserId) || {};
  4620. const name = localCustomer.name || '-';
  4621. const phone = localCustomer.phone || '-';
  4622. const tags = localCustomer.tags || [];
  4623. const sourceMap = {
  4624. keyword: ['badge-warning', '关键词'],
  4625. agent: ['badge-info', 'AI'],
  4626. unknown: ['badge-default', '未知']
  4627. };
  4628. const [srcCls, srcText] = sourceMap[portrait.source] || sourceMap.unknown;
  4629. const groupMap = {
  4630. '购房需求': ['intent', 'budgetRange', 'preferredAreas', 'houseType'],
  4631. '决策状态': ['timeline', 'urgency', 'negotiationStage', 'decisionMaker'],
  4632. '关注与顾虑': ['keyConcerns', 'coreAnxiety', 'loanCapacity'],
  4633. '综合分析': ['aiSummary', 'personaType', 'fiveW2H', 'priorityMatrix', 'confidence']
  4634. };
  4635. const fieldCount = Object.keys(portraitFields).length;
  4636. const customerInitial = (name && name !== '-' ? name : '客').slice(0, 1);
  4637. const topFields = ['intent', 'budgetRange', 'preferredAreas', 'timeline', 'keyConcerns']
  4638. .filter(k => portraitFields[k] !== undefined && portraitFields[k] !== null && portraitFields[k] !== '')
  4639. .slice(0, 4);
  4640. const topHighlights = topFields.length
  4641. ? topFields.map(k => renderPortraitInsightCard(k, portraitFields[k])).join('')
  4642. : '<div class="portrait-empty-panel">暂无可展示的核心画像字段</div>';
  4643. let sectionsHtml = '';
  4644. for (const [groupName, keys] of Object.entries(groupMap)) {
  4645. sectionsHtml += renderPortraitSection(groupName, keys, portraitFields);
  4646. }
  4647. if (!sectionsHtml) {
  4648. sectionsHtml = '<div class="portrait-empty-panel">当前画像没有结构化字段。请先同步更多客户对话,再重新生成画像。</div>';
  4649. }
  4650. openModal('画像详情', `
  4651. <div class="portrait-profile">
  4652. <div class="portrait-hero">
  4653. <div class="portrait-avatar">${escapeHtml(customerInitial)}</div>
  4654. <div class="portrait-identity">
  4655. <div class="portrait-name">${escapeHtml(name || '未命名客户')}</div>
  4656. <div class="portrait-subtitle">${escapeHtml(phone || '-')} · ${escapeHtml(externalUserId)}</div>
  4657. <div class="portrait-chip-row">
  4658. <span class="badge ${srcCls}">${escapeHtml(srcText)}</span>
  4659. <span class="portrait-soft-chip">${formatNumber(portrait.messageCount || 0)} 条消息</span>
  4660. <span class="portrait-soft-chip">${formatNumber(fieldCount)} 个画像字段</span>
  4661. </div>
  4662. </div>
  4663. <div class="portrait-meta">
  4664. <div>生成时间</div>
  4665. <strong>${escapeHtml(formatDateTime(portrait.updatedAt))}</strong>
  4666. </div>
  4667. </div>
  4668. ${tags.length ? `<div class="portrait-tags">${tags.map(t => `<span class="tag">${escapeHtml(t)}</span>`).join(' ')}</div>` : ''}
  4669. <div class="portrait-highlight-grid">
  4670. ${topHighlights}
  4671. </div>
  4672. </div>
  4673. ${sectionsHtml}
  4674. ${renderRawToggle(portraitFields)}
  4675. `, `<button class="btn btn-secondary" onclick="window.dashboardCloseModal()">关闭</button>`, 'portrait-modal');
  4676. bindRawToggle();
  4677. } catch (err) {
  4678. toast(err.message || '查询失败', 'error');
  4679. }
  4680. };
  4681. async function renderTransfersPage() {
  4682. els.pageTitle.textContent = '客户交接';
  4683. const page = document.createElement('div');
  4684. page.className = 'page customer-workspace-page';
  4685. page.innerHTML = `
  4686. <section class="workspace-hero customer-workspace-hero transfer-dashboard-hero">
  4687. <div>
  4688. <span>CUSTOMER HANDOVER WORKFLOW</span>
  4689. <h2>客户交接</h2>
  4690. <p>先生成交接预览,再执行顾问变更;客户、群聊、欢迎语与执行结果全程留痕。</p>
  4691. </div>
  4692. <div class="handover-flow" aria-label="交接流程">
  4693. <span><b>1</b>准备范围</span><i></i><span><b>2</b>生成预览</span><i></i><span><b>3</b>确认执行</span>
  4694. </div>
  4695. </section>
  4696. <div class="dashboard-grid customer-dashboard-grid" id="tf-dashboard">
  4697. <div class="kpi-row" id="tf-kpi-row"></div>
  4698. <div class="chart-card transfer-progress-card">
  4699. <h3 class="chart-title"><span>交接流程转化</span><span class="chart-subtitle">从预览准备到成功交接</span></h3>
  4700. <div class="transfer-stage-board" id="tf-stage-board"></div>
  4701. </div>
  4702. <div class="chart-card">
  4703. <h3 class="chart-title"><span>交接结果分布</span></h3>
  4704. <div class="chart-container" id="tf-result-chart"></div>
  4705. </div>
  4706. <div class="chart-card full">
  4707. <h3 class="chart-title"><span>近7日交接执行趋势</span></h3>
  4708. <div class="chart-container" id="tf-trend-chart"></div>
  4709. </div>
  4710. <div class="operation-toolbar">
  4711. <div class="toolbar-left">
  4712. <button class="btn" id="btn-new-transfer">新建交接预览</button>
  4713. </div>
  4714. <div class="toolbar-right">
  4715. <button class="btn btn-sm btn-secondary" id="btn-tf-refresh">刷新</button>
  4716. <button class="btn btn-sm btn-secondary" id="btn-tf-rebuild">重建索引</button>
  4717. </div>
  4718. </div>
  4719. <div class="data-table-card">
  4720. <div class="card-header">
  4721. <div>
  4722. <h3 class="card-title">交接包列表</h3>
  4723. <div class="card-subtitle">预览包与执行记录</div>
  4724. </div>
  4725. </div>
  4726. <div class="filter-bar" style="margin-bottom:16px">
  4727. <select id="tf-filter-status">
  4728. <option value="">全部状态</option>
  4729. <option value="DRAFT" ${state.transfers.status === 'DRAFT' ? 'selected' : ''}>草稿</option>
  4730. <option value="EXECUTED" ${state.transfers.status === 'EXECUTED' ? 'selected' : ''}>已执行</option>
  4731. </select>
  4732. <input id="tf-filter-user" type="text" placeholder="按顾问 userId 搜索" style="width:200px" value="${escapeHtml(state.transfers.user || '')}" />
  4733. </div>
  4734. <div id="tf-transfer-table-wrap"></div>
  4735. <div id="tf-pagination"></div>
  4736. </div>
  4737. </div>
  4738. `;
  4739. els.content.appendChild(page);
  4740. await loadTransfersDashboard(page);
  4741. dashboardRefreshTimer = setInterval(() => {
  4742. if (state.page === 'transfers') loadTransfersDashboard(page, true);
  4743. }, 30000);
  4744. page.querySelector('#btn-tf-refresh').addEventListener('click', () => loadTransfersDashboard(page));
  4745. page.querySelector('#btn-tf-rebuild').addEventListener('click', async () => {
  4746. try {
  4747. await api('POST', '/api/dashboard/rebuild');
  4748. toast('索引已重建');
  4749. loadTransfersDashboard(page);
  4750. } catch (err) {
  4751. toast(err.message || '重建失败', 'error');
  4752. }
  4753. });
  4754. page.querySelector('#btn-new-transfer').addEventListener('click', () => openTransferModal(page));
  4755. page.querySelector('#tf-filter-status').addEventListener('change', () => loadTransfersTable(page));
  4756. page.querySelector('#tf-filter-user').addEventListener('input', () => loadTransfersTable(page));
  4757. }
  4758. async function loadTransfersDashboard(page, silent = false) {
  4759. const tableWrap = page.querySelector('#tf-transfer-table-wrap');
  4760. if (!silent) showLoading(tableWrap, 'table');
  4761. try {
  4762. const [summaryRes, transfersRes] = await Promise.all([
  4763. api('GET', '/api/dashboard/summary'),
  4764. api('GET', '/api/dashboard/transfers')
  4765. ]);
  4766. const summary = summaryRes.data || {};
  4767. const transfers = transfersRes.data || {};
  4768. hideLoading(tableWrap);
  4769. renderTransfersKpi(page, summary);
  4770. renderTransfersCharts(page, summary, transfers);
  4771. state.transfers = { ...state.transfers, ...transfers };
  4772. loadTransfersTable(page);
  4773. } catch (err) {
  4774. if (!silent) showError(tableWrap, err.message || '加载看板失败', () => loadTransfersDashboard(page));
  4775. }
  4776. }
  4777. function renderTransfersKpi(page, summary) {
  4778. const wrap = page.querySelector('#tf-kpi-row');
  4779. const t = summary.transfers || {};
  4780. const kpi = [
  4781. { label: '交接预览包', value: formatNumber(t.previews) },
  4782. { label: '已执行交接', value: formatNumber(t.executions) },
  4783. { label: '交接成功率', value: (t.successRate || 0) + '%', sub: '成功记录 / 总记录' },
  4784. { label: '近7日交接', value: formatNumber(t.recent7d), sub: '最近7天执行数' }
  4785. ];
  4786. wrap.innerHTML = renderKpiRow(kpi);
  4787. }
  4788. function renderTransfersCharts(page, summary, transfers) {
  4789. const colors = getChartColors();
  4790. const t = summary.transfers || {};
  4791. const previews = Number(t.previews || 0);
  4792. const executions = Number(t.executions || 0);
  4793. const successful = Math.round(((t.successRate || 0) / 100) * executions);
  4794. const executionRate = previews ? Math.min(100, Math.round((executions / previews) * 100)) : 0;
  4795. const successRate = executions ? Math.min(100, Math.round((successful / executions) * 100)) : 0;
  4796. const overallRate = previews ? Math.min(100, Math.round((successful / previews) * 100)) : 0;
  4797. const stageBoard = page.querySelector('#tf-stage-board');
  4798. if (stageBoard) {
  4799. stageBoard.innerHTML = `
  4800. <div class="transfer-stage-track">
  4801. <article class="transfer-stage preview">
  4802. <div class="transfer-stage-icon"><svg viewBox="0 0 24 24"><path d="M6 2h9l5 5v15H6V2Zm8 2H8v16h10V8h-4V4Zm-4 8h6v2h-6v-2Zm0 4h6v2h-6v-2Z"/></svg></div>
  4803. <div><span>01 · 准备完成</span><strong>${formatNumber(previews)}</strong><small>份交接预览包</small></div>
  4804. </article>
  4805. <div class="transfer-stage-connector"><strong>${executionRate}%</strong><span>进入执行</span><i><b style="width:${executionRate}%"></b></i></div>
  4806. <article class="transfer-stage execution">
  4807. <div class="transfer-stage-icon"><svg viewBox="0 0 24 24"><path d="m9 16.17-3.88-3.88L3.7 13.7 9 19l11.3-11.3-1.42-1.42L9 16.17Z"/></svg></div>
  4808. <div><span>02 · 已确认执行</span><strong>${formatNumber(executions)}</strong><small>次顾问交接</small></div>
  4809. </article>
  4810. <div class="transfer-stage-connector"><strong>${successRate}%</strong><span>执行成功</span><i><b style="width:${successRate}%"></b></i></div>
  4811. <article class="transfer-stage success">
  4812. <div class="transfer-stage-icon"><svg viewBox="0 0 24 24"><path d="M16 11c1.66 0 3-1.34 3-3s-1.34-3-3-3-3 1.34-3 3 1.34 3 3 3ZM8 11c1.66 0 3-1.34 3-3S9.66 5 8 5 5 6.34 5 8s1.34 3 3 3Zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5C15 14.17 10.33 13 8 13Zm8 0c-.29 0-.62.02-.97.05A4.2 4.2 0 0 1 17 16.5V19h6v-2.5c0-2.33-4.67-3.5-7-3.5Z"/></svg></div>
  4813. <div><span>03 · 交接成功</span><strong>${formatNumber(successful)}</strong><small>条成功记录</small></div>
  4814. </article>
  4815. </div>
  4816. <div class="transfer-overall-progress">
  4817. <div><span>整体交接完成率</span><strong>${overallRate}%</strong></div>
  4818. <i><b style="width:${overallRate}%"></b></i>
  4819. <small>${previews ? `共 ${formatNumber(previews)} 份预览包,其中 ${formatNumber(successful)} 份已完成成功交接` : '创建交接预览后,这里将展示阶段转化情况'}</small>
  4820. </div>`;
  4821. }
  4822. const executionRecords = transfers.executions || [];
  4823. const success = executionRecords.reduce((sum, e) => sum + (e.success || 0), 0);
  4824. const failed = executionRecords.reduce((sum, e) => sum + (e.failed || 0), 0);
  4825. initEChart('tf-result-chart', {
  4826. tooltip: { trigger: 'item' },
  4827. legend: { bottom: 0 },
  4828. color: [colors.success, colors.error],
  4829. series: [{
  4830. type: 'pie',
  4831. radius: ['40%', '65%'],
  4832. center: ['50%', '45%'],
  4833. data: [
  4834. { name: '成功', value: success },
  4835. { name: '失败', value: failed }
  4836. ]
  4837. }]
  4838. });
  4839. const days = last7Days();
  4840. const counts = countByDay(executionRecords, 'executedAt');
  4841. initEChart('tf-trend-chart', {
  4842. tooltip: { trigger: 'axis' },
  4843. xAxis: { type: 'category', data: days.map(d => formatDate(d)) },
  4844. yAxis: { type: 'value' },
  4845. series: [{ name: '交接执行', type: 'bar', data: counts, itemStyle: { color: colors.brand }, barWidth: 20, borderRadius: [4, 4, 0, 0] }]
  4846. });
  4847. }
  4848. function loadTransfersTable(page) {
  4849. const wrap = page.querySelector('#tf-transfer-table-wrap');
  4850. const transfers = state.transfers || {};
  4851. const statusFilter = page.querySelector('#tf-filter-status').value;
  4852. const userFilter = page.querySelector('#tf-filter-user').value.trim().toLowerCase();
  4853. state.transfers.status = statusFilter;
  4854. state.transfers.user = userFilter;
  4855. saveFilters(TF_FILTERS_KEY, {
  4856. tablePage: state.transfers.tablePage,
  4857. status: statusFilter,
  4858. user: userFilter
  4859. });
  4860. let items = [
  4861. ...(transfers.previews || []).map(p => ({ ...p, type: 'preview' })),
  4862. ...(transfers.executions || []).map(e => ({ ...e, type: 'execution' }))
  4863. ];
  4864. let filtered = items.filter(item => {
  4865. if (statusFilter && item.status !== statusFilter) return false;
  4866. if (userFilter) {
  4867. const text = `${item.fromUserId || ''} ${item.toUserId || ''}`.toLowerCase();
  4868. if (!text.includes(userFilter)) return false;
  4869. }
  4870. return true;
  4871. });
  4872. filtered.sort((a, b) => (b.createdAt || b.executedAt || '').localeCompare(a.createdAt || a.executedAt || ''));
  4873. const pageSize = 20;
  4874. const currentPage = state.transfers.tablePage || 1;
  4875. const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
  4876. if (currentPage > totalPages) {
  4877. state.transfers.tablePage = totalPages;
  4878. saveFilters(TF_FILTERS_KEY, {
  4879. tablePage: state.transfers.tablePage,
  4880. status: state.transfers.status,
  4881. user: state.transfers.user
  4882. });
  4883. }
  4884. const start = (state.transfers.tablePage - 1) * pageSize;
  4885. const pageItems = filtered.slice(start, start + pageSize);
  4886. wrap.innerHTML = `
  4887. <div class="table-wrap">
  4888. <table>
  4889. <thead>
  4890. <tr>
  4891. <th>类型</th>
  4892. <th>原顾问</th>
  4893. <th>新顾问</th>
  4894. <th>记录数</th>
  4895. <th>状态</th>
  4896. <th>创建/执行时间</th>
  4897. <th>操作</th>
  4898. </tr>
  4899. </thead>
  4900. <tbody>
  4901. ${pageItems.length ? pageItems.map(item => `
  4902. <tr>
  4903. <td>${item.type === 'preview' ? '<span class="badge badge-warning">预览</span>' : '<span class="badge badge-info">执行</span>'}</td>
  4904. <td>${escapeHtml(item.fromUserId || '-')}</td>
  4905. <td>${escapeHtml(item.toUserId || '-')}</td>
  4906. <td>${formatNumber(item.itemCount || item.total || 0)}</td>
  4907. <td>${item.status === 'EXECUTED' ? '<span class="badge badge-success">已执行</span>' : '<span class="badge badge-warning">草稿</span>'}</td>
  4908. <td>${formatDateTime(item.createdAt || item.executedAt)}</td>
  4909. <td>
  4910. ${item.type === 'preview' ? `<button class="btn btn-sm" onclick="window.dashboardExecuteTransfer('${escapeHtml(item.filePath || '')}')">执行</button>` : `<button class="btn btn-sm btn-secondary" onclick="window.dashboardTransferDetail('${escapeHtml(item.filePath || '')}')">详情</button>`}
  4911. </td>
  4912. </tr>
  4913. `).join('') : renderEmptyTableRow(7, '暂无交接记录', '点击新建交接预览')}
  4914. </tbody>
  4915. </table>
  4916. </div>
  4917. `;
  4918. const pagination = page.querySelector('#tf-pagination');
  4919. pagination.innerHTML = renderPagination(state.transfers.tablePage, filtered.length, pageSize);
  4920. pagination.querySelectorAll('button').forEach(btn => {
  4921. btn.addEventListener('click', () => {
  4922. state.transfers.tablePage = Number(btn.dataset.page);
  4923. saveFilters(TF_FILTERS_KEY, {
  4924. tablePage: state.transfers.tablePage,
  4925. status: state.transfers.status,
  4926. user: state.transfers.user
  4927. });
  4928. loadTransfersTable(page);
  4929. });
  4930. });
  4931. }
  4932. function openTransferModal(page) {
  4933. const accountOptions = state.accounts.map(a => `<option value="${escapeHtml(a.userId || a.guid || '')}">${escapeHtml(a.nickname || a.userId || a.guid || '未知账号')}</option>`).join('');
  4934. const body = `
  4935. <div class="form-group" style="margin-bottom:16px">
  4936. <label>原顾问 userId</label>
  4937. <div style="display:flex;gap:8px">
  4938. <input id="new-tf-from" type="text" placeholder="fromUserId" style="flex:1" />
  4939. <select id="quick-tf-from" style="width:140px">
  4940. <option value="">快速选择</option>
  4941. ${accountOptions}
  4942. </select>
  4943. </div>
  4944. </div>
  4945. <div class="form-group" style="margin-bottom:16px">
  4946. <label>新顾问 userId</label>
  4947. <div style="display:flex;gap:8px">
  4948. <input id="new-tf-to" type="text" placeholder="toUserId" style="flex:1" />
  4949. <select id="quick-tf-to" style="width:140px">
  4950. <option value="">快速选择</option>
  4951. ${accountOptions}
  4952. </select>
  4953. </div>
  4954. </div>
  4955. <div class="form-group" style="margin-bottom:16px">
  4956. <label>选择要交接的客户(可选)</label>
  4957. <button type="button" class="customer-selector-trigger" id="btn-select-transfer">点击选择客户</button>
  4958. <div class="customer-selector-value" id="transfer-customer-selected"><span style="color:var(--text-muted);font-size:13px">未选择客户</span></div>
  4959. </div>
  4960. <div class="form-group">
  4961. <label>已确认群 roomIds(逗号分隔,可选)</label>
  4962. <input id="new-tf-rooms" type="text" placeholder="roomId1,roomId2" />
  4963. </div>
  4964. `;
  4965. openModal('新建交接预览', body, `
  4966. <button class="btn btn-secondary" onclick="window.dashboardCloseModal()">取消</button>
  4967. <button class="btn" id="btn-confirm-new-transfer">生成预览</button>
  4968. `);
  4969. const fromInput = document.getElementById('new-tf-from');
  4970. const toInput = document.getElementById('new-tf-to');
  4971. document.getElementById('quick-tf-from').addEventListener('change', (e) => { if (e.target.value) fromInput.value = e.target.value; });
  4972. document.getElementById('quick-tf-to').addEventListener('change', (e) => { if (e.target.value) toInput.value = e.target.value; });
  4973. let selectedCustomers = [];
  4974. document.getElementById('btn-select-transfer').addEventListener('click', async () => {
  4975. const result = await openCustomerSelector({ multiple: true, selectedIds: selectedCustomers.map(c => c.externalUserId), title: '选择要交接的客户' });
  4976. selectedCustomers = result.customers;
  4977. renderCustomerSelectorValue('transfer-customer-selected', selectedCustomers);
  4978. });
  4979. document.getElementById('btn-confirm-new-transfer').addEventListener('click', async () => {
  4980. const fromUserId = fromInput.value.trim();
  4981. const toUserId = toInput.value.trim();
  4982. const externalUserIds = selectedCustomers.map(c => c.externalUserId).filter(Boolean);
  4983. const roomIds = document.getElementById('new-tf-rooms').value.split(/[,,]/).map(s => s.trim()).filter(Boolean);
  4984. if (!fromUserId || !toUserId) {
  4985. toast('请输入原顾问和新顾问 userId', 'warning');
  4986. return;
  4987. }
  4988. if (!externalUserIds.length && !roomIds.length) {
  4989. toast('请选择客户或输入 roomIds', 'warning');
  4990. return;
  4991. }
  4992. try {
  4993. await api('POST', '/api/transfers/preview', { fromUserId, toUserId, externalUserIds, roomIds });
  4994. toast('预览已生成');
  4995. closeModal();
  4996. loadTransfersDashboard(page);
  4997. } catch (err) {
  4998. toast(err.message || '生成失败', 'error');
  4999. }
  5000. });
  5001. }
  5002. window.dashboardExecuteTransfer = (filePath) => {
  5003. const body = `
  5004. <div class="form-group" style="margin-bottom:16px">
  5005. <label>预览文件路径</label>
  5006. <input id="exec-tf-file" type="text" value="${escapeHtml(filePath || '')}" placeholder="留空使用最新预览" />
  5007. </div>
  5008. <div class="form-group" style="margin-bottom:16px">
  5009. <label>欢迎语</label>
  5010. <input id="exec-tf-welcome" type="text" value="您好,我是您的新服务顾问,后续由我为您服务。" />
  5011. </div>
  5012. <div class="checkbox-row">
  5013. <input id="exec-tf-remove" type="checkbox" />
  5014. <label for="exec-tf-remove">交接后移除原顾问</label>
  5015. </div>
  5016. `;
  5017. openModal('执行交接', body, `
  5018. <button class="btn btn-secondary" onclick="window.dashboardCloseModal()">取消</button>
  5019. <button class="btn" id="btn-confirm-exec-transfer">执行</button>
  5020. `);
  5021. document.getElementById('btn-confirm-exec-transfer').addEventListener('click', () => {
  5022. const previewFile = document.getElementById('exec-tf-file').value.trim() || undefined;
  5023. const welcomeText = document.getElementById('exec-tf-welcome').value.trim();
  5024. const removeOldBroker = document.getElementById('exec-tf-remove').checked;
  5025. closeModal();
  5026. startJob('execute-transfer', '/api/transfers/execute', { guid: getGuid(), previewFile, welcomeText, removeOldBroker }, () => loadTransfersDashboard(document.querySelector('#tf-dashboard')?.closest('.page')));
  5027. });
  5028. };
  5029. window.dashboardTransferDetail = async (filePath) => {
  5030. if (!filePath) return;
  5031. try {
  5032. openModal('交接详情', `
  5033. <div class="detail-panel">${escapeHtml(JSON.stringify({ filePath }, null, 2))}</div>
  5034. <div style="margin-top:12px">${renderFileLink(filePath, '下载文件')}</div>
  5035. `, `<button class="btn btn-secondary" onclick="window.dashboardCloseModal()">关闭</button>`);
  5036. } catch (err) {
  5037. toast(err.message || '查询失败', 'error');
  5038. }
  5039. };
  5040. // ==================== Init ====================
  5041. async function checkExistingLogin() {
  5042. try {
  5043. const res = await api('GET', '/api/status');
  5044. const login = res.data?.login;
  5045. if (login?.summary?.online && login?.data?.detail) {
  5046. const detail = login.data.detail;
  5047. const uid = login.data.uid || login.summary?.uid || '';
  5048. const guid = detail.guid || '';
  5049. const existing = state.accounts.find(a => (uid && a.uid === uid) || (guid && a.guid === guid) || a.userId === detail.userId);
  5050. if (!existing) {
  5051. addAccount({
  5052. uid,
  5053. guid,
  5054. userId: detail.userId || '',
  5055. nickname: detail.nickname || detail.userId || '',
  5056. corpName: detail.corpName || '',
  5057. corpId: detail.corpId || '',
  5058. online: true
  5059. });
  5060. } else {
  5061. existing.uid = uid || existing.uid;
  5062. if (guid && (!existing.guid || existing.guid === 'server-managed')) existing.guid = guid;
  5063. existing.online = true;
  5064. existing.nickname = existing.nickname || detail.nickname || detail.userId;
  5065. existing.corpName = existing.corpName || detail.corpName;
  5066. state.currentAccountId = existing.id;
  5067. saveAccounts();
  5068. }
  5069. if (!state.currentAccountId && state.accounts.length) {
  5070. state.currentAccountId = state.accounts[0].id;
  5071. saveAccounts();
  5072. }
  5073. updateAccountSwitcher();
  5074. } else {
  5075. // 账号不在线:更新账号状态,但不清空已同步记录
  5076. const acc = currentAccount();
  5077. const statusCode = login?.data?.statusCode;
  5078. if (acc) {
  5079. acc.online = false;
  5080. saveAccounts();
  5081. updateAccountSwitcher();
  5082. }
  5083. // 如果设备已配置且状态为 0(可免扫码),自动尝试恢复登录
  5084. if (acc && statusCode === 0 && !state.autoRecoverAttempted) {
  5085. state.autoRecoverAttempted = true;
  5086. const recovered = await attemptAutoRecover(true);
  5087. if (recovered) {
  5088. toast('账号已自动恢复登录');
  5089. }
  5090. }
  5091. }
  5092. } catch (err) {
  5093. console.error('检测已有登录失败', err);
  5094. try {
  5095. const fallback = await api('GET', '/api/agent/status', undefined, 4000);
  5096. const active = fallback.data?.account || {};
  5097. if (!active.uid) return;
  5098. let existing = state.accounts.find(account => account.uid === active.uid || (active.userId && account.userId === active.userId));
  5099. if (!existing) {
  5100. addAccount({
  5101. uid: active.uid,
  5102. guid: active.guid && active.guid !== 'server-managed' ? active.guid : '',
  5103. userId: active.userId || '',
  5104. nickname: active.nickname || active.userId || '',
  5105. corpName: active.corpName || '',
  5106. online: Boolean(active.online),
  5107. });
  5108. } else {
  5109. existing.uid = active.uid;
  5110. if (active.guid && active.guid !== 'server-managed') existing.guid = active.guid;
  5111. existing.userId = active.userId || existing.userId;
  5112. existing.nickname = active.nickname || existing.nickname || active.userId;
  5113. existing.corpName = active.corpName || existing.corpName || '';
  5114. existing.online = Boolean(active.online);
  5115. state.currentAccountId = existing.id;
  5116. saveAccounts();
  5117. updateAccountSwitcher();
  5118. }
  5119. } catch {}
  5120. }
  5121. }
  5122. async function hydrateCurrentAccountBinding() {
  5123. const account = currentAccount();
  5124. if (!account) return false;
  5125. try {
  5126. const result = await api('GET', '/api/agent/status');
  5127. const active = result.data?.account || {};
  5128. if (!account.uid && !active.uid) return false;
  5129. account.uid = account.uid || active.uid;
  5130. if (active.guid && active.guid !== 'server-managed' && (!account.guid || account.guid === 'server-managed')) account.guid = active.guid;
  5131. account.userId = account.userId || active.userId || '';
  5132. account.nickname = account.nickname || active.nickname || account.userId;
  5133. account.corpName = account.corpName || active.corpName || '';
  5134. saveAccounts();
  5135. updateAccountSwitcher();
  5136. return await switchAccount(account.id, { silent: true });
  5137. } catch (error) {
  5138. console.error('恢复当前账号绑定失败', error);
  5139. return false;
  5140. }
  5141. }
  5142. async function init() {
  5143. updateAccountSwitcher();
  5144. await checkExistingLogin();
  5145. await hydrateCurrentAccountBinding();
  5146. els.accountBtn.addEventListener('click', e => {
  5147. e.stopPropagation();
  5148. els.accountDropdown.classList.toggle('open');
  5149. });
  5150. document.addEventListener('click', e => {
  5151. if (!els.accountDropdown.contains(e.target) && e.target !== els.accountBtn) {
  5152. els.accountDropdown.classList.remove('open');
  5153. }
  5154. });
  5155. document.getElementById('btn-add-account').addEventListener('click', () => {
  5156. state.loginUid = createDeviceUid();
  5157. state.currentAccountId = null;
  5158. saveAccounts();
  5159. location.hash = '#groups';
  5160. renderPage();
  5161. });
  5162. els.menuBtn.addEventListener('click', () => {
  5163. els.sidebar.classList.toggle('open');
  5164. });
  5165. window.addEventListener('hashchange', renderPage);
  5166. document.addEventListener('click', e => {
  5167. const link = e.target.closest('.nav-item');
  5168. if (link) els.sidebar.classList.remove('open');
  5169. });
  5170. renderPage();
  5171. }
  5172. init();
  5173. })();