| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585358635873588358935903591359235933594359535963597359835993600360136023603360436053606360736083609361036113612361336143615361636173618361936203621362236233624362536263627362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651365236533654365536563657365836593660366136623663366436653666366736683669367036713672367336743675367636773678367936803681368236833684368536863687368836893690369136923693369436953696369736983699370037013702370337043705370637073708370937103711371237133714371537163717371837193720372137223723372437253726372737283729373037313732373337343735373637373738373937403741374237433744374537463747374837493750375137523753375437553756375737583759376037613762376337643765376637673768376937703771377237733774377537763777377837793780378137823783378437853786378737883789379037913792379337943795379637973798379938003801380238033804380538063807380838093810381138123813381438153816381738183819382038213822382338243825382638273828382938303831383238333834383538363837383838393840384138423843384438453846384738483849385038513852385338543855385638573858385938603861386238633864386538663867386838693870387138723873387438753876387738783879388038813882388338843885388638873888388938903891389238933894389538963897389838993900390139023903390439053906390739083909391039113912391339143915391639173918391939203921392239233924392539263927392839293930393139323933393439353936393739383939394039413942394339443945394639473948394939503951395239533954395539563957395839593960396139623963396439653966396739683969397039713972397339743975397639773978397939803981398239833984398539863987398839893990399139923993399439953996399739983999400040014002400340044005400640074008400940104011401240134014401540164017401840194020402140224023402440254026402740284029403040314032403340344035403640374038403940404041404240434044404540464047404840494050405140524053405440554056405740584059406040614062406340644065406640674068406940704071407240734074407540764077407840794080408140824083408440854086408740884089409040914092409340944095409640974098409941004101410241034104410541064107410841094110411141124113411441154116411741184119412041214122412341244125412641274128412941304131413241334134413541364137413841394140414141424143414441454146414741484149415041514152415341544155415641574158415941604161416241634164416541664167416841694170417141724173417441754176417741784179418041814182418341844185418641874188418941904191419241934194419541964197419841994200420142024203420442054206420742084209421042114212421342144215421642174218421942204221422242234224422542264227422842294230423142324233423442354236423742384239424042414242424342444245424642474248424942504251425242534254425542564257425842594260426142624263426442654266426742684269427042714272427342744275427642774278427942804281428242834284428542864287428842894290429142924293429442954296429742984299430043014302430343044305430643074308430943104311431243134314431543164317431843194320432143224323432443254326432743284329433043314332433343344335433643374338433943404341434243434344434543464347434843494350435143524353435443554356435743584359436043614362436343644365436643674368436943704371437243734374437543764377437843794380438143824383438443854386438743884389439043914392439343944395439643974398439944004401440244034404440544064407440844094410441144124413441444154416441744184419442044214422442344244425442644274428442944304431443244334434443544364437443844394440444144424443444444454446444744484449445044514452445344544455445644574458445944604461446244634464446544664467446844694470447144724473447444754476447744784479448044814482448344844485448644874488448944904491449244934494449544964497449844994500450145024503450445054506450745084509451045114512451345144515451645174518451945204521452245234524452545264527452845294530453145324533453445354536453745384539454045414542454345444545454645474548454945504551455245534554455545564557455845594560456145624563456445654566456745684569457045714572457345744575457645774578457945804581458245834584458545864587458845894590459145924593459445954596459745984599460046014602460346044605460646074608460946104611461246134614461546164617461846194620462146224623462446254626462746284629463046314632463346344635463646374638463946404641464246434644464546464647464846494650465146524653465446554656465746584659466046614662466346644665466646674668466946704671467246734674467546764677467846794680468146824683468446854686468746884689469046914692469346944695469646974698469947004701470247034704470547064707470847094710471147124713471447154716471747184719472047214722472347244725472647274728472947304731473247334734473547364737473847394740474147424743474447454746474747484749475047514752475347544755475647574758475947604761476247634764476547664767476847694770477147724773477447754776477747784779478047814782478347844785478647874788478947904791479247934794479547964797479847994800480148024803480448054806480748084809481048114812481348144815481648174818481948204821482248234824482548264827482848294830483148324833483448354836483748384839484048414842484348444845484648474848484948504851485248534854485548564857485848594860486148624863486448654866486748684869487048714872487348744875487648774878487948804881488248834884488548864887488848894890489148924893489448954896489748984899490049014902490349044905490649074908490949104911491249134914491549164917491849194920492149224923492449254926492749284929493049314932493349344935493649374938493949404941494249434944494549464947494849494950495149524953495449554956495749584959496049614962496349644965496649674968496949704971497249734974497549764977497849794980498149824983498449854986498749884989499049914992499349944995499649974998499950005001500250035004500550065007500850095010501150125013501450155016501750185019502050215022502350245025502650275028502950305031503250335034503550365037503850395040504150425043504450455046504750485049505050515052505350545055505650575058505950605061506250635064506550665067506850695070507150725073507450755076507750785079508050815082508350845085508650875088508950905091509250935094509550965097509850995100510151025103510451055106510751085109511051115112511351145115511651175118511951205121512251235124512551265127512851295130513151325133513451355136513751385139514051415142514351445145514651475148514951505151515251535154515551565157515851595160516151625163516451655166516751685169517051715172517351745175517651775178517951805181518251835184518551865187518851895190519151925193519451955196519751985199520052015202520352045205520652075208520952105211521252135214521552165217521852195220522152225223522452255226522752285229523052315232523352345235523652375238523952405241524252435244524552465247524852495250525152525253525452555256525752585259526052615262526352645265526652675268526952705271527252735274527552765277527852795280528152825283528452855286528752885289529052915292529352945295529652975298529953005301530253035304530553065307530853095310531153125313531453155316531753185319532053215322532353245325532653275328532953305331533253335334533553365337533853395340534153425343534453455346534753485349535053515352535353545355535653575358535953605361536253635364536553665367536853695370537153725373537453755376537753785379538053815382538353845385538653875388538953905391539253935394539553965397539853995400540154025403540454055406540754085409541054115412541354145415541654175418541954205421542254235424542554265427542854295430543154325433543454355436543754385439544054415442544354445445544654475448544954505451545254535454545554565457545854595460546154625463546454655466546754685469547054715472547354745475547654775478547954805481548254835484548554865487548854895490549154925493549454955496549754985499550055015502550355045505550655075508550955105511551255135514551555165517551855195520552155225523552455255526552755285529553055315532553355345535553655375538553955405541554255435544554555465547 |
- (function () {
- 'use strict';
- const API_BASE = '';
- const ACCOUNTS_KEY = 'qiwei-accounts';
- const CURRENT_ACCOUNT_KEY = 'qiwei-current-account';
- const CO_FILTERS_KEY = 'qiwei-co-filters';
- const PT_FILTERS_KEY = 'qiwei-pt-filters';
- const TF_FILTERS_KEY = 'qiwei-tf-filters';
- const GROUP_FILTERS_KEY = 'qiwei-group-filters';
- function loadFilters(key, defaults = {}) {
- try {
- return { ...defaults, ...JSON.parse(localStorage.getItem(key) || '{}') };
- } catch {
- return defaults;
- }
- }
- function saveFilters(key, filters) {
- localStorage.setItem(key, JSON.stringify(filters));
- }
- const state = {
- page: 'agent',
- accounts: JSON.parse(localStorage.getItem(ACCOUNTS_KEY) || '[]'),
- currentAccountId: localStorage.getItem(CURRENT_ACCOUNT_KEY) || null,
- status: null,
- groups: [],
- groupSync: {},
- groupFilters: loadFilters(GROUP_FILTERS_KEY, { keyword: '', status: '', showCustomerOnly: false }),
- groupKeywords: null,
- jobs: {},
- renderToken: 0,
- toastId: 0,
- modal: null,
- loginTimer: null,
- loginPhase: 'idle',
- loginUid: null,
- autoRecoverAttempted: false,
- preRecoveryPage: null,
- selectedGroupIds: new Set(),
- 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: '' }) },
- portraits: { lastPortrait: null, lastTags: [], personalLabels: [], lastTransferPreview: null, ...loadFilters(PT_FILTERS_KEY, { tablePage: 1, keyword: '', source: '', tag: '' }) },
- agent: { status: null, conversations: [], responseMonitor: null, selectedId: null, timer: null, loading: false, signature: null },
- skills: { registry: null, selectedPackageId: null, selectedSkillId: null, keyword: '' },
- 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' } } },
- transfers: { lastTransferPreview: null, ...loadFilters(TF_FILTERS_KEY, { tablePage: 1, status: '', user: '' }) }
- };
- const els = {
- content: document.getElementById('content'),
- pageTitle: document.getElementById('page-title'),
- nav: document.getElementById('nav'),
- sidebar: document.getElementById('sidebar'),
- menuBtn: document.getElementById('menu-btn'),
- toastContainer: document.getElementById('toast-container'),
- accountBtn: document.getElementById('account-btn'),
- accountDropdown: document.getElementById('account-dropdown'),
- accountName: document.getElementById('account-name'),
- accountDot: document.getElementById('account-dot'),
- accountList: document.getElementById('account-list')
- };
- function api(method, path, body, timeoutMs = 0) {
- const opts = { method, headers: {} };
- const controller = timeoutMs > 0 ? new AbortController() : null;
- const timeout = controller ? setTimeout(() => controller.abort(), timeoutMs) : null;
- if (controller) opts.signal = controller.signal;
- if (body !== undefined) {
- opts.headers['Content-Type'] = 'application/json; charset=utf-8';
- opts.body = JSON.stringify(body);
- }
- return fetch(`${API_BASE}${path}`, opts).then(async res => {
- const data = await res.json().catch(() => ({}));
- if (!res.ok) return Promise.reject(data);
- return data;
- }).catch(error => {
- if (error?.name === 'AbortError') throw new Error('状态检查超时,请稍后重试');
- throw error;
- }).finally(() => {
- if (timeout) clearTimeout(timeout);
- });
- }
- function currentAccount() {
- return state.accounts.find(a => a.id === state.currentAccountId) || null;
- }
- function getGuid() {
- const acc = currentAccount();
- return acc ? ((acc.guid && acc.guid !== 'server-managed' ? acc.guid : '') || acc.userId || '') : '';
- }
- function saveAccounts() {
- localStorage.setItem(ACCOUNTS_KEY, JSON.stringify(state.accounts));
- localStorage.setItem(CURRENT_ACCOUNT_KEY, state.currentAccountId || '');
- }
- function isGroupSynced(roomId) {
- return !!(state.groupSync[roomId] && state.groupSync[roomId].lastSyncAt);
- }
- function addAccount(account) {
- const existing = state.accounts.find(a =>
- (account.uid && a.uid === account.uid) ||
- ((account.guid || account.userId) && a.guid === account.guid && a.userId === account.userId)
- );
- if (existing) {
- Object.assign(existing, account, { id: existing.id });
- state.currentAccountId = existing.id;
- } else {
- account.id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
- state.accounts.push(account);
- state.currentAccountId = account.id;
- }
- saveAccounts();
- updateAccountSwitcher();
- }
- async function switchAccount(id, options = {}) {
- const account = state.accounts.find(item => item.id === id);
- if (!account) return false;
- if (!account.uid) {
- toast('该账号缺少 Fmode 设备绑定,请重新扫码登录后再切换', 'error');
- return false;
- }
- try {
- const result = await api('POST', '/api/accounts/switch', {
- uid: account.uid,
- guid: account.guid === 'server-managed' ? '' : account.guid,
- userId: account.userId,
- nickname: account.nickname,
- corpName: account.corpName,
- });
- const active = result.data?.account || {};
- state.currentAccountId = id;
- account.uid = active.uid || account.uid;
- account.guid = active.guid || account.guid;
- account.userId = active.userId || account.userId;
- account.nickname = account.nickname || active.nickname || account.userId;
- account.corpName = account.corpName || active.corpName || '';
- account.online = Boolean(active.online);
- state.agent.status = null;
- state.agent.conversations = [];
- state.agent.selectedId = null;
- state.agent.signature = null;
- saveAccounts();
- updateAccountSwitcher();
- renderPage();
- if (!options.silent) toast(result.assistantMessage || `已切换账号:${account.nickname}`);
- return true;
- } catch (error) {
- toast(error.message || '账号切换失败', 'error');
- return false;
- }
- }
- function removeAccount(id) {
- state.accounts = state.accounts.filter(a => a.id !== id);
- if (state.currentAccountId === id) {
- state.currentAccountId = state.accounts.length ? state.accounts[0].id : null;
- }
- saveAccounts();
- updateAccountSwitcher();
- renderPage();
- }
- function updateAccountSwitcher() {
- const acc = currentAccount();
- if (acc) {
- els.accountName.textContent = acc.nickname || acc.userId || '已登录账号';
- els.accountDot.className = 'account-dot ' + (acc.online ? 'online' : 'offline');
- } else {
- els.accountName.textContent = '未登录';
- els.accountDot.className = 'account-dot offline';
- }
- els.accountList.innerHTML = state.accounts.map(a => `
- <button class="account-option ${a.id === state.currentAccountId ? 'active' : ''}" data-id="${escapeHtml(a.id)}">
- <span class="status-dot ${a.online ? 'online' : 'offline'}"></span>
- <span class="account-info">
- <strong>${escapeHtml(a.nickname || a.userId || a.guid || '未知账号')}</strong>
- <span>${escapeHtml(a.corpName || '企业微信账号')}</span>
- </span>
- </button>
- `).join('');
- els.accountList.querySelectorAll('.account-option').forEach(btn => {
- btn.addEventListener('click', async () => {
- const switched = await switchAccount(btn.dataset.id);
- if (switched) els.accountDropdown.classList.remove('open');
- });
- });
- }
- async function refreshAgentAccountHeader() {
- if (currentAccount()) return;
- try {
- const result = await api('GET', '/api/agent/status');
- if (currentAccount() || !['skills', 'knowledge', 'customer-ops'].includes(state.page)) return;
- const account = result.data?.account || {};
- if (!account.configured) return;
- els.accountName.textContent = account.nickname || '企微账号';
- els.accountDot.className = `account-dot ${account.online ? 'online' : 'offline'}`;
- } catch {}
- }
- function toast(message, type = 'success') {
- const id = ++state.toastId;
- const el = document.createElement('div');
- el.className = `toast ${type}`;
- el.innerHTML = `<span style="flex:1;min-width:0;word-break:break-word">${escapeHtml(message)}</span>
- <button class="toast-close" aria-label="关闭" title="关闭">×</button>`;
- const closeBtn = el.querySelector('.toast-close');
- let timer = null;
- const remove = () => {
- if (timer) clearTimeout(timer);
- el.style.opacity = '0';
- el.style.transform = 'translateX(100%)';
- setTimeout(() => el.remove(), 300);
- };
- closeBtn.addEventListener('click', remove);
- els.toastContainer.appendChild(el);
- timer = setTimeout(remove, 4000);
- }
- function escapeHtml(str) {
- return String(str || '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
- }
- function formatNumber(n) {
- return Number(n || 0).toLocaleString();
- }
- function badge(status) {
- const map = {
- 'AUTO_CONFIRMED': ['badge-success', '自动确认'],
- 'CONFIRMED': ['badge-success', '已确认'],
- 'SUGGESTED': ['badge-warning', '建议确认'],
- 'IMPORTED': ['badge-default', '普通群'],
- 'REJECTED': ['badge-error', '已忽略']
- };
- const [cls, text] = map[status] || ['badge-default', status || '未知'];
- return `<span class="badge ${cls}">${escapeHtml(text)}</span>`;
- }
- const modalStack = [];
- function openModal(title, bodyHtml, actionsHtml, modalClass = '') {
- closeModal();
- const previousFocus = document.activeElement;
- const overlay = document.createElement('div');
- overlay.className = 'modal-overlay';
- overlay.innerHTML = `<div class="modal ${escapeHtml(modalClass)}"><div class="modal-title">${escapeHtml(title)}</div>${bodyHtml}<div class="modal-actions">${actionsHtml}</div></div>`;
- document.body.appendChild(overlay);
- modalStack.push(overlay);
- state.modal = overlay;
- const onKeydown = event => {
- if (event.key === 'Escape') closeModal();
- };
- document.addEventListener('keydown', onKeydown);
- overlay.__modalCleanup = () => document.removeEventListener('keydown', onKeydown);
- overlay.__previousFocus = previousFocus;
- overlay.addEventListener('click', e => {
- if (e.target === overlay) closeModal();
- });
- }
- function removeModalElement(overlay) {
- if (!overlay) return;
- overlay.__modalCleanup?.();
- overlay.remove();
- if (state.modal === overlay) state.modal = null;
- if (overlay.__previousFocus?.isConnected) overlay.__previousFocus.focus();
- }
- function closeModal() {
- const overlay = modalStack.pop() || state.modal;
- if (!overlay) return;
- if (typeof overlay.__cancelModal === 'function') {
- overlay.__cancelModal();
- return;
- }
- removeModalElement(overlay);
- }
- function closeAllModals() {
- while (modalStack.length) closeModal();
- state.modal = null;
- }
- window.dashboardCloseModal = closeModal;
- function businessModalField(field, index) {
- const id = `business-modal-field-${Date.now()}-${index}`;
- const value = field.value ?? '';
- const required = field.required ? 'required' : '';
- const readonly = field.readonly ? 'readonly' : '';
- const placeholder = field.placeholder ? `placeholder="${escapeHtml(field.placeholder)}"` : '';
- const autocomplete = field.autocomplete ? `autocomplete="${escapeHtml(field.autocomplete)}"` : 'autocomplete="off"';
- const step = field.step ? `step="${escapeHtml(field.step)}"` : '';
- let control;
- if (field.type === 'textarea') {
- control = `<textarea id="${id}" name="${escapeHtml(field.name)}" ${required} ${readonly} ${placeholder}>${escapeHtml(value)}</textarea>`;
- } else if (field.type === 'select') {
- 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>`;
- } else {
- const type = ['text', 'datetime-local', 'date', 'number', 'email'].includes(field.type) ? field.type : 'text';
- control = `<input id="${id}" name="${escapeHtml(field.name)}" type="${type}" value="${escapeHtml(value)}" ${required} ${readonly} ${placeholder} ${autocomplete} ${step} />`;
- }
- 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>`;
- }
- function showBusinessModal(options = {}) {
- closeModal();
- const previousFocus = document.activeElement;
- const overlay = document.createElement('div');
- const titleId = `business-modal-title-${Date.now()}`;
- const fields = Array.isArray(options.fields) ? options.fields : [];
- const shortcuts = Array.isArray(options.shortcuts) ? options.shortcuts : [];
- overlay.className = 'business-modal-backdrop';
- overlay.innerHTML = `<section class="business-modal" role="dialog" aria-modal="true" aria-labelledby="${titleId}">
- <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>
- <form class="business-modal-form" novalidate>
- <div class="business-modal-fields">${fields.map(businessModalField).join('')}</div>
- ${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>` : ''}
- ${options.warning ? `<div class="business-modal-live-warning"><i>!</i><div><strong>${escapeHtml(options.warningTitle || '真实业务操作')}</strong><span>${escapeHtml(options.warning)}</span></div></div>` : ''}
- <p class="business-modal-error" data-business-error role="alert" hidden></p>
- <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>
- </form>
- </section>`;
- document.body.appendChild(overlay);
- document.body.classList.add('business-modal-open');
- state.modal = overlay;
- overlay.__previousFocus = previousFocus;
- return new Promise(resolve => {
- let settled = false;
- const form = overlay.querySelector('.business-modal-form');
- const errorBox = overlay.querySelector('[data-business-error]');
- const focusableSelector = 'button:not([disabled]), input:not([disabled]):not([readonly]), textarea:not([disabled]):not([readonly]), select:not([disabled])';
- const finish = value => {
- if (settled) return;
- settled = true;
- document.body.classList.remove('business-modal-open');
- removeModalElement(overlay);
- resolve(value);
- };
- overlay.__cancelModal = () => finish(null);
- const showError = validation => {
- const detail = typeof validation === 'string' ? { message: validation } : validation || {};
- overlay.querySelectorAll('.business-modal-field.invalid').forEach(field => field.classList.remove('invalid'));
- errorBox.textContent = detail.message || '请检查填写内容';
- errorBox.hidden = false;
- if (detail.field) {
- const wrapper = overlay.querySelector(`[data-business-field="${detail.field}"]`);
- wrapper?.classList.add('invalid');
- wrapper?.querySelector('input, textarea, select')?.focus();
- }
- };
- overlay.querySelectorAll('[data-business-cancel]').forEach(button => button.addEventListener('click', () => finish(null)));
- overlay.addEventListener('click', event => {
- if (event.target === overlay) finish(null);
- });
- overlay.querySelectorAll('[data-business-shortcut]').forEach(button => button.addEventListener('click', () => {
- const shortcut = shortcuts[Number(button.dataset.businessShortcut)];
- const input = form.elements.namedItem(shortcut?.field || 'dueAt');
- if (!input || !shortcut) return;
- input.value = String(typeof shortcut.value === 'function' ? shortcut.value() : shortcut.value || '');
- input.dispatchEvent(new Event('input', { bubbles: true }));
- input.focus();
- }));
- form.addEventListener('input', event => {
- event.target.closest('.business-modal-field')?.classList.remove('invalid');
- errorBox.hidden = true;
- });
- form.addEventListener('submit', event => {
- event.preventDefault();
- const values = Object.fromEntries(new FormData(form).entries());
- const missing = fields.find(field => field.required && !String(values[field.name] || '').trim());
- if (missing) {
- showError({ field: missing.name, message: `请填写${missing.label}` });
- return;
- }
- const validation = typeof options.validate === 'function' ? options.validate(values) : null;
- if (validation) {
- showError(validation);
- return;
- }
- finish(values);
- });
- const onKeydown = event => {
- if (event.key === 'Escape') {
- event.preventDefault();
- finish(null);
- return;
- }
- if (event.key !== 'Tab') return;
- const focusable = [...overlay.querySelectorAll(focusableSelector)].filter(item => item.offsetParent !== null);
- if (!focusable.length) return;
- const first = focusable[0];
- const last = focusable[focusable.length - 1];
- if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
- else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
- };
- document.addEventListener('keydown', onKeydown);
- overlay.__modalCleanup = () => document.removeEventListener('keydown', onKeydown);
- requestAnimationFrame(() => (overlay.querySelector('.business-modal-field input:not([readonly]), .business-modal-field textarea:not([readonly]), .business-modal-field select') || overlay.querySelector(focusableSelector))?.focus());
- });
- }
- function localDeadlineValue(dayOffset, hour = 18, minute = 0) {
- const date = new Date();
- date.setDate(date.getDate() + dayOffset);
- date.setHours(hour, minute, 0, 0);
- 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')}`;
- }
- function deadlineInputValue(value) {
- const text = String(value || '').trim();
- const direct = text.match(/^(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})[ T](\d{1,2}):(\d{1,2})(?::\d{1,2})?$/);
- 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')}`;
- const date = new Date(text);
- if (Number.isNaN(date.getTime())) return '';
- 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')}`;
- }
- async function showOfficialTodoSyncModal(task = {}) {
- const currentOwner = task.owner && task.owner !== '待分配' ? task.owner : '';
- const values = await showBusinessModal({
- eyebrow: 'OFFICIAL TODO · LIVE',
- title: '同步至企微待办',
- description: '确认负责人和截止时间后,将本地跟进任务同步到企业微信。',
- fields: [
- { name: 'title', label: '待办内容', type: 'text', value: task.title || '客户跟进', readonly: true },
- { name: 'owner', label: '企微内部负责人', type: 'text', value: currentOwner, placeholder: '输入姓名或别名', required: true, help: '系统会自动匹配唯一的企微成员' },
- { name: 'dueAt', label: '截止时间', type: 'datetime-local', value: deadlineInputValue(task.dueAt) || localDeadlineValue(1), required: true, step: '60', help: '直接选择或使用下方快捷时间,无需手写格式' },
- ],
- shortcutLabel: '常用截止时间',
- shortcuts: [
- { label: '今天 18:00', field: 'dueAt', value: () => localDeadlineValue(0) },
- { label: '明天 18:00', field: 'dueAt', value: () => localDeadlineValue(1) },
- { label: '三天后', field: 'dueAt', value: () => localDeadlineValue(3) },
- { label: '一周后', field: 'dueAt', value: () => localDeadlineValue(7) },
- ],
- warningTitle: '将写入真实企业微信',
- warning: '点击确认后会创建真实企微待办,并与当前本地任务建立关联。',
- confirmText: '创建企微待办',
- validate: values => String(values.owner || '').trim() ? null : { field: 'owner', message: '请填写企微内部负责人' },
- });
- if (!values) return null;
- return { owner: String(values.owner).trim(), dueAt: String(values.dueAt).replace('T', ' ').slice(0, 16) };
- }
- async function copyText(value) {
- const text = String(value || '');
- try {
- await navigator.clipboard.writeText(text);
- } catch {
- const textarea = document.createElement('textarea');
- textarea.value = text;
- textarea.style.position = 'fixed';
- textarea.style.opacity = '0';
- document.body.appendChild(textarea);
- textarea.select();
- document.execCommand('copy');
- textarea.remove();
- }
- }
- function openAgentSessionGuide(guide = {}) {
- const ready = Boolean(guide.ready);
- openModal(
- '客户 Claude Code 会话',
- `<div class="agent-session-modal">
- <div class="agent-session-modal-status ${ready ? 'ready' : ''}"><strong>${ready ? '已检测到客户专属 Session' : 'Session 等待首次运行'}</strong><span>${escapeHtml(guide.guidance || '')}</span></div>
- <dl><div><dt>会话名称</dt><dd>${escapeHtml(guide.displayName || '首次运行后生成')}</dd></div><div><dt>打开位置</dt><dd>${escapeHtml(guide.openLocation || 'Fmode Studio → 当前企微项目 → 终端')}</dd></div></dl>
- <label>安全打开命令</label>
- <pre>${escapeHtml(guide.openCommand || 'npm run agent:session:list')}</pre>
- <p>打开时会自动 fork 审阅副本。你可以查看历史、模型思考和工具记录,也可以继续追问;生产客户 Session 不会被修改。</p>
- </div>`,
- `<button class="btn btn-secondary" data-session-close>关闭</button><button class="btn" data-session-copy ${guide.openCommand ? '' : 'disabled'}>复制打开命令</button>`
- );
- state.modal.querySelector('[data-session-close]')?.addEventListener('click', closeModal);
- state.modal.querySelector('[data-session-copy]')?.addEventListener('click', async () => {
- await copyText(guide.openCommand || '');
- toast('打开命令已复制');
- });
- }
- function startJob(name, path, body, onDone, trackerContainerId) {
- const btn = document.querySelector(`[data-job="${name}"]`);
- if (btn) {
- btn.disabled = true;
- btn.dataset.original = btn.innerHTML;
- btn.innerHTML = `<span class="spinner"></span> 执行中…`;
- }
- api('POST', path, body)
- .then(res => {
- const id = res.data?.jobId;
- if (!id) {
- if (btn) {
- btn.disabled = false;
- btn.innerHTML = btn.dataset.original;
- }
- return;
- }
- if (trackerContainerId) {
- const container = document.getElementById(trackerContainerId);
- if (container) container.innerHTML = renderJobTracker(name, id);
- }
- state.jobs[id] = { name, onDone };
- pollJob(id);
- })
- .catch(err => {
- if (btn) {
- btn.disabled = false;
- btn.innerHTML = btn.dataset.original;
- }
- toast(err.message || '启动任务失败', 'error');
- });
- }
- function pollJob(id) {
- const interval = setInterval(() => {
- api('GET', `/api/jobs/${id}`)
- .then(res => {
- const job = res.data;
- updateJobUI(id, job);
- if (job.status === 'done' || job.status === 'error') {
- clearInterval(interval);
- finishJob(id, job);
- }
- })
- .catch(() => {
- clearInterval(interval);
- });
- }, 1500);
- }
- function updateJobUI(id, job) {
- const wrap = document.getElementById(`job-${id}`);
- if (!wrap) return;
- const fill = wrap.querySelector('.progress-bar-fill');
- if (fill) fill.style.width = `${job.progress}%`;
- const text = wrap.querySelector('.job-status-text');
- if (text) text.textContent = job.status === 'running' ? '执行中…' : (job.status === 'done' ? '已完成' : '执行失败');
- }
- function finishJob(id, job) {
- const record = state.jobs[id];
- if (record) {
- const btn = document.querySelector(`[data-job="${record.name}"]`);
- if (btn) {
- btn.disabled = false;
- btn.innerHTML = btn.dataset.original;
- }
- const isError = job.status === 'error' || job.result?.status === 'error';
- if (job.status === 'done' && !isError) {
- toast(job.result?.assistantMessage || '任务完成');
- if (record.onDone) record.onDone(job.result);
- } else {
- toast(job.error || job.result?.assistantMessage || '任务失败', 'error');
- }
- delete state.jobs[id];
- // 清理该任务对应的进度条
- setTimeout(() => {
- const tracker = document.getElementById(`job-${id}`);
- if (tracker) tracker.remove();
- }, 2000);
- }
- }
- function renderJobTracker(name, id, title) {
- const label = title || (name === 'batch-portraits' ? '正在调用 LLM 生成画像…' : '执行中…');
- return `<div class="progress-wrap" id="job-${id}">
- <div style="font-size:13px;color:var(--text-muted);margin-bottom:6px">${escapeHtml(label)}</div>
- <div class="progress-bar"><div class="progress-bar-fill" style="width:0%"></div></div>
- <div class="job-status-text" style="font-size:12px;color:var(--text-muted);margin-top:6px">执行中…</div>
- </div>`;
- }
- // ==================== Dashboard 辅助函数 ====================
- const dashboardCharts = new Map();
- let dashboardRefreshTimer = null;
- function disposeDashboard() {
- if (dashboardRefreshTimer) {
- clearInterval(dashboardRefreshTimer);
- dashboardRefreshTimer = null;
- }
- for (const chart of dashboardCharts.values()) {
- try { chart.dispose(); } catch {}
- }
- dashboardCharts.clear();
- }
- function showLoading(container, type = 'overlay') {
- if (!container) return;
- container.classList.add('loading-overlay');
- if (type === 'overlay') {
- container.innerHTML = '<div class="loading-spinner"></div>';
- } else if (type === 'list') {
- container.innerHTML = `
- <div class="skeleton-list">
- <div class="skeleton skeleton-line" style="width:100%"></div>
- <div class="skeleton skeleton-line" style="width:92%"></div>
- <div class="skeleton skeleton-line" style="width:96%"></div>
- <div class="skeleton skeleton-line" style="width:88%"></div>
- </div>`;
- } else if (type === 'table') {
- container.innerHTML = `
- <div class="table-skeleton">
- <div class="skeleton skeleton-line"></div>
- <div class="skeleton skeleton-line" style="opacity:0.85"></div>
- <div class="skeleton skeleton-line" style="opacity:0.7"></div>
- <div class="skeleton skeleton-line" style="opacity:0.55"></div>
- <div class="skeleton skeleton-line" style="opacity:0.4"></div>
- </div>`;
- }
- }
- function hideLoading(container) {
- if (!container) return;
- container.classList.remove('loading-overlay');
- }
- function showError(container, message, onRetry) {
- if (!container) return;
- container.innerHTML = `
- <div class="error-state">
- <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>
- <div class="error-state-title">加载失败</div>
- <div class="error-state-desc">${escapeHtml(message || '请稍后重试')}</div>
- ${onRetry ? '<button class="btn btn-sm" id="btn-retry-loading">重试</button>' : ''}
- </div>`;
- if (onRetry) {
- container.querySelector('#btn-retry-loading').addEventListener('click', onRetry);
- }
- }
- function renderEmptyChart(container, title = '暂无数据') {
- if (!container) return;
- container.innerHTML = `
- <div class="empty-chart">
- <svg viewBox="0 0 24 24" style="width:48px;height:48px;fill:var(--text-muted);opacity:0.5;margin-bottom:10px">
- <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"/>
- </svg>
- <div>${escapeHtml(title)}</div>
- </div>`;
- }
- function isEmptyChartData(option) {
- if (!option || !option.series) return true;
- const series = Array.isArray(option.series) ? option.series : [option.series];
- if (!series.length) return true;
- return series.every(s => {
- if (!s.data || !s.data.length) return true;
- return s.data.every(item => {
- const value = typeof item === 'object' ? (item.value ?? 0) : (item ?? 0);
- return Number(value) === 0;
- });
- });
- }
- function initEChart(containerId, option) {
- const container = document.getElementById(containerId);
- if (!container) return null;
- if (typeof echarts === 'undefined') {
- renderEmptyChart(container, '图表库加载失败,请检查网络');
- return null;
- }
- if (isEmptyChartData(option)) {
- renderEmptyChart(container, '暂无数据');
- return null;
- }
- if (dashboardCharts.has(containerId)) {
- try { dashboardCharts.get(containerId).dispose(); } catch {}
- }
- const chart = echarts.init(container);
- chart.setOption(option, true);
- requestAnimationFrame(() => {
- try { chart.resize(); } catch {}
- });
- dashboardCharts.set(containerId, chart);
- return chart;
- }
- function getChartColors() {
- if (!getChartColors.cache) {
- const root = getComputedStyle(document.documentElement);
- const read = (name, fallback) => root.getPropertyValue(name).trim() || fallback;
- getChartColors.cache = {
- brand: read('--brand', '#fa8c16'),
- success: read('--success', '#52c41a'),
- error: read('--error', '#ff4d4f'),
- warning: read('--warning', '#faad14'),
- info: read('--info', '#1890ff'),
- muted: read('--text-muted', '#8c8c8c'),
- textPrimary: read('--text-primary', '#1f1f1f'),
- purple: '#722ed1',
- cyan: '#13c2c2'
- };
- }
- return getChartColors.cache;
- }
- function hexToRgba(hex, alpha) {
- const clean = hex.replace('#', '');
- if (clean.length === 3) {
- const r = parseInt(clean[0] + clean[0], 16);
- const g = parseInt(clean[1] + clean[1], 16);
- const b = parseInt(clean[2] + clean[2], 16);
- return `rgba(${r}, ${g}, ${b}, ${alpha})`;
- }
- const r = parseInt(clean.slice(0, 2), 16);
- const g = parseInt(clean.slice(2, 4), 16);
- const b = parseInt(clean.slice(4, 6), 16);
- return `rgba(${r}, ${g}, ${b}, ${alpha})`;
- }
- function resizeDashboardCharts() {
- for (const chart of dashboardCharts.values()) {
- try { chart.resize(); } catch {}
- }
- }
- window.addEventListener('resize', resizeDashboardCharts);
- document.addEventListener('keydown', (e) => {
- if (e.key === 'Escape' && modalStack.length) {
- closeModal();
- }
- });
- function renderKpiRow(items) {
- return items.map((item, i) => `
- <div class="kpi-card" style="animation:fadeInUp 0.4s ease ${i * 0.05}s both">
- <div class="kpi-label">${escapeHtml(item.label)}</div>
- <div class="kpi-value">${escapeHtml(String(item.value))}</div>
- ${item.sub ? `<div class="kpi-sub">${escapeHtml(item.sub)}</div>` : ''}
- ${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>` : ''}
- </div>
- `).join('');
- }
- function renderChartCard(id, title, subtitle, heightClass = '') {
- return `<div class="chart-card ${heightClass}">
- <h3 class="chart-title">
- <span>${escapeHtml(title)}</span>
- ${subtitle ? `<span class="chart-subtitle">${escapeHtml(subtitle)}</span>` : ''}
- </h3>
- <div class="chart-container ${heightClass}" id="${id}"></div>
- </div>`;
- }
- function renderOperationToolbar(leftHtml, rightHtml) {
- return `<div class="operation-toolbar">
- <div class="toolbar-left">${leftHtml}</div>
- <div class="toolbar-right">${rightHtml}</div>
- </div>`;
- }
- function renderEmptyTableRow(colspan, title = '暂无数据', desc = '') {
- return `
- <tr>
- <td colspan="${colspan}" style="padding:0;border-bottom:none">
- <div class="empty-state" style="padding:32px 20px">
- <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>
- <div class="empty-state-title">${escapeHtml(title)}</div>
- ${desc ? `<div class="empty-state-desc">${escapeHtml(desc)}</div>` : ''}
- </div>
- </td>
- </tr>`;
- }
- function renderDataTableCard(title, subtitle, tableHtml, metaHtml = '') {
- return `<div class="data-table-card">
- <div class="card-header">
- <div>
- <h3 class="card-title">${escapeHtml(title)}</h3>
- ${subtitle ? `<div class="card-subtitle">${escapeHtml(subtitle)}</div>` : ''}
- </div>
- </div>
- ${metaHtml ? `<div class="table-meta">${metaHtml}</div>` : ''}
- ${tableHtml}
- </div>`;
- }
- function renderPagination(current, total, pageSize, onPageChange) {
- const totalPages = Math.max(1, Math.ceil(total / pageSize));
- return `<div class="pagination">
- <span>共 ${formatNumber(total)} 条,第 ${current} / ${totalPages} 页</span>
- <button ${current <= 1 ? 'disabled' : ''} data-page="${current - 1}">上一页</button>
- <button ${current >= totalPages ? 'disabled' : ''} data-page="${current + 1}">下一页</button>
- </div>`;
- }
- async function openCustomerSelector(options = {}) {
- const { multiple = true, selectedIds = [], title = '选择客户', confirmText = '确定' } = options;
- const selected = new Set(selectedIds.filter(Boolean));
- const loadedCustomers = new Map();
- let query = '';
- let portraitFilter = '';
- let sortBy = 'default';
- let currentPage = 1;
- const pageSize = 20;
- let total = 0;
- let totalPages = 1;
- let isLoading = false;
- let searchTimer = null;
- async function loadCustomers(resetPage = false) {
- if (isLoading) return;
- isLoading = true;
- if (resetPage) currentPage = 1;
- const offset = (currentPage - 1) * pageSize;
- const listEl = document.getElementById('customer-selector-list');
- if (listEl) listEl.innerHTML = '<div class="customer-selector-empty">加载中…</div>';
- try {
- const params = new URLSearchParams();
- params.set('keyword', query);
- params.set('limit', String(pageSize));
- params.set('offset', String(offset));
- params.set('sortBy', sortBy);
- if (portraitFilter) params.set('hasPortrait', portraitFilter === 'true' ? 'true' : 'false');
- const res = await api('GET', `/api/dashboard/customers/search?${params.toString()}`);
- const data = res.data || {};
- const items = data.customers || [];
- total = data.total || 0;
- totalPages = Math.max(1, Math.ceil(total / pageSize));
- if (currentPage > totalPages) currentPage = totalPages || 1;
- loadedCustomers.clear();
- for (const c of items) {
- if (c.externalUserId) loadedCustomers.set(c.externalUserId, c);
- }
- renderList();
- renderPagination();
- } catch (err) {
- toast('加载客户列表失败', 'error');
- } finally {
- isLoading = false;
- }
- }
- function toggleSelected(id) {
- if (multiple) {
- if (selected.has(id)) selected.delete(id); else selected.add(id);
- } else {
- selected.clear();
- selected.add(id);
- }
- renderList();
- }
- function renderList() {
- const listEl = document.getElementById('customer-selector-list');
- const countEl = document.getElementById('customer-selector-count');
- if (!listEl) return;
- if (countEl) countEl.textContent = `已选 ${selected.size} / 共 ${total} 位客户`;
- const items = Array.from(loadedCustomers.values());
- if (!items.length) {
- listEl.innerHTML = '<div class="customer-selector-empty">无匹配客户</div>';
- return;
- }
- listEl.innerHTML = items.map(c => {
- const isSelected = selected.has(c.externalUserId);
- const portraitBadge = c.hasPortrait ? '<span class="badge badge-info" style="margin-left:6px">已画像</span>' : '';
- return `
- <div class="customer-selector-item ${isSelected ? 'selected' : ''}" data-id="${escapeHtml(c.externalUserId || '')}">
- <input type="${multiple ? 'checkbox' : 'radio'}" ${isSelected ? 'checked' : ''} />
- <div class="customer-selector-info">
- <div class="customer-selector-name">${escapeHtml(c.name || '未命名客户')}${portraitBadge}</div>
- <div class="customer-selector-phone">${escapeHtml(c.phone || '-')}${c.externalUserId ? ` · ${escapeHtml(c.externalUserId)}` : ''}</div>
- </div>
- </div>
- `;
- }).join('');
- listEl.querySelectorAll('.customer-selector-item').forEach(item => {
- item.addEventListener('click', (e) => {
- if (e.target.tagName === 'INPUT') return;
- toggleSelected(item.dataset.id);
- });
- const input = item.querySelector('input');
- input.addEventListener('change', () => {
- const id = item.dataset.id;
- if (multiple) {
- if (input.checked) selected.add(id); else selected.delete(id);
- } else {
- selected.clear();
- if (input.checked) selected.add(id);
- }
- renderList();
- });
- });
- }
- function renderPagination() {
- const wrap = document.getElementById('customer-selector-pagination');
- if (!wrap) return;
- if (totalPages <= 1) {
- wrap.innerHTML = '';
- return;
- }
- const pages = [];
- const maxButtons = 5;
- let start = Math.max(1, currentPage - Math.floor(maxButtons / 2));
- let end = Math.min(totalPages, start + maxButtons - 1);
- if (end - start + 1 < maxButtons) start = Math.max(1, end - maxButtons + 1);
- for (let i = start; i <= end; i++) {
- pages.push(`<button class="btn btn-sm ${i === currentPage ? 'btn-primary' : 'btn-secondary'}" data-page="${i}">${i}</button>`);
- }
- wrap.innerHTML = `
- <button class="btn btn-sm btn-secondary" data-page="prev" ${currentPage === 1 ? 'disabled' : ''}>上一页</button>
- ${pages.join('')}
- <button class="btn btn-sm btn-secondary" data-page="next" ${currentPage === totalPages ? 'disabled' : ''}>下一页</button>
- <span style="font-size:12px;color:var(--text-muted);margin-left:8px">${currentPage} / ${totalPages} 页</span>
- `;
- wrap.querySelectorAll('button').forEach(btn => {
- btn.addEventListener('click', () => {
- let page = btn.dataset.page;
- if (page === 'prev') page = currentPage - 1;
- else if (page === 'next') page = currentPage + 1;
- else page = Number(page);
- if (page >= 1 && page <= totalPages && page !== currentPage) {
- currentPage = page;
- loadCustomers(false);
- }
- });
- });
- }
- const body = `
- <div class="form-group" style="margin-bottom:0">
- <div class="customer-selector-filters">
- <input id="customer-selector-search" class="customer-selector-search" type="text" placeholder="搜索姓名、手机号或 externalUserId" />
- <select id="customer-selector-portrait" class="customer-selector-filter">
- <option value="">全部画像状态</option>
- <option value="true">已生成画像</option>
- <option value="false">未生成画像</option>
- </select>
- <select id="customer-selector-sort" class="customer-selector-filter">
- <option value="default">默认排序</option>
- <option value="lastActive">最近活跃</option>
- <option value="portraitDesc">已画像优先</option>
- <option value="portraitAsc">未画像优先</option>
- <option value="nameAsc">姓名 A-Z</option>
- </select>
- </div>
- <div class="customer-selector-count" id="customer-selector-count">已选 0 / 共 0 位客户</div>
- <div class="customer-selector-list" id="customer-selector-list">
- <div class="customer-selector-empty">加载中…</div>
- </div>
- <div class="customer-selector-pagination" id="customer-selector-pagination"></div>
- </div>
- `;
- return new Promise((resolve) => {
- openModal(title, body, `
- <button class="btn btn-secondary" onclick="window.dashboardCloseModal()">取消</button>
- <button class="btn" id="btn-confirm-customer-selector">${escapeHtml(confirmText)}</button>
- `, 'customer-selector-modal');
- loadCustomers(true);
- const searchEl = document.getElementById('customer-selector-search');
- const portraitEl = document.getElementById('customer-selector-portrait');
- const sortEl = document.getElementById('customer-selector-sort');
- const scheduleReload = () => {
- if (searchTimer) clearTimeout(searchTimer);
- searchTimer = setTimeout(() => {
- query = searchEl.value.trim();
- portraitFilter = portraitEl.value;
- sortBy = sortEl.value;
- loadCustomers(true);
- }, 300);
- };
- searchEl.addEventListener('input', scheduleReload);
- portraitEl.addEventListener('change', scheduleReload);
- sortEl.addEventListener('change', scheduleReload);
- document.getElementById('btn-confirm-customer-selector').addEventListener('click', () => {
- const selectedCustomers = Array.from(loadedCustomers.values()).filter(c => selected.has(c.externalUserId));
- const externalUserIds = selectedCustomers.map(c => c.externalUserId).filter(Boolean);
- closeModal();
- resolve({ externalUserIds, customers: selectedCustomers });
- });
- });
- }
- function renderCustomerSelectorValue(containerId, customers) {
- const container = document.getElementById(containerId);
- if (!container) return;
- if (!customers.length) {
- container.innerHTML = '<span style="color:var(--text-muted);font-size:13px">未选择客户</span>';
- return;
- }
- container.innerHTML = customers.map(c => `
- <span class="tag">${escapeHtml(c.name || '未命名')} ${c.phone ? `(${escapeHtml(c.phone)})` : ''}</span>
- `).join('');
- }
- function formatDateTime(iso) {
- if (!iso) return '-';
- try {
- const d = new Date(iso);
- 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')}`;
- } catch {
- return iso;
- }
- }
- function formatDate(iso) {
- if (!iso) return '-';
- try {
- const d = new Date(iso);
- return `${d.getMonth() + 1}/${d.getDate()}`;
- } catch {
- return iso;
- }
- }
- function last7Days() {
- const days = [];
- for (let i = 6; i >= 0; i--) {
- const d = new Date();
- d.setDate(d.getDate() - i);
- days.push(d.toISOString().slice(0, 10));
- }
- return days;
- }
- function countByDay(items, dateField) {
- const days = last7Days();
- const counts = Object.fromEntries(days.map(d => [d, 0]));
- for (const item of items) {
- const date = item[dateField] ? item[dateField].slice(0, 10) : null;
- if (date && counts[date] !== undefined) counts[date]++;
- }
- return days.map(d => counts[d]);
- }
- function setActiveNav(page) {
- state.page = page;
- document.querySelectorAll('.nav-item').forEach(el => el.classList.remove('active'));
- const active = document.querySelector(`.nav-item[data-page="${page}"]`);
- if (active) active.classList.add('active');
- }
- function renderPage() {
- state.renderToken += 1;
- closeAllModals();
- const page = location.hash.slice(1) || 'agent';
- const loggedIn = !!currentAccount();
- els.nav.classList.toggle('nav-locked', !loggedIn);
- setActiveNav(page);
- updateAccountSwitcher();
- els.content.innerHTML = '';
- if (state.agent.timer) {
- clearInterval(state.agent.timer);
- state.agent.timer = null;
- }
- disposeDashboard();
- const acc = currentAccount();
- if (!acc && !['agent', 'skills', 'knowledge', 'customer-ops'].includes(page)) {
- renderLoginPage();
- return;
- }
- if (page === 'agent') renderAgentPage();
- else if (page === 'skills') renderSkillsPage();
- else if (page === 'knowledge') renderKnowledgePage();
- else if (page === 'status') renderStatusPage();
- else if (page === 'customer-ops') renderCustomerOpsPage();
- else if (page === 'portraits') renderPortraitsPage();
- else if (page === 'transfers') renderTransfersPage();
- else renderGroupsPage();
- }
- // ==================== Unified Skill Center ====================
- function markdownInline(value) {
- return escapeHtml(value)
- .replace(/`([^`]+)`/g, '<code>$1</code>')
- .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
- .replace(/\*([^*]+)\*/g, '<em>$1</em>');
- }
- function renderMarkdown(content) {
- const source = String(content || '').replace(/^---\s*\r?\n[\s\S]*?\r?\n---\s*/, '').trim();
- const lines = source.split(/\r?\n/);
- const html = [];
- let listType = '';
- let inCode = false;
- let codeLines = [];
- const closeList = () => {
- if (!listType) return;
- html.push(`</${listType}>`);
- listType = '';
- };
- for (const line of lines) {
- if (line.trim().startsWith('```')) {
- closeList();
- if (inCode) {
- html.push(`<pre><code>${escapeHtml(codeLines.join('\n'))}</code></pre>`);
- codeLines = [];
- inCode = false;
- } else {
- inCode = true;
- }
- continue;
- }
- if (inCode) { codeLines.push(line); continue; }
- const heading = line.match(/^(#{1,4})\s+(.+)$/);
- if (heading) {
- closeList();
- const level = Math.min(4, heading[1].length + 1);
- html.push(`<h${level}>${markdownInline(heading[2])}</h${level}>`);
- continue;
- }
- const unordered = line.match(/^\s*[-*]\s+(.+)$/);
- if (unordered) {
- if (listType !== 'ul') { closeList(); listType = 'ul'; html.push('<ul>'); }
- html.push(`<li>${markdownInline(unordered[1])}</li>`);
- continue;
- }
- const ordered = line.match(/^\s*\d+[.)]\s+(.+)$/);
- if (ordered) {
- if (listType !== 'ol') { closeList(); listType = 'ol'; html.push('<ol>'); }
- html.push(`<li>${markdownInline(ordered[1])}</li>`);
- continue;
- }
- closeList();
- if (!line.trim()) continue;
- if (line.startsWith('>')) html.push(`<blockquote>${markdownInline(line.replace(/^>\s?/, ''))}</blockquote>`);
- else if (/^\|.*\|$/.test(line.trim())) html.push(`<div class="md-table-line">${markdownInline(line.trim())}</div>`);
- else html.push(`<p>${markdownInline(line)}</p>`);
- }
- if (inCode) html.push(`<pre><code>${escapeHtml(codeLines.join('\n'))}</code></pre>`);
- closeList();
- return html.join('');
- }
- function skillStatusLabel(status) {
- return ({ running: '主运行包', connected: '已接入', merged: '已合并', integrated: '已集成', 'data-connected': '数据已接入' })[status] || status;
- }
- function selectedSkillPackages() {
- const registry = state.skills.registry;
- if (!registry) return [];
- const keyword = state.skills.keyword.trim().toLowerCase();
- return registry.packages.map(pkg => ({
- ...pkg,
- skills: pkg.skills.filter(skill => !keyword || `${skill.name} ${skill.title} ${skill.description} ${(skill.tags || []).join(' ')}`.toLowerCase().includes(keyword)),
- })).filter(pkg => !keyword || pkg.skills.length);
- }
- function renderSkillsWorkspace(page) {
- const registry = state.skills.registry || { packages: [], summary: {} };
- const packages = selectedSkillPackages();
- const selectedPackage = packages.find(item => item.id === state.skills.selectedPackageId) || packages[0] || null;
- if (selectedPackage) state.skills.selectedPackageId = selectedPackage.id;
- const selectedSkill = selectedPackage?.skills.find(item => item.id === state.skills.selectedSkillId)
- || selectedPackage?.skills[0]
- || packages.flatMap(item => item.skills)[0]
- || null;
- if (selectedSkill) state.skills.selectedSkillId = selectedSkill.id;
- page.innerHTML = `
- <section class="workspace-hero skills-hero">
- <div><span>UNIFIED QIWEI CAPABILITY HUB</span><h2>技能中心</h2><p>把源码版、OpenClaw、Agent Workbench 和花巷房源匹配统一到一个 4320 工作台。</p></div>
- <div class="workspace-stat-row">
- <div><strong>${registry.summary.packageCount || 0}</strong><span>能力来源</span></div>
- <div><strong>${registry.summary.skillCount || 0}</strong><span>技能实例</span></div>
- <div><strong>${registry.summary.uniqueCapabilityCount || 0}</strong><span>去重能力</span></div>
- </div>
- </section>
- <section class="skill-toolbar">
- <div><strong>统一能力目录</strong><span>已有操作页直接跳转;其余 Skill/MCP 能力展示完整说明和调用边界。</span></div>
- <input id="skill-search" type="search" value="${escapeHtml(state.skills.keyword)}" placeholder="搜索技能、能力或触发语" />
- </section>
- <section class="skill-hub-layout">
- <aside class="skill-package-list">
- ${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>'}
- </aside>
- <main class="skill-card-panel">
- <header><div><strong>${escapeHtml(selectedPackage?.label || '技能包')}</strong><span>${escapeHtml(selectedPackage?.description || '')}</span></div><code>${escapeHtml(selectedPackage?.root || '')}</code></header>
- <div class="skill-card-grid">
- ${(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>'}
- </div>
- </main>
- <aside class="skill-detail-panel">
- ${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>'}
- </aside>
- </section>
- `;
- }
- async function renderSkillsPage() {
- els.pageTitle.textContent = '技能中心';
- void refreshAgentAccountHeader();
- const page = document.createElement('div');
- page.className = 'page skills-page';
- page.innerHTML = '<div class="agent-loading">正在扫描本地技能包…</div>';
- els.content.appendChild(page);
- page.addEventListener('click', event => {
- const packageButton = event.target.closest('[data-skill-package]');
- if (packageButton) {
- state.skills.selectedPackageId = packageButton.dataset.skillPackage;
- state.skills.selectedSkillId = null;
- renderSkillsWorkspace(page);
- return;
- }
- const skillButton = event.target.closest('[data-skill-id]');
- if (skillButton) {
- state.skills.selectedSkillId = skillButton.dataset.skillId;
- renderSkillsWorkspace(page);
- return;
- }
- const openButton = event.target.closest('[data-open-page]');
- if (openButton) location.hash = `#${openButton.dataset.openPage}`;
- });
- page.addEventListener('input', event => {
- if (event.target.id !== 'skill-search') return;
- state.skills.keyword = event.target.value;
- renderSkillsWorkspace(page);
- const input = page.querySelector('#skill-search');
- if (input) { input.focus(); input.setSelectionRange(input.value.length, input.value.length); }
- });
- try {
- const result = await api('GET', '/api/skills');
- state.skills.registry = result.data;
- state.skills.selectedPackageId = state.skills.selectedPackageId || result.data.packages[0]?.id || null;
- renderSkillsWorkspace(page);
- } catch (error) {
- page.innerHTML = `<div class="library-error">${escapeHtml(error.message || '技能包扫描失败')}</div>`;
- }
- }
- // ==================== Knowledge Library ====================
- function flattenKnowledgeNodes(nodes = []) {
- return nodes.flatMap(node => [node, ...(node.children ? flattenKnowledgeNodes(node.children) : [])]);
- }
- function knowledgeIcon(node) {
- if (node.type === 'folder') return '▸';
- return ({ 'property-dataset': '⌂', 'meeting-dashboard': '会', 'doc-dashboard': '文', 'todo-dashboard': '办', 'task-dashboard': '任', markdown: 'M', 'skill-document': 'S', json: '{ }', code: '</>', csv: '▦' })[node.kind] || '•';
- }
- function renderKnowledgeTreeNode(node, depth = 0) {
- if (node.type === 'folder') {
- const isOpen = state.knowledge.expandedFolderIds.has(node.id);
- 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>`;
- }
- 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>`;
- }
- function formatFileSize(value) {
- const bytes = Number(value || 0);
- if (bytes < 1024) return `${bytes} B`;
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
- return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
- }
- function propertyMeta(property) {
- return [property.district, property.layout, property.area ? `${property.area}㎡` : '', property.decoration].filter(Boolean).join(' · ');
- }
- function renderPropertyDetail(property) {
- if (!property) return '<div class="property-detail-empty"><strong>选择一套房源</strong><span>点击左侧房源卡片查看全部字段与数据来源。</span></div>';
- const rows = [
- ['区域', property.district], ['户型', property.layout], ['面积', property.area ? `${property.area}㎡` : ''],
- ['楼层', property.floor], ['朝向', property.orientation], ['装修', property.decoration],
- ['单价', property.unitPrice ? `${property.unitPrice} 元/㎡` : ''], ['学区', property.isSchoolDistrict ? property.schoolName || '是' : '否'],
- ['停车', property.parking], ['房龄', property.buildingAge ? `${property.buildingAge} 年` : ''],
- ['满五唯一', property.isFiveYearOnly ? '是' : '否'], ['议价空间', property.priceDropSpace ? `${property.priceDropSpace} 万` : ''],
- ].filter(([, value]) => value !== '' && value !== null && value !== undefined);
- 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>`;
- }
- function renderPropertiesView() {
- const data = state.knowledge.properties || { items: [], stats: {}, total: 0, page: 1, pageCount: 1 };
- const filters = state.knowledge.filters;
- const selected = state.knowledge.selectedProperty;
- return `<div class="property-library-view">
- <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>
- <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>
- <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>
- <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>
- </div>`;
- }
- function meetingInputValue(value) {
- return String(value || '').replace(' ', 'T').slice(0, 16);
- }
- function meetingAnalysisLabel(status) {
- return ({ completed: 'AI 已分析', stale: '资料已变化', failed: '分析失败', pending: '待分析' })[status] || '待分析';
- }
- function renderMeetingBullets(items, emptyText) {
- return (items || []).length
- ? `<ul>${items.map(item => `<li>${escapeHtml(item)}</li>`).join('')}</ul>`
- : `<p class="meeting-empty-copy">${escapeHtml(emptyText)}</p>`;
- }
- function renderMeetingDetail(meeting) {
- if (!meeting) {
- return '<div class="library-empty large"><strong>暂无真实会议</strong><span>完成官方 CLI 扫码授权并同步后,会议详情会显示在这里;系统不会生成 Mock 会议。</span></div>';
- }
- const analysis = meeting.analysis || {};
- const actionItems = analysis.actionItems || [];
- const analysisReady = analysis.status === 'completed';
- return `<article class="meeting-detail">
- <header>
- <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>
- <button class="btn" data-analyze-meeting="${escapeHtml(meeting.id)}" ${state.knowledge.meetings.loading ? 'disabled' : ''}>${analysisReady ? '重新分析' : '生成 AI 分析'}</button>
- </header>
- <div class="meeting-meta-grid">
- <div><small>会议类型</small><strong>${escapeHtml(meeting.meetingTypeLabel || '-')}</strong></div>
- <div><small>会议时长</small><strong>${meeting.durationSeconds ? `${Math.round(meeting.durationSeconds / 60)} 分钟` : '-'}</strong></div>
- <div><small>参会统计</small><strong>${meeting.attendees?.internalCount || 0} 内部 / ${meeting.attendees?.externalCount || 0} 外部</strong></div>
- <div><small>AI 状态</small><strong>${escapeHtml(meetingAnalysisLabel(analysis.status))}</strong></div>
- </div>
- <section class="meeting-source-note"><strong>会议描述(官方资料)</strong><p>${escapeHtml(meeting.description || '官方会议资料未填写描述。')}</p><small>会议 ID:${escapeHtml(meeting.id)} · 同步于 ${escapeHtml(formatAgentTime(meeting.syncedAt))}</small></section>
- ${analysis.status === 'failed' ? `<div class="meeting-analysis-error"><strong>AI 分析未生成</strong><span>${escapeHtml(analysis.error || '模型调用失败,真实会议资料已保留。')}</span></div>` : ''}
- <section class="meeting-analysis-summary"><span>AI SUMMARY · 需人工复核</span><h4>${escapeHtml(analysis.summary || '尚未生成会议摘要')}</h4><p>${escapeHtml(analysis.analysisBasis || '仅基于官方会议元数据和描述,不包含录音、转写或人工纪要。')}</p></section>
- <div class="meeting-analysis-grid">
- <section><h4>关键议题</h4>${renderMeetingBullets(analysis.topics, '资料中暂无可验证的关键议题')}</section>
- <section><h4>明确决策</h4>${renderMeetingBullets(analysis.decisions, '资料中没有可验证的明确决策')}</section>
- <section><h4>风险与待确认</h4>${renderMeetingBullets(analysis.risks, '暂无已识别风险')}</section>
- <section><h4>建议动作(非已确认任务)</h4>${renderMeetingBullets(analysis.suggestedActions, '暂无建议动作')}</section>
- </div>
- <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>
- <section class="meeting-tags"><strong>知识标签</strong><div>${(analysis.knowledgeTags || []).map(tag => `<span>${escapeHtml(tag)}</span>`).join('') || '<small>暂无标签</small>'}</div></section>
- </article>`;
- }
- function renderMeetingKnowledgeView() {
- const hub = state.knowledge.meetings.hub || { summary: {}, data: { cli: {}, range: {}, meetings: [] } };
- const data = hub.data || {};
- const summary = hub.summary || {};
- const cli = data.cli || {};
- const meetingReady = Boolean(summary.meetingReady);
- const capabilityBlocked = Boolean(cli.ready && !meetingReady);
- const meetings = [...(data.meetings || [])].sort((a, b) => String(b.startAt).localeCompare(String(a.startAt)));
- const selected = meetings.find(item => item.id === state.knowledge.meetings.selectedId) || meetings[0] || null;
- if (selected) state.knowledge.meetings.selectedId = selected.id;
- const range = data.range || {};
- const meetingDetail = selected
- ? renderMeetingDetail(selected)
- : capabilityBlocked
- ? '<div class="library-empty large"><strong>当前企业无法通过官方 CLI 拉取会议</strong><span>扫码授权已经成功;请换用 10 人及以下测试企业演示会议,当前企业可继续演示官方文档和待办能力。</span></div>'
- : renderMeetingDetail(null);
- return `<div class="meeting-workbench">
- <header class="knowledge-view-header"><div><span>WECOM MEETING KNOWLEDGE</span><h3>企微会议知识沉淀</h3><p>官方 CLI 真实同步 · 本地脱敏存储 · AI 分析需人工复核</p></div><em>${meetingReady ? '真实通道已就绪' : capabilityBlocked ? '企业未开放会议' : '等待扫码授权'}</em></header>
- <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>
- <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>
- <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>
- <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>
- </div>`;
- }
- function officeCapabilityBanner(cli, label) {
- const ready = Boolean(cli?.ready && cli?.available);
- 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>`;
- }
- function renderDocumentDetail(document) {
- if (!document) return '<div class="library-empty large"><strong>尚未沉淀企微文档</strong><span>可创建一篇真实企微文档,或粘贴已有文档链接读取到知识库。</span></div>';
- const analysis = document.analysis || {};
- const bullets = (items, empty) => (items || []).length ? `<ul>${items.map(item => `<li>${escapeHtml(item)}</li>`).join('')}</ul>` : `<p>${escapeHtml(empty)}</p>`;
- const link = /^https?:\/\//i.test(document.url || '') ? `<a href="${escapeHtml(document.url)}" target="_blank" rel="noopener">在企微中打开</a>` : '';
- 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>
- ${document.readbackStatus === 'pending-permission' ? '<div class="doc-readback-warning"><strong>创建和写入已成功</strong><span>机器人暂缺读取授权,当前原文来自本次已确认写入的 Markdown;授权后可重新读取复核。</span></div>' : ''}
- <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>
- <section class="doc-original"><header><strong>企微文档原文</strong><span>Markdown 只读预览</span></header><article class="markdown-view">${renderMarkdown(document.markdown || '文档为空')}</article></section></article>`;
- }
- function renderDocumentKnowledgeView() {
- const hub = state.knowledge.documents.hub || { summary: {}, data: { cli: {}, documents: [] } };
- const data = hub.data || {};
- const docs = data.documents || [];
- const selected = docs.find(item => item.id === state.knowledge.documents.selectedId) || docs[0] || null;
- if (selected) state.knowledge.documents.selectedId = selected.id;
- 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>
- <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>
- ${officeCapabilityBanner(data.cli, '文档')}
- <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="# 文档正文 支持 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>
- <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>`;
- }
- function renderTodoDetail(todo) {
- if (!todo) return '<div class="library-empty large"><strong>选择一条待办</strong><span>这里只展示当前机器人创建并同步的真实企微待办。</span></div>';
- 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>`;
- }
- function renderTodoKnowledgeView() {
- const hub = state.knowledge.todos.hub || { summary: {}, data: { cli: {}, todos: [] } };
- const data = hub.data || {};
- const todos = data.todos || [];
- const selected = todos.find(item => item.id === state.knowledge.todos.selectedId) || todos[0] || null;
- if (selected) state.knowledge.todos.selectedId = selected.id;
- const users = state.knowledge.todos.userResults || [];
- 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>
- <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>
- ${officeCapabilityBanner(data.cli, '待办')}
- <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>
- ${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>` : ''}
- <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>
- <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>`;
- }
- function unifiedTaskStatusLabel(status) {
- return ({ open: '待处理', in_progress: '处理中', blocked: '已阻塞', done: '已完成', dismissed: '已忽略', suggested: 'AI 候选' })[status] || status || '待处理';
- }
- function unifiedTaskDueLabel(task) {
- if (!task.dueAt) return '未设置截止时间';
- return `${task.overdue ? '已逾期 · ' : ''}${formatAgentTime(task.dueAt)}`;
- }
- function unifiedTaskActions(task) {
- if (task.sourceKind === 'candidate') {
- return task.promotedTo
- ? '<span class="task-promoted">已转为正式任务</span>'
- : `<button class="btn btn-sm" data-task-action="promote" data-task-id="${escapeHtml(task.id)}">确认并转为任务</button>`;
- }
- const active = ['open', 'in_progress', 'blocked'].includes(task.status);
- const buttons = [];
- if (task.source === 'customer') {
- buttons.push(`<button class="btn btn-sm btn-secondary" data-task-open-conversation="${escapeHtml(task.conversationId)}">转到智能会话</button>`);
- 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>`);
- }
- 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>`);
- if (active && task.actions?.complete) buttons.push(`<button class="btn btn-sm" data-task-action="complete" data-task-id="${escapeHtml(task.id)}">完成</button>`);
- return buttons.join('') || `<span>${escapeHtml(unifiedTaskStatusLabel(task.status))}</span>`;
- }
- function renderUnifiedTaskCenter() {
- const hub = state.knowledge.tasks.hub || { summary: {}, data: { tasks: [], policy: {} } };
- const summary = hub.summary || {};
- const data = hub.data || {};
- const filters = state.knowledge.tasks.filters;
- const allTasks = data.tasks || [];
- const q = String(filters.q || '').trim().toLowerCase();
- const tasks = allTasks.filter(task => {
- if (q && ![task.title, task.owner, task.contactName, task.sourceTitle, task.evidence].some(value => String(value || '').toLowerCase().includes(q))) return false;
- if (filters.source && task.source !== filters.source) return false;
- if (filters.status === 'active' && !['open', 'in_progress', 'blocked'].includes(task.status)) return false;
- if (filters.status === 'overdue' && !task.overdue) return false;
- if (filters.status === 'completed' && task.status !== 'done') return false;
- if (filters.status === 'suggested' && task.status !== 'suggested') return false;
- return true;
- });
- const sourceOptions = Object.keys(summary.bySource || {}).map(source => {
- const sample = allTasks.find(task => task.source === source);
- return `<option value="${escapeHtml(source)}" ${filters.source === source ? 'selected' : ''}>${escapeHtml(sample?.sourceLabel || source)}(${summary.bySource[source]})</option>`;
- }).join('');
- return `<div class="unified-task-workbench">
- <header class="knowledge-view-header"><div><span>UNIFIED ACTION CENTER</span><h3>统一任务中心</h3><p>真实企微会话 · 官方待办 · 目标计划 · 文档/会议行动项</p></div><em>内部任务中枢</em></header>
- <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>
- <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>
- <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>
- <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>
- <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>
- </div>`;
- }
- function renderKnowledgeFileView(file) {
- if (!file) return '<div class="library-empty large"><strong>选择左侧文件</strong><span>Markdown、JSON、CSV 和代码文件会在这里可视化预览。</span></div>';
- 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>`;
- if (['markdown', 'skill-document'].includes(file.kind)) return `${header}<article class="markdown-view">${renderMarkdown(file.content)}</article>`;
- if (file.kind === 'json') {
- const summary = Array.isArray(file.structured)
- ? `${file.structured.length} 条数组记录`
- : `${Object.keys(file.structured || {}).length} 个顶层字段`;
- 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>`;
- }
- return `${header}<pre class="code-view"><code>${escapeHtml(file.content)}</code></pre>`;
- }
- function renderKnowledgeWorkspace(page) {
- const tree = state.knowledge.tree || { roots: [], summary: {} };
- const selectedNode = flattenKnowledgeNodes(tree.roots).find(item => item.id === state.knowledge.selectedNodeId);
- const viewer = selectedNode?.kind === 'property-dataset'
- ? renderPropertiesView()
- : selectedNode?.kind === 'meeting-dashboard'
- ? renderMeetingKnowledgeView()
- : selectedNode?.kind === 'doc-dashboard'
- ? renderDocumentKnowledgeView()
- : selectedNode?.kind === 'todo-dashboard'
- ? renderTodoKnowledgeView()
- : selectedNode?.kind === 'task-dashboard'
- ? renderUnifiedTaskCenter()
- : renderKnowledgeFileView(state.knowledge.file);
- page.innerHTML = `
- <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>
- <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>
- `;
- }
- async function loadKnowledgeProperties(page) {
- const params = new URLSearchParams({ pageSize: '30' });
- for (const [key, value] of Object.entries(state.knowledge.filters)) if (String(value || '').trim()) params.set(key, value);
- const result = await api('GET', `/api/knowledge/properties?${params}`);
- state.knowledge.properties = result.data;
- if (state.knowledge.selectedProperty) {
- state.knowledge.selectedProperty = result.data.items.find(item => item.id === state.knowledge.selectedProperty.id) || null;
- }
- renderKnowledgeWorkspace(page);
- }
- async function loadMeetingKnowledgeHub(page) {
- const result = await api('GET', '/api/knowledge/meetings');
- state.knowledge.meetings.hub = result;
- const meetings = result.data?.meetings || [];
- if (!meetings.some(item => item.id === state.knowledge.meetings.selectedId)) {
- state.knowledge.meetings.selectedId = [...meetings].sort((a, b) => String(b.startAt).localeCompare(String(a.startAt)))[0]?.id || null;
- }
- renderKnowledgeWorkspace(page);
- }
- async function loadDocumentKnowledgeHub(page) {
- const result = await api('GET', '/api/knowledge/docs');
- state.knowledge.documents.hub = result;
- const documents = result.data?.documents || [];
- if (!documents.some(item => item.id === state.knowledge.documents.selectedId)) state.knowledge.documents.selectedId = documents[0]?.id || null;
- renderKnowledgeWorkspace(page);
- }
- async function loadTodoKnowledgeHub(page) {
- const result = await api('GET', '/api/knowledge/todos');
- state.knowledge.todos.hub = result;
- const todos = result.data?.todos || [];
- if (!todos.some(item => item.id === state.knowledge.todos.selectedId)) state.knowledge.todos.selectedId = todos[0]?.id || null;
- renderKnowledgeWorkspace(page);
- }
- async function loadUnifiedTaskHub(page) {
- const result = await api('GET', '/api/knowledge/tasks');
- state.knowledge.tasks.hub = result;
- renderKnowledgeWorkspace(page);
- }
- async function openKnowledgeNode(page, nodeId) {
- const node = flattenKnowledgeNodes(state.knowledge.tree?.roots || []).find(item => item.id === nodeId);
- if (!node || node.type !== 'file') return;
- state.knowledge.selectedNodeId = nodeId;
- state.knowledge.file = null;
- state.knowledge.selectedProperty = null;
- if (node.kind === 'meeting-dashboard') await loadMeetingKnowledgeHub(page);
- else if (node.kind === 'doc-dashboard') await loadDocumentKnowledgeHub(page);
- else if (node.kind === 'todo-dashboard') await loadTodoKnowledgeHub(page);
- else if (node.kind === 'task-dashboard') await loadUnifiedTaskHub(page);
- else if (node.kind === 'property-dataset') await loadKnowledgeProperties(page);
- else {
- const result = await api('GET', `/api/knowledge/file?id=${encodeURIComponent(nodeId)}`);
- state.knowledge.file = result.data;
- renderKnowledgeWorkspace(page);
- }
- }
- async function renderKnowledgePage() {
- els.pageTitle.textContent = '知识库';
- void refreshAgentAccountHeader();
- const page = document.createElement('div');
- page.className = 'page knowledge-page';
- page.innerHTML = '<div class="agent-loading">正在扫描知识库目录…</div>';
- els.content.appendChild(page);
- page.addEventListener('click', async event => {
- const fileButton = event.target.closest('[data-knowledge-node]');
- if (fileButton) {
- try { await openKnowledgeNode(page, fileButton.dataset.knowledgeNode); }
- catch (error) { toast(error.message || '文件读取失败', 'error'); }
- return;
- }
- const copyInitButton = event.target.closest('[data-copy-init]');
- if (copyInitButton) {
- try {
- await navigator.clipboard.writeText(copyInitButton.dataset.copyInit);
- toast('初始化命令已复制');
- } catch { toast('复制失败,请手动选择命令', 'error'); }
- return;
- }
- const analyzeMeetingButton = event.target.closest('[data-analyze-meeting]');
- if (analyzeMeetingButton) {
- state.knowledge.meetings.loading = true;
- renderKnowledgeWorkspace(page);
- try {
- const result = await api('POST', `/api/knowledge/meetings/${encodeURIComponent(analyzeMeetingButton.dataset.analyzeMeeting)}/analyze`, {});
- await loadMeetingKnowledgeHub(page);
- toast(result.assistantMessage || (result.status === 'ok' ? '会议分析完成' : '会议分析未完成'), result.status === 'ok' ? 'success' : 'error');
- } catch (error) { toast(error.assistantMessage || error.message || '会议分析失败', 'error'); }
- finally {
- state.knowledge.meetings.loading = false;
- renderKnowledgeWorkspace(page);
- }
- return;
- }
- const meetingButton = event.target.closest('[data-meeting-id]');
- if (meetingButton) {
- state.knowledge.meetings.selectedId = meetingButton.dataset.meetingId;
- renderKnowledgeWorkspace(page);
- return;
- }
- const analyzeDocButton = event.target.closest('[data-analyze-doc]');
- if (analyzeDocButton) {
- state.knowledge.documents.loading = true;
- renderKnowledgeWorkspace(page);
- try {
- const result = await api('POST', `/api/knowledge/docs/${encodeURIComponent(analyzeDocButton.dataset.analyzeDoc)}/analyze`, {});
- await loadDocumentKnowledgeHub(page);
- toast(result.assistantMessage || '文档分析完成', result.status === 'ok' ? 'success' : 'error');
- } catch (error) { toast(error.assistantMessage || error.message || '文档分析失败', 'error'); }
- finally { state.knowledge.documents.loading = false; renderKnowledgeWorkspace(page); }
- return;
- }
- const refreshDocButton = event.target.closest('[data-refresh-doc]');
- if (refreshDocButton) {
- state.knowledge.documents.loading = true;
- renderKnowledgeWorkspace(page);
- try {
- const result = await api('POST', `/api/knowledge/docs/${encodeURIComponent(refreshDocButton.dataset.refreshDoc)}/refresh`, {});
- await loadDocumentKnowledgeHub(page);
- toast(result.assistantMessage || '文档重新读取完成', result.status === 'ok' ? 'success' : 'error');
- } catch (error) { toast(error.assistantMessage || error.message || '文档重新读取失败', 'error'); }
- finally { state.knowledge.documents.loading = false; renderKnowledgeWorkspace(page); }
- return;
- }
- const docButton = event.target.closest('[data-doc-id]');
- if (docButton) {
- state.knowledge.documents.selectedId = docButton.dataset.docId;
- renderKnowledgeWorkspace(page);
- return;
- }
- const useTodoUserButton = event.target.closest('[data-use-todo-user]');
- if (useTodoUserButton) {
- const id = useTodoUserButton.dataset.useTodoUser;
- page.querySelectorAll('input[name="followerId"], input[name="followerIds"]').forEach(input => { input.value = id; });
- toast(`已填入成员 userid:${id}`);
- return;
- }
- const completeTodoButton = event.target.closest('[data-complete-todo]');
- if (completeTodoButton) {
- state.knowledge.todos.loading = true;
- renderKnowledgeWorkspace(page);
- try {
- const result = await api('POST', `/api/knowledge/todos/${encodeURIComponent(completeTodoButton.dataset.completeTodo)}/complete`, {});
- await loadTodoKnowledgeHub(page);
- toast(result.assistantMessage || '待办已完成', result.status === 'ok' ? 'success' : 'error');
- } catch (error) { toast(error.assistantMessage || error.message || '待办更新失败', 'error'); }
- finally { state.knowledge.todos.loading = false; renderKnowledgeWorkspace(page); }
- return;
- }
- const todoButton = event.target.closest('[data-todo-id]');
- if (todoButton) {
- state.knowledge.todos.selectedId = todoButton.dataset.todoId;
- renderKnowledgeWorkspace(page);
- return;
- }
- const openTaskConversationButton = event.target.closest('[data-task-open-conversation]');
- if (openTaskConversationButton) {
- state.agent.selectedId = openTaskConversationButton.dataset.taskOpenConversation;
- location.hash = '#agent';
- return;
- }
- const taskActionButton = event.target.closest('[data-task-action]');
- if (taskActionButton) {
- const task = (state.knowledge.tasks.hub?.data?.tasks || []).find(item => item.id === taskActionButton.dataset.taskId);
- if (!task) { toast('任务记录已变化,请刷新后重试', 'error'); return; }
- const action = taskActionButton.dataset.taskAction;
- state.knowledge.tasks.loading = true;
- renderKnowledgeWorkspace(page);
- try {
- let result;
- if (action === 'promote') {
- if (!window.confirm(`确认将以下 AI 候选转为正式内部任务?\n\n${task.title}\n\n依据:${task.evidence || '待补充'}\n\n创建后仍需人工分配负责人并推进。`)) return;
- 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 });
- } else if (action === 'sync-official') {
- const syncInput = await showOfficialTodoSyncModal(task);
- if (!syncInput) return;
- result = await api('POST', `/api/agent/tasks/${encodeURIComponent(task.id)}/sync-official`, syncInput);
- } else {
- const nextStatus = action === 'complete' ? 'done' : 'dismissed';
- const officialWarning = task.source === 'official' || task.officialTodoId ? '\n\n该操作会同步更新真实企微待办。' : '';
- if (!window.confirm(`确认将“${task.title}”标记为${action === 'complete' ? '已完成' : '已忽略'}?${officialWarning}`)) return;
- result = await api('POST', '/api/knowledge/tasks/update', { source: task.source, taskId: task.id, goalId: task.goalId, status: nextStatus });
- }
- if (result && result.status !== 'ok') throw result;
- await loadUnifiedTaskHub(page);
- toast(result?.assistantMessage || '任务已更新');
- } catch (error) { toast(error.assistantMessage || error.message || '任务操作失败', 'error'); }
- finally { state.knowledge.tasks.loading = false; renderKnowledgeWorkspace(page); }
- return;
- }
- const propertyButton = event.target.closest('[data-property-id]');
- if (propertyButton) {
- try {
- const result = await api('GET', `/api/knowledge/properties/${encodeURIComponent(propertyButton.dataset.propertyId)}`);
- state.knowledge.selectedProperty = result.data.property;
- renderKnowledgeWorkspace(page);
- } catch (error) { toast(error.message || '房源读取失败', 'error'); }
- }
- });
- page.addEventListener('submit', async event => {
- if (event.target.id === 'unified-task-filter-form') {
- event.preventDefault();
- const form = new FormData(event.target);
- state.knowledge.tasks.filters.q = String(form.get('q') || '').trim();
- state.knowledge.tasks.filters.source = String(form.get('source') || '').trim();
- state.knowledge.tasks.filters.status = String(form.get('status') || 'active');
- renderKnowledgeWorkspace(page);
- return;
- }
- if (event.target.id === 'unified-task-create-form') {
- event.preventDefault();
- const form = new FormData(event.target);
- state.knowledge.tasks.loading = true;
- renderKnowledgeWorkspace(page);
- try {
- const result = await api('POST', '/api/knowledge/tasks/create', {
- title: String(form.get('title') || '').trim(),
- owner: String(form.get('owner') || '').trim(),
- dueAt: String(form.get('dueAt') || '').trim(),
- priority: String(form.get('priority') || 'medium'),
- sourceTitle: '任务中心人工创建',
- });
- if (result.status !== 'ok') throw result;
- await loadUnifiedTaskHub(page);
- toast(result.assistantMessage || '内部任务已创建');
- } catch (error) { toast(error.assistantMessage || error.message || '内部任务创建失败', 'error'); }
- finally { state.knowledge.tasks.loading = false; renderKnowledgeWorkspace(page); }
- return;
- }
- if (event.target.id === 'doc-create-form' || event.target.id === 'doc-import-form') {
- event.preventDefault();
- const formId = event.target.id;
- const form = new FormData(event.target);
- const body = formId === 'doc-create-form'
- ? { title: String(form.get('title') || '').trim(), content: String(form.get('content') || '') }
- : { ...(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() };
- state.knowledge.documents.loading = true;
- renderKnowledgeWorkspace(page);
- try {
- const result = await api('POST', formId === 'doc-create-form' ? '/api/knowledge/docs/create' : '/api/knowledge/docs/import', body);
- if (result.status !== 'ok') throw result;
- state.knowledge.documents.selectedId = result.data?.document?.id || null;
- const treeResult = await api('GET', '/api/knowledge/tree');
- state.knowledge.tree = treeResult.data;
- await loadDocumentKnowledgeHub(page);
- toast(result.assistantMessage || '企微文档处理完成');
- } catch (error) { toast(error.assistantMessage || error.message || '企微文档处理失败', 'error'); }
- finally { state.knowledge.documents.loading = false; renderKnowledgeWorkspace(page); }
- return;
- }
- if (event.target.id === 'todo-user-search-form') {
- event.preventDefault();
- const form = new FormData(event.target);
- try {
- const result = await api('POST', '/api/knowledge/todos/search-users', { keyword: String(form.get('keyword') || '').trim() });
- state.knowledge.todos.userResults = result.data?.users || [];
- renderKnowledgeWorkspace(page);
- toast(result.assistantMessage || '成员搜索完成', result.status === 'ok' ? 'success' : 'error');
- } catch (error) { toast(error.assistantMessage || error.message || '成员搜索失败', 'error'); }
- return;
- }
- if (event.target.id === 'todo-sync-form' || event.target.id === 'todo-create-form') {
- event.preventDefault();
- const formId = event.target.id;
- const form = new FormData(event.target);
- const body = formId === 'todo-sync-form'
- ? { followerId: String(form.get('followerId') || '').trim(), limit: 20 }
- : { content: String(form.get('content') || '').trim(), followerIds: String(form.get('followerIds') || '').trim(), endTime: String(form.get('endTime') || '').trim(), remindType: Number(form.get('remindType') || 1) };
- state.knowledge.todos.loading = true;
- renderKnowledgeWorkspace(page);
- try {
- const result = await api('POST', formId === 'todo-sync-form' ? '/api/knowledge/todos/sync' : '/api/knowledge/todos/create', body);
- if (result.status !== 'ok') throw result;
- state.knowledge.todos.selectedId = result.data?.todo?.id || state.knowledge.todos.selectedId;
- const treeResult = await api('GET', '/api/knowledge/tree');
- state.knowledge.tree = treeResult.data;
- await loadTodoKnowledgeHub(page);
- toast(result.assistantMessage || '企微待办处理完成');
- } catch (error) { toast(error.assistantMessage || error.message || '企微待办处理失败', 'error'); }
- finally { state.knowledge.todos.loading = false; renderKnowledgeWorkspace(page); }
- return;
- }
- if (event.target.id === 'meeting-sync-form') {
- event.preventDefault();
- const form = new FormData(event.target);
- state.knowledge.meetings.loading = true;
- renderKnowledgeWorkspace(page);
- try {
- const result = await api('POST', '/api/knowledge/meetings/sync', {
- beginDatetime: String(form.get('beginDatetime') || ''),
- endDatetime: String(form.get('endDatetime') || ''),
- limit: Number(form.get('limit') || 20),
- });
- if (result.status !== 'ok') throw result;
- const treeResult = await api('GET', '/api/knowledge/tree');
- state.knowledge.tree = treeResult.data;
- await loadMeetingKnowledgeHub(page);
- toast(result.assistantMessage || '真实会议同步完成');
- } catch (error) { toast(error.assistantMessage || error.message || '会议同步失败', 'error'); }
- finally {
- state.knowledge.meetings.loading = false;
- renderKnowledgeWorkspace(page);
- }
- return;
- }
- if (event.target.id === 'property-filter-form') {
- event.preventDefault();
- const form = new FormData(event.target);
- for (const key of Object.keys(state.knowledge.filters)) state.knowledge.filters[key] = String(form.get(key) || '').trim();
- try { await loadKnowledgeProperties(page); }
- catch (error) { toast(error.message || '房源筛选失败', 'error'); }
- }
- });
- page.addEventListener('toggle', event => {
- const folder = event.target.closest?.('[data-knowledge-folder]');
- if (!folder) return;
- if (folder.open) state.knowledge.expandedFolderIds.add(folder.dataset.knowledgeFolder);
- else state.knowledge.expandedFolderIds.delete(folder.dataset.knowledgeFolder);
- }, true);
- try {
- const result = await api('GET', '/api/knowledge/tree');
- state.knowledge.tree = result.data;
- if (!state.knowledge.foldersInitialized) {
- for (const root of result.data.roots) {
- if (['agent-knowledge', 'property-data', 'meeting-knowledge', 'doc-knowledge', 'todo-knowledge', 'task-knowledge'].includes(root.libraryId)) {
- state.knowledge.expandedFolderIds.add(root.id);
- }
- }
- state.knowledge.foldersInitialized = true;
- }
- const nodes = flattenKnowledgeNodes(result.data.roots);
- 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');
- if (defaultNode) await openKnowledgeNode(page, state.knowledge.selectedNodeId || defaultNode.id);
- else renderKnowledgeWorkspace(page);
- } catch (error) {
- page.innerHTML = `<div class="library-error">${escapeHtml(error.message || '知识库扫描失败')}</div>`;
- }
- }
- // ==================== Intelligent Conversations ====================
- function formatAgentTime(value) {
- const date = new Date(value || 0);
- if (Number.isNaN(date.getTime())) return '-';
- return date.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
- }
- function agentTaskStatusLabel(status) {
- return ({ open: '待处理', in_progress: '处理中', done: '已完成', dismissed: '已忽略' })[status] || status || '待处理';
- }
- function agentOfficialTodoLabel(task = {}) {
- if (!task.officialTodoId) return '仅本地';
- return task.officialSyncStatus === 'done'
- ? '企微已完成'
- : task.officialSyncStatus === 'completion_pending'
- ? '企微待确认完成'
- : task.officialSyncStatus === 'error'
- ? '企微同步异常'
- : '已同步企微';
- }
- function renderAgentCustomerTask(task = {}) {
- const evidenceCount = Array.isArray(task.evidenceItems) ? task.evidenceItems.length : (task.evidence ? 1 : 0);
- const active = ['open', 'in_progress'].includes(task.status);
- const officialClass = task.officialSyncStatus === 'error' ? 'error' : task.officialTodoId ? 'synced' : 'local';
- const needsOfficialCompletion = task.status === 'done' && task.officialTodoId && task.officialSyncStatus !== 'done';
- const actions = active
- ? `${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>`
- : `${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>`;
- return `<div class="agent-customer-task ${escapeHtml(task.priority)} ${escapeHtml(task.status)}">
- <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>
- <div class="agent-intelligence-actions">${actions}</div>
- </div>`;
- }
- function demandItems(demand = {}) {
- const districts = Array.isArray(demand.districts) ? demand.districts.join('/') : (demand.preferredRegion || demand.region || demand.intent_area || '');
- const min = demand.budgetMin ?? demand.budget_min;
- const max = demand.budgetMax ?? demand.budget_max;
- const rooms = demand.rooms || (demand.house_type ? String(demand.house_type).replace(/[^0-9]/g, '') : '');
- const values = [
- ['区域', districts],
- ['预算', demand.budgetWan ? `${demand.budgetWan}万${demand.budgetType && demand.budgetType !== '待确认' ? `(${demand.budgetType})` : ''}` : (min && max ? `${min}-${max}万` : (max ? `${max}万内` : ''))],
- ['户型', demand.layout || (rooms ? `${rooms}室` : (demand.house_type || ''))],
- ['面积', demand.areaMin && demand.areaMax ? `${demand.areaMin}-${demand.areaMax}㎡` : ''],
- ['装修', demand.decoration],
- ['用途', demand.purpose || demand.buyerType || demand.purchase_purpose],
- ['偏好', [demand.floor, demand.orientation].filter(Boolean).join(' / ')],
- ['学区', demand.schoolRequired ? '需要' : ''],
- ['时间', demand.timeline],
- ['紧迫度', demand.urgency],
- ];
- return values.filter(([, value]) => value !== undefined && value !== null && String(value).trim());
- }
- function renderAgentMessage(message) {
- const roleLabel = message.role === 'customer' ? '客户' : message.role === 'human' ? '人工客服' : 'Agent 建议';
- const statusLabel = message.status === 'pending' ? '待确认' : message.status === 'sending' ? '发送中' : message.status === 'sent' ? '已发送' : message.status === 'submitted' ? '已提交企微' : '';
- return `<div class="agent-message ${escapeHtml(message.role)}">
- <div class="agent-message-meta"><span>${roleLabel}</span><span>${formatAgentTime(message.timestamp)}${statusLabel ? ` · ${statusLabel}` : ''}</span></div>
- <div class="agent-message-body">${escapeHtml(message.content).replace(/\n/g, '<br>')}</div>
- </div>`;
- }
- function agentModeLabel(mode) {
- return ({ review: '待审核', auto: '高置信自动', human: '人工接管', paused: '会话暂停' })[mode] || mode || '待审核';
- }
- function formatReplyWaiting(minutes) {
- const value = Math.max(0, Number(minutes) || 0);
- if (value < 60) return `${value} 分钟`;
- if (value < 1440) return `${Math.floor(value / 60)} 小时 ${value % 60} 分钟`;
- return `${Math.floor(value / 1440)} 天 ${Math.floor((value % 1440) / 60)} 小时`;
- }
- function renderResponseMonitor(monitor = {}) {
- const summary = monitor.summary || {};
- const config = monitor.config || {};
- const scope = monitor.scope || { customers: [], groups: [] };
- const reminders = monitor.reminders || [];
- const scopeItems = [
- ...(scope.customers || []).map(item => ({ ...item, typeLabel: '客户' })),
- ...(scope.groups || []).map(item => ({ ...item, typeLabel: '客户群' })),
- ];
- return `
- <section class="response-monitor ${summary.urgent ? 'has-urgent' : ''}">
- <header class="response-monitor-header">
- <div>
- <span>RESPONSE SLA MONITOR</span>
- <strong>客服回复提醒</strong>
- <small>最后一条是客户消息且尚未回复时计时;仅本地提醒,不自动外发</small>
- </div>
- <div class="response-monitor-kpis">
- <span><b>${summary.monitoredCustomers || 0}</b> 个客户</span>
- <span><b>${summary.monitoredGroups || 0}</b> 个客户群</span>
- <span class="${summary.overdue ? 'danger' : ''}"><b>${summary.overdue || 0}</b> 个超时</span>
- </div>
- </header>
- <div class="response-monitor-body">
- <div class="response-monitor-scope">
- <div class="response-monitor-section-title"><strong>当前监听范围</strong><span>${config.warningMinutes || 15} 分钟提醒 · ${config.urgentMinutes || 60} 分钟紧急</span></div>
- <div class="response-scope-list">
- ${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>'}
- </div>
- </div>
- <div class="response-reminder-list">
- <div class="response-monitor-section-title"><strong>需要客服处理</strong><span>${summary.urgent || 0} 个紧急 · ${summary.warning || 0} 个提醒</span></div>
- ${reminders.length ? reminders.slice(0, 8).map(item => `
- <article class="response-reminder ${escapeHtml(item.severity)}">
- <div><i>${item.kind === 'group' ? '群' : '客'}</i><strong>${escapeHtml(item.name)}</strong><em>${item.severity === 'urgent' ? '紧急' : '提醒'}</em></div>
- <p>${escapeHtml(item.lastMessagePreview || '收到新消息')}</p>
- <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>
- </article>
- `).join('') : '<div class="response-monitor-empty success">当前没有超时未回复会话</div>'}
- </div>
- </div>
- </section>`;
- }
- function agentAuditLabel(action) {
- return ({
- message_received: '收到真实企微消息',
- draft_created: 'Agent 生成草稿',
- draft_approved: '人工审核通过',
- draft_rejected: '人工驳回草稿',
- draft_regenerate_requested: '请求重新生成',
- message_sent: '回复已发送',
- manual_message_sent: '人工消息已发送',
- message_send_failed: '发送失败',
- agent_failed: 'Agent 上游失败',
- agent_not_configured: 'Agent 未配置',
- agent_no_reply_needed: 'Agent 判断无需回复',
- agent_skipped_global_paused: '全局暂停,跳过 Agent',
- agent_skipped_human: '人工接管,跳过 Agent',
- agent_skipped_paused: '会话暂停,跳过 Agent',
- message_duplicate_content_skipped: '重复消息已拦截',
- conversation_mode_changed: '会话模式已更新',
- manual_agent_run_requested: '手动请求 Agent',
- conversation_history_synced: '人工补采企微会话',
- customer_intelligence_updated: '客户画像、待办与预警已更新',
- customer_task_updated: '客户待办状态已更新',
- customer_task_synced_to_official_todo: '客户待办已同步企微',
- customer_alert_updated: '客户预警状态已更新',
- })[action] || action;
- }
- function scrollAgentMessagesToLatest(page) {
- requestAnimationFrame(() => {
- const stream = page.querySelector('.agent-message-stream');
- if (stream) stream.scrollTop = stream.scrollHeight;
- });
- }
- function captureAgentInsightScroll(page) {
- const panel = page.querySelector('.agent-insight-panel');
- if (!panel) return null;
- return {
- conversationId: panel.dataset.conversationId || '',
- scrollTop: panel.scrollTop,
- };
- }
- function restoreAgentInsightScroll(page, snapshot, conversationId) {
- if (!snapshot || snapshot.conversationId !== String(conversationId || '')) return;
- requestAnimationFrame(() => {
- const panel = page.querySelector('.agent-insight-panel');
- if (!panel || panel.dataset.conversationId !== snapshot.conversationId) return;
- const maxScrollTop = Math.max(0, panel.scrollHeight - panel.clientHeight);
- panel.scrollTop = Math.min(snapshot.scrollTop, maxScrollTop);
- });
- }
- function renderAgentWorkspace(page) {
- const status = state.agent.status || {};
- const listener = status.listener || {};
- const account = status.account || {};
- const config = status.config || {};
- const accountName = currentAccount()?.nickname || account.nickname || account.userId || '当前企微账号';
- const mode = status.globalMode || 'suggest';
- const aiActive = Boolean(listener.running && mode === 'auto');
- const conversations = state.agent.conversations || [];
- const selected = conversations.find(item => item.id === state.agent.selectedId) || conversations[0] || null;
- if (selected) state.agent.selectedId = selected.id;
- const analysis = selected?.analysis || {};
- const customerIntelligence = selected?.customerIntelligence || {};
- const customerTasks = customerIntelligence.tasks || [];
- const customerAlerts = customerIntelligence.alerts || [];
- const intelligenceSummary = customerIntelligence.summary || {};
- const demands = demandItems(analysis.demand || {});
- const matches = analysis.matches || [];
- const pendingText = selected?.pendingReply?.content || '';
- const isHistorical = selected?.source === 'verified-history';
- const manualActive = Boolean(!aiActive || selected?.mode === 'manual');
- els.accountName.textContent = accountName;
- els.accountDot.className = `account-dot ${account.online ? 'online' : 'offline'}`;
- page.innerHTML = `
- <section class="agent-hero">
- <div>
- <div class="agent-eyebrow">WECOM · AGENTIC SERVICE</div>
- <h2>把企微消息变成可执行的客户线索</h2>
- <p>启动监听后由 AI 自动回复白名单客户;关闭监听后停止 AI,由人工继续回复。</p>
- </div>
- <div class="agent-safety-card">
- <span class="agent-live-dot ${account.online ? 'online' : ''}"></span>
- <div><strong>${escapeHtml(accountName)} · ${account.online ? '在线' : '离线'}</strong><small>${escapeHtml(account.corpName || config.transport || '企微通道')} · ${aiActive ? 'AI 自动回复中' : '当前人工回复'}</small></div>
- <div class="agent-safety-actions">
- <button class="btn ${aiActive ? 'btn-secondary' : ''}" data-agent-action="${aiActive ? 'stop-listener' : 'start-listener'}" ${account.online ? '' : 'disabled'}>${aiActive ? '关闭 AI 监听' : '启动 AI 监听'}</button>
- </div>
- </div>
- </section>
- <section class="agent-modebar">
- <div class="agent-mode-copy"><strong>当前处理方式</strong><span>启停监听即切换 AI / 人工</span></div>
- <div class="agent-mode-switch">
- <span class="agent-mode-btn ${!aiActive ? 'active' : ''}">人工回复</span>
- <span class="agent-mode-btn ${aiActive ? 'active' : ''}">AI 自动回复</span>
- </div>
- <div class="agent-guard"><span>安全边界</span>AI 只回复唯一白名单测试联系人</div>
- </section>
- <section class="agent-workspace">
- <aside class="agent-conversation-list">
- <div class="agent-panel-heading"><div><strong>客户会话</strong><small>${conversations.length} 个已归集会话</small></div><span class="agent-count">${conversations.length}</span></div>
- <div class="agent-list-scroll">
- ${conversations.length ? conversations.map(item => {
- const last = [...(item.messages || [])].reverse().find(message => message.role === 'customer') || item.messages?.[item.messages.length - 1];
- return `<button class="agent-conversation-item ${item.id === selected?.id ? 'active' : ''}" data-conversation-id="${item.id}">
- <span class="agent-avatar">${escapeHtml((item.displayName || '客').slice(0, 1))}</span>
- <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>
- <time>${formatAgentTime(item.lastMessageAt)}</time>
- </button>`;
- }).join('') : `<div class="agent-empty"><strong>等待第一条真实消息</strong><span>启动监听后,用白名单企微发送测试内容。</span></div>`}
- </div>
- </aside>
- <main class="agent-chat-panel">
- ${selected ? `
- <header class="agent-chat-header">
- <div><strong>${escapeHtml(selected.displayName)}</strong><span>${escapeHtml(selected.maskedId)} · ${isHistorical ? '已验证真实收发记录' : '实时企微会话'}</span></div>
- ${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>'}
- </header>
- <div class="agent-message-stream">${(selected.messages || []).map(renderAgentMessage).join('')}</div>
- <div class="agent-composer ${manualActive ? 'manual' : ''}">
- <div class="agent-composer-label"><strong>${manualActive ? '人工回复' : 'AI 自动回复'}</strong><span>${selected.pendingReply ? '等待处理' : (isHistorical ? '历史记录无待发送内容' : aiActive ? 'AI 将自动发送新回复' : 'AI 已停止')}</span></div>
- <textarea id="agent-reply-editor" placeholder="${manualActive ? '输入人工回复内容…' : 'AI 自动回复运行中…'}">${escapeHtml(pendingText)}</textarea>
- <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>
- </div>
- ` : `<div class="agent-empty large"><strong>智能会话准备就绪</strong><span>启动监听,然后从白名单企微发送:“我想在新北区买婚房,预算150万,三室,最好精装”</span></div>`}
- </main>
- <aside class="agent-insight-panel">
- ${selected ? `
- <div class="agent-insight-section">
- <div class="agent-section-title"><span>01</span><div><strong>意图判断</strong><small>Agent 结构化理解</small></div></div>
- <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>
- <p class="agent-reasoning">${escapeHtml(analysis.reasoning || '等待新消息进入。')}</p>
- </div>
- <div class="agent-insight-section">
- <div class="agent-section-title"><span>02</span><div><strong>需求画像</strong><small>从多轮会话持续归集</small></div></div>
- <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>
- </div>
- <div class="agent-insight-section">
- <div class="agent-section-title"><span>03</span><div><strong>匹配建议</strong><small>本地房源数据与评分模型</small></div></div>
- <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>
- </div>
- <div class="agent-insight-section knowledge">
- <div class="agent-section-title"><span>04</span><div><strong>依据与边界</strong><small>让建议可解释、可追溯</small></div></div>
- <ul>${(analysis.knowledgeSources || []).map(source => `<li>${escapeHtml(source)}</li>`).join('')}</ul>
- </div>
- ` : '<div class="agent-empty"><strong>暂无分析</strong><span>选择会话后显示 Agent 工作过程。</span></div>'}
- </aside>
- </section>
- `;
- scrollAgentMessagesToLatest(page);
- }
- function renderAgentWorkspaceV2(page) {
- const insightScroll = captureAgentInsightScroll(page);
- const status = state.agent.status || {};
- const listener = status.listener || {};
- const account = status.account || {};
- const config = status.config || {};
- const accountName = currentAccount()?.nickname || account.nickname || account.userId || '当前企微账号';
- const global = status.global || {};
- const globalPaused = global.paused !== undefined ? Boolean(global.paused) : status.globalMode === 'paused';
- const globalMode = global.defaultMode || (status.globalMode === 'auto' ? 'auto' : 'review');
- const listenerRunning = Boolean(listener.running);
- const agent = status.agent || {};
- const knowledge = status.knowledge || {};
- const conversations = state.agent.conversations || [];
- const selected = conversations.find(item => item.id === state.agent.selectedId) || conversations[0] || null;
- if (selected) state.agent.selectedId = selected.id;
- const sessionGuide = selected?.claudeSession || null;
- const analysis = selected?.analysis || {};
- const customerIntelligence = selected?.customerIntelligence || {};
- const customerTasks = customerIntelligence.tasks || [];
- const customerAlerts = customerIntelligence.alerts || [];
- const intelligenceSummary = customerIntelligence.summary || {};
- const demands = demandItems(analysis.demand || {});
- const matches = analysis.matches || [];
- const pending = selected?.pendingReply || null;
- const noReplyNotice = selected?.agentNotice || null;
- const activeDraft = pending;
- const pendingText = pending?.content || '';
- const manualActive = selected?.mode === 'human';
- const conversationPaused = selected?.mode === 'paused';
- const audit = selected?.audit || [];
- const toolTrace = activeDraft?.toolTrace || activeDraft?.tool_trace || [];
- const citations = activeDraft?.citations || [];
- els.accountName.textContent = accountName;
- els.accountDot.className = `account-dot ${account.online ? 'online' : 'offline'}`;
- page.innerHTML = `
- <section class="agent-hero">
- <div>
- <div class="agent-eyebrow">WECOM · REAL AGENT · HUMAN IN THE LOOP</div>
- <h2>真实企微消息,由 Agent 思考,人工可随时监管</h2>
- <p>消息进入后,Agent 自主检索规则库、知识库、画像和房源工具;草稿、依据、置信度和工具轨迹全部留痕。</p>
- </div>
- <div class="agent-safety-card">
- <span class="agent-live-dot ${account.online ? 'online' : ''}"></span>
- <div><strong>${escapeHtml(accountName)} · ${account.online ? '在线' : '离线'}</strong><small>${escapeHtml(account.corpName || config.transport || '企微通道')} · ${listenerRunning ? '真实消息监听中' : '监听已停止'}</small></div>
- <div class="agent-safety-actions">
- <button class="btn btn-secondary" data-agent-action="sync-conversations" ${account.online ? '' : 'disabled'}>补采最近消息</button>
- <button class="btn ${listenerRunning ? 'btn-secondary' : ''}" data-agent-action="${listenerRunning ? 'stop-listener' : 'start-listener'}" ${account.online ? '' : 'disabled'}>${listenerRunning ? '关闭 AI 监听(转人工)' : '启动 AI 监听'}</button>
- </div>
- </div>
- </section>
- <section class="agent-modebar">
- <div class="agent-mode-copy"><strong>全局 Agent 策略</strong><span>监听与 Agent 策略相互独立,暂停时仍可收消息</span></div>
- <div class="agent-mode-switch">
- <button class="agent-mode-btn ${!globalPaused && globalMode === 'review' ? 'active' : ''}" data-agent-mode="review">待审核</button>
- <button class="agent-mode-btn ${!globalPaused && globalMode === 'auto' ? 'active' : ''}" data-agent-mode="auto">高置信自动</button>
- <button class="agent-mode-btn ${globalPaused ? 'active danger' : ''}" data-agent-mode="paused">全局暂停</button>
- </div>
- <div class="agent-guard"><span>安全边界</span>仅处理 1 个白名单联系人 · 演示回复已禁用</div>
- </section>
- ${renderResponseMonitor(state.agent.responseMonitor || {})}
- ${selected?.agentError
- ? `<div class="agent-runtime-alert"><strong>Agent 上游不可用</strong><span>${escapeHtml(selected.agentError.message)}。真实消息已保存,系统没有生成或发送 Mock 回复。</span></div>`
- : noReplyNotice
- ? `<div class="agent-runtime-alert notice"><strong>Agent 已处理</strong><span>${escapeHtml(noReplyNotice.message)},系统没有向客户发送消息。</span></div>`
- : ''}
- <section class="agent-workspace">
- <aside class="agent-conversation-list">
- <div class="agent-panel-heading"><div><strong>真实客户会话</strong><small>${conversations.length} 个白名单会话</small></div><span class="agent-count">${conversations.length}</span></div>
- <div class="agent-list-scroll">
- ${conversations.length ? conversations.map(item => {
- const last = [...(item.messages || [])].reverse().find(message => message.role === 'customer') || item.messages?.[item.messages.length - 1];
- return `<button class="agent-conversation-item ${item.id === selected?.id ? 'active' : ''}" data-conversation-id="${item.id}">
- <span class="agent-avatar">${escapeHtml((item.displayName || '客').slice(0, 1))}</span>
- <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>
- <time>${formatAgentTime(item.lastMessageAt)}</time>
- </button>`;
- }).join('') : `<div class="agent-empty"><strong>等待第一条真实消息</strong><span>开始接收后,用“王刚”的企微发送测试内容。</span></div>`}
- </div>
- </aside>
- <main class="agent-chat-panel">
- ${selected ? `
- <header class="agent-chat-header">
- <div><strong>${escapeHtml(selected.displayName)}</strong><span>${escapeHtml(selected.maskedId)} · 真实企微会话 · ${agentModeLabel(selected.mode)}</span></div>
- <div class="agent-conversation-modes">
- <button class="btn btn-sm ${selected.mode === 'review' ? '' : 'btn-secondary'}" data-agent-conversation-mode="review" data-id="${selected.id}">待审核</button>
- <button class="btn btn-sm ${selected.mode === 'auto' ? '' : 'btn-secondary'}" data-agent-conversation-mode="auto" data-id="${selected.id}">自动</button>
- <button class="btn btn-sm ${selected.mode === 'human' ? '' : 'btn-secondary'}" data-agent-conversation-mode="human" data-id="${selected.id}">人工接管</button>
- <button class="btn btn-sm ${selected.mode === 'paused' ? '' : 'btn-secondary'}" data-agent-conversation-mode="paused" data-id="${selected.id}">暂停</button>
- </div>
- </header>
- <div class="agent-session-strip ${sessionGuide?.ready ? 'ready' : ''}">
- <span class="agent-session-dot"></span>
- <div><strong>${sessionGuide?.ready ? 'Claude Code 客户 Session 已识别' : 'Claude Code 客户 Session 等待首次运行'}</strong><small>${escapeHtml(sessionGuide?.displayName || 'Agent 首次生成草稿后自动创建')} · ${sessionGuide?.ready ? '可安全打开审阅副本' : '不会与其他客户共用上下文'}</small></div>
- <button class="btn btn-sm btn-secondary" data-agent-action="session-guide" data-id="${selected.id}">${sessionGuide?.ready ? '查看会话与打开方式' : '查看说明'}</button>
- </div>
- <div class="agent-message-stream">${(selected.messages || []).map(renderAgentMessage).join('')}</div>
- <div class="agent-composer ${manualActive ? 'manual' : ''}">
- <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>
- <textarea id="agent-reply-editor" placeholder="${manualActive ? '输入人工回复内容…' : pending ? '可先编辑 Agent 草稿…' : noReplyNotice ? '最新客户消息无需回复' : '点击下方按钮,让 Agent 处理最近一条消息'}" ${conversationPaused ? 'disabled' : ''}>${escapeHtml(pendingText)}</textarea>
- <div class="agent-composer-actions">
- <span>${pending ? escapeHtml(pending.reason || '请核对内容与依据后再发送') : `模型:${escapeHtml(agent.model || '未配置')} · ${agent.configured ? '已配置' : '未配置'}`}</span>
- <div class="agent-review-actions">
- ${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>`}
- </div>
- </div>
- </div>
- ` : `<div class="agent-empty large"><strong>真实 Agent 会话准备就绪</strong><span>开始接收,然后从“王刚”的企微发送:“我想在新北区买婚房,预算150万,三室,最好精装”</span></div>`}
- </main>
- <aside class="agent-insight-panel" data-conversation-id="${escapeHtml(selected?.id || '')}">
- ${selected ? `
- <div class="agent-insight-section">
- <div class="agent-section-title"><span>01</span><div><strong>意图判断</strong><small>Agent 结构化理解</small></div></div>
- <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>
- <p class="agent-reasoning">${escapeHtml(analysis.reasoning || '等待新消息进入。')}</p>
- </div>
- <div class="agent-insight-section">
- <div class="agent-section-title"><span>02</span><div><strong>需求画像</strong><small>从多轮会话持续归集</small></div></div>
- <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>
- </div>
- <div class="agent-insight-section">
- <div class="agent-section-title"><span>03</span><div><strong>客户待办</strong><small>${intelligenceSummary.openTasks || 0} 项待处理 · 仅内部建议</small></div></div>
- <div class="agent-customer-task-list">${customerTasks.length ? customerTasks.slice(0, 8).map(renderAgentCustomerTask).join('') : '<p class="agent-muted">继续对话后,Agent 会从明确需求中生成内部跟进待办</p>'}</div>
- </div>
- <div class="agent-insight-section">
- <div class="agent-section-title"><span>04</span><div><strong>客户预警</strong><small>${intelligenceSummary.openAlerts || 0} 项未处理 · ${intelligenceSummary.highAlerts || 0} 项高优先级</small></div></div>
- <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>
- </div>
- <div class="agent-insight-section">
- <div class="agent-section-title"><span>05</span><div><strong>匹配建议</strong><small>已接入房源工具的返回结果</small></div></div>
- <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>
- </div>
- <div class="agent-insight-section knowledge">
- <div class="agent-section-title"><span>06</span><div><strong>依据与引用</strong><small>${knowledge.knowledgeChunks || 0} 个知识块 · ${knowledge.propertyDataLabel || '房源未接入'}</small></div></div>
- <ul>${(citations.length ? citations.map(item => `${item.heading || item.source} · ${item.source || ''}`) : analysis.knowledgeSources || []).map(source => `<li>${escapeHtml(source)}</li>`).join('')}</ul>
- </div>
- <div class="agent-insight-section">
- <div class="agent-section-title"><span>07</span><div><strong>工具调用轨迹</strong><small>Agent 自主检索过程</small></div></div>
- <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>
- </div>
- <div class="agent-insight-section">
- <div class="agent-section-title"><span>08</span><div><strong>审计日志</strong><small>接收、决策、审核和发送全留痕</small></div></div>
- <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>
- </div>
- ` : '<div class="agent-empty"><strong>暂无分析</strong><span>选择会话后显示 Agent 工作过程。</span></div>'}
- </aside>
- </section>
- `;
- restoreAgentInsightScroll(page, insightScroll, selected?.id);
- scrollAgentMessagesToLatest(page);
- }
- async function loadAgentData(page, silent = false) {
- if (state.agent.loading) return;
- state.agent.loading = true;
- try {
- const [statusRes, conversationsRes, responseMonitorRes] = await Promise.all([
- api('GET', '/api/agent/status'),
- api('GET', '/api/agent/conversations'),
- api('GET', '/api/agent/response-monitor'),
- ]);
- const nextStatus = statusRes.data || {};
- const nextConversations = conversationsRes.data?.conversations || [];
- const nextResponseMonitor = responseMonitorRes.data || {};
- const localAccount = currentAccount();
- const activeAccount = nextStatus.account || {};
- if (localAccount && localAccount.uid && activeAccount.uid === localAccount.uid) {
- let accountChanged = false;
- for (const [key, value] of [
- ['guid', activeAccount.guid],
- ['userId', activeAccount.userId],
- ['corpName', activeAccount.corpName],
- ]) {
- if (!localAccount[key] && value) {
- localAccount[key] = value;
- accountChanged = true;
- }
- }
- if (!localAccount.nickname && activeAccount.nickname) {
- localAccount.nickname = activeAccount.nickname;
- accountChanged = true;
- }
- if (localAccount.online !== Boolean(activeAccount.online)) {
- localAccount.online = Boolean(activeAccount.online);
- accountChanged = true;
- }
- if (accountChanged) {
- saveAccounts();
- updateAccountSwitcher();
- }
- }
- const nextSignature = JSON.stringify({
- globalMode: nextStatus.globalMode,
- listenerRunning: Boolean(nextStatus.listener?.running),
- account: nextStatus.account,
- config: nextStatus.config,
- conversations: nextConversations,
- responseMonitor: nextResponseMonitor,
- });
- const changed = nextSignature !== state.agent.signature;
- state.agent.status = nextStatus;
- state.agent.conversations = nextConversations;
- state.agent.responseMonitor = nextResponseMonitor;
- state.agent.signature = nextSignature;
- if (!state.agent.selectedId && state.agent.conversations.length) state.agent.selectedId = state.agent.conversations[0].id;
- const waitingForFirstRender = Boolean(page.querySelector('.agent-loading'));
- if ((changed || waitingForFirstRender) && state.page === 'agent' && page.isConnected) renderAgentWorkspaceV2(page);
- } catch (error) {
- if (!silent) toast(error.message || '智能会话加载失败', 'error');
- } finally {
- state.agent.loading = false;
- }
- }
- function renderAgentPage() {
- els.pageTitle.textContent = '智能会话';
- state.agent.signature = null;
- if (!currentAccount()) {
- els.accountName.textContent = '正在检测账号';
- els.accountDot.className = 'account-dot offline';
- }
- const page = document.createElement('div');
- page.className = 'page agent-page';
- page.innerHTML = '<div class="agent-loading">正在读取企微智能会话…</div>';
- els.content.appendChild(page);
- page.addEventListener('click', async event => {
- const responseMonitorButton = event.target.closest('[data-response-monitor-target]');
- if (responseMonitorButton) {
- if (responseMonitorButton.dataset.responseMonitorTarget === 'customer') {
- state.agent.selectedId = responseMonitorButton.dataset.id;
- renderAgentWorkspaceV2(page);
- } else {
- location.hash = '#groups';
- }
- return;
- }
- const conversationButton = event.target.closest('[data-conversation-id]');
- if (conversationButton) {
- state.agent.selectedId = conversationButton.dataset.conversationId;
- renderAgentWorkspaceV2(page);
- return;
- }
- const modeButton = event.target.closest('[data-agent-mode]');
- if (modeButton) {
- const mode = modeButton.dataset.agentMode;
- if (mode === 'auto' && !window.confirm('高置信自动模式会真实发送回复,仅对白名单联系人“王刚”生效。确认开启吗?')) return;
- try {
- const result = await api('POST', '/api/agent/mode', { mode });
- toast(result.assistantMessage || '模式已更新');
- await loadAgentData(page);
- } catch (error) {
- toast(error.message || '模式切换失败', 'error');
- }
- return;
- }
- const conversationModeButton = event.target.closest('[data-agent-conversation-mode]');
- if (conversationModeButton) {
- const id = conversationModeButton.dataset.id;
- const mode = conversationModeButton.dataset.agentConversationMode;
- if (mode === 'auto' && !window.confirm('该会话的高置信草稿可能自动真实发送。确认开启吗?')) return;
- const action = ({ review: 'resume', auto: 'auto', human: 'takeover', paused: 'pause' })[mode];
- conversationModeButton.disabled = true;
- try {
- const result = await api('POST', `/api/agent/conversations/${id}/${action}`, {});
- toast(result.assistantMessage || '会话模式已更新');
- await loadAgentData(page);
- } catch (error) {
- toast(error.message || '会话模式切换失败', 'error');
- } finally {
- if (conversationModeButton.isConnected) conversationModeButton.disabled = false;
- }
- return;
- }
- const actionButton = event.target.closest('[data-agent-action]');
- if (!actionButton) return;
- const action = actionButton.dataset.agentAction;
- const id = actionButton.dataset.id;
- if (action === 'session-guide') {
- const conversation = (state.agent.conversations || []).find(item => item.id === id);
- openAgentSessionGuide(conversation?.claudeSession || {});
- return;
- }
- actionButton.disabled = true;
- try {
- let result;
- if (action === 'sync-conversations') {
- if (!window.confirm('只补采白名单联系人最近的真实消息,不运行 Agent,也不发送任何回复。确认继续吗?')) return;
- result = await api('POST', '/api/agent/conversations/sync', {});
- const synced = result.data?.conversations || [];
- if (synced.length) {
- state.agent.conversations = synced;
- state.agent.selectedId = synced[0].id;
- }
- } else if (action === 'customer-task-sync-official') {
- const selected = (state.agent.conversations || []).find(item => item.id === state.agent.selectedId) || (state.agent.conversations || [])[0];
- const task = selected?.customerIntelligence?.tasks?.find(item => item.id === id);
- if (!task) throw new Error('待办记录已变化,请刷新后重试');
- const syncInput = await showOfficialTodoSyncModal(task);
- if (!syncInput) return;
- result = await api('POST', `/api/agent/tasks/${encodeURIComponent(id)}/sync-official`, syncInput);
- } else if (action === 'customer-task-complete-official') {
- if (!window.confirm('本地待办已经完成。确认现在将绑定的真实企业微信待办也标记为完成?')) return;
- result = await api('POST', `/api/agent/tasks/${id}`, { status: 'done' });
- } else if (action === 'customer-task-done' || action === 'customer-task-dismiss') {
- const selected = (state.agent.conversations || []).find(item => item.id === state.agent.selectedId) || (state.agent.conversations || [])[0];
- const task = selected?.customerIntelligence?.tasks?.find(item => item.id === id);
- if (action === 'customer-task-done' && task?.officialTodoId && !window.confirm('该待办已绑定企业微信。确认后会同时将真实企微待办标记为完成,是否继续?')) return;
- result = await api('POST', `/api/agent/tasks/${id}`, { status: action === 'customer-task-done' ? 'done' : 'dismissed' });
- } else if (action === 'customer-alert-ack' || action === 'customer-alert-dismiss') {
- result = await api('POST', `/api/agent/alerts/${id}`, { status: action === 'customer-alert-ack' ? 'acknowledged' : 'dismissed' });
- } else if (action === 'start-listener' || action === 'stop-listener') {
- if (action === 'start-listener' && !window.confirm('启动后,白名单客户的新消息会由 AI 处理;高置信回复可能自动真实发送。人工可随时关闭监听或接管会话。确认启动吗?')) return;
- result = await api('POST', `/api/agent/listener/${action === 'start-listener' ? 'start' : 'stop'}`, {});
- } else if (action === 'approve') {
- const content = page.querySelector('#agent-reply-editor')?.value.trim();
- if (!content) throw new Error('请先输入回复内容');
- if (!window.confirm('确认审核通过,并真实发送给白名单联系人“王刚”?')) return;
- result = await api('POST', `/api/agent/drafts/${actionButton.dataset.draftId}/approve`, { content });
- } else if (action === 'reject') {
- const reason = window.prompt('驳回原因(可选)', '需要人工调整');
- if (reason === null) return;
- result = await api('POST', `/api/agent/drafts/${actionButton.dataset.draftId}/reject`, { reason });
- } else if (action === 'regenerate') {
- result = await api('POST', `/api/agent/drafts/${actionButton.dataset.draftId}/regenerate`, {});
- } else if (action === 'generate') {
- result = await api('POST', `/api/agent/conversations/${id}/generate`, {});
- } else if (action === 'manual-send') {
- const content = page.querySelector('#agent-reply-editor')?.value.trim();
- if (!content) throw new Error('请先输入人工回复内容');
- if (!window.confirm('确认由人工真实发送给白名单联系人“王刚”?')) return;
- result = await api('POST', `/api/agent/conversations/${id}/manual-send`, { content });
- }
- if (result) toast(result.assistantMessage || '操作成功');
- await loadAgentData(page);
- } catch (error) {
- toast(error.message || '操作失败', 'error');
- } finally {
- if (actionButton.isConnected) actionButton.disabled = false;
- }
- });
- loadAgentData(page);
- state.agent.timer = setInterval(() => loadAgentData(page, true), 5000);
- }
- // ==================== Login ====================
- async function openSubscribeFlowPage() {
- try {
- const res = await api('POST', '/api/login/start', { flowUi: true });
- const flowUrl = res.data?.flowUrl || res.summary?.flowUrl;
- if (flowUrl) {
- window.open(flowUrl, '_blank');
- return true;
- }
- toast('未获取到订阅流程页地址', 'warning');
- return false;
- } catch (err) {
- toast(err.message || '打开订阅流程页失败', 'error');
- return false;
- }
- }
- function renderLoginPage() {
- els.pageTitle.textContent = '登录企微账号';
- const page = document.createElement('div');
- page.className = 'login-page';
- page.innerHTML = `
- <div class="login-card">
- <div class="brand-icon" style="margin:0 auto 16px;width:48px;height:48px">
- <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>
- </div>
- <h2>请先登录企业微信</h2>
- <p>登录后才能扫描和管理客户群。如需管理多个账号,可重复登录并切换。</p>
- <div id="login-qr-wrap">
- <button class="btn" id="btn-start-login" style="width:100%">生成登录二维码</button>
- </div>
- </div>
- `;
- els.content.appendChild(page);
- page.querySelector('#btn-start-login').addEventListener('click', () => startLoginFlow());
- }
- function createDeviceUid() {
- const id = typeof crypto?.randomUUID === 'function'
- ? crypto.randomUUID()
- : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
- return `qiwei-${id}`;
- }
- async function startLoginFlow() {
- if (!state.loginUid) state.loginUid = currentAccount()?.uid || createDeviceUid();
- const wrap = document.getElementById('login-qr-wrap');
- wrap.innerHTML = `<div class="qr-wrap">
- <div class="skeleton" style="width:220px;height:220px;margin:0 auto"></div>
- </div>
- <div class="qr-status" id="login-status-text">正在生成二维码…</div>`;
- try {
- const res = await api('POST', '/api/login/start', { uid: state.loginUid, persistConfig: false });
- if (res.status === 'needs_subscription') {
- wrap.innerHTML = `<div class="hint-box" style="text-align:left">
- <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>
- <div>${escapeHtml(res.assistantMessage || '需要先开通订阅')}</div>
- </div>
- <button class="btn" id="btn-subscribe-login" style="width:100%;margin-bottom:10px">打开订阅与登录流程页</button>
- <button class="btn btn-secondary" id="btn-retry-login" style="width:100%">重试</button>`;
- document.getElementById('btn-subscribe-login').addEventListener('click', openSubscribeFlowPage);
- document.getElementById('btn-retry-login').addEventListener('click', startLoginFlow);
- return;
- }
- const qrcodeFile = res.data?.qrcodeFile || res.summary?.qrcodeFile;
- const flowUrl = res.data?.flowUrl;
- if (flowUrl && !qrcodeFile) {
- wrap.innerHTML = `<div class="hint-box" style="text-align:left">
- <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>
- <div>已打开登录流程页,请在新窗口中完成扫码。</div>
- </div>
- <a class="btn" href="${escapeHtml(flowUrl)}" target="_blank" style="width:100%;text-decoration:none">打开登录页面</a>`;
- return;
- }
- if (!qrcodeFile) {
- wrap.innerHTML = `<div class="hint-box" style="text-align:left">
- <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>
- <div>未获取到二维码,请重试。</div>
- </div>
- <button class="btn" id="btn-retry-login">重试</button>`;
- document.getElementById('btn-retry-login').addEventListener('click', startLoginFlow);
- return;
- }
- wrap.innerHTML = `<div class="qr-wrap">
- <img src="/api/outputs?path=${encodeURIComponent(qrcodeFile)}" alt="登录二维码" id="login-qr">
- </div>
- <div class="qr-status" id="login-status-text">请用企业微信扫码</div>
- <button class="btn btn-secondary" id="btn-retry-login" style="margin-top:12px">重新生成二维码</button>`;
- document.getElementById('btn-retry-login').addEventListener('click', () => {
- clearInterval(state.loginTimer);
- startLoginFlow();
- });
- state.loginPhase = 'polling';
- pollLoginStatus();
- } catch (err) {
- wrap.innerHTML = `<div class="hint-box" style="text-align:left;background:var(--error-bg);color:var(--error);border-color:var(--error-border)">
- <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>
- <div>${escapeHtml(err.message || '登录启动失败')}</div>
- </div>
- <button class="btn" id="btn-retry-login">重试</button>`;
- document.getElementById('btn-retry-login').addEventListener('click', startLoginFlow);
- }
- }
- function pollLoginStatus() {
- clearInterval(state.loginTimer);
- let checkCount = 0;
- const statusText = document.getElementById('login-status-text');
- state.loginTimer = setInterval(async () => {
- checkCount++;
- try {
- const res = await api('POST', '/api/login/check', { uid: state.loginUid, persistConfig: false });
- const statusCode = res.summary?.statusCode;
- if (statusCode === 2) {
- clearInterval(state.loginTimer);
- if (statusText) statusText.textContent = '登录成功,正在获取账号信息…';
- await finalizeLogin(res.data || {});
- return;
- }
- if (res.status === 'needs_verify_code') {
- clearInterval(state.loginTimer);
- if (statusText) statusText.textContent = '请输入手机端验证码';
- showVerifyCodeInput();
- return;
- }
- if (statusCode === 4) {
- clearInterval(state.loginTimer);
- if (statusText) statusText.textContent = '登录已取消,请重新生成二维码';
- return;
- }
- if (statusText) {
- if (statusCode === 1) statusText.textContent = '已扫码,请在手机上确认';
- else if (statusCode === 0 && checkCount > 3) statusText.textContent = '等待扫码…(可尝试免扫码登录)';
- else statusText.textContent = '请用企业微信扫码';
- }
- } catch (err) {
- if (statusText) statusText.textContent = '状态检测异常,重试中…';
- }
- }, 2500);
- }
- function showVerifyCodeInput() {
- const wrap = document.getElementById('login-qr-wrap');
- wrap.innerHTML = `
- <div class="form-group" style="text-align:left">
- <label>手机端显示的 6 位验证码</label>
- <input id="verify-code" type="text" maxlength="6" placeholder="000000" style="font-size:22px;letter-spacing:12px;text-align:center" />
- </div>
- <button class="btn" id="btn-submit-verify" style="width:100%;margin-top:8px">提交验证码</button>
- <button class="btn btn-secondary" id="btn-back-qr" style="width:100%;margin-top:10px">返回二维码</button>
- `;
- const input = document.getElementById('verify-code');
- input.focus();
- input.addEventListener('input', () => {
- if (/^\d{6}$/.test(input.value.trim())) submitVerifyCode(input.value.trim());
- });
- document.getElementById('btn-submit-verify').addEventListener('click', () => {
- submitVerifyCode(input.value.trim());
- });
- document.getElementById('btn-back-qr').addEventListener('click', () => {
- clearInterval(state.loginTimer);
- startLoginFlow();
- });
- }
- async function submitVerifyCode(code) {
- if (!/^\d{6}$/.test(code)) {
- toast('请输入 6 位数字验证码', 'warning');
- return;
- }
- try {
- await api('POST', '/api/login/verify', { code, uid: state.loginUid });
- toast('验证码已提交,正在确认登录…');
- pollLoginStatus();
- const statusText = document.getElementById('login-status-text');
- if (statusText) statusText.textContent = '验证码已提交,正在确认…';
- } catch (err) {
- toast(err.message || '验证码提交失败', 'error');
- }
- }
- async function finalizeLogin(result = {}) {
- try {
- const loginDetail = result.status || result.detail || {};
- const uid = String(result.uid || state.loginUid || '').trim();
- const guid = String(result.guid || loginDetail.guid || loginDetail.userId || '').trim();
- const account = {
- uid,
- guid,
- userId: loginDetail.userId || '',
- nickname: loginDetail.nickname || loginDetail.userId || '',
- corpName: loginDetail.corpName || '',
- corpId: loginDetail.corpId || '',
- online: true
- };
- if (!account.uid) {
- toast('登录成功,但未获取到 Fmode 设备标识,请重新扫码', 'error');
- return;
- }
- addAccount(account);
- const switched = await switchAccount(state.currentAccountId, { silent: true });
- if (!switched) return;
- state.loginUid = null;
- toast(`登录成功:${account.nickname || account.userId}`);
- if (state.preRecoveryPage) {
- location.hash = `#${state.preRecoveryPage}`;
- state.preRecoveryPage = null;
- }
- renderPage();
- } catch (err) {
- toast('登录成功,但获取账号信息失败', 'error');
- }
- }
- // ==================== Login Recovery ====================
- function renderRecoverButton(container, options = {}) {
- const { loading = false, message = '' } = options;
- if (!container) return;
- container.innerHTML = '';
- if (message) {
- const msg = document.createElement('div');
- msg.className = 'recover-message';
- 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)}`;
- container.appendChild(msg);
- }
- if (!loading) {
- const btn = document.createElement('button');
- btn.className = 'btn btn-sm';
- btn.id = 'btn-recover-login';
- btn.innerHTML = '恢复登录';
- btn.addEventListener('click', () => recoverLogin());
- container.appendChild(btn);
- }
- }
- function updateRecoverMessage(message, loading = false) {
- const wrap = document.getElementById('status-recover-wrap');
- if (wrap) renderRecoverButton(wrap, { loading, message });
- }
- async function attemptAutoRecover(auto = false) {
- if (state.loginPhase === 'recovering') return false;
- state.loginPhase = 'recovering';
- updateRecoverMessage('正在尝试免扫码恢复登录…', true);
- try {
- const uid = state.loginUid || currentAccount()?.uid || '';
- if (!uid) throw new Error('该账号缺少 Fmode 设备绑定,请重新扫码登录');
- state.loginUid = uid;
- const res = await api('POST', '/api/login/check', { manual: true, uid, persistConfig: false });
- const statusCode = res.summary?.statusCode;
- if (statusCode === 2) {
- await finalizeLogin(res.data || {});
- state.loginPhase = 'idle';
- return true;
- }
- if (res.status === 'needs_verify_code') {
- state.loginPhase = 'verify';
- state.preRecoveryPage = location.hash.slice(1) || 'groups';
- state.currentAccountId = null;
- renderPage();
- setTimeout(() => showVerifyCodeInput(), 100);
- return false;
- }
- state.loginPhase = 'idle';
- if (auto) {
- toast('账号离线,无法自动恢复,请进入「账号状态」手动登录', 'warning');
- } else {
- updateRecoverMessage('无法自动恢复,请手动登录');
- }
- return false;
- } catch (err) {
- state.loginPhase = 'idle';
- if (auto) {
- toast('账号离线,自动恢复失败,请进入「账号状态」手动登录', 'warning');
- } else {
- updateRecoverMessage('自动恢复失败,请手动登录');
- }
- return false;
- }
- }
- async function recoverLogin() {
- state.autoRecoverAttempted = true;
- state.loginUid = currentAccount()?.uid || null;
- const recovered = await attemptAutoRecover(false);
- if (!recovered) {
- state.preRecoveryPage = location.hash.slice(1) || 'groups';
- state.currentAccountId = null;
- renderPage();
- setTimeout(() => startLoginFlow(), 100);
- }
- }
- function syncAccountOnlineStatus(online, detail = {}) {
- const current = currentAccount();
- const currentGuid = current?.guid || detail?.guid || detail?.userId || '';
- const currentUserId = current?.userId || detail?.userId || '';
- let changed = false;
- state.accounts.forEach(a => {
- const match = (currentGuid && (a.guid === currentGuid || a.userId === currentGuid)) ||
- (currentUserId && (a.userId === currentUserId || a.guid === currentUserId));
- if (match) {
- if (a.online !== online) {
- a.online = online;
- changed = true;
- }
- if (detail?.nickname && a.nickname !== detail.nickname) {
- a.nickname = detail.nickname;
- changed = true;
- }
- if (detail?.corpName && a.corpName !== detail.corpName) {
- a.corpName = detail.corpName;
- changed = true;
- }
- }
- });
- if (changed) {
- saveAccounts();
- updateAccountSwitcher();
- }
- }
- // ==================== Status Page ====================
- async function renderStatusPage() {
- els.pageTitle.textContent = '账号状态';
- const page = document.createElement('div');
- page.className = 'page';
- els.content.appendChild(page);
- showLoading(page, 'list');
- try {
- const res = await api('GET', '/api/status', undefined, 8000);
- hideLoading(page);
- const login = res.data?.login || {};
- const sub = res.data?.subscription || {};
- const online = !!login.summary?.online;
- const subscribed = !!sub.summary?.subscribed;
- const acc = currentAccount() || {};
- // 同步已保存账号的在线状态,确保表格显示与当前状态一致
- syncAccountOnlineStatus(online, login.data?.detail);
- const currentAcc = {
- nickname: acc.nickname || login.summary?.nickname || '当前账号',
- corpName: acc.corpName || login.data?.detail?.corpName || '',
- userId: acc.userId || login.data?.detail?.userId || ''
- };
- page.innerHTML = `
- <div class="status-cards">
- <div class="status-card">
- <div class="status-icon ${online ? 'green' : 'red'}">
- <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>
- </div>
- <div class="status-content">
- <h3>${escapeHtml(currentAcc.nickname)}</h3>
- <p>${escapeHtml(currentAcc.corpName || '企业微信账号')}</p>
- <div class="status-detail">
- <span class="status-dot ${online ? 'online' : 'offline'}"></span>
- ${online ? '账号在线,可正常操作' : '账号离线,请重新登录'}
- </div>
- ${!online ? `<div id="status-recover-wrap" style="margin-top:12px"></div>` : ''}
- </div>
- </div>
- <div class="status-card">
- <div class="status-icon ${subscribed ? 'green' : 'orange'}">
- <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>
- </div>
- <div class="status-content">
- <h3>${subscribed ? '订阅有效' : '未开通订阅'}</h3>
- <p>${escapeHtml(sub.summary?.expireAt ? `到期时间:${sub.summary.expireAt}` : '请检查订阅状态')}</p>
- ${sub.summary?.seats ? `<div class="status-detail">席位:${formatNumber(sub.summary.seats)} 个</div>` : ''}
- <button class="btn btn-sm" id="btn-subscribe-action" style="margin-top:10px">${subscribed ? '续费/管理' : '开通订阅'}</button>
- </div>
- </div>
- <div class="status-card">
- <div class="status-icon ${res.summary?.authConfigured ? 'green' : 'red'}">
- <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>
- </div>
- <div class="status-content">
- <h3>${res.summary?.authConfigured ? '鉴权已配置' : '鉴权未配置'}</h3>
- <p>${res.summary?.authConfigured ? '已检测到 Fmode 鉴权 Token' : '请配置 QIWEI_AUTH_TOKEN 或 FMODE_API_KEY'}</p>
- </div>
- </div>
- </div>
- <div class="card">
- <div class="card-header">
- <h3 class="card-title">已保存账号</h3>
- <button class="btn btn-sm" id="btn-add-account-page">添加账号</button>
- </div>
- ${state.accounts.length ? `
- <div class="table-wrap">
- <table>
- <thead><tr><th>账号</th><th>企业</th><th>状态</th><th style="width:120px">操作</th></tr></thead>
- <tbody>
- ${state.accounts.map(a => `
- <tr class="${a.id === state.currentAccountId ? 'active-row' : ''}">
- <td>${escapeHtml(a.nickname || a.userId || a.guid)}</td>
- <td>${escapeHtml(a.corpName || '-')}</td>
- <td><span class="status-dot ${a.online ? 'online' : 'offline'}"></span>${a.online ? '在线' : '离线'}</td>
- <td>
- <div style="display:flex;gap:6px">
- ${a.id !== state.currentAccountId ? `<button class="btn btn-sm btn-secondary" data-action="switch" data-id="${escapeHtml(a.id)}">切换</button>` : ''}
- <button class="btn btn-sm btn-danger" data-action="remove" data-id="${escapeHtml(a.id)}">删除</button>
- </div>
- </td>
- </tr>
- `).join('')}
- </tbody>
- </table>
- </div>
- ` : `
- <div class="empty-state" style="padding:32px">
- <div class="empty-state-title">还没有保存的账号</div>
- <div class="empty-state-desc">点击右上角「添加账号」开始登录</div>
- </div>
- `}
- </div>
- `;
- page.querySelector('#btn-add-account-page').addEventListener('click', () => {
- state.loginUid = createDeviceUid();
- state.currentAccountId = null;
- saveAccounts();
- renderPage();
- });
- const subscribeBtn = page.querySelector('#btn-subscribe-action');
- if (subscribeBtn) {
- subscribeBtn.addEventListener('click', openSubscribeFlowPage);
- }
- page.querySelectorAll('[data-action="switch"]').forEach(btn => {
- btn.addEventListener('click', () => { void switchAccount(btn.dataset.id); });
- });
- page.querySelectorAll('[data-action="remove"]').forEach(btn => {
- btn.addEventListener('click', () => {
- if (confirm('确定删除这个账号吗?')) removeAccount(btn.dataset.id);
- });
- });
- const recoverWrap = page.querySelector('#status-recover-wrap');
- if (recoverWrap) {
- renderRecoverButton(recoverWrap);
- }
- } catch (err) {
- showError(page, err.message || '加载状态失败', () => renderStatusPage());
- }
- }
- // ==================== Groups Page ====================
- async function renderGroupsPage() {
- els.pageTitle.textContent = '客户群管理';
- state.selectedGroupIds = state.selectedGroupIds || new Set();
- const page = document.createElement('div');
- page.className = 'page customer-workspace-page';
- page.innerHTML = `
- <section class="workspace-hero customer-workspace-hero group-hero">
- <div>
- <span>WECOM CUSTOMER GROUP OPERATIONS</span>
- <h2>客户群管理</h2>
- <p>识别真实客户群、同步历史消息,并集中完成人工确认与群运营准备。</p>
- </div>
- ${renderGroupStatsCard(null)}
- </section>
- <section class="group-workspace">
- <aside class="group-sync-panel">${renderGroupSyncCard()}</aside>
- <main class="group-list-panel">
- <div class="operation-note">
- <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>
- <div><strong>先识别,再同步</strong><span>勾选目标群后可批量确认并保存历史消息;未确认的普通群不会进入客户运营流程。</span></div>
- </div>
- ${renderGroupFilters()}
- <div id="groups-table-card"></div>
- </main>
- </section>
- `;
- els.content.appendChild(page);
- bindGroupSync(page);
- bindGroupFilters(page);
- await loadGroups();
- }
- function renderGroupSyncCard() {
- return `
- <div class="card group-sync-card">
- <div class="card-header">
- <div>
- <h3 class="card-title">
- <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>
- 扫描客户群
- </h3>
- <div class="card-subtitle">从企业微信拉取群数据,并自动判断哪些可能是客户群</div>
- </div>
- </div>
- <div class="group-sync-fields">
- <div class="form-group">
- <label>扫描范围</label>
- <select id="sync-scope">
- <option value="all">全部群</option>
- <option value="self">我创建的群</option>
- <option value="session">最近聊天里的群</option>
- <option value="messages">从消息记录里找群</option>
- </select>
- </div>
- <div class="form-group">
- <label>扫描深度</label>
- <input id="sync-max-pages" type="number" value="20" min="1" max="300" />
- <span class="field-help">数值越大扫描越完整,耗时也越长</span>
- </div>
- </div>
- <details class="advanced-settings">
- <summary>识别规则设置</summary>
- <div class="advanced-settings-body">
- <div class="form-group">
- <label>客户群关键词(用逗号隔开)</label>
- <textarea id="sync-keywords" rows="3">客户群,服务群,售后群,咨询群,VIP群,专属群,业主群,项目群,订单群,用户群</textarea>
- </div>
- <div class="form-group">
- <label>高置信度词</label>
- <input id="sync-high-confidence" type="text" value="客户群,服务群" />
- </div>
- </div>
- </details>
- <div class="checkbox-row group-auto-classify">
- <input id="sync-auto-classify" type="checkbox" checked />
- <label for="sync-auto-classify">自动识别客户群</label>
- </div>
- <button class="btn group-primary-action" id="btn-sync-groups" data-job="sync-groups">开始扫描</button>
- <div id="sync-job-tracker"></div>
- </div>
- `;
- }
- function renderGroupStatsCard(summary) {
- const s = summary || {};
- return `
- <div class="workspace-stat-row group-stat-row" id="group-stats">
- <div><strong data-count="${s.total || 0}">${formatNumber(s.total)}</strong><span>已扫描群聊</span></div>
- <div><strong data-count="${(s.autoConfirmed || 0) + (s.confirmed || 0)}">${formatNumber((s.autoConfirmed || 0) + (s.confirmed || 0))}</strong><span>确认客户群</span></div>
- <div><strong data-count="${s.suggested || 0}">${formatNumber(s.suggested)}</strong><span>待人工确认</span></div>
- </div>
- `;
- }
- function renderGroupFilters() {
- return `
- <div class="card group-filter-card">
- <div class="card-header">
- <div><h3 class="card-title">群列表</h3><div class="card-subtitle">识别结果与消息同步状态</div></div>
- <div class="filter-bar">
- <input id="filter-keyword" type="text" placeholder="搜索群名" value="${escapeHtml(state.groupFilters.keyword)}" />
- <select id="filter-status">
- <option value="" ${state.groupFilters.status === '' ? 'selected' : ''}>全部状态</option>
- <option value="AUTO_CONFIRMED" ${state.groupFilters.status === 'AUTO_CONFIRMED' ? 'selected' : ''}>自动确认</option>
- <option value="SUGGESTED" ${state.groupFilters.status === 'SUGGESTED' ? 'selected' : ''}>建议确认</option>
- <option value="CONFIRMED" ${state.groupFilters.status === 'CONFIRMED' ? 'selected' : ''}>已确认</option>
- <option value="REJECTED" ${state.groupFilters.status === 'REJECTED' ? 'selected' : ''}>已忽略</option>
- </select>
- <button class="btn btn-sm btn-secondary ${state.groupFilters.showCustomerOnly ? 'active' : ''}" id="btn-filter-customer-only">${state.groupFilters.showCustomerOnly ? '展示全部状态的群聊' : '只看可能的客户群'}</button>
- <button class="btn btn-sm btn-secondary" id="btn-refresh-groups">刷新</button>
- </div>
- </div>
- </div>
- `;
- }
- function renderGroupTable(groups) {
- const customerStatuses = new Set(['AUTO_CONFIRMED', 'SUGGESTED', 'CONFIRMED', 'REJECTED']);
- const filtered = groups.filter(g => {
- // 过滤无效群记录:无名称且无成员数通常是单聊或系统通知
- if (!g.roomName && !g.memberCount) return false;
- const kw = state.groupFilters.keyword.trim();
- if (kw && !(g.roomName || '').includes(kw) && !(g.roomId || '').includes(kw)) return false;
- if (state.groupFilters.status && g.reviewStatus !== state.groupFilters.status) return false;
- if (state.groupFilters.showCustomerOnly && !customerStatuses.has(g.reviewStatus)) return false;
- return true;
- });
- const card = document.getElementById('groups-table-card');
- if (!filtered.length) {
- card.innerHTML = `
- <div class="card">
- <div class="empty-state">
- <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>
- <div class="empty-state-title">还没有群数据</div>
- <div class="empty-state-desc">点击上方「开始扫描」从企业微信拉取</div>
- </div>
- </div>
- `;
- return;
- }
- const allSelected = filtered.length > 0 && filtered.every(g => state.selectedGroupIds.has(g.roomId));
- card.innerHTML = `
- <div class="card" style="padding:0;overflow:hidden">
- <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">
- <label class="checkbox-row" style="margin:0">
- <input type="checkbox" id="select-all-groups" ${allSelected ? 'checked' : ''} />
- <span>全选本页</span>
- </label>
- <span style="color:var(--text-muted);font-size:13px">已选 ${formatNumber(state.selectedGroupIds.size)} 个</span>
- <button class="btn btn-sm" id="btn-batch-confirm" ${state.selectedGroupIds.size ? '' : 'disabled'}>
- 确认选中为客户群
- </button>
- <button class="btn btn-sm" id="btn-batch-sync-msg" ${state.selectedGroupIds.size ? '' : 'disabled'}>
- 同步选中群消息
- </button>
- </div>
- <div class="table-wrap">
- <table>
- <thead>
- <tr>
- <th style="width:40px"></th>
- <th>群名</th>
- <th style="width:70px">人数</th>
- <th style="width:100px">识别结果</th>
- <th style="width:100px">置信度</th>
- <th style="width:100px">消息同步</th>
- <th>命中关键词</th>
- <th style="width:120px">操作</th>
- </tr>
- </thead>
- <tbody>
- ${filtered.map((g, i) => {
- const synced = isGroupSynced(g.roomId);
- return `
- <tr style="animation:fadeInUp 0.3s ease ${i * 0.03}s both">
- <td>
- <input type="checkbox" class="group-checkbox" data-room="${escapeHtml(g.roomId)}" ${state.selectedGroupIds.has(g.roomId) ? 'checked' : ''} />
- </td>
- <td>
- <div style="font-weight:600">${escapeHtml(g.roomName || '未命名')}</div>
- <div style="font-size:12px;color:var(--text-muted);margin-top:2px">${escapeHtml(g.roomId || '')}</div>
- </td>
- <td>${formatNumber(g.memberCount)}</td>
- <td>${badge(g.reviewStatus)}</td>
- <td>
- <div style="display:flex;align-items:center;gap:8px">
- <div class="confidence-bar"><div class="confidence-bar-fill" style="width:${Math.round((g.confidence || 0) * 100)}%"></div></div>
- <span style="font-size:12px;color:var(--text-muted)">${Math.round((g.confidence || 0) * 100)}%</span>
- </div>
- </td>
- <td>${synced ? `<span class="badge badge-success">已同步</span>` : `<span class="badge badge-default">未同步</span>`}</td>
- <td>${(g.matchedKeywords || []).map(k => `<span class="tag" style="margin-bottom:4px">${escapeHtml(k)}</span>`).join(' ') || '-'}</td>
- <td>
- <div style="display:flex;gap:6px;flex-wrap:wrap">
- ${!['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>` : ''}
- ${g.reviewStatus === 'CONFIRMED' ? `<button class="btn btn-sm" data-action="detail" data-room="${escapeHtml(g.roomId)}" data-name="${escapeHtml(g.roomName || '')}">群详情</button>` : ''}
- ${g.reviewStatus !== 'REJECTED' ? `<button class="btn btn-sm btn-secondary" data-action="reject" data-room="${escapeHtml(g.roomId)}" data-name="${escapeHtml(g.roomName || '')}">忽略</button>` : ''}
- <button class="btn btn-sm btn-secondary" data-action="sync-msg" data-room="${escapeHtml(g.roomId)}">${synced ? '重新同步' : '同步消息'}</button>
- </div>
- </td>
- </tr>`;
- }).join('')}
- </tbody>
- </table>
- </div>
- </div>
- `;
- bindGroupTableActions(card, filtered);
- }
- function bindGroupTableActions(card, filtered) {
- const selectAll = card.querySelector('#select-all-groups');
- if (selectAll) {
- selectAll.addEventListener('change', () => {
- filtered.forEach(g => {
- if (selectAll.checked) state.selectedGroupIds.add(g.roomId);
- else state.selectedGroupIds.delete(g.roomId);
- });
- renderGroupTable(state.groups);
- });
- }
- card.querySelectorAll('.group-checkbox').forEach(cb => {
- cb.addEventListener('change', () => {
- const roomId = cb.dataset.room;
- if (cb.checked) state.selectedGroupIds.add(roomId);
- else state.selectedGroupIds.delete(roomId);
- renderGroupTable(state.groups);
- });
- });
- const batchConfirmBtn = card.querySelector('#btn-batch-confirm');
- if (batchConfirmBtn) {
- batchConfirmBtn.addEventListener('click', () => {
- const roomIds = Array.from(state.selectedGroupIds);
- if (!roomIds.length) return;
- batchConfirmGroups(roomIds);
- });
- }
- const batchSyncBtn = card.querySelector('#btn-batch-sync-msg');
- if (batchSyncBtn) {
- batchSyncBtn.addEventListener('click', () => {
- const roomIds = Array.from(state.selectedGroupIds);
- if (!roomIds.length) return;
- syncGroupMessages(roomIds, batchSyncBtn);
- });
- }
- card.querySelectorAll('[data-action]').forEach(btn => {
- btn.addEventListener('click', () => {
- const roomId = btn.dataset.room;
- const roomName = btn.dataset.name;
- if (btn.dataset.action === 'confirm') confirmGroup(roomId, roomName);
- if (btn.dataset.action === 'reject') openRejectModal(roomId, roomName);
- if (btn.dataset.action === 'sync-msg') syncGroupMessages([roomId], btn);
- if (btn.dataset.action === 'detail') openGroupDetailModal(roomId, roomName);
- });
- });
- }
- async function confirmGroup(roomId, roomName) {
- const guid = getGuid();
- if (!guid) {
- toast('请先登录企业微信账号', 'warning');
- state.currentAccountId = null;
- renderPage();
- return;
- }
- const acc = currentAccount();
- if (!acc || !acc.online) {
- toast('账号当前离线,请先恢复登录后再确认客户群', 'warning');
- location.hash = '#status';
- return;
- }
- try {
- const res = await api('POST', '/api/groups/confirm', { roomId });
- toast(res.assistantMessage || '已确认为客户群');
- loadGroups();
- } catch (err) {
- toast(err.message || '确认失败', 'error');
- }
- }
- async function batchConfirmGroups(roomIds) {
- let success = 0;
- let failed = 0;
- for (const roomId of roomIds) {
- try {
- await api('POST', '/api/groups/confirm', { roomId });
- success++;
- } catch (err) {
- failed++;
- console.error('确认客户群失败', roomId, err);
- }
- }
- toast(`批量确认完成:成功 ${success} 个${failed ? `,失败 ${failed} 个` : ''}`);
- loadGroups();
- }
- async function loadGroups() {
- try {
- const [groupsRes, syncRes] = await Promise.all([
- api('GET', '/api/groups/list'),
- api('GET', '/api/dashboard/groups')
- ]);
- state.groups = groupsRes.data?.groups || [];
- state.groupSync = syncRes.data || {};
- updateGroupStats(groupsRes.summary);
- renderGroupTable(state.groups);
- } catch (err) {
- toast(err.message || '加载群列表失败', 'error');
- }
- }
- function updateGroupStats(summary) {
- if (!summary) {
- const counts = state.groups.reduce((acc, g) => {
- acc[g.reviewStatus] = (acc[g.reviewStatus] || 0) + 1;
- return acc;
- }, {});
- summary = {
- total: state.groups.length,
- autoConfirmed: counts.AUTO_CONFIRMED || 0,
- suggested: counts.SUGGESTED || 0,
- imported: counts.IMPORTED || 0,
- confirmed: counts.CONFIRMED || 0,
- rejected: counts.REJECTED || 0
- };
- }
- const stats = document.getElementById('group-stats');
- if (stats) stats.outerHTML = renderGroupStatsCard(summary);
- }
- function bindGroupSync(page) {
- page.querySelector('#btn-sync-groups').addEventListener('click', () => {
- const guid = getGuid();
- if (!guid) {
- toast('请先登录企业微信账号', 'warning');
- state.currentAccountId = null;
- renderPage();
- return;
- }
- const acc = currentAccount();
- if (!acc || !acc.online) {
- toast('账号当前离线,请先恢复登录后再扫描群聊', 'warning');
- location.hash = '#status';
- return;
- }
- const keywords = document.getElementById('sync-keywords').value.split(/[,,]/).map(s => s.trim()).filter(Boolean);
- const highConfidence = document.getElementById('sync-high-confidence').value.split(/[,,]/).map(s => s.trim()).filter(Boolean);
- const body = {
- guid,
- scope: document.getElementById('sync-scope').value,
- maxPages: Number(document.getElementById('sync-max-pages').value) || 20,
- customerKeywords: keywords,
- highConfidenceTerms: highConfidence,
- autoClassify: document.getElementById('sync-auto-classify').checked,
- scanFromMessages: document.getElementById('sync-scope').value === 'messages'
- };
- const tracker = document.getElementById('sync-job-tracker');
- const id = Date.now().toString(36);
- tracker.innerHTML = renderJobTracker('sync-groups', id);
- startJob('sync-groups', '/api/groups/sync', body, result => {
- tracker.innerHTML = '';
- updateGroupStats(result.summary);
- loadGroups();
- });
- });
- }
- function bindGroupFilters(page) {
- const refresh = () => {
- state.groupFilters.keyword = document.getElementById('filter-keyword').value;
- state.groupFilters.status = document.getElementById('filter-status').value;
- saveFilters(GROUP_FILTERS_KEY, state.groupFilters);
- renderGroupTable(state.groups);
- };
- page.querySelector('#filter-keyword').addEventListener('input', refresh);
- page.querySelector('#filter-status').addEventListener('change', refresh);
- page.querySelector('#btn-refresh-groups').addEventListener('click', loadGroups);
- const customerOnlyBtn = page.querySelector('#btn-filter-customer-only');
- customerOnlyBtn.addEventListener('click', () => {
- state.groupFilters.showCustomerOnly = !state.groupFilters.showCustomerOnly;
- customerOnlyBtn.classList.toggle('active', state.groupFilters.showCustomerOnly);
- customerOnlyBtn.textContent = state.groupFilters.showCustomerOnly ? '展示全部状态的群聊' : '只看可能的客户群';
- saveFilters(GROUP_FILTERS_KEY, state.groupFilters);
- renderGroupTable(state.groups);
- });
- }
- function openGroupDetailModal(roomId, roomName) {
- const group = state.groups.find(g => g.roomId === roomId) || {};
- const members = group.members || [];
- // 自动识别客户:优先选择外部联系人(type === 2)
- let suggestedCustomer = null;
- const externalMembers = members.filter(m => m.type === 2 || String(m.type) === '2');
- if (externalMembers.length) {
- suggestedCustomer = externalMembers[0];
- } else if (members.length) {
- // 没有明确外部标识时,排除常见内部角色与当前账号后取第一个
- const current = currentAccount();
- const currentIds = new Set([current?.userId, current?.guid, current?.nickname].filter(Boolean));
- suggestedCustomer = members.find(m => {
- const name = String(m.userName || '').trim();
- const id = String(m.userId || '').trim();
- if (!name) return false;
- if (currentIds.has(name) || currentIds.has(id)) return false;
- return !/^(企业微信|WeChat|系统|小助手|机器人|客服|顾问|销售|管理员)$/.test(name);
- }) || members[0];
- }
- const defaultCustomerName = group.customerName || (suggestedCustomer?.userName) || '';
- const defaultExternalUserId = group.externalUserId || (suggestedCustomer?.userId) || '';
- const defaultCustomerId = group.customerId || '';
- openModal('群详情 / 客户信息', `
- <p style="color:var(--text-secondary);font-size:13px;margin-bottom:16px">群名:${escapeHtml(roomName || roomId)}</p>
- <div class="form-group" style="margin-bottom:12px">
- <label>客户编号(可选)</label>
- <input id="detail-customer-id" type="text" value="${escapeHtml(defaultCustomerId)}" />
- </div>
- <div class="form-group" style="margin-bottom:12px">
- <label>客户企微 ID(可选)</label>
- <input id="detail-external-user-id" type="text" value="${escapeHtml(defaultExternalUserId)}" />
- </div>
- <div class="form-group" style="margin-bottom:12px">
- <label>客户姓名(可选)</label>
- <input id="detail-customer-name" type="text" value="${escapeHtml(defaultCustomerName)}" />
- </div>
- ${members.length ? `
- <div class="form-group">
- <label>群成员(自动识别参考)</label>
- <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)">
- ${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('')}
- </div>
- </div>
- ` : ''}
- `, `
- <button class="btn btn-secondary" onclick="window.dashboardCloseModal()">取消</button>
- <button class="btn" id="btn-save-group-detail">保存</button>
- `);
- document.getElementById('btn-save-group-detail').addEventListener('click', async () => {
- const body = {
- roomId,
- customerId: document.getElementById('detail-customer-id').value.trim() || undefined,
- externalUserId: document.getElementById('detail-external-user-id').value.trim() || undefined,
- customerName: document.getElementById('detail-customer-name').value.trim() || undefined
- };
- try {
- const res = await api('POST', '/api/groups/confirm', body);
- closeModal();
- toast(res.assistantMessage || '已保存');
- loadGroups();
- } catch (err) {
- toast(err.message || '保存失败', 'error');
- }
- });
- }
- function openRejectModal(roomId, roomName) {
- openModal('忽略这个群', `
- <p style="color:var(--text-secondary);font-size:13px;margin-bottom:16px">群名:${escapeHtml(roomName || roomId)}</p>
- <div class="form-group">
- <label>原因(可选)</label>
- <input id="reject-reason" type="text" value="不是客户群" />
- </div>
- `, `
- <button class="btn btn-secondary" onclick="window.dashboardCloseModal()">取消</button>
- <button class="btn btn-danger" id="btn-do-reject">忽略</button>
- `);
- document.getElementById('btn-do-reject').addEventListener('click', async () => {
- const body = { roomId, reason: document.getElementById('reject-reason').value.trim() };
- try {
- const res = await api('POST', '/api/groups/reject', body);
- closeModal();
- toast(res.assistantMessage || '已忽略');
- loadGroups();
- } catch (err) {
- toast(err.message || '忽略失败', 'error');
- }
- });
- }
- async function syncGroupMessages(roomIds, triggerButton = null) {
- const guid = getGuid();
- if (!guid) {
- toast('请先登录企业微信账号', 'warning');
- return;
- }
- const acc = currentAccount();
- if (!acc || !acc.online) {
- toast('账号当前离线,请先恢复登录后再同步消息', 'warning');
- location.hash = '#status';
- return;
- }
- const body = { guid, roomIds, maxPages: 20, maxMessagesPerRoom: 500 };
- const name = 'sync-messages-' + roomIds[0];
- if (triggerButton) {
- triggerButton.dataset.job = name;
- }
- startJob(name, '/api/groups/messages/sync', body, result => {
- toast(`共同步 ${result.summary?.totalNew || 0} 条消息`);
- loadGroups();
- });
- }
- // ==================== Helpers ====================
- function requireOnlineAccount() {
- const acc = currentAccount();
- if (!acc) {
- toast('请先登录企业微信账号', 'warning');
- state.currentAccountId = null;
- renderPage();
- return false;
- }
- const guid = getGuid();
- if (!guid) {
- toast('当前账号缺少 guid,请重新登录', 'warning');
- return false;
- }
- if (!acc.online) {
- toast('账号当前离线,请先恢复登录', 'warning');
- location.hash = '#status';
- return false;
- }
- return true;
- }
- function renderFileLink(filePath, label = '下载文件') {
- if (!filePath) return '';
- return `<a class="btn btn-sm btn-secondary" href="/api/outputs?path=${encodeURIComponent(filePath)}" target="_blank" rel="noopener">${escapeHtml(label)}</a>`;
- }
- function readFileBase64(file) {
- return new Promise((resolve, reject) => {
- const reader = new FileReader();
- reader.onload = () => resolve(String(reader.result).split(',')[1]);
- reader.onerror = reject;
- reader.readAsDataURL(file);
- });
- }
- // ==================== Customer Operations ====================
- function parseCustomerText(text) {
- return text.split(/\n|\r/).map(line => line.trim()).filter(Boolean).map(line => {
- const parts = line.split(/\s+/);
- const phone = parts.find(p => /^1[3-9]\d{9}$/.test(p)) || '';
- const name = parts.find(p => !/^1[3-9]\d{9}$/.test(p)) || '';
- return { phone, name };
- }).filter(c => c.phone);
- }
- function customerProfileInput(profile, ...keys) {
- for (const key of keys) {
- const value = profile?.[key];
- if (value !== undefined && value !== null) return Array.isArray(value) ? value.join('、') : String(value);
- }
- return '';
- }
- function renderCustomerMasterSection() {
- const hub = state.customerOps.master.hub || { summary: {}, data: { customers: [], policy: {} } };
- const summary = hub.summary || {};
- const data = hub.data || {};
- const keyword = String(state.customerOps.master.keyword || '').trim().toLowerCase();
- 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)));
- const selected = (data.customers || []).find(item => item.id === state.customerOps.master.selectedId) || customers[0] || data.customers?.[0] || null;
- if (selected) state.customerOps.master.selectedId = selected.id;
- const profile = selected?.profile || {};
- const evidenceItems = (selected?.fields || []).filter(field => field.evidence);
- const openTasks = (selected?.tasks || []).filter(task => ['open', 'in_progress'].includes(task.status));
- const openAlerts = (selected?.alerts || []).filter(alert => alert.status === 'open');
- const nextActions = selected?.nextActions || [];
- const recommendations = selected?.recommendations || [];
- const timeline = selected?.timeline || [];
- 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('')}`;
- const detail = selected ? `<article class="customer-master-detail">
- <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>
- <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>
- <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>
- <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>
- <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>
- <div class="customer-master-intelligence-grid">
- <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>
- <section><header><strong>跟进任务与预警</strong><span>${openTasks.length + openAlerts.length} 项</span></header>${intelligenceItems || '<p class="agent-muted">当前没有待处理任务或预警。</p>'}</section>
- </div>
- ${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>` : ''}
- <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>
- <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>
- </article>` : '<div class="library-empty large"><strong>暂无已同步客户</strong><span>从真实企微会话同步消息后,画像会自动沉淀到这里。</span></div>';
- return `<section class="customer-master-shell">
- <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>
- <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>
- <section class="customer-master-policy"><strong>自动沉淀已启用</strong><span>${escapeHtml(data.policy?.autoSync || '')}</span><small>${escapeHtml(data.policy?.evidence || '')}</small></section>
- <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>
- </section>`;
- }
- async function renderCustomerOpsPage() {
- const renderToken = state.renderToken;
- els.pageTitle.textContent = '客户管理';
- void refreshAgentAccountHeader();
- try {
- state.customerOps.master.hub = await api('GET', '/api/customers');
- const customers = state.customerOps.master.hub.data?.customers || [];
- if (!customers.some(item => item.id === state.customerOps.master.selectedId)) state.customerOps.master.selectedId = customers[0]?.id || null;
- } catch (error) {
- state.customerOps.master.hub = { status: 'error', summary: {}, data: { customers: [], policy: {} }, assistantMessage: error.message || '客户主档读取失败' };
- }
- if (renderToken !== state.renderToken || state.page !== 'customer-ops') return;
- const page = document.createElement('div');
- page.className = 'page';
- page.innerHTML = `
- <div id="customer-master-root">${renderCustomerMasterSection()}</div>
- <section class="workspace-hero customer-workspace-hero" style="margin-top:20px">
- <div>
- <span>OPTIONAL BATCH OPERATIONS</span>
- <h2>批量客户运营(可选)</h2>
- <p>用于手机号或 externalUserId 导入、批量加好友和自动建群;上方真实企微会话客户无需重复导入。</p>
- </div>
- </section>
- <div class="dashboard-grid" id="co-dashboard">
- <div class="kpi-row" id="co-kpi-row"></div>
- <div class="chart-card">
- <h3 class="chart-title"><span>好友状态分布</span><span class="chart-subtitle">全部客户</span></h3>
- <div class="chart-container" id="co-friend-chart"></div>
- </div>
- <div class="chart-card">
- <h3 class="chart-title"><span>近7日运营趋势</span><span class="chart-subtitle">加好友 / 建群</span></h3>
- <div class="chart-container" id="co-trend-chart"></div>
- </div>
- <div class="operation-toolbar">
- <div class="toolbar-left">
- <button class="btn" id="btn-import-customers">导入客户</button>
- <button class="btn btn-secondary" id="btn-co-refresh">刷新</button>
- <button class="btn btn-secondary" id="btn-co-rebuild">重建索引</button>
- </div>
- <div class="toolbar-right">
- <button class="btn btn-sm" id="btn-batch-add" data-job="batch-add-friends" disabled>批量加好友</button>
- <button class="btn btn-sm btn-secondary" id="btn-check-friends" data-job="check-friend-status" disabled>检查好友状态</button>
- <button class="btn btn-sm btn-secondary" id="btn-auto-group" data-job="auto-create-group" disabled>为通过好友建群</button>
- </div>
- </div>
- <div class="data-table-card">
- <div class="card-header">
- <div>
- <h3 class="card-title">客户列表</h3>
- <div class="card-subtitle">支持按好友状态、画像、标签筛选</div>
- </div>
- </div>
- <div class="filter-bar" style="margin-bottom:16px">
- <input id="co-filter-keyword" type="text" placeholder="搜索手机号/姓名/externalUserId" style="width:220px" value="${escapeHtml(state.customerOps.keyword || '')}" />
- <select id="co-filter-status">
- <option value="">全部好友状态</option>
- <option value="ACCEPTED" ${state.customerOps.status === 'ACCEPTED' ? 'selected' : ''}>已是好友</option>
- <option value="PENDING" ${state.customerOps.status === 'PENDING' ? 'selected' : ''}>待通过</option>
- <option value="NOT_FOUND" ${state.customerOps.status === 'NOT_FOUND' ? 'selected' : ''}>未找到</option>
- <option value="FAILED" ${state.customerOps.status === 'FAILED' ? 'selected' : ''}>失败</option>
- </select>
- <select id="co-filter-portrait">
- <option value="">全部画像状态</option>
- <option value="true" ${state.customerOps.portrait === 'true' ? 'selected' : ''}>已生成画像</option>
- <option value="false" ${state.customerOps.portrait === 'false' ? 'selected' : ''}>未生成画像</option>
- </select>
- <select id="co-filter-tag">
- <option value="">全部标签</option>
- </select>
- <button class="btn btn-sm btn-secondary" id="btn-co-reset-filters">重置筛选</button>
- </div>
- <div id="co-customer-table-wrap"></div>
- <div id="co-pagination"></div>
- </div>
- </div>
- `;
- els.content.appendChild(page);
- bindCustomerOpsPage(page);
- }
- function bindCustomerOpsPage(page) {
- const textArea = page.querySelector('#co-customer-text');
- const fileInput = page.querySelector('#co-customer-file');
- const greetingInput = page.querySelector('#co-default-greeting');
- const previewText = page.querySelector('#co-preview-text');
- const tableWrap = page.querySelector('#co-customer-table-wrap');
- const masterRoot = page.querySelector('#customer-master-root');
- function rerenderCustomerMaster() {
- if (masterRoot) masterRoot.innerHTML = renderCustomerMasterSection();
- }
- async function reloadCustomerMaster(message = '') {
- state.customerOps.master.loading = true;
- rerenderCustomerMaster();
- try {
- state.customerOps.master.hub = await api('GET', '/api/customers');
- const customers = state.customerOps.master.hub.data?.customers || [];
- if (!customers.some(item => item.id === state.customerOps.master.selectedId)) state.customerOps.master.selectedId = customers[0]?.id || null;
- rerenderCustomerMaster();
- if (message) toast(message);
- } catch (error) { toast(error.assistantMessage || error.message || '客户主档读取失败', 'error'); }
- finally { state.customerOps.master.loading = false; rerenderCustomerMaster(); }
- }
- masterRoot?.addEventListener('click', event => {
- const customerButton = event.target.closest('[data-customer-master-id]');
- if (customerButton) {
- state.customerOps.master.selectedId = customerButton.dataset.customerMasterId;
- rerenderCustomerMaster();
- return;
- }
- const openButton = event.target.closest('[data-customer-master-open]');
- if (openButton) {
- state.agent.selectedId = openButton.dataset.customerMasterOpen;
- location.hash = '#agent';
- return;
- }
- if (event.target.closest('#btn-customer-master-refresh')) void reloadCustomerMaster('客户主档已刷新');
- const feedbackButton = event.target.closest('[data-recommendation-feedback]');
- if (feedbackButton) {
- const status = feedbackButton.dataset.feedbackStatus;
- let feedbackReason = '';
- if (status === 'rejected') {
- const answer = window.prompt('请记录不合适原因(价格、区域、楼层、装修或其他)', '');
- if (answer === null) return;
- feedbackReason = answer.trim();
- } else if (status === 'interested') {
- const answer = window.prompt('可选:记录客户感兴趣的原因', '');
- if (answer === null) return;
- feedbackReason = answer.trim();
- }
- state.customerOps.master.loading = true;
- rerenderCustomerMaster();
- api('POST', `/api/customers/${encodeURIComponent(state.customerOps.master.selectedId)}/recommendations/${encodeURIComponent(feedbackButton.dataset.recommendationFeedback)}`, { status, feedbackReason })
- .then(async result => {
- if (result.status !== 'ok') throw result;
- state.customerOps.master.hub = await api('GET', '/api/customers');
- toast(result.assistantMessage || '房源反馈已记录');
- })
- .catch(error => toast(error.assistantMessage || error.message || '房源反馈记录失败', 'error'))
- .finally(() => { state.customerOps.master.loading = false; rerenderCustomerMaster(); });
- }
- });
- masterRoot?.addEventListener('change', event => {
- if (event.target.id !== 'customer-master-search') return;
- state.customerOps.master.keyword = event.target.value;
- rerenderCustomerMaster();
- });
- masterRoot?.addEventListener('submit', async event => {
- if (event.target.id !== 'customer-master-profile-form') return;
- event.preventDefault();
- const form = new FormData(event.target);
- const budgetText = String(form.get('budgetWan') || '').trim();
- const profile = {
- preferredRegion: String(form.get('preferredRegion') || '').trim(),
- budgetWan: budgetText ? Number(budgetText) : '',
- layout: String(form.get('layout') || '').trim(),
- intent: String(form.get('intent') || '').trim(),
- timeline: String(form.get('timeline') || '').trim(),
- };
- state.customerOps.master.loading = true;
- rerenderCustomerMaster();
- try {
- const result = await api('POST', `/api/customers/${encodeURIComponent(event.target.dataset.customerId)}/profile`, {
- profile,
- tags: String(form.get('tags') || '').split(/[,,]/).map(item => item.trim()).filter(Boolean),
- reason: String(form.get('reason') || '').trim(),
- });
- if (result.status !== 'ok') throw result;
- state.customerOps.master.hub = await api('GET', '/api/customers');
- toast(result.assistantMessage || '客户主档已保存');
- } catch (error) { toast(error.assistantMessage || error.message || '客户主档保存失败', 'error'); }
- finally { state.customerOps.master.loading = false; rerenderCustomerMaster(); }
- });
- void loadCustomerOpsDashboard(page);
- dashboardRefreshTimer = setInterval(() => {
- if (state.page === 'customer-ops') loadCustomerOpsDashboard(page, true);
- }, 30000);
- page.querySelector('#btn-co-refresh').addEventListener('click', () => loadCustomerOpsDashboard(page));
- page.querySelector('#btn-co-rebuild').addEventListener('click', async () => {
- try {
- await api('POST', '/api/dashboard/rebuild');
- toast('索引已重建');
- loadCustomerOpsDashboard(page);
- } catch (err) {
- toast(err.message || '重建失败', 'error');
- }
- });
- page.querySelector('#btn-import-customers').addEventListener('click', () => openImportCustomersModal(page));
- page.querySelector('#co-filter-keyword').addEventListener('input', () => loadCustomerOpsTable(page));
- page.querySelector('#co-filter-status').addEventListener('change', () => loadCustomerOpsTable(page));
- page.querySelector('#co-filter-portrait').addEventListener('change', () => loadCustomerOpsTable(page));
- page.querySelector('#co-filter-tag').addEventListener('change', () => loadCustomerOpsTable(page));
- page.querySelector('#btn-co-reset-filters').addEventListener('click', () => resetCustomerOpsFilters(page));
- page.querySelector('#btn-batch-add').addEventListener('click', () => {
- const selected = getSelectedCustomers(page);
- if (!selected.length) return;
- const phoneCustomers = selected.filter(c => c.phone).map(c => ({ phone: c.phone, name: c.name }));
- if (!phoneCustomers.length) {
- toast('选中的客户没有手机号,无法批量加好友', 'warning');
- return;
- }
- const body = { customers: phoneCustomers, guid: getGuid() };
- startJob('batch-add-friends', '/api/customer-ops/batch-add-friends', body, () => loadCustomerOpsDashboard(page));
- });
- page.querySelector('#btn-check-friends').addEventListener('click', () => {
- const selected = getSelectedCustomers(page);
- if (!selected.length) return;
- const body = {
- phones: selected.map(c => c.phone).filter(Boolean),
- externalUserIds: selected.map(c => c.externalUserId).filter(Boolean),
- guid: getGuid()
- };
- startJob('check-friend-status', '/api/customer-ops/check-friend-status', body, () => loadCustomerOpsDashboard(page));
- });
- page.querySelector('#btn-auto-group').addEventListener('click', () => {
- const selected = getSelectedCustomers(page).filter(c => c.friendRequestStatus === 'ACCEPTED' && !['CREATED', 'IN_GROUP'].includes(c.groupStatus));
- if (!selected.length) {
- toast('请选中已是好友且未建群的客户', 'warning');
- return;
- }
- const body = { memberList: selected.map(c => c.externalUserId).filter(Boolean), guid: getGuid() };
- startJob('auto-create-group', '/api/customer-ops/auto-create-group', body, () => loadCustomerOpsDashboard(page));
- });
- }
- async function loadCustomerOpsDashboard(page, silent = false) {
- const tableWrap = page.querySelector('#co-customer-table-wrap');
- if (!silent) showLoading(tableWrap, 'table');
- try {
- const [summaryRes, customersRes, tagsRes] = await Promise.all([
- api('GET', '/api/dashboard/summary'),
- api('GET', '/api/dashboard/customers'),
- api('GET', '/api/dashboard/tags')
- ]);
- const summary = summaryRes.data || {};
- const customers = Object.values(customersRes.data?.customers || {});
- const tags = tagsRes.data?.allTags || [];
- hideLoading(tableWrap);
- renderCustomerOpsKpi(page, summary);
- renderCustomerOpsCharts(page, summary, customers);
- renderCustomerOpsTagFilter(page, tags);
- state.customerOps.allCustomers = customers;
- loadCustomerOpsTable(page);
- } catch (err) {
- if (!silent) showError(tableWrap, err.message || '加载看板失败', () => loadCustomerOpsDashboard(page));
- }
- }
- function renderCustomerOpsKpi(page, summary) {
- const wrap = page.querySelector('#co-kpi-row');
- const c = summary.customers || {};
- const friendStatus = c.friendStatusCounts || {};
- const kpi = [
- { label: '批量导入客户', value: formatNumber(c.total) },
- { label: '已是好友', value: formatNumber(friendStatus.ACCEPTED || 0), sub: '已通过好友申请' },
- { label: '待通过', value: formatNumber(friendStatus.PENDING || 0), sub: '等待对方确认' },
- { label: '自动建群', value: formatNumber(c.autoCreatedGroups || 0), sub: '已创建客户群' },
- { label: '今日新增', value: formatNumber(c.todayNew || 0), sub: '今日通过好友' }
- ];
- wrap.innerHTML = renderKpiRow(kpi);
- }
- function renderCustomerOpsCharts(page, summary, customers) {
- const colors = getChartColors();
- const friendStatus = summary.customers?.friendStatusCounts || {};
- initEChart('co-friend-chart', {
- tooltip: { trigger: 'item' },
- legend: { bottom: 0 },
- color: [colors.success, colors.warning, colors.error, colors.purple, colors.muted],
- series: [{
- type: 'pie',
- radius: ['45%', '70%'],
- center: ['50%', '45%'],
- data: [
- { name: '已是好友', value: friendStatus.ACCEPTED || 0 },
- { name: '待通过', value: friendStatus.PENDING || 0 },
- { name: '未找到', value: friendStatus.NOT_FOUND || 0 },
- { name: '失败', value: friendStatus.FAILED || 0 },
- { name: '未知', value: friendStatus.UNKNOWN || 0 }
- ]
- }]
- });
- const days = last7Days();
- const addCounts = countByDay(customers.filter(c => c.lastAddAttemptAt), 'lastAddAttemptAt');
- const groupCounts = countByDay(customers.filter(c => c.autoCreatedAt), 'autoCreatedAt');
- const newCustomerCounts = countByDay(customers.filter(c => c.createdAt), 'createdAt');
- initEChart('co-trend-chart', {
- tooltip: { trigger: 'axis' },
- legend: { bottom: 0 },
- grid: { left: 8, right: 18, top: 20, bottom: 42, containLabel: true },
- xAxis: { type: 'category', data: days.map(d => formatDate(d)), axisTick: { show: false } },
- yAxis: { type: 'value', minInterval: 1 },
- series: [
- { name: '新增客户', type: 'bar', data: newCustomerCounts, itemStyle: { color: colors.brand, borderRadius: [4, 4, 0, 0] }, barMaxWidth: 28 },
- { name: '加好友', type: 'line', smooth: true, data: addCounts, itemStyle: { color: colors.info } },
- { name: '建群', type: 'line', smooth: true, data: groupCounts, itemStyle: { color: colors.success } }
- ]
- });
- }
- function renderCustomerOpsTagFilter(page, tags) {
- const select = page.querySelector('#co-filter-tag');
- const current = select.value;
- select.innerHTML = '<option value="">全部标签</option>' + tags.map(t => `<option value="${escapeHtml(t)}">${escapeHtml(t)}</option>`).join('');
- select.value = current;
- }
- function resetCustomerOpsFilters(page) {
- state.customerOps.keyword = '';
- state.customerOps.status = '';
- state.customerOps.portrait = '';
- state.customerOps.tag = '';
- state.customerOps.tablePage = 1;
- saveFilters(CO_FILTERS_KEY, {
- tablePage: 1,
- keyword: '',
- status: '',
- portrait: '',
- tag: ''
- });
- page.querySelector('#co-filter-keyword').value = '';
- page.querySelector('#co-filter-status').value = '';
- page.querySelector('#co-filter-portrait').value = '';
- page.querySelector('#co-filter-tag').value = '';
- loadCustomerOpsTable(page);
- toast('筛选已重置');
- }
- function getSelectedCustomers(page) {
- const checkboxes = page.querySelectorAll('#co-customer-table-wrap input[type="checkbox"]:checked');
- return Array.from(checkboxes).map(cb => {
- const idx = Number(cb.dataset.index);
- return state.customerOps.filteredCustomers?.[idx];
- }).filter(Boolean);
- }
- function loadCustomerOpsTable(page) {
- const wrap = page.querySelector('#co-customer-table-wrap');
- const customers = state.customerOps.allCustomers || [];
- const keyword = page.querySelector('#co-filter-keyword').value.trim().toLowerCase();
- const status = page.querySelector('#co-filter-status').value;
- const portrait = page.querySelector('#co-filter-portrait').value;
- const tag = page.querySelector('#co-filter-tag').value;
- state.customerOps.keyword = keyword;
- state.customerOps.status = status;
- state.customerOps.portrait = portrait;
- state.customerOps.tag = tag;
- saveFilters(CO_FILTERS_KEY, {
- tablePage: state.customerOps.tablePage,
- keyword,
- status,
- portrait,
- tag
- });
- let filtered = customers.filter(c => {
- if (keyword) {
- const text = `${c.phone || ''} ${c.name || ''} ${c.externalUserId || ''}`.toLowerCase();
- if (!text.includes(keyword)) return false;
- }
- if (status && c.friendRequestStatus !== status) return false;
- if (portrait === 'true' && !c.hasPortrait) return false;
- if (portrait === 'false' && c.hasPortrait) return false;
- if (tag && !(Array.isArray(c.tags) && c.tags.includes(tag))) return false;
- return true;
- });
- state.customerOps.filteredCustomers = filtered;
- const pageSize = 20;
- const currentPage = state.customerOps.tablePage || 1;
- const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
- if (currentPage > totalPages) {
- state.customerOps.tablePage = totalPages;
- saveFilters(CO_FILTERS_KEY, {
- tablePage: state.customerOps.tablePage,
- keyword: state.customerOps.keyword,
- status: state.customerOps.status,
- portrait: state.customerOps.portrait,
- tag: state.customerOps.tag
- });
- }
- const start = (state.customerOps.tablePage - 1) * pageSize;
- const pageItems = filtered.slice(start, start + pageSize);
- const friendBadge = (status) => {
- const map = {
- ACCEPTED: ['badge-success', '已是好友'],
- PENDING: ['badge-warning', '待通过'],
- NOT_FOUND: ['badge-error', '未找到'],
- FAILED: ['badge-error', '失败']
- };
- const [cls, text] = map[status] || ['badge-default', '未知'];
- return `<span class="badge ${cls}">${escapeHtml(text)}</span>`;
- };
- const groupBadge = (status) => {
- if (status === 'CREATED') return '<span class="badge badge-success">已建群</span>';
- if (status === 'IN_GROUP') return '<span class="badge badge-info">群内客户</span>';
- return '<span class="badge badge-default">未建群</span>';
- };
- wrap.innerHTML = `
- <div class="table-wrap">
- <table>
- <thead>
- <tr>
- <th><input type="checkbox" id="co-select-all"></th>
- <th>手机号</th>
- <th>姓名</th>
- <th>externalUserId</th>
- <th>好友状态</th>
- <th>建群状态</th>
- <th>画像</th>
- <th>标签</th>
- <th>最后操作</th>
- <th>操作</th>
- </tr>
- </thead>
- <tbody>
- ${pageItems.length ? pageItems.map((c, i) => `
- <tr>
- <td><input type="checkbox" data-index="${start + i}"></td>
- <td>${escapeHtml(c.phone || '-')}</td>
- <td>${escapeHtml(c.name || '-')}</td>
- <td style="font-family:monospace;font-size:12px">${escapeHtml(c.externalUserId || '-')}</td>
- <td>${friendBadge(c.friendRequestStatus)}</td>
- <td>${groupBadge(c.groupStatus)}</td>
- <td>${c.hasPortrait ? '<span class="badge badge-info">已生成</span>' : '<span class="badge badge-default">未生成</span>'}</td>
- <td>${Array.isArray(c.tags) && c.tags.length ? c.tags.map(t => `<span class="tag">${escapeHtml(t)}</span>`).join('') : '-'}</td>
- <td>${formatDateTime(c.updatedAt)}</td>
- <td>
- <button class="btn btn-sm btn-secondary" onclick="window.dashboardCustomerDetail('${escapeHtml(c.customerId || c.externalUserId || c.phone || '')}')">详情</button>
- </td>
- </tr>
- `).join('') : renderEmptyTableRow(10, '暂无客户数据', '导入客户或同步群成员后将显示')}
- </tbody>
- </table>
- </div>
- `;
- const pagination = page.querySelector('#co-pagination');
- pagination.innerHTML = renderPagination(state.customerOps.tablePage, filtered.length, pageSize);
- pagination.querySelectorAll('button').forEach(btn => {
- btn.addEventListener('click', () => {
- state.customerOps.tablePage = Number(btn.dataset.page);
- saveFilters(CO_FILTERS_KEY, {
- tablePage: state.customerOps.tablePage,
- keyword: state.customerOps.keyword,
- status: state.customerOps.status,
- portrait: state.customerOps.portrait,
- tag: state.customerOps.tag
- });
- loadCustomerOpsTable(page);
- });
- });
- const selectAll = wrap.querySelector('#co-select-all');
- const rowCheckboxes = wrap.querySelectorAll('tbody input[type="checkbox"]');
- selectAll?.addEventListener('change', () => {
- rowCheckboxes.forEach(cb => cb.checked = selectAll.checked);
- updateCustomerOpsActionButtons(page);
- });
- rowCheckboxes.forEach(cb => cb.addEventListener('change', () => updateCustomerOpsActionButtons(page)));
- updateCustomerOpsActionButtons(page);
- }
- function updateCustomerOpsActionButtons(page) {
- const selected = getSelectedCustomers(page);
- page.querySelector('#btn-batch-add').disabled = !selected.some(c => c.phone);
- page.querySelector('#btn-check-friends').disabled = !selected.length;
- page.querySelector('#btn-auto-group').disabled = !selected.some(c => c.friendRequestStatus === 'ACCEPTED' && !['CREATED', 'IN_GROUP'].includes(c.groupStatus));
- }
- function openImportCustomersModal(page) {
- const body = `
- <div class="form-group" style="margin-bottom:16px">
- <label>粘贴客户列表(每行:手机号 姓名)</label>
- <textarea id="import-customer-text" rows="6" placeholder="13800138000 张三"></textarea>
- </div>
- <div class="form-group" style="margin-bottom:16px">
- <label>或上传 Excel</label>
- <input type="file" id="import-customer-file" accept=".xlsx,.xls" />
- </div>
- <div class="form-group">
- <label>默认验证消息</label>
- <input id="import-greeting" type="text" value="${escapeHtml(state.customerOps.defaultGreeting)}" />
- </div>
- `;
- openModal('导入客户', body, `
- <button class="btn btn-secondary" onclick="window.dashboardCloseModal()">取消</button>
- <button class="btn" id="btn-confirm-import">导入</button>
- `);
- document.getElementById('btn-confirm-import').addEventListener('click', async () => {
- const text = document.getElementById('import-customer-text').value;
- const file = document.getElementById('import-customer-file').files[0];
- const greeting = document.getElementById('import-greeting').value;
- let customers = parseCustomerText(text);
- if (file) {
- const base64 = await readFileBase64(file);
- const upload = await api('POST', '/api/upload', { data: base64, name: file.name });
- const filePath = upload.data?.path;
- if (filePath) {
- const res = await api('POST', '/api/customer-ops/batch-add-friends', { filePath, defaultGreeting: greeting, guid: getGuid() });
- toast(res.assistantMessage || '导入任务已启动');
- closeModal();
- loadCustomerOpsDashboard(page);
- return;
- }
- }
- if (!customers.length) {
- toast('请输入有效客户', 'warning');
- return;
- }
- closeModal();
- startJob('batch-add-friends', '/api/customer-ops/batch-add-friends', { customers, defaultGreeting: greeting, guid: getGuid() }, () => loadCustomerOpsDashboard(page));
- });
- }
- function renderDetailCard(title, iconSvg, contentHtml) {
- return `
- <div class="detail-card">
- <div class="detail-section-title">
- ${iconSvg}
- ${escapeHtml(title)}
- </div>
- ${contentHtml}
- </div>
- `;
- }
- function renderDetailItem(label, value) {
- return `
- <div class="detail-item">
- <div class="detail-item-label">${escapeHtml(label)}</div>
- <div class="detail-item-value">${value || '-'}</div>
- </div>
- `;
- }
- function renderPortraitField(label, value) {
- const displayValue = value === undefined || value === null || value === '' ? '-' : String(value);
- return renderDetailItem(label, escapeHtml(displayValue));
- }
- function customerFriendBadge(status) {
- const map = {
- ACCEPTED: ['badge-success', '已是好友'],
- PENDING: ['badge-warning', '待通过'],
- NOT_FOUND: ['badge-error', '未找到'],
- FAILED: ['badge-error', '失败'],
- UNKNOWN: ['badge-default', '未知']
- };
- const [cls, text] = map[status] || map.UNKNOWN;
- return `<span class="badge ${cls}">${escapeHtml(text)}</span>`;
- }
- function customerGroupBadge(status) {
- if (status === 'CREATED') return '<span class="badge badge-success">已建群</span>';
- if (status === 'IN_GROUP') return '<span class="badge badge-info">群内客户</span>';
- return '<span class="badge badge-default">未建群</span>';
- }
- function renderCustomerOpsSummary(customer) {
- const tags = Array.isArray(customer.tags) ? customer.tags : [];
- const groupNames = Array.isArray(customer.groupNames) ? customer.groupNames.filter(Boolean) : [];
- const sourceRoomIds = Array.isArray(customer.sourceRoomIds) ? customer.sourceRoomIds.filter(Boolean) : [];
- const groupLabels = groupNames.length
- ? groupNames
- : sourceRoomIds;
- const sourceGroupHtml = groupLabels.length
- ? `<div class="detail-list compact">
- ${groupLabels.slice(0, 5).map((name, index) => `
- <div class="detail-list-item">
- <div>
- <div>${escapeHtml(name)}</div>
- ${sourceRoomIds[index] && sourceRoomIds[index] !== name ? `<div class="detail-muted">${escapeHtml(sourceRoomIds[index])}</div>` : ''}
- </div>
- </div>
- `).join('')}
- ${groupLabels.length > 5 ? `<div class="detail-muted">还有 ${formatNumber(groupLabels.length - 5)} 个来源群未展示</div>` : ''}
- </div>`
- : '<div class="detail-empty">暂无来源群记录</div>';
- const basicInfo = `
- <div class="detail-grid compact">
- ${renderDetailItem('姓名', escapeHtml(customer.name || '-'))}
- ${renderDetailItem('手机号', escapeHtml(customer.phone || '-'))}
- ${renderDetailItem('好友状态', customerFriendBadge(customer.friendRequestStatus))}
- ${renderDetailItem('群状态', customerGroupBadge(customer.groupStatus))}
- ${renderDetailItem('画像状态', customer.hasPortrait ? '<span class="badge badge-info">已生成</span>' : '<span class="badge badge-default">未生成</span>')}
- ${renderDetailItem('最近更新', formatDateTime(customer.updatedAt))}
- </div>
- ${tags.length ? `<div class="detail-tags">${tags.map(t => `<span class="tag">${escapeHtml(t)}</span>`).join(' ')}</div>` : ''}
- <div class="detail-id-line">externalUserId:${escapeHtml(customer.externalUserId || '-')}</div>
- `;
- return {
- basicInfo,
- sourceGroupHtml
- };
- }
- function renderRawToggle(rawData) {
- return `
- <div class="raw-toggle" id="raw-toggle">
- <svg viewBox="0 0 24 24"><path d="M7 10l5 5 5-5z"/></svg>
- <span>查看原始数据</span>
- </div>
- <div class="detail-panel raw-panel" id="raw-panel">${escapeHtml(JSON.stringify(rawData || {}, null, 2))}</div>
- `;
- }
- function bindRawToggle() {
- const toggle = document.getElementById('raw-toggle');
- const panel = document.getElementById('raw-panel');
- if (toggle && panel) {
- toggle.addEventListener('click', () => {
- const isOpen = panel.style.display === 'block';
- panel.style.display = isOpen ? 'none' : 'block';
- toggle.classList.toggle('open', !isOpen);
- });
- }
- }
- async function loadCustomerFullProfile(externalUserId) {
- const target = document.getElementById('customer-full-profile');
- const button = document.getElementById('btn-load-customer-full-profile');
- if (!target || !externalUserId) return;
- if (button) {
- button.disabled = true;
- button.textContent = '加载中...';
- }
- target.innerHTML = '<div class="detail-empty">正在加载完整资料...</div>';
- try {
- const res = await api('POST', '/api/customer-ops/customer-profile', { externalUserId, guid: getGuid(), mode: 'full' });
- const data = res.data || {};
- const portrait = data.portrait || {};
- const contactList = data.contactList || [];
- const rooms = data.rooms || [];
- const contactHtml = contactList.length
- ? `<div class="detail-list compact">
- ${contactList.slice(0, 5).map(c => `
- <div class="detail-list-item">
- <div>
- <div>${escapeHtml(c.nickName || c.userName || c.externalUserId || '未知联系人')}</div>
- <div class="detail-muted">${escapeHtml(c.externalUserId || c.userId || '-')}</div>
- </div>
- </div>
- `).join('')}
- </div>`
- : '<div class="detail-empty">暂无匹配联系人</div>';
- const roomsHtml = rooms.length
- ? `<div class="detail-list compact">
- ${rooms.slice(0, 8).map(r => `
- <div class="detail-list-item">
- <div>
- <div>${escapeHtml(r.roomName || '未命名群')}</div>
- <div class="detail-muted">${escapeHtml(r.roomId || '-')}</div>
- </div>
- </div>
- `).join('')}
- </div>`
- : '<div class="detail-empty">暂无相关群</div>';
- const portraitFields = portrait.portrait || {};
- const portraitKeys = Object.keys(portraitFields);
- const portraitHtml = portraitKeys.length
- ? `<div class="detail-grid compact">
- ${portraitKeys.slice(0, 8).map(k => renderPortraitField(k, portraitFields[k])).join('')}
- </div>`
- : '<div class="detail-empty">暂无画像数据</div>';
- 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>';
- 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>';
- 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>';
- target.innerHTML = `
- ${renderDetailCard(`联系人 (${formatNumber(contactList.length)})`, contactsIcon, contactHtml)}
- ${renderDetailCard(`相关群 (${formatNumber(rooms.length)})`, roomsIcon, roomsHtml)}
- ${renderDetailCard('客户画像', portraitIcon, portraitHtml)}
- ${renderRawToggle(data)}
- `;
- bindRawToggle();
- if (button) button.remove();
- } catch (err) {
- target.innerHTML = '<div class="detail-empty">完整资料加载失败</div>';
- if (button) {
- button.disabled = false;
- button.textContent = '重新加载完整资料';
- }
- toast(err.message || '查询失败', 'error');
- }
- }
- window.dashboardCustomerDetail = (externalUserId) => {
- if (!externalUserId) return;
- const localCustomer = (state.customerOps.allCustomers || []).find(c => c.externalUserId === externalUserId || c.phone === externalUserId) || { externalUserId };
- const { basicInfo, sourceGroupHtml } = renderCustomerOpsSummary(localCustomer);
- 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>';
- 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>';
- openModal('客户详情', `
- ${renderDetailCard('运营信息', personIcon, basicInfo)}
- ${renderDetailCard('来源群聊', roomsIcon, sourceGroupHtml)}
- <div class="detail-card" style="margin-top:16px">
- <div class="detail-card-header">
- <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>
- <span>编辑资料</span>
- </div>
- <div class="detail-card-body">
- <div class="form-group" style="margin-bottom:12px">
- <label>姓名</label>
- <input id="customer-edit-name" type="text" value="${escapeHtml(localCustomer.name || '')}" placeholder="客户姓名" />
- </div>
- <div class="form-group" style="margin-bottom:12px">
- <label>手机号</label>
- <input id="customer-edit-phone" type="text" value="${escapeHtml(localCustomer.phone || '')}" placeholder="手机号" />
- </div>
- <div id="customer-edit-message" style="font-size:13px;color:var(--text-muted);min-height:20px"></div>
- </div>
- </div>
- <div class="detail-actions">
- <button class="btn btn-sm btn-secondary" id="btn-load-customer-full-profile">加载完整资料</button>
- <button class="btn btn-sm" id="btn-save-customer-info">保存修改</button>
- </div>
- <div id="customer-full-profile"></div>
- `, `<button class="btn btn-secondary" onclick="window.dashboardCloseModal()">关闭</button>`);
- document.getElementById('btn-load-customer-full-profile')?.addEventListener('click', () => loadCustomerFullProfile(externalUserId));
- document.getElementById('btn-save-customer-info')?.addEventListener('click', async () => {
- const name = document.getElementById('customer-edit-name').value.trim();
- const phone = document.getElementById('customer-edit-phone').value.trim();
- const msgEl = document.getElementById('customer-edit-message');
- try {
- const res = await api('POST', '/api/customer-ops/update', { externalUserId, name, phone });
- if (res.status === 'ok') {
- msgEl.textContent = '已同步更新';
- msgEl.style.color = 'var(--success)';
- // 更新本地客户列表状态
- const customer = (state.customerOps.allCustomers || []).find(c => c.externalUserId === externalUserId);
- if (customer) {
- customer.name = name || customer.name;
- customer.phone = phone || customer.phone;
- customer.updatedAt = new Date().toISOString();
- }
- // 刷新客户运营列表
- const page = document.querySelector('#co-customer-table-wrap')?.closest('.page');
- if (page) loadCustomerOpsTable(page);
- toast('客户资料已同步更新');
- } else {
- msgEl.textContent = res.assistantMessage || '保存失败';
- msgEl.style.color = 'var(--error)';
- }
- } catch (err) {
- msgEl.textContent = err.message || '保存失败';
- msgEl.style.color = 'var(--error)';
- }
- });
- };
- async function renderPortraitsPage() {
- els.pageTitle.textContent = '画像与标签';
- const page = document.createElement('div');
- page.className = 'page customer-workspace-page';
- page.innerHTML = `
- <section class="workspace-hero customer-workspace-hero portrait-dashboard-hero">
- <div>
- <span>CUSTOMER INTELLIGENCE & TAGS</span>
- <h2>画像与标签</h2>
- <p>把客户对话沉淀为可追溯的结构化画像,用统一标签支持筛选、运营和后续跟进。</p>
- </div>
- <div class="hero-feature-list" aria-label="能力说明">
- <span>AI 画像</span><span>证据可追溯</span><span>批量标签</span>
- </div>
- </section>
- <div class="dashboard-grid customer-dashboard-grid" id="pt-dashboard">
- <div class="kpi-row" id="pt-kpi-row"></div>
- <div class="chart-card third">
- <h3 class="chart-title"><span>画像覆盖率</span></h3>
- <div class="chart-container small" id="pt-coverage-chart"></div>
- </div>
- <div class="chart-card third">
- <h3 class="chart-title"><span>画像来源分布</span></h3>
- <div class="chart-container small" id="pt-source-chart"></div>
- </div>
- <div class="chart-card third">
- <h3 class="chart-title"><span>Top 标签</span></h3>
- <div class="chart-container small" id="pt-tags-chart"></div>
- </div>
- <div class="chart-card full">
- <h3 class="chart-title"><span>近7日画像生成趋势</span></h3>
- <div class="chart-container" id="pt-trend-chart"></div>
- </div>
- <div class="operation-toolbar">
- <div class="toolbar-left">
- <button class="btn" id="btn-pt-batch" data-job="batch-portraits">批量生成画像</button>
- <button class="btn btn-secondary" id="btn-pt-tag">为客户打标签</button>
- <button class="btn btn-secondary" id="btn-pt-export">导出全部画像</button>
- </div>
- <div class="toolbar-right">
- <button class="btn btn-sm btn-secondary" id="btn-pt-refresh">刷新</button>
- <button class="btn btn-sm btn-secondary" id="btn-pt-rebuild">重建索引</button>
- </div>
- </div>
- <div id="pt-job-tracker" style="margin:12px 0"></div>
- <div class="data-table-card">
- <div class="card-header">
- <div>
- <h3 class="card-title">客户画像列表</h3>
- <div class="card-subtitle">支持按画像状态、来源、标签筛选</div>
- </div>
- </div>
- <div class="filter-bar" style="margin-bottom:16px">
- <input id="pt-filter-keyword" type="text" placeholder="搜索 externalUserId / 姓名" style="width:220px" value="${escapeHtml(state.portraits.keyword || '')}" />
- <select id="pt-filter-source">
- <option value="">全部来源</option>
- <option value="keyword" ${state.portraits.source === 'keyword' ? 'selected' : ''}>关键词</option>
- <option value="agent" ${state.portraits.source === 'agent' ? 'selected' : ''}>AI</option>
- </select>
- <select id="pt-filter-tag">
- <option value="">全部标签</option>
- </select>
- </div>
- <div id="pt-portrait-table-wrap"></div>
- <div id="pt-pagination"></div>
- </div>
- </div>
- `;
- els.content.appendChild(page);
- await loadPortraitsDashboard(page);
- dashboardRefreshTimer = setInterval(() => {
- if (state.page === 'portraits') loadPortraitsDashboard(page, true);
- }, 30000);
- page.querySelector('#btn-pt-refresh').addEventListener('click', () => loadPortraitsDashboard(page));
- page.querySelector('#btn-pt-rebuild').addEventListener('click', async () => {
- try {
- await api('POST', '/api/dashboard/rebuild');
- toast('索引已重建');
- loadPortraitsDashboard(page);
- } catch (err) {
- toast(err.message || '重建失败', 'error');
- }
- });
- page.querySelector('#pt-filter-keyword').addEventListener('input', () => loadPortraitsTable(page));
- page.querySelector('#pt-filter-source').addEventListener('change', () => loadPortraitsTable(page));
- page.querySelector('#pt-filter-tag').addEventListener('change', () => loadPortraitsTable(page));
- page.querySelector('#btn-pt-batch').addEventListener('click', () => openBatchPortraitSelector(page));
- page.querySelector('#btn-pt-tag').addEventListener('click', () => openTagModal(page));
- page.querySelector('#btn-pt-export').addEventListener('click', () => {
- const portraits = state.portraits?.allPortraits || {};
- if (!Object.keys(portraits).length) {
- toast('当前没有可导出的画像,请先生成画像', 'warning');
- return;
- }
- startJob('export-portraits', '/api/portraits/export', {}, (result) => {
- if (result?.status === 'error') {
- toast(result.assistantMessage || '导出失败', 'error');
- return;
- }
- const filePath = result?.files?.[0];
- if (!filePath) {
- toast('导出未生成文件', 'warning');
- return;
- }
- toast('导出成功,点击下载', 'success');
- window.open(`/api/outputs?path=${encodeURIComponent(filePath)}`, '_blank');
- });
- });
- }
- async function loadPortraitsDashboard(page, silent = false) {
- const tableWrap = page.querySelector('#pt-portrait-table-wrap');
- if (!silent) showLoading(tableWrap, 'table');
- try {
- const [summaryRes, portraitsRes, tagsRes, customersRes, customerMasterRes] = await Promise.all([
- api('GET', '/api/dashboard/summary'),
- api('GET', '/api/dashboard/portraits'),
- api('GET', '/api/dashboard/tags'),
- api('GET', '/api/dashboard/customers'),
- api('GET', '/api/customers')
- ]);
- const summary = summaryRes.data || {};
- const legacyPortraits = portraitsRes.data?.portraits || {};
- const legacyCustomers = customersRes.data?.customers || {};
- const customerMaster = customerMasterRes.data?.customers || [];
- const canonicalPortraits = {};
- const canonicalCustomers = {};
- for (const customer of customerMaster) {
- const hasProfile = customer.profile && Object.keys(customer.profile).length > 0;
- if (!hasProfile) continue;
- canonicalPortraits[customer.id] = {
- externalUserId: customer.id,
- displayId: customer.maskedId,
- fields: customer.fields || [],
- profile: customer.profile || {},
- source: 'agent',
- canonical: true,
- messageCount: customer.messageCount || 0,
- updatedAt: customer.profileUpdatedAt || customer.lastMessageAt,
- };
- canonicalCustomers[customer.id] = {
- externalUserId: customer.id,
- name: customer.displayName,
- phone: customer.maskedId,
- tags: customer.tags || [],
- };
- }
- const portraits = { ...legacyPortraits, ...canonicalPortraits };
- const customers = { ...legacyCustomers, ...canonicalCustomers };
- const tags = [...new Set([
- ...(tagsRes.data?.allTags || []),
- ...customerMaster.flatMap(customer => customer.tags || []),
- ])];
- const portraitItems = Object.values(portraits);
- const customerTotal = Math.max(Number(summary.customers?.total || 0), customerMaster.length);
- const bySource = portraitItems.reduce((counts, portrait) => {
- const source = portrait.source === 'agent' ? 'agent' : portrait.source === 'keyword' ? 'keyword' : 'other';
- counts[source] += 1;
- return counts;
- }, { agent: 0, keyword: 0, other: 0 });
- summary.portraits = {
- ...(summary.portraits || {}),
- total: portraitItems.length,
- coverage: customerTotal ? Math.round((portraitItems.length / customerTotal) * 100) : 0,
- bySource,
- };
- summary.tags = {
- ...(summary.tags || {}),
- total: tags.length,
- avgPerCustomer: customerTotal ? Number((tags.length / customerTotal).toFixed(1)) : 0,
- };
- state.portraits.allPortraits = portraits;
- state.portraits.allCustomers = customers;
- hideLoading(tableWrap);
- renderPortraitsKpi(page, summary);
- renderPortraitsCharts(page, summary, portraits, tags);
- renderPortraitsTagFilter(page, tags);
- loadPortraitsTable(page);
- } catch (err) {
- if (!silent) showError(tableWrap, err.message || '加载看板失败', () => loadPortraitsDashboard(page));
- }
- }
- function renderPortraitsKpi(page, summary) {
- const wrap = page.querySelector('#pt-kpi-row');
- const p = summary.portraits || {};
- const t = summary.tags || {};
- const kpi = [
- { label: '已生成画像', value: formatNumber(p.total) },
- { label: '画像覆盖率', value: (p.coverage || 0) + '%', sub: '有画像客户占比' },
- { label: '本地标签', value: formatNumber(t.total), sub: '去重后标签数' },
- { label: '人均标签', value: t.avgPerCustomer || 0, sub: '每个客户平均标签' },
- { label: 'AI 画像', value: formatNumber(p.bySource?.agent || 0), sub: 'Agent 分析生成' },
- { label: '关键词画像', value: formatNumber(p.bySource?.keyword || 0), sub: '关键词匹配生成' }
- ];
- wrap.innerHTML = renderKpiRow(kpi);
- }
- function renderPortraitsCharts(page, summary, portraits, tags) {
- const colors = getChartColors();
- const coverage = summary.portraits?.coverage || 0;
- initEChart('pt-coverage-chart', {
- series: [{
- type: 'gauge',
- startAngle: 180,
- endAngle: 0,
- min: 0,
- max: 100,
- progress: { show: true, width: 12, itemStyle: { color: colors.brand } },
- axisLine: { lineStyle: { width: 12, color: [[1, 'rgba(0,0,0,0.06)']] } },
- axisTick: { show: false },
- splitLine: { show: false },
- axisLabel: { show: false },
- pointer: { show: false },
- anchor: { show: false },
- title: { show: false },
- detail: {
- valueAnimation: true,
- fontSize: 28,
- fontWeight: 800,
- color: colors.textPrimary,
- formatter: '{value}%',
- offsetCenter: [0, '0%']
- },
- data: [{ value: coverage }]
- }]
- });
- const bySource = summary.portraits?.bySource || {};
- initEChart('pt-source-chart', {
- tooltip: { trigger: 'item' },
- legend: { bottom: 0 },
- color: [colors.brand, colors.info, colors.muted],
- series: [{
- type: 'pie',
- radius: ['40%', '65%'],
- center: ['50%', '45%'],
- data: [
- { name: '关键词', value: bySource.keyword || 0 },
- { name: 'AI', value: bySource.agent || 0 },
- { name: '其他', value: bySource.other || 0 }
- ]
- }]
- });
- const customers = Object.values(state.portraits.allCustomers || {});
- const topTags = tags
- .map(t => ({
- name: t,
- value: customers.filter(c => Array.isArray(c.tags) && c.tags.includes(t)).length
- }))
- .filter(t => t.value > 0)
- .sort((a, b) => b.value - a.value)
- .slice(0, 10);
- initEChart('pt-tags-chart', {
- tooltip: { trigger: 'axis' },
- grid: { left: 8, right: 34, top: 10, bottom: 8, containLabel: true },
- xAxis: {
- type: 'value',
- splitLine: { lineStyle: { color: 'rgba(0,0,0,0.06)' } },
- axisLabel: { color: colors.muted }
- },
- yAxis: {
- type: 'category',
- data: topTags.map(t => t.name).reverse(),
- axisLabel: {
- width: 96,
- overflow: 'truncate',
- interval: 0,
- color: colors.textPrimary
- },
- axisTick: { show: false }
- },
- series: [{
- type: 'bar',
- data: topTags.map(t => t.value).reverse(),
- itemStyle: { color: colors.brand, borderRadius: [0, 5, 5, 0] },
- barWidth: 14,
- label: {
- show: true,
- position: 'right',
- color: colors.textPrimary,
- fontWeight: 600
- }
- }]
- });
- const days = last7Days();
- const portraitItems = Object.values(portraits);
- const counts = countByDay(portraitItems, 'updatedAt');
- initEChart('pt-trend-chart', {
- tooltip: { trigger: 'axis' },
- xAxis: { type: 'category', data: days.map(d => formatDate(d)) },
- yAxis: { type: 'value' },
- series: [{ name: '画像生成', type: 'line', smooth: true, data: counts, itemStyle: { color: colors.brand }, areaStyle: { color: hexToRgba(colors.brand, 0.15) } }]
- });
- }
- function renderPortraitsTagFilter(page, tags) {
- const select = page.querySelector('#pt-filter-tag');
- const current = select.value;
- select.innerHTML = '<option value="">全部标签</option>' + tags.map(t => `<option value="${escapeHtml(t)}">${escapeHtml(t)}</option>`).join('');
- select.value = current;
- }
- function loadPortraitsTable(page) {
- const wrap = page.querySelector('#pt-portrait-table-wrap');
- const portraits = state.portraits.allPortraits || {};
- const customers = state.portraits.allCustomers || {};
- const keyword = page.querySelector('#pt-filter-keyword').value.trim().toLowerCase();
- const source = page.querySelector('#pt-filter-source').value;
- const tag = page.querySelector('#pt-filter-tag').value;
- state.portraits.keyword = keyword;
- state.portraits.source = source;
- state.portraits.tag = tag;
- saveFilters(PT_FILTERS_KEY, {
- tablePage: state.portraits.tablePage,
- keyword,
- source,
- tag
- });
- let items = Object.values(portraits).map(p => {
- const customer = Object.values(customers).find(c => c.externalUserId === p.externalUserId) || {};
- return { ...p, name: customer.name, phone: customer.phone, tags: customer.tags || [] };
- });
- let filtered = items.filter(p => {
- if (keyword) {
- const text = `${p.externalUserId || ''} ${p.name || ''} ${p.phone || ''}`.toLowerCase();
- if (!text.includes(keyword)) return false;
- }
- if (source && p.source !== source) return false;
- if (tag && !p.tags.includes(tag)) return false;
- return true;
- });
- const pageSize = 20;
- const currentPage = state.portraits.tablePage || 1;
- const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
- if (currentPage > totalPages) {
- state.portraits.tablePage = totalPages;
- saveFilters(PT_FILTERS_KEY, {
- tablePage: state.portraits.tablePage,
- keyword: state.portraits.keyword,
- source: state.portraits.source,
- tag: state.portraits.tag
- });
- }
- const start = (state.portraits.tablePage - 1) * pageSize;
- const pageItems = filtered.slice(start, start + pageSize);
- wrap.innerHTML = `
- <div class="table-wrap">
- <table>
- <thead>
- <tr>
- <th>externalUserId</th>
- <th>姓名</th>
- <th>标签</th>
- <th>画像字段</th>
- <th>来源</th>
- <th>消息数</th>
- <th>更新时间</th>
- <th>操作</th>
- </tr>
- </thead>
- <tbody>
- ${pageItems.length ? pageItems.map(p => `
- <tr>
- <td style="font-family:monospace;font-size:12px">${escapeHtml(p.displayId || p.externalUserId || '-')}</td>
- <td>${escapeHtml(p.name || '-')}</td>
- <td>${p.tags.length ? p.tags.map(t => `<span class="tag">${escapeHtml(t)}</span>`).join('') : '-'}</td>
- <td>${formatNumber(p.fields?.length || 0)} 个字段</td>
- <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>
- <td>${formatNumber(p.messageCount)}</td>
- <td>${formatDateTime(p.updatedAt)}</td>
- <td>
- <button class="btn btn-sm btn-secondary" onclick="window.dashboardPortraitDetail('${escapeHtml(p.externalUserId)}')">查看</button>
- </td>
- </tr>
- `).join('') : renderEmptyTableRow(8, '暂无画像数据', '选择客户后批量生成画像')}
- </tbody>
- </table>
- </div>
- `;
- const pagination = page.querySelector('#pt-pagination');
- pagination.innerHTML = renderPagination(state.portraits.tablePage, filtered.length, pageSize);
- pagination.querySelectorAll('button').forEach(btn => {
- btn.addEventListener('click', () => {
- state.portraits.tablePage = Number(btn.dataset.page);
- saveFilters(PT_FILTERS_KEY, {
- tablePage: state.portraits.tablePage,
- keyword: state.portraits.keyword,
- source: state.portraits.source,
- tag: state.portraits.tag
- });
- loadPortraitsTable(page);
- });
- });
- }
- async function openBatchPortraitSelector(page) {
- const result = await openCustomerSelector({
- multiple: true,
- title: '选择要生成画像的客户',
- confirmText: '生成画像'
- });
- const ids = result.externalUserIds || [];
- if (!ids.length) {
- toast('未选择客户', 'warning');
- return;
- }
- startJob('batch-portraits', '/api/portraits/batch', { externalUserIds: ids, aiMode: 'ai', guid: getGuid() }, () => loadPortraitsDashboard(page), 'pt-job-tracker');
- }
- function openTagModal(page) {
- const body = `
- <div class="form-group" style="margin-bottom:16px">
- <label>选择客户</label>
- <button type="button" class="customer-selector-trigger" id="btn-select-tag-customer">点击选择客户</button>
- <div class="customer-selector-value" id="tag-customer-selected"><span style="color:var(--text-muted);font-size:13px">未选择客户</span></div>
- </div>
- <div class="form-group" style="margin-bottom:16px">
- <label>标签(逗号分隔)</label>
- <input id="tag-input" type="text" placeholder="高意向,学区房" />
- </div>
- <div class="form-group">
- <label>操作</label>
- <select id="tag-action">
- <option value="add">添加标签</option>
- <option value="remove">移除标签</option>
- </select>
- </div>
- `;
- openModal('为客户打标签', body, `
- <button class="btn btn-secondary" onclick="window.dashboardCloseModal()">取消</button>
- <button class="btn" id="btn-confirm-tag">确定</button>
- `);
- let selectedCustomer = null;
- document.getElementById('btn-select-tag-customer').addEventListener('click', async () => {
- const result = await openCustomerSelector({ multiple: false, selectedIds: selectedCustomer ? [selectedCustomer.externalUserId] : [], title: '选择要打标签的客户' });
- selectedCustomer = result.customers[0] || null;
- renderCustomerSelectorValue('tag-customer-selected', selectedCustomer ? [selectedCustomer] : []);
- });
- document.getElementById('btn-confirm-tag').addEventListener('click', async () => {
- const externalUserId = selectedCustomer ? selectedCustomer.externalUserId : '';
- const tags = document.getElementById('tag-input').value.split(/[,,]/).map(s => s.trim()).filter(Boolean);
- const action = document.getElementById('tag-action').value;
- if (!externalUserId || !tags.length) {
- toast('请选择客户并输入标签', 'warning');
- return;
- }
- try {
- await api('POST', `/api/tags/${action}`, { externalUserId, tags });
- toast('标签已更新');
- closeModal();
- loadPortraitsDashboard(page);
- } catch (err) {
- toast(err.message || '更新失败', 'error');
- }
- });
- }
- function portraitLabel(key) {
- const labels = {
- intent: '意向',
- budgetRange: '预算',
- preferredAreas: '区域',
- houseType: '房型',
- timeline: '时间计划',
- keyConcerns: '关注点',
- urgency: '紧迫度',
- aiSummary: '综合判断',
- personaType: '客户类型',
- fiveW2H: '5W2H',
- priorityMatrix: '优先级',
- confidence: '置信度',
- coreAnxiety: '核心顾虑',
- decisionMaker: '决策人',
- loanCapacity: '贷款能力',
- negotiationStage: '谈判阶段'
- };
- return labels[key] || key;
- }
- function normalizePortraitValue(value) {
- if (value === undefined || value === null || value === '') return { text: '-', chips: [], note: '' };
- if (Array.isArray(value)) {
- return { text: value.map(v => typeof v === 'object' ? JSON.stringify(v) : String(v)).join('、'), chips: value.map(String), note: '' };
- }
- if (typeof value !== 'object') return { text: String(value), chips: [], note: '' };
- const chips = [];
- if (Array.isArray(value.keywords)) chips.push(...value.keywords.map(String));
- if (Array.isArray(value.evidence)) chips.push(...value.evidence.map(String));
- if (Array.isArray(value.values)) chips.push(...value.values.map(String));
- const textCandidates = [
- value.value,
- value.result,
- value.summary,
- value.note,
- value.inference,
- value.description,
- value.label
- ].filter(v => v !== undefined && v !== null && v !== '');
- if (textCandidates.length) {
- return {
- text: textCandidates.map(v => typeof v === 'object' ? JSON.stringify(v) : String(v)).join(';'),
- chips: [...new Set(chips)],
- note: value.inferred ? '根据聊天记录推断' : ''
- };
- }
- return {
- text: JSON.stringify(value, null, 2),
- chips: [...new Set(chips)],
- note: value.inferred ? '根据聊天记录推断' : ''
- };
- }
- function renderPortraitInsightCard(key, value) {
- const normalized = normalizePortraitValue(value);
- const chipHtml = normalized.chips.length
- ? `<div class="portrait-evidence">${normalized.chips.slice(0, 8).map(chip => `<span>${escapeHtml(chip)}</span>`).join('')}</div>`
- : '';
- return `
- <div class="portrait-insight-card">
- <div class="portrait-insight-label">${escapeHtml(portraitLabel(key))}</div>
- <div class="portrait-insight-value">${escapeHtml(normalized.text)}</div>
- ${normalized.note ? `<div class="portrait-insight-note">${escapeHtml(normalized.note)}</div>` : ''}
- ${chipHtml}
- </div>
- `;
- }
- function renderPortraitSection(title, keys, portraitFields) {
- const available = keys.filter(k => portraitFields[k] !== undefined && portraitFields[k] !== null && portraitFields[k] !== '');
- if (!available.length) return '';
- return `
- <section class="portrait-section">
- <div class="portrait-section-head">
- <h3>${escapeHtml(title)}</h3>
- <span>${formatNumber(available.length)} 项</span>
- </div>
- <div class="portrait-insight-grid">
- ${available.map(k => renderPortraitInsightCard(k, portraitFields[k])).join('')}
- </div>
- </section>
- `;
- }
- window.dashboardPortraitDetail = async (externalUserId) => {
- if (!externalUserId) return;
- const cachedPortrait = state.portraits.allPortraits?.[externalUserId];
- if (cachedPortrait?.canonical) {
- const customer = state.portraits.allCustomers?.[externalUserId] || {};
- const fields = cachedPortrait.fields || [];
- openModal('画像详情', `
- <div class="portrait-profile">
- <div class="portrait-hero">
- <div class="portrait-avatar">${escapeHtml((customer.name || '客').slice(0, 1))}</div>
- <div class="portrait-identity">
- <div class="portrait-name">${escapeHtml(customer.name || '未命名客户')}</div>
- <div class="portrait-subtitle">${escapeHtml(cachedPortrait.displayId || '-')} · 真实企微会话画像</div>
- <div class="portrait-chip-row">
- <span class="badge badge-info">Agent 自动沉淀</span>
- <span class="portrait-soft-chip">${formatNumber(cachedPortrait.messageCount || 0)} 条消息</span>
- <span class="portrait-soft-chip">${formatNumber(fields.length)} 个画像字段</span>
- </div>
- </div>
- </div>
- <div class="portrait-highlight-grid">
- ${fields.map(field => `<div class="portrait-insight-card"><span>${escapeHtml(field.label || field.key)}</span><strong>${escapeHtml(field.displayValue ?? '-')}</strong></div>`).join('')}
- </div>
- </div>
- `, `<button class="btn btn-secondary" onclick="window.dashboardCloseModal()">关闭</button>`, 'portrait-modal');
- return;
- }
- try {
- const res = await api('POST', '/api/customer-ops/customer-profile', { externalUserId, guid: getGuid() });
- const data = res.data || {};
- const portrait = data.portrait || {};
- const portraitFields = portrait.portrait || {};
- const localCustomer = Object.values(state.portraits.allCustomers || {}).find(c => c.externalUserId === externalUserId) || {};
- const name = localCustomer.name || '-';
- const phone = localCustomer.phone || '-';
- const tags = localCustomer.tags || [];
- const sourceMap = {
- keyword: ['badge-warning', '关键词'],
- agent: ['badge-info', 'AI'],
- unknown: ['badge-default', '未知']
- };
- const [srcCls, srcText] = sourceMap[portrait.source] || sourceMap.unknown;
- const groupMap = {
- '购房需求': ['intent', 'budgetRange', 'preferredAreas', 'houseType'],
- '决策状态': ['timeline', 'urgency', 'negotiationStage', 'decisionMaker'],
- '关注与顾虑': ['keyConcerns', 'coreAnxiety', 'loanCapacity'],
- '综合分析': ['aiSummary', 'personaType', 'fiveW2H', 'priorityMatrix', 'confidence']
- };
- const fieldCount = Object.keys(portraitFields).length;
- const customerInitial = (name && name !== '-' ? name : '客').slice(0, 1);
- const topFields = ['intent', 'budgetRange', 'preferredAreas', 'timeline', 'keyConcerns']
- .filter(k => portraitFields[k] !== undefined && portraitFields[k] !== null && portraitFields[k] !== '')
- .slice(0, 4);
- const topHighlights = topFields.length
- ? topFields.map(k => renderPortraitInsightCard(k, portraitFields[k])).join('')
- : '<div class="portrait-empty-panel">暂无可展示的核心画像字段</div>';
- let sectionsHtml = '';
- for (const [groupName, keys] of Object.entries(groupMap)) {
- sectionsHtml += renderPortraitSection(groupName, keys, portraitFields);
- }
- if (!sectionsHtml) {
- sectionsHtml = '<div class="portrait-empty-panel">当前画像没有结构化字段。请先同步更多客户对话,再重新生成画像。</div>';
- }
- openModal('画像详情', `
- <div class="portrait-profile">
- <div class="portrait-hero">
- <div class="portrait-avatar">${escapeHtml(customerInitial)}</div>
- <div class="portrait-identity">
- <div class="portrait-name">${escapeHtml(name || '未命名客户')}</div>
- <div class="portrait-subtitle">${escapeHtml(phone || '-')} · ${escapeHtml(externalUserId)}</div>
- <div class="portrait-chip-row">
- <span class="badge ${srcCls}">${escapeHtml(srcText)}</span>
- <span class="portrait-soft-chip">${formatNumber(portrait.messageCount || 0)} 条消息</span>
- <span class="portrait-soft-chip">${formatNumber(fieldCount)} 个画像字段</span>
- </div>
- </div>
- <div class="portrait-meta">
- <div>生成时间</div>
- <strong>${escapeHtml(formatDateTime(portrait.updatedAt))}</strong>
- </div>
- </div>
- ${tags.length ? `<div class="portrait-tags">${tags.map(t => `<span class="tag">${escapeHtml(t)}</span>`).join(' ')}</div>` : ''}
- <div class="portrait-highlight-grid">
- ${topHighlights}
- </div>
- </div>
- ${sectionsHtml}
- ${renderRawToggle(portraitFields)}
- `, `<button class="btn btn-secondary" onclick="window.dashboardCloseModal()">关闭</button>`, 'portrait-modal');
- bindRawToggle();
- } catch (err) {
- toast(err.message || '查询失败', 'error');
- }
- };
- async function renderTransfersPage() {
- els.pageTitle.textContent = '客户交接';
- const page = document.createElement('div');
- page.className = 'page customer-workspace-page';
- page.innerHTML = `
- <section class="workspace-hero customer-workspace-hero transfer-dashboard-hero">
- <div>
- <span>CUSTOMER HANDOVER WORKFLOW</span>
- <h2>客户交接</h2>
- <p>先生成交接预览,再执行顾问变更;客户、群聊、欢迎语与执行结果全程留痕。</p>
- </div>
- <div class="handover-flow" aria-label="交接流程">
- <span><b>1</b>准备范围</span><i></i><span><b>2</b>生成预览</span><i></i><span><b>3</b>确认执行</span>
- </div>
- </section>
- <div class="dashboard-grid customer-dashboard-grid" id="tf-dashboard">
- <div class="kpi-row" id="tf-kpi-row"></div>
- <div class="chart-card transfer-progress-card">
- <h3 class="chart-title"><span>交接流程转化</span><span class="chart-subtitle">从预览准备到成功交接</span></h3>
- <div class="transfer-stage-board" id="tf-stage-board"></div>
- </div>
- <div class="chart-card">
- <h3 class="chart-title"><span>交接结果分布</span></h3>
- <div class="chart-container" id="tf-result-chart"></div>
- </div>
- <div class="chart-card full">
- <h3 class="chart-title"><span>近7日交接执行趋势</span></h3>
- <div class="chart-container" id="tf-trend-chart"></div>
- </div>
- <div class="operation-toolbar">
- <div class="toolbar-left">
- <button class="btn" id="btn-new-transfer">新建交接预览</button>
- </div>
- <div class="toolbar-right">
- <button class="btn btn-sm btn-secondary" id="btn-tf-refresh">刷新</button>
- <button class="btn btn-sm btn-secondary" id="btn-tf-rebuild">重建索引</button>
- </div>
- </div>
- <div class="data-table-card">
- <div class="card-header">
- <div>
- <h3 class="card-title">交接包列表</h3>
- <div class="card-subtitle">预览包与执行记录</div>
- </div>
- </div>
- <div class="filter-bar" style="margin-bottom:16px">
- <select id="tf-filter-status">
- <option value="">全部状态</option>
- <option value="DRAFT" ${state.transfers.status === 'DRAFT' ? 'selected' : ''}>草稿</option>
- <option value="EXECUTED" ${state.transfers.status === 'EXECUTED' ? 'selected' : ''}>已执行</option>
- </select>
- <input id="tf-filter-user" type="text" placeholder="按顾问 userId 搜索" style="width:200px" value="${escapeHtml(state.transfers.user || '')}" />
- </div>
- <div id="tf-transfer-table-wrap"></div>
- <div id="tf-pagination"></div>
- </div>
- </div>
- `;
- els.content.appendChild(page);
- await loadTransfersDashboard(page);
- dashboardRefreshTimer = setInterval(() => {
- if (state.page === 'transfers') loadTransfersDashboard(page, true);
- }, 30000);
- page.querySelector('#btn-tf-refresh').addEventListener('click', () => loadTransfersDashboard(page));
- page.querySelector('#btn-tf-rebuild').addEventListener('click', async () => {
- try {
- await api('POST', '/api/dashboard/rebuild');
- toast('索引已重建');
- loadTransfersDashboard(page);
- } catch (err) {
- toast(err.message || '重建失败', 'error');
- }
- });
- page.querySelector('#btn-new-transfer').addEventListener('click', () => openTransferModal(page));
- page.querySelector('#tf-filter-status').addEventListener('change', () => loadTransfersTable(page));
- page.querySelector('#tf-filter-user').addEventListener('input', () => loadTransfersTable(page));
- }
- async function loadTransfersDashboard(page, silent = false) {
- const tableWrap = page.querySelector('#tf-transfer-table-wrap');
- if (!silent) showLoading(tableWrap, 'table');
- try {
- const [summaryRes, transfersRes] = await Promise.all([
- api('GET', '/api/dashboard/summary'),
- api('GET', '/api/dashboard/transfers')
- ]);
- const summary = summaryRes.data || {};
- const transfers = transfersRes.data || {};
- hideLoading(tableWrap);
- renderTransfersKpi(page, summary);
- renderTransfersCharts(page, summary, transfers);
- state.transfers = { ...state.transfers, ...transfers };
- loadTransfersTable(page);
- } catch (err) {
- if (!silent) showError(tableWrap, err.message || '加载看板失败', () => loadTransfersDashboard(page));
- }
- }
- function renderTransfersKpi(page, summary) {
- const wrap = page.querySelector('#tf-kpi-row');
- const t = summary.transfers || {};
- const kpi = [
- { label: '交接预览包', value: formatNumber(t.previews) },
- { label: '已执行交接', value: formatNumber(t.executions) },
- { label: '交接成功率', value: (t.successRate || 0) + '%', sub: '成功记录 / 总记录' },
- { label: '近7日交接', value: formatNumber(t.recent7d), sub: '最近7天执行数' }
- ];
- wrap.innerHTML = renderKpiRow(kpi);
- }
- function renderTransfersCharts(page, summary, transfers) {
- const colors = getChartColors();
- const t = summary.transfers || {};
- const previews = Number(t.previews || 0);
- const executions = Number(t.executions || 0);
- const successful = Math.round(((t.successRate || 0) / 100) * executions);
- const executionRate = previews ? Math.min(100, Math.round((executions / previews) * 100)) : 0;
- const successRate = executions ? Math.min(100, Math.round((successful / executions) * 100)) : 0;
- const overallRate = previews ? Math.min(100, Math.round((successful / previews) * 100)) : 0;
- const stageBoard = page.querySelector('#tf-stage-board');
- if (stageBoard) {
- stageBoard.innerHTML = `
- <div class="transfer-stage-track">
- <article class="transfer-stage preview">
- <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>
- <div><span>01 · 准备完成</span><strong>${formatNumber(previews)}</strong><small>份交接预览包</small></div>
- </article>
- <div class="transfer-stage-connector"><strong>${executionRate}%</strong><span>进入执行</span><i><b style="width:${executionRate}%"></b></i></div>
- <article class="transfer-stage execution">
- <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>
- <div><span>02 · 已确认执行</span><strong>${formatNumber(executions)}</strong><small>次顾问交接</small></div>
- </article>
- <div class="transfer-stage-connector"><strong>${successRate}%</strong><span>执行成功</span><i><b style="width:${successRate}%"></b></i></div>
- <article class="transfer-stage success">
- <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>
- <div><span>03 · 交接成功</span><strong>${formatNumber(successful)}</strong><small>条成功记录</small></div>
- </article>
- </div>
- <div class="transfer-overall-progress">
- <div><span>整体交接完成率</span><strong>${overallRate}%</strong></div>
- <i><b style="width:${overallRate}%"></b></i>
- <small>${previews ? `共 ${formatNumber(previews)} 份预览包,其中 ${formatNumber(successful)} 份已完成成功交接` : '创建交接预览后,这里将展示阶段转化情况'}</small>
- </div>`;
- }
- const executionRecords = transfers.executions || [];
- const success = executionRecords.reduce((sum, e) => sum + (e.success || 0), 0);
- const failed = executionRecords.reduce((sum, e) => sum + (e.failed || 0), 0);
- initEChart('tf-result-chart', {
- tooltip: { trigger: 'item' },
- legend: { bottom: 0 },
- color: [colors.success, colors.error],
- series: [{
- type: 'pie',
- radius: ['40%', '65%'],
- center: ['50%', '45%'],
- data: [
- { name: '成功', value: success },
- { name: '失败', value: failed }
- ]
- }]
- });
- const days = last7Days();
- const counts = countByDay(executionRecords, 'executedAt');
- initEChart('tf-trend-chart', {
- tooltip: { trigger: 'axis' },
- xAxis: { type: 'category', data: days.map(d => formatDate(d)) },
- yAxis: { type: 'value' },
- series: [{ name: '交接执行', type: 'bar', data: counts, itemStyle: { color: colors.brand }, barWidth: 20, borderRadius: [4, 4, 0, 0] }]
- });
- }
- function loadTransfersTable(page) {
- const wrap = page.querySelector('#tf-transfer-table-wrap');
- const transfers = state.transfers || {};
- const statusFilter = page.querySelector('#tf-filter-status').value;
- const userFilter = page.querySelector('#tf-filter-user').value.trim().toLowerCase();
- state.transfers.status = statusFilter;
- state.transfers.user = userFilter;
- saveFilters(TF_FILTERS_KEY, {
- tablePage: state.transfers.tablePage,
- status: statusFilter,
- user: userFilter
- });
- let items = [
- ...(transfers.previews || []).map(p => ({ ...p, type: 'preview' })),
- ...(transfers.executions || []).map(e => ({ ...e, type: 'execution' }))
- ];
- let filtered = items.filter(item => {
- if (statusFilter && item.status !== statusFilter) return false;
- if (userFilter) {
- const text = `${item.fromUserId || ''} ${item.toUserId || ''}`.toLowerCase();
- if (!text.includes(userFilter)) return false;
- }
- return true;
- });
- filtered.sort((a, b) => (b.createdAt || b.executedAt || '').localeCompare(a.createdAt || a.executedAt || ''));
- const pageSize = 20;
- const currentPage = state.transfers.tablePage || 1;
- const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
- if (currentPage > totalPages) {
- state.transfers.tablePage = totalPages;
- saveFilters(TF_FILTERS_KEY, {
- tablePage: state.transfers.tablePage,
- status: state.transfers.status,
- user: state.transfers.user
- });
- }
- const start = (state.transfers.tablePage - 1) * pageSize;
- const pageItems = filtered.slice(start, start + pageSize);
- wrap.innerHTML = `
- <div class="table-wrap">
- <table>
- <thead>
- <tr>
- <th>类型</th>
- <th>原顾问</th>
- <th>新顾问</th>
- <th>记录数</th>
- <th>状态</th>
- <th>创建/执行时间</th>
- <th>操作</th>
- </tr>
- </thead>
- <tbody>
- ${pageItems.length ? pageItems.map(item => `
- <tr>
- <td>${item.type === 'preview' ? '<span class="badge badge-warning">预览</span>' : '<span class="badge badge-info">执行</span>'}</td>
- <td>${escapeHtml(item.fromUserId || '-')}</td>
- <td>${escapeHtml(item.toUserId || '-')}</td>
- <td>${formatNumber(item.itemCount || item.total || 0)}</td>
- <td>${item.status === 'EXECUTED' ? '<span class="badge badge-success">已执行</span>' : '<span class="badge badge-warning">草稿</span>'}</td>
- <td>${formatDateTime(item.createdAt || item.executedAt)}</td>
- <td>
- ${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>`}
- </td>
- </tr>
- `).join('') : renderEmptyTableRow(7, '暂无交接记录', '点击新建交接预览')}
- </tbody>
- </table>
- </div>
- `;
- const pagination = page.querySelector('#tf-pagination');
- pagination.innerHTML = renderPagination(state.transfers.tablePage, filtered.length, pageSize);
- pagination.querySelectorAll('button').forEach(btn => {
- btn.addEventListener('click', () => {
- state.transfers.tablePage = Number(btn.dataset.page);
- saveFilters(TF_FILTERS_KEY, {
- tablePage: state.transfers.tablePage,
- status: state.transfers.status,
- user: state.transfers.user
- });
- loadTransfersTable(page);
- });
- });
- }
- function openTransferModal(page) {
- const accountOptions = state.accounts.map(a => `<option value="${escapeHtml(a.userId || a.guid || '')}">${escapeHtml(a.nickname || a.userId || a.guid || '未知账号')}</option>`).join('');
- const body = `
- <div class="form-group" style="margin-bottom:16px">
- <label>原顾问 userId</label>
- <div style="display:flex;gap:8px">
- <input id="new-tf-from" type="text" placeholder="fromUserId" style="flex:1" />
- <select id="quick-tf-from" style="width:140px">
- <option value="">快速选择</option>
- ${accountOptions}
- </select>
- </div>
- </div>
- <div class="form-group" style="margin-bottom:16px">
- <label>新顾问 userId</label>
- <div style="display:flex;gap:8px">
- <input id="new-tf-to" type="text" placeholder="toUserId" style="flex:1" />
- <select id="quick-tf-to" style="width:140px">
- <option value="">快速选择</option>
- ${accountOptions}
- </select>
- </div>
- </div>
- <div class="form-group" style="margin-bottom:16px">
- <label>选择要交接的客户(可选)</label>
- <button type="button" class="customer-selector-trigger" id="btn-select-transfer">点击选择客户</button>
- <div class="customer-selector-value" id="transfer-customer-selected"><span style="color:var(--text-muted);font-size:13px">未选择客户</span></div>
- </div>
- <div class="form-group">
- <label>已确认群 roomIds(逗号分隔,可选)</label>
- <input id="new-tf-rooms" type="text" placeholder="roomId1,roomId2" />
- </div>
- `;
- openModal('新建交接预览', body, `
- <button class="btn btn-secondary" onclick="window.dashboardCloseModal()">取消</button>
- <button class="btn" id="btn-confirm-new-transfer">生成预览</button>
- `);
- const fromInput = document.getElementById('new-tf-from');
- const toInput = document.getElementById('new-tf-to');
- document.getElementById('quick-tf-from').addEventListener('change', (e) => { if (e.target.value) fromInput.value = e.target.value; });
- document.getElementById('quick-tf-to').addEventListener('change', (e) => { if (e.target.value) toInput.value = e.target.value; });
- let selectedCustomers = [];
- document.getElementById('btn-select-transfer').addEventListener('click', async () => {
- const result = await openCustomerSelector({ multiple: true, selectedIds: selectedCustomers.map(c => c.externalUserId), title: '选择要交接的客户' });
- selectedCustomers = result.customers;
- renderCustomerSelectorValue('transfer-customer-selected', selectedCustomers);
- });
- document.getElementById('btn-confirm-new-transfer').addEventListener('click', async () => {
- const fromUserId = fromInput.value.trim();
- const toUserId = toInput.value.trim();
- const externalUserIds = selectedCustomers.map(c => c.externalUserId).filter(Boolean);
- const roomIds = document.getElementById('new-tf-rooms').value.split(/[,,]/).map(s => s.trim()).filter(Boolean);
- if (!fromUserId || !toUserId) {
- toast('请输入原顾问和新顾问 userId', 'warning');
- return;
- }
- if (!externalUserIds.length && !roomIds.length) {
- toast('请选择客户或输入 roomIds', 'warning');
- return;
- }
- try {
- await api('POST', '/api/transfers/preview', { fromUserId, toUserId, externalUserIds, roomIds });
- toast('预览已生成');
- closeModal();
- loadTransfersDashboard(page);
- } catch (err) {
- toast(err.message || '生成失败', 'error');
- }
- });
- }
- window.dashboardExecuteTransfer = (filePath) => {
- const body = `
- <div class="form-group" style="margin-bottom:16px">
- <label>预览文件路径</label>
- <input id="exec-tf-file" type="text" value="${escapeHtml(filePath || '')}" placeholder="留空使用最新预览" />
- </div>
- <div class="form-group" style="margin-bottom:16px">
- <label>欢迎语</label>
- <input id="exec-tf-welcome" type="text" value="您好,我是您的新服务顾问,后续由我为您服务。" />
- </div>
- <div class="checkbox-row">
- <input id="exec-tf-remove" type="checkbox" />
- <label for="exec-tf-remove">交接后移除原顾问</label>
- </div>
- `;
- openModal('执行交接', body, `
- <button class="btn btn-secondary" onclick="window.dashboardCloseModal()">取消</button>
- <button class="btn" id="btn-confirm-exec-transfer">执行</button>
- `);
- document.getElementById('btn-confirm-exec-transfer').addEventListener('click', () => {
- const previewFile = document.getElementById('exec-tf-file').value.trim() || undefined;
- const welcomeText = document.getElementById('exec-tf-welcome').value.trim();
- const removeOldBroker = document.getElementById('exec-tf-remove').checked;
- closeModal();
- startJob('execute-transfer', '/api/transfers/execute', { guid: getGuid(), previewFile, welcomeText, removeOldBroker }, () => loadTransfersDashboard(document.querySelector('#tf-dashboard')?.closest('.page')));
- });
- };
- window.dashboardTransferDetail = async (filePath) => {
- if (!filePath) return;
- try {
- openModal('交接详情', `
- <div class="detail-panel">${escapeHtml(JSON.stringify({ filePath }, null, 2))}</div>
- <div style="margin-top:12px">${renderFileLink(filePath, '下载文件')}</div>
- `, `<button class="btn btn-secondary" onclick="window.dashboardCloseModal()">关闭</button>`);
- } catch (err) {
- toast(err.message || '查询失败', 'error');
- }
- };
- // ==================== Init ====================
- async function checkExistingLogin() {
- try {
- const res = await api('GET', '/api/status');
- const login = res.data?.login;
- if (login?.summary?.online && login?.data?.detail) {
- const detail = login.data.detail;
- const uid = login.data.uid || login.summary?.uid || '';
- const guid = detail.guid || '';
- const existing = state.accounts.find(a => (uid && a.uid === uid) || (guid && a.guid === guid) || a.userId === detail.userId);
- if (!existing) {
- addAccount({
- uid,
- guid,
- userId: detail.userId || '',
- nickname: detail.nickname || detail.userId || '',
- corpName: detail.corpName || '',
- corpId: detail.corpId || '',
- online: true
- });
- } else {
- existing.uid = uid || existing.uid;
- if (guid && (!existing.guid || existing.guid === 'server-managed')) existing.guid = guid;
- existing.online = true;
- existing.nickname = existing.nickname || detail.nickname || detail.userId;
- existing.corpName = existing.corpName || detail.corpName;
- state.currentAccountId = existing.id;
- saveAccounts();
- }
- if (!state.currentAccountId && state.accounts.length) {
- state.currentAccountId = state.accounts[0].id;
- saveAccounts();
- }
- updateAccountSwitcher();
- } else {
- // 账号不在线:更新账号状态,但不清空已同步记录
- const acc = currentAccount();
- const statusCode = login?.data?.statusCode;
- if (acc) {
- acc.online = false;
- saveAccounts();
- updateAccountSwitcher();
- }
- // 如果设备已配置且状态为 0(可免扫码),自动尝试恢复登录
- if (acc && statusCode === 0 && !state.autoRecoverAttempted) {
- state.autoRecoverAttempted = true;
- const recovered = await attemptAutoRecover(true);
- if (recovered) {
- toast('账号已自动恢复登录');
- }
- }
- }
- } catch (err) {
- console.error('检测已有登录失败', err);
- try {
- const fallback = await api('GET', '/api/agent/status', undefined, 4000);
- const active = fallback.data?.account || {};
- if (!active.uid) return;
- let existing = state.accounts.find(account => account.uid === active.uid || (active.userId && account.userId === active.userId));
- if (!existing) {
- addAccount({
- uid: active.uid,
- guid: active.guid && active.guid !== 'server-managed' ? active.guid : '',
- userId: active.userId || '',
- nickname: active.nickname || active.userId || '',
- corpName: active.corpName || '',
- online: Boolean(active.online),
- });
- } else {
- existing.uid = active.uid;
- if (active.guid && active.guid !== 'server-managed') existing.guid = active.guid;
- existing.userId = active.userId || existing.userId;
- existing.nickname = active.nickname || existing.nickname || active.userId;
- existing.corpName = active.corpName || existing.corpName || '';
- existing.online = Boolean(active.online);
- state.currentAccountId = existing.id;
- saveAccounts();
- updateAccountSwitcher();
- }
- } catch {}
- }
- }
- async function hydrateCurrentAccountBinding() {
- const account = currentAccount();
- if (!account) return false;
- try {
- const result = await api('GET', '/api/agent/status');
- const active = result.data?.account || {};
- if (!account.uid && !active.uid) return false;
- account.uid = account.uid || active.uid;
- if (active.guid && active.guid !== 'server-managed' && (!account.guid || account.guid === 'server-managed')) account.guid = active.guid;
- account.userId = account.userId || active.userId || '';
- account.nickname = account.nickname || active.nickname || account.userId;
- account.corpName = account.corpName || active.corpName || '';
- saveAccounts();
- updateAccountSwitcher();
- return await switchAccount(account.id, { silent: true });
- } catch (error) {
- console.error('恢复当前账号绑定失败', error);
- return false;
- }
- }
- async function init() {
- updateAccountSwitcher();
- await checkExistingLogin();
- await hydrateCurrentAccountBinding();
- els.accountBtn.addEventListener('click', e => {
- e.stopPropagation();
- els.accountDropdown.classList.toggle('open');
- });
- document.addEventListener('click', e => {
- if (!els.accountDropdown.contains(e.target) && e.target !== els.accountBtn) {
- els.accountDropdown.classList.remove('open');
- }
- });
- document.getElementById('btn-add-account').addEventListener('click', () => {
- state.loginUid = createDeviceUid();
- state.currentAccountId = null;
- saveAccounts();
- location.hash = '#groups';
- renderPage();
- });
- els.menuBtn.addEventListener('click', () => {
- els.sidebar.classList.toggle('open');
- });
- window.addEventListener('hashchange', renderPage);
- document.addEventListener('click', e => {
- const link = e.target.closest('.nav-item');
- if (link) els.sidebar.classList.remove('open');
- });
- renderPage();
- }
- init();
- })();
|