| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585358635873588358935903591359235933594359535963597359835993600360136023603360436053606360736083609361036113612361336143615361636173618361936203621362236233624362536263627362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651365236533654365536563657365836593660366136623663366436653666366736683669367036713672367336743675367636773678367936803681368236833684368536863687368836893690369136923693369436953696369736983699370037013702370337043705370637073708370937103711371237133714371537163717371837193720372137223723372437253726372737283729373037313732373337343735373637373738373937403741374237433744374537463747374837493750375137523753375437553756375737583759376037613762376337643765376637673768376937703771377237733774377537763777377837793780378137823783378437853786378737883789379037913792379337943795379637973798379938003801380238033804380538063807380838093810381138123813381438153816381738183819382038213822382338243825382638273828382938303831383238333834383538363837383838393840384138423843384438453846384738483849385038513852385338543855385638573858385938603861386238633864386538663867386838693870387138723873387438753876387738783879388038813882388338843885388638873888388938903891389238933894389538963897389838993900390139023903390439053906390739083909391039113912391339143915391639173918391939203921392239233924392539263927392839293930393139323933393439353936393739383939394039413942394339443945394639473948394939503951395239533954395539563957395839593960396139623963396439653966396739683969397039713972397339743975397639773978397939803981398239833984398539863987398839893990399139923993399439953996399739983999400040014002400340044005400640074008400940104011401240134014401540164017401840194020402140224023402440254026402740284029403040314032403340344035403640374038403940404041404240434044404540464047404840494050405140524053405440554056405740584059406040614062406340644065406640674068406940704071407240734074407540764077407840794080408140824083408440854086408740884089409040914092409340944095409640974098409941004101410241034104410541064107410841094110411141124113411441154116411741184119412041214122412341244125412641274128412941304131413241334134413541364137413841394140414141424143414441454146414741484149415041514152415341544155415641574158415941604161416241634164416541664167416841694170417141724173417441754176417741784179418041814182418341844185418641874188418941904191419241934194419541964197419841994200420142024203420442054206420742084209421042114212421342144215421642174218421942204221422242234224422542264227422842294230423142324233423442354236423742384239424042414242424342444245424642474248424942504251425242534254425542564257425842594260426142624263426442654266426742684269427042714272427342744275427642774278427942804281428242834284428542864287428842894290429142924293429442954296429742984299430043014302430343044305430643074308430943104311431243134314431543164317431843194320432143224323432443254326432743284329433043314332433343344335433643374338433943404341434243434344434543464347434843494350435143524353435443554356435743584359436043614362436343644365436643674368436943704371437243734374437543764377437843794380438143824383438443854386438743884389439043914392439343944395439643974398439944004401440244034404440544064407440844094410441144124413441444154416441744184419442044214422442344244425442644274428442944304431443244334434443544364437443844394440444144424443444444454446444744484449445044514452445344544455445644574458445944604461446244634464446544664467446844694470447144724473447444754476447744784479448044814482448344844485448644874488448944904491449244934494449544964497449844994500450145024503450445054506450745084509451045114512451345144515451645174518451945204521452245234524452545264527452845294530453145324533453445354536453745384539454045414542454345444545454645474548454945504551455245534554455545564557455845594560456145624563456445654566456745684569457045714572457345744575457645774578457945804581458245834584458545864587458845894590459145924593459445954596459745984599460046014602460346044605460646074608460946104611461246134614461546164617461846194620462146224623462446254626462746284629463046314632463346344635463646374638463946404641464246434644464546464647464846494650465146524653465446554656465746584659466046614662466346644665466646674668466946704671467246734674467546764677467846794680468146824683468446854686468746884689469046914692469346944695469646974698469947004701470247034704470547064707470847094710471147124713471447154716471747184719472047214722472347244725472647274728472947304731473247334734473547364737473847394740474147424743474447454746474747484749475047514752475347544755475647574758475947604761476247634764476547664767476847694770477147724773477447754776477747784779478047814782478347844785478647874788478947904791479247934794479547964797479847994800480148024803480448054806480748084809481048114812481348144815481648174818481948204821482248234824482548264827482848294830483148324833483448354836483748384839484048414842484348444845484648474848484948504851485248534854485548564857485848594860486148624863486448654866486748684869487048714872487348744875487648774878487948804881488248834884488548864887488848894890489148924893489448954896489748984899490049014902490349044905490649074908490949104911491249134914491549164917491849194920492149224923492449254926492749284929493049314932493349344935493649374938493949404941494249434944494549464947494849494950495149524953495449554956495749584959496049614962496349644965496649674968496949704971497249734974497549764977497849794980498149824983498449854986498749884989499049914992499349944995499649974998499950005001500250035004500550065007500850095010501150125013501450155016501750185019502050215022502350245025502650275028502950305031503250335034503550365037503850395040504150425043504450455046504750485049505050515052505350545055505650575058505950605061506250635064506550665067506850695070507150725073507450755076507750785079508050815082508350845085508650875088508950905091509250935094509550965097509850995100510151025103510451055106510751085109511051115112511351145115511651175118511951205121512251235124512551265127512851295130513151325133513451355136513751385139514051415142514351445145514651475148514951505151515251535154515551565157515851595160516151625163516451655166516751685169517051715172517351745175517651775178517951805181518251835184518551865187518851895190519151925193519451955196519751985199520052015202520352045205520652075208520952105211521252135214521552165217521852195220522152225223522452255226522752285229523052315232523352345235523652375238523952405241524252435244524552465247524852495250525152525253525452555256525752585259526052615262526352645265526652675268526952705271527252735274527552765277527852795280528152825283528452855286528752885289529052915292529352945295529652975298529953005301530253035304530553065307530853095310531153125313531453155316531753185319532053215322532353245325532653275328532953305331533253335334533553365337533853395340534153425343534453455346534753485349535053515352535353545355535653575358535953605361536253635364536553665367536853695370537153725373537453755376537753785379538053815382538353845385538653875388538953905391539253935394539553965397539853995400540154025403540454055406540754085409541054115412541354145415541654175418541954205421542254235424542554265427542854295430543154325433543454355436543754385439544054415442544354445445544654475448544954505451545254535454545554565457545854595460546154625463546454655466546754685469547054715472547354745475547654775478547954805481548254835484548554865487548854895490549154925493549454955496549754985499550055015502550355045505550655075508550955105511551255135514551555165517551855195520552155225523552455255526552755285529553055315532553355345535553655375538553955405541554255435544554555465547554855495550555155525553555455555556555755585559556055615562556355645565556655675568556955705571557255735574557555765577557855795580558155825583558455855586558755885589559055915592559355945595559655975598559956005601560256035604560556065607560856095610561156125613561456155616561756185619562056215622562356245625562656275628562956305631563256335634563556365637563856395640564156425643564456455646564756485649565056515652565356545655565656575658565956605661566256635664566556665667566856695670567156725673567456755676567756785679568056815682568356845685568656875688568956905691569256935694569556965697569856995700570157025703570457055706570757085709571057115712571357145715571657175718571957205721572257235724572557265727572857295730573157325733573457355736573757385739574057415742574357445745574657475748574957505751575257535754575557565757575857595760576157625763576457655766576757685769577057715772577357745775577657775778577957805781578257835784578557865787578857895790579157925793579457955796579757985799580058015802580358045805580658075808580958105811581258135814581558165817581858195820582158225823582458255826582758285829583058315832583358345835583658375838583958405841584258435844584558465847584858495850585158525853585458555856585758585859586058615862586358645865586658675868586958705871587258735874587558765877587858795880588158825883588458855886588758885889589058915892589358945895589658975898589959005901590259035904590559065907590859095910591159125913591459155916591759185919592059215922592359245925592659275928592959305931593259335934593559365937593859395940594159425943594459455946594759485949595059515952595359545955595659575958595959605961596259635964596559665967596859695970597159725973597459755976597759785979598059815982598359845985598659875988598959905991599259935994599559965997599859996000600160026003600460056006600760086009601060116012601360146015601660176018601960206021602260236024602560266027602860296030603160326033603460356036603760386039604060416042604360446045604660476048604960506051605260536054605560566057605860596060606160626063606460656066606760686069607060716072607360746075607660776078607960806081608260836084608560866087608860896090609160926093609460956096609760986099610061016102610361046105610661076108610961106111611261136114611561166117611861196120612161226123612461256126612761286129613061316132613361346135613661376138613961406141614261436144614561466147614861496150615161526153615461556156615761586159616061616162616361646165616661676168616961706171617261736174617561766177617861796180618161826183618461856186618761886189619061916192619361946195619661976198619962006201620262036204620562066207620862096210621162126213621462156216621762186219622062216222622362246225622662276228622962306231623262336234623562366237623862396240624162426243624462456246624762486249625062516252625362546255625662576258625962606261626262636264626562666267626862696270627162726273627462756276627762786279628062816282628362846285628662876288628962906291629262936294629562966297629862996300630163026303630463056306630763086309631063116312631363146315631663176318631963206321632263236324632563266327632863296330633163326333633463356336633763386339634063416342634363446345634663476348634963506351635263536354635563566357635863596360636163626363636463656366636763686369637063716372637363746375637663776378637963806381638263836384638563866387638863896390639163926393639463956396639763986399640064016402640364046405640664076408640964106411641264136414641564166417641864196420642164226423642464256426642764286429643064316432643364346435643664376438643964406441644264436444644564466447644864496450645164526453645464556456645764586459646064616462646364646465646664676468646964706471647264736474647564766477647864796480648164826483648464856486648764886489649064916492649364946495649664976498649965006501650265036504650565066507650865096510651165126513651465156516651765186519652065216522652365246525652665276528652965306531653265336534653565366537653865396540654165426543654465456546654765486549655065516552655365546555655665576558 |
- <div class="app-container theme-day">
- <!-- 头部导航 -->
- <header class="header shadow">
- <span class="logo">抖音AI视频生成系统 <small>v1.0</small></span>
- <span class="spacer"></span>
- <button class="account-chip" type="button" (click)="setCurrentTab(auth.isLoggedIn ? 'user-center' : 'login')">
- <span class="account-chip__dot">{{ auth.isLoggedIn ? (auth.currentUser?.displayName || auth.currentUser?.username || 'U').charAt(0) : '未' }}</span>
- <span class="account-chip__text">{{ auth.isLoggedIn ? (auth.currentUser?.displayName || auth.currentUser?.username) : '登录' }}</span>
- <span class="account-chip__mode" *ngIf="auth.localMode">测试</span>
- </button>
- </header>
- <!-- 主内容区域 -->
- <div class="main-content">
- <!-- 侧边栏导航(P1 重构:3 大分区纯字排版,参考 components/app-sidebar) -->
- <app-sidebar
- *ngIf="auth.isLoggedIn"
- [currentTab]="currentTab"
- (tabChange)="setCurrentTab($any($event))">
- </app-sidebar>
- <!-- 工作区内容 -->
- <main class="workbench">
- <ng-container *ngIf="!auth.isLoggedIn">
- <app-auth-page
- mode="login"
- [allowRegister]="false"
- (modeChange)="setCurrentTab($event)"
- (signedIn)="onAuthSignedIn()">
- </app-auth-page>
- </ng-container>
- <ng-container *ngIf="auth.isLoggedIn">
- <!-- 首屏 Hero(CTA-first,P2) -->
- <app-home *ngIf="currentTab === 'home'"
- [monitorSummary]="getHomeMonitorSummary()"
- (navigate)="onHomeNavigate($event)"
- (suggestionPick)="onAssistantSuggestion($event)"
- (inlineChatChange)="homeChatActive = $event">
- </app-home>
- <!-- 生成模式中心(Pipelines Hub) -->
- <section class="dh-page pipeline-hub-page" *ngIf="currentTab === 'pipelines'">
- <header class="dh-page-header">
- <div class="dh-header-icon">
- <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
- <rect x="3" y="3" width="7" height="7" rx="1"></rect>
- <rect x="14" y="3" width="7" height="7" rx="1"></rect>
- <rect x="3" y="14" width="7" height="7" rx="1"></rect>
- <rect x="14" y="14" width="7" height="7" rx="1"></rect>
- </svg>
- </div>
- <div class="dh-header-text">
- <h2>生成模式中心</h2>
- <p>选择一个 AI 视频生成模式开始创作。已上线模式可直接进入;规划中模式即将推出。</p>
- </div>
- </header>
- <div class="pipeline-gallery">
- <article *ngFor="let p of pipelines"
- class="pipeline-card"
- [class.is-stable]="p.status === 'stable'"
- [class.is-beta]="p.status === 'beta'"
- [class.is-planned]="p.status === 'planned'"
- (click)="p.status !== 'planned' && setCurrentTab(p.route)">
- <div class="pipeline-card__top">
- <div class="pipeline-card__icon">
- <svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" [innerHTML]="p.iconSvg"></svg>
- </div>
- <span class="pipeline-card__badge"
- [class.badge-stable]="p.status === 'stable'"
- [class.badge-beta]="p.status === 'beta'"
- [class.badge-planned]="p.status === 'planned'">
- {{ p.status === 'planned' ? '即将推出' : '可使用' }}
- </span>
- </div>
- <h3 class="pipeline-card__title">{{ p.displayName }}</h3>
- <p class="pipeline-card__desc">{{ p.description }}</p>
- <div class="pipeline-card__hint">
- <span class="pipeline-card__hint-label">输入</span>
- <span class="pipeline-card__hint-value">{{ p.inputHint }}</span>
- </div>
- <button class="pipeline-card__cta" type="button"
- [disabled]="p.status === 'planned'"
- (click)="$event.stopPropagation(); p.status !== 'planned' && setCurrentTab(p.route)">
- {{ p.status === 'planned' ? '敬请期待' : '进入模式 →' }}
- </button>
- </article>
- </div>
- </section>
- <!-- 图生视频 Pipeline (P1) -->
- <app-image-generation *ngIf="currentTab === 'image-generation'"></app-image-generation>
- <app-text-to-video *ngIf="currentTab === 'video-generation'"></app-text-to-video>
- <app-batch-production *ngIf="currentTab === 'batch-production'"></app-batch-production>
- <app-retrospective *ngIf="currentTab === 'retrospective'"></app-retrospective>
- <!-- 图生视频 Pipeline (P1) -->
- <app-image-to-video *ngIf="currentTab === 'image-to-video'"></app-image-to-video>
- <!-- 动作迁移 Pipeline (P2) -->
- <app-action-transfer *ngIf="currentTab === 'action-transfer'"></app-action-transfer>
- <!-- 素材合成视频 Pipeline (P3) -->
- <app-asset-remix *ngIf="currentTab === 'asset-remix'"></app-asset-remix>
- <!-- 主题生视频 Pipeline (P4) -->
- <app-topic-to-video *ngIf="currentTab === 'topic-to-video'"></app-topic-to-video>
- <!-- 我的创作(跨 Pipeline 草稿与 artifact 时间线) -->
- <app-drafts *ngIf="currentTab === 'drafts' && auth.isLoggedIn"
- (navigateToPipeline)="onDraftsNavigate($event)">
- </app-drafts>
- <!-- 素材库(跨 Pipeline AI 生成成果聚合) -->
- <app-library *ngIf="currentTab === 'library'"
- (navigateToDraft)="onLibraryNavigate($event)"
- (navigateToRetrospective)="onLibraryRetrospective($event)">
- </app-library>
- <app-templates *ngIf="currentTab === 'templates'"></app-templates>
- <app-auth-page *ngIf="currentTab === 'login'"
- mode="login"
- [allowRegister]="false"
- (modeChange)="setCurrentTab($event)"
- (signedIn)="onAuthSignedIn()">
- </app-auth-page>
- <app-auth-page *ngIf="currentTab === 'register'"
- mode="register"
- [allowRegister]="false"
- (modeChange)="setCurrentTab($event)"
- (signedIn)="onAuthSignedIn()">
- </app-auth-page>
- <app-user-center *ngIf="currentTab === 'user-center'"
- (navigate)="setCurrentTab($any($event))">
- </app-user-center>
- <!-- 视频发现模块 -->
- <section class="dh-page search-page" *ngIf="currentTab === 'search'">
- <header class="dh-page-header">
- <div class="dh-header-icon">
- <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
- <circle cx="11" cy="11" r="8"></circle>
- <line x1="21" y1="21" x2="16.65" y2="16.65"></line>
- </svg>
- </div>
- <div class="dh-header-text">
- <h1>视频发现</h1>
- <p>搜索抖音视频,获取 AI 创作灵感与素材</p>
- </div>
- </header>
- <div class="search-controls">
- <div class="search-input-group">
- <div class="form-field search-field">
- <label>输入关键词搜索抖音视频</label>
- <input type="text" placeholder="例如:科技博主、AI新闻、产品评测..."
- [(ngModel)]="searchKeyword">
- </div>
- <div class="form-field filter-field">
- <label>排序方式</label>
- <select [(ngModel)]="sortType" (ngModelChange)="onSearchFilterChange()">
- <option value="0">综合排序</option>
- <option value="1">最多点赞</option>
- <option value="2">最新发布</option>
- </select>
- </div>
- <div class="form-field filter-field">
- <label>发布时间</label>
- <select [(ngModel)]="publishTime" (ngModelChange)="onSearchFilterChange()">
- <option value="0">不限</option>
- <option value="1">一天内</option>
- <option value="7">一周内</option>
- <option value="180">半年内</option>
- </select>
- </div>
- <div class="form-field filter-field">
- <label>视频时长</label>
- <select [(ngModel)]="filterDuration" (ngModelChange)="onSearchFilterChange()">
- <option value="0">不限</option>
- <option value="0-1">1分钟内</option>
- <option value="1-5">1-5分钟</option>
- <option value="5-10000">5分钟以上</option>
- </select>
- </div>
- <button class="btn btn-primary search-btn"
- (click)="searchVideos()" [disabled]="!searchKeyword">
- <span class="icon">search</span> 搜索视频
- </button>
- </div>
- </div>
- <div class="video-link-analysis-card">
- <div class="video-link-analysis-copy">
- <span class="video-link-analysis-kicker">快速分析</span>
- <strong>粘贴抖音视频链接,直接进入爆款分析</strong>
- </div>
- <div class="video-link-analysis-form">
- <input type="text"
- placeholder="粘贴抖音视频链接、分享文案或作品 ID"
- [(ngModel)]="videoLinkAnalysisInput"
- (keyup.enter)="analyzeVideoLink()">
- <button class="btn btn-primary" type="button"
- (click)="analyzeVideoLink()"
- [disabled]="!videoLinkAnalysisInput.trim() || isAnalyzingVideoLink || isAnalyzingViralVideo">
- <span class="icon">{{isAnalyzingVideoLink ? 'hourglass_empty' : 'link'}}</span>
- {{isAnalyzingVideoLink ? '解析中...' : '解析并分析'}}
- </button>
- </div>
- <div class="video-link-analysis-error" *ngIf="videoLinkAnalysisError">{{videoLinkAnalysisError}}</div>
- </div>
- <div class="video-results" *ngIf="searchResults.length > 0">
- <div class="results-header">
- <h3>搜索结果 ({{filteredSearchResults.length}} / {{searchResults.length}})</h3>
- <button class="btn" (click)="loadMore()" *ngIf="hasMore">
- <span class="icon">refresh</span> 加载更多
- </button>
- </div>
- <div class="video-grid" *ngIf="filteredSearchResults.length > 0">
- <div class="video-card" *ngFor="let video of filteredSearchResults">
- <div class="video-thumbnail">
- <img [src]="video.cover_url" [alt]="video.desc" referrerpolicy="no-referrer" (error)="handleVideoCoverError(video)">
- <div class="video-duration">{{video.duration | timeFormat}}</div>
- </div>
- <div class="card-content">
- <h4 class="video-title">{{video.desc}}</h4>
- <div class="video-info-row">
- <div class="video-meta-row">
- <span class="video-meta-chip" *ngIf="video.publishDate">
- {{video.publishDate | date:'MM-dd HH:mm'}}
- </span>
- <span class="video-meta-chip" *ngIf="video.duration">
- {{video.duration | timeFormat}}
- </span>
- <span class="video-author-inline">{{video.author.nickname}}</span>
- <span class="video-id" *ngIf="video.aweme_id">ID: {{video.aweme_id}}</span>
- </div>
- <div class="video-interactions">
- <span class="interaction-stat like">{{video.statistics.digg_count | numberFormat}}</span>
- <span class="interaction-stat comment">{{video.statistics.comment_count | numberFormat}}</span>
- <span class="interaction-stat share">{{video.statistics.share_count | numberFormat}}</span>
- </div>
- </div>
- <div class="video-tags" *ngIf="video.text_extra">
- <div class="tag-list">
- <span class="tag" *ngFor="let tag of video.text_extra">
- #{{tag}}
- </span>
- </div>
- </div>
- </div>
- <div class="card-actions">
- <button class="btn" (click)="viewVideoDetail(video)">
- <span class="icon">info</span> 查看详情
- </button>
- <button class="btn" type="button" (click)="analyzeSearchVideo(video)" [disabled]="isAnalyzingViralVideo">
- <span class="icon">insights</span> 爆款分析
- </button>
- <button class="btn btn-primary" (click)="downloadVideo(video)"
- [disabled]="video.isDownloading">
- <span class="icon">{{ video.isDownloading ? 'hourglass_empty' : 'download' }}</span>
- {{ video.isDownloading ? '下载中 ' + video.downloadProgress + '%' : '下载视频' }}
- </button>
- <button class="btn btn-accent" (click)="startAIGeneration(video)">
- <span class="icon">auto_awesome</span> AI重塑
- </button>
- </div>
- @if (video.isDownloading) {
- <div class="download-progress">
- <div class="progress-bar">
- <div class="progress-fill" [style.width]="(video.downloadProgress || 0) + '%'"></div>
- </div>
- </div>
- }
- </div>
- </div>
- <div class="empty-state" *ngIf="filteredSearchResults.length === 0 && !isSearching">
- <span class="icon large">filter_alt_off</span>
- <h3>没有符合筛选条件的视频</h3>
- <p>请调整排序、发布时间或视频时长后再试</p>
- </div>
- </div>
- <div class="empty-state" *ngIf="searchResults.length === 0 && !isSearching">
- <span class="icon large">search</span>
- <h3>开始搜索抖音视频</h3>
- <p>输入关键词发现优质内容,让AI帮您重新创作</p>
- </div>
- <div class="loading-state" *ngIf="isSearching">
- <div class="spinner"></div>
- <p>正在搜索相关视频...</p>
- </div>
- </section>
- <section class="dh-page monitor-page monitor-section" *ngIf="currentTab === 'monitor' && auth.isLoggedIn">
- <header class="dh-page-header">
- <div class="dh-header-icon">
- <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
- <polyline points="22 12 18 12 15 21 9 3 6 12 2 12"></polyline>
- </svg>
- </div>
- <div class="dh-header-text">
- <h1>博主监测</h1>
- <p>通过抖音号 unique_id 添加博主,快速查看主页信息与作品分布</p>
- </div>
- </header>
- <div class="monitor-header monitor-toolbar-row">
- <div class="monitor-toolbar">
- <div class="form-field search-field">
- <label>输入抖音号(unique_id)添加博主</label>
- <input type="text" placeholder="例如:TheChief" [(ngModel)]="monitorUniqueIdInput" (keyup.enter)="addMonitorAuthor()">
- </div>
- <button class="btn btn-primary search-btn" (click)="addMonitorAuthor()" [disabled]="!monitorUniqueIdInput.trim() || isAddingMonitorAuthor">
- <span class="icon">{{ isAddingMonitorAuthor ? 'hourglass_empty' : 'person_add' }}</span>
- {{ isAddingMonitorAuthor ? '添加中...' : '添加博主' }}
- </button>
- <button class="btn" type="button" (click)="generateMonitorDailyReport()" [disabled]="isDailyReportGenerating || monitorAuthors.length === 0 || getTotalMonitorWorksCount() === 0">
- <span class="icon">summarize</span>
- {{isDailyReportGenerating ? '生成中...' : '生成监测日报'}}
- </button>
- </div>
- </div>
- <div class="monitor-error" *ngIf="monitorAddError">{{monitorAddError}}</div>
- <ng-container *ngIf="!getSelectedMonitorAuthor(); else monitorDetailView">
- <div class="monitor-summary" *ngIf="monitorAuthors.length > 0">
- <div class="monitor-summary-card">
- <span class="monitor-summary-label">已监测博主</span>
- <strong>{{monitorAuthors.length}}</strong>
- </div>
- <div class="monitor-summary-card">
- <span class="monitor-summary-label">累计作品</span>
- <strong>{{getTotalMonitorWorksCount()}}</strong>
- </div>
- </div>
- <div class="monitor-author-grid" *ngIf="monitorAuthors.length > 0">
- <article class="monitor-author-card" *ngFor="let author of monitorAuthors" (click)="openMonitorAuthor(author)">
- <div class="monitor-author-top">
- <div class="monitor-author-identity">
- <img [src]="author.avatarUrl" [alt]="author.nickname" class="monitor-author-avatar" referrerpolicy="no-referrer" (error)="handleMonitorAuthorAvatarError(author)">
- <div>
- <h3>{{author.nickname}}</h3>
- <p class="monitor-author-verified" *ngIf="author.verifiedText">{{author.verifiedText}}</p>
- <p class="monitor-author-uid">抖音号:{{author.uniqueId || author.shortId || author.uid}}</p>
- </div>
- </div>
- <div class="monitor-author-actions">
- <button class="btn btn-primary" type="button" (click)="$event.stopPropagation(); openMonitorAuthor(author)">
- 查看主页
- </button>
- <button class="btn btn-warn" type="button" (click)="$event.stopPropagation(); removeMonitorAuthor(author)">
- 删除
- </button>
- </div>
- </div>
- <div class="monitor-author-stats">
- <span class="monitor-author-stat">
- <span class="monitor-author-stat-label">获赞</span>
- <strong class="monitor-author-stat-value">{{author.totalFavorited | numberFormat}}</strong>
- </span>
- <span class="monitor-author-stat">
- <span class="monitor-author-stat-label">粉丝</span>
- <strong class="monitor-author-stat-value">{{author.followerCount | numberFormat}}</strong>
- </span>
- <span class="monitor-author-stat">
- <span class="monitor-author-stat-label">作品</span>
- <strong class="monitor-author-stat-value">{{author.awemeCount | numberFormat}}</strong>
- </span>
- </div>
- <p class="monitor-author-signature">{{author.signature || '该用户暂未填写简介'}}</p>
- <div class="monitor-author-preview" *ngIf="author.works.length > 0">
- <div class="monitor-preview-item" *ngFor="let work of author.works.slice(0, 3)">
- <img [src]="work.coverUrl" [alt]="work.desc" referrerpolicy="no-referrer" (error)="handleMonitorWorkCoverError(work)">
- </div>
- </div>
- <div class="monitor-author-foot">
- <span>{{getMonitorIpLocationText(author)}}</span>
- <span *ngIf="author.loadingWorks">作品加载中...</span>
- <span *ngIf="!author.loadingWorks">已载入 {{author.works.length}} 条作品</span>
- </div>
- <span *ngIf="author.lastCheckedAt">上次刷新 {{author.lastCheckedAt | date:'MM-dd HH:mm'}}</span>
- <span *ngIf="author.newWorksCount > 0">新增 {{author.newWorksCount}} 条</span>
- </article>
- </div>
- <div class="empty-state" *ngIf="monitorAuthors.length === 0 && !isAddingMonitorAuthor">
- <span class="icon large">monitoring</span>
- <h3>还没有监测中的博主</h3>
- <p>输入抖音号 unique_id 后即可把博主加入监测页,并查看主页作品排布</p>
- </div>
- </ng-container>
- <ng-template #monitorDetailView>
- <ng-container *ngIf="getSelectedMonitorAuthor() as selectedAuthor">
- <div class="monitor-detail-shell">
- <div class="monitor-detail-topbar">
- <button class="btn" (click)="backToMonitorList()">
- <span class="icon">arrow_back</span> 返回列表
- </button>
- <div class="monitor-detail-actions">
- <button class="btn btn-warn" (click)="removeMonitorAuthor(selectedAuthor)">
- <span class="icon">delete</span> 删除博主
- </button>
- <button class="btn" (click)="reloadMonitorAuthorWorks(selectedAuthor)">
- <span class="icon">refresh</span> 刷新作品
- </button>
- <button class="btn" (click)="setCurrentTab('search')">
- <span class="icon">search</span> 去发现页
- </button>
- </div>
- </div>
- <div class="monitor-hero" [style.background]="getMonitorHeroBackground(selectedAuthor)">
- <div class="monitor-hero-main">
- <img [src]="selectedAuthor.avatarUrl" [alt]="selectedAuthor.nickname" class="monitor-hero-avatar" referrerpolicy="no-referrer" (error)="handleMonitorAuthorAvatarError(selectedAuthor)">
- <div class="monitor-hero-info">
- <div class="monitor-hero-title-row">
- <h3>{{selectedAuthor.nickname}}</h3>
- <span class="monitor-hero-verified" *ngIf="selectedAuthor.verifiedText">{{selectedAuthor.verifiedText}}</span>
- </div>
- <div class="monitor-hero-meta">
- <span>关注 {{selectedAuthor.followingCount | numberFormat}}</span>
- <span>粉丝 {{selectedAuthor.followerCount | numberFormat}}</span>
- <span>获赞 {{selectedAuthor.totalFavorited | numberFormat}}</span>
- </div>
- <div class="monitor-hero-submeta">
- <span>抖音号:{{selectedAuthor.uniqueId || selectedAuthor.shortId || selectedAuthor.uid}}</span>
- <span>{{getMonitorIpLocationText(selectedAuthor)}}</span>
- </div>
- <p class="monitor-hero-signature">{{selectedAuthor.signature || '该用户暂未填写简介'}}</p>
- </div>
- </div>
- </div>
- <div class="monitor-work-header">
- <div>
- <h3>作品 {{selectedAuthor.awemeCount | numberFormat}}</h3>
- <p>已加载 {{selectedAuthor.works.length}} 条主页作品</p>
- </div>
- <div class="monitor-work-tools">
- <span class="monitor-work-status" *ngIf="selectedAuthor.loadingWorks">正在加载作品...</span>
- </div>
- </div>
- <div class="monitor-works-grid" *ngIf="selectedAuthor.works.length > 0">
- <article class="monitor-work-card" *ngFor="let work of selectedAuthor.works">
- <div class="monitor-work-cover">
- <img [src]="work.coverUrl" [alt]="work.desc" referrerpolicy="no-referrer" (error)="handleMonitorWorkCoverError(work)">
- <span class="monitor-work-badge">{{work.type === 'image' ? '图文' : '视频'}}</span>
- <span class="monitor-work-duration" *ngIf="work.type === 'video' && work.duration">{{work.duration | timeFormat}}</span>
- <span class="monitor-work-badge monitor-work-badge--new" *ngIf="work.isNew">新增</span>
- </div>
- <div class="monitor-work-content">
- <p class="monitor-work-desc">{{work.desc}}</p>
- <div class="monitor-work-stats">
- <span>点赞 {{work.statistics.digg_count | numberFormat}}</span>
- <span>评论 {{work.statistics.comment_count | numberFormat}}</span>
- </div>
- <div class="monitor-work-date" *ngIf="work.createTime">{{work.createTime | date:'yyyy-MM-dd HH:mm'}}</div>
- <div class="monitor-work-actions" *ngIf="work.type === 'video'">
- <button class="btn" type="button" (click)="previewMonitorWork(selectedAuthor, work)">
- <span class="icon">visibility</span> 查看
- </button>
- <button class="btn" type="button" (click)="analyzeMonitorWork(selectedAuthor, work)" [disabled]="isAnalyzingViralVideo">
- <span class="icon">insights</span> 爆款分析
- </button>
- <button class="btn btn-primary" type="button" (click)="downloadMonitorWork(selectedAuthor, work)" [disabled]="work.isDownloading">
- <span class="icon">{{ work.isDownloading ? 'hourglass_empty' : 'download' }}</span>
- {{ work.isDownloading ? '下载中 ' + (work.downloadProgress || 0) + '%' : '下载' }}
- </button>
- </div>
- <div class="monitor-work-progress" *ngIf="work.isDownloading">
- <div class="monitor-work-progress-bar">
- <div class="monitor-work-progress-fill" [style.width]="(work.downloadProgress || 0) + '%'"></div>
- </div>
- </div>
- </div>
- </article>
- </div>
- <div class="empty-state" *ngIf="selectedAuthor.works.length === 0 && !selectedAuthor.loadingWorks">
- <span class="icon large">perm_media</span>
- <h3>暂无可展示作品</h3>
- <p>暂时没有获取到这个博主的作品,请稍后刷新或更换账号</p>
- </div>
- <div class="monitor-load-more" *ngIf="selectedAuthor.hasMoreWorks">
- <button class="btn btn-primary" (click)="loadMoreMonitorWorks(selectedAuthor)" [disabled]="selectedAuthor.loadingWorks">
- <span class="icon">refresh</span>
- {{selectedAuthor.loadingWorks ? '加载中...' : '加载更多作品'}}
- </button>
- </div>
- </div>
- </ng-container>
- </ng-template>
- </section>
- <app-topic-pool *ngIf="currentTab === 'topic-pool'"
- (navigateToPipeline)="onTopicPoolNavigate($event)"
- (sourceVideoOpen)="onTopicSourceVideoOpen($event)"
- (navigateToBatch)="setCurrentTab('batch-production')">
- </app-topic-pool>
- <app-ip-operator *ngIf="currentTab === 'ip-operator'"
- (navigateToTopicPool)="setCurrentTab('topic-pool')"
- (navigateToTopicVideo)="setCurrentTab('topic-to-video')"
- (navigateToDigitalHuman)="onIpOperatorDigitalHuman($event)"
- (navigateToLibrary)="setCurrentTab('library')">
- </app-ip-operator>
- <app-analysis-history *ngIf="currentTab === 'analysis-history' && auth.isLoggedIn"
- (analysisOpen)="openSavedViralAnalysis($event)">
- </app-analysis-history>
- <app-daily-report-history *ngIf="currentTab === 'daily-reports' && auth.isLoggedIn"
- (reportOpen)="openSavedDailyReport($event)">
- </app-daily-report-history>
- <!-- 任务管理模块 -->
- <section class="dh-page tasks-page" *ngIf="currentTab === 'tasks'">
- <header class="dh-page-header">
- <div class="dh-header-icon">
- <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
- <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon>
- </svg>
- </div>
- <div class="dh-header-text">
- <h1>任务管理</h1>
- <p>监控和管理您的 AI 视频生成任务</p>
- </div>
- </header>
- <!-- 顶部统计卡:可点击作为筛选器 -->
- <div class="task-stats">
- <button type="button" class="task-stat task-stat--pending"
- [class.task-stat--active]="tasksFilter === 'pending'"
- (click)="toggleTasksFilter('pending')">
- <span class="task-stat__icon">
- <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
- <circle cx="12" cy="12" r="10"></circle>
- <polyline points="12 6 12 12 16 14"></polyline>
- </svg>
- </span>
- <div class="task-stat__body">
- <span class="task-stat__number">{{taskStats.pending}}</span>
- <span class="task-stat__label">等待中</span>
- </div>
- </button>
- <button type="button" class="task-stat task-stat--processing"
- [class.task-stat--active]="tasksFilter === 'processing'"
- (click)="toggleTasksFilter('processing')">
- <span class="task-stat__icon">
- <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
- <path d="M21 12a9 9 0 1 1-3-6.7L21 8"></path>
- <polyline points="21 3 21 8 16 8"></polyline>
- </svg>
- </span>
- <div class="task-stat__body">
- <span class="task-stat__number">{{taskStats.processing}}</span>
- <span class="task-stat__label">处理中</span>
- </div>
- </button>
- <button type="button" class="task-stat task-stat--completed"
- [class.task-stat--active]="tasksFilter === 'completed'"
- (click)="toggleTasksFilter('completed')">
- <span class="task-stat__icon">
- <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
- <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path>
- <polyline points="22 4 12 14.01 9 11.01"></polyline>
- </svg>
- </span>
- <div class="task-stat__body">
- <span class="task-stat__number">{{taskStats.completed}}</span>
- <span class="task-stat__label">已完成</span>
- </div>
- </button>
- <button type="button" class="task-stat task-stat--failed"
- [class.task-stat--active]="tasksFilter === 'failed'"
- (click)="toggleTasksFilter('failed')">
- <span class="task-stat__icon">
- <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
- <circle cx="12" cy="12" r="10"></circle>
- <line x1="15" y1="9" x2="9" y2="15"></line>
- <line x1="9" y1="9" x2="15" y2="15"></line>
- </svg>
- </span>
- <div class="task-stat__body">
- <span class="task-stat__number">{{taskStats.failed}}</span>
- <span class="task-stat__label">失败</span>
- </div>
- </button>
- </div>
- <section class="task-recovery-audit"
- *ngIf="latestRecoveryRun as recoveryRun"
- [class.task-recovery-audit--highlight]="isHighlightedRecoveryRun(recoveryRun)">
- <div class="task-recovery-audit__main">
- <span class="task-recovery-audit__label">最近恢复扫描</span>
- <strong>{{formatRecoveryRunTime(recoveryRun.finishedAt)}}</strong>
- <span>
- 已扫描 {{recoveryRun.summary.scanned}} 个任务,可恢复 {{recoveryRun.summary.recoverable}} 个,完成 {{recoveryRun.summary.completed}} 个,失败 {{recoveryRun.summary.failed}} 个,不可恢复 {{recoveryRun.summary.unrecoverable}} 个
- </span>
- </div>
- <div class="task-recovery-audit__actions">
- <span class="task-recovery-audit__id">Run {{recoveryRun.runId}}</span>
- <button type="button"
- class="task-btn task-btn--ghost"
- *ngIf="isHighlightedRecoveryRun(recoveryRun)"
- (click)="clearRecoveryRunHighlight()">
- 取消高亮
- </button>
- </div>
- </section>
- <section class="task-recovery-audit task-recovery-audit--storage"
- *ngIf="latestStorageAuditEvent as storageEvent"
- [class.task-recovery-audit--highlight]="showStorageAudit">
- <div class="task-recovery-audit__main">
- <span class="task-recovery-audit__label">存储审计</span>
- <strong>最近 {{storageAuditEvents.length}} 条</strong>
- <span>
- {{storageEvent.namespace}} / {{storageEvent.operation}} / {{storageEvent.status}},{{formatRecoveryRunTime(storageEvent.at)}}
- </span>
- </div>
- <div class="task-recovery-audit__actions">
- <span class="task-recovery-audit__id" [title]="storageEvent.message || storageEvent.eventId">
- {{storageEvent.storageKey}}
- </span>
- <button type="button"
- class="task-btn task-btn--ghost"
- *ngIf="showStorageAudit"
- (click)="clearStorageAuditHighlight()">
- 取消高亮
- </button>
- </div>
- </section>
- <!-- 任务列表 -->
- <div class="task-list-section" *ngIf="activeTasks.length > 0">
- <div class="task-list-section__head">
- <h3 class="task-list-section__title">
- 任务列表
- <span class="task-list-section__count">{{getFilteredTasks().length}}</span>
- </h3>
- <button type="button" class="task-btn task-btn--ghost" (click)="exportP0Diagnostics()">
- 导出 P0 诊断
- </button>
- <button type="button" class="task-btn task-btn--ghost" *ngIf="tasksFilter" (click)="toggleTasksFilter(tasksFilter)">
- 清除筛选
- </button>
- </div>
- <div class="task-list" *ngIf="getFilteredTasks().length > 0">
- <article class="task-row"
- *ngFor="let task of getFilteredTasks()"
- [class.task-row--processing]="task.status === 'processing'"
- [class.task-row--success]="task.status === 'completed'"
- [class.task-row--failed]="task.status === 'failed'">
- <span class="task-row__accent" aria-hidden="true"></span>
- <!-- 头部:标题 + 类型 + 状态 + 时间 -->
- <div class="task-row__head">
- <div class="task-row__title-wrap">
- <span class="task-row__type-badge" *ngIf="task.type === 'video-generation'">视频生成</span>
- <h4 class="task-row__title" [title]="task.original_video_title || task.keyword">
- {{task.original_video_title || task.keyword || '未命名任务'}}
- </h4>
- </div>
- <div class="task-row__head-right">
- <span class="task-row__status-pill"
- [class.task-row__status-pill--pending]="task.status === 'pending'"
- [class.task-row__status-pill--processing]="task.status === 'processing'"
- [class.task-row__status-pill--success]="task.status === 'completed'"
- [class.task-row__status-pill--failed]="task.status === 'failed'">
- <span class="task-row__status-dot"></span>
- {{getTaskStatusText(task.status)}}
- </span>
- <span class="task-row__date">
- <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
- <rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
- <line x1="16" y1="2" x2="16" y2="6"></line>
- <line x1="8" y1="2" x2="8" y2="6"></line>
- <line x1="3" y1="10" x2="21" y2="10"></line>
- </svg>
- {{task.updated_at | date:'MM-dd HH:mm'}}
- </span>
- </div>
- </div>
- <!-- 进度条 -->
- <div class="task-row__progress">
- <div class="task-row__progress-info">
- <span class="task-row__stage">{{getTaskStepText(task.step)}}</span>
- <span class="task-row__progress-pct">{{task.progress}}%</span>
- </div>
- <div class="task-row__progress-bar">
- <div class="task-row__progress-fill"
- [style.width]="task.progress + '%'"
- [class.task-row__progress-fill--success]="task.status === 'completed'"
- [class.task-row__progress-fill--failed]="task.status === 'failed'"></div>
- </div>
- </div>
- <!-- 元信息 chips -->
- <div class="task-row__meta" *ngIf="task.duration || task.keyword || task.vgData || task.estimatedCredits || task.cost">
- <span class="task-row__chip" *ngIf="task.duration">
- <svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
- <polygon points="23 7 16 12 23 17 23 7"></polygon>
- <rect x="1" y="5" width="15" height="14" rx="2" ry="2"></rect>
- </svg>
- {{task.duration}}
- </span>
- <span class="task-row__chip" *ngIf="task.keyword && (task.original_video_title && task.original_video_title !== task.keyword)">
- <svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
- <circle cx="12" cy="12" r="10"></circle>
- <circle cx="12" cy="12" r="4"></circle>
- </svg>
- {{task.keyword}}
- </span>
- <span class="task-row__chip" *ngIf="task.type === 'video-generation' && task.vgData">
- <svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
- <polygon points="12 2 2 7 12 12 22 7 12 2"></polygon>
- <polyline points="2 17 12 22 22 17"></polyline>
- <polyline points="2 12 12 17 22 12"></polyline>
- </svg>
- 步骤 {{task.vgData.step}}/6
- </span>
- <span class="task-row__chip" *ngIf="task.type === 'video-generation' && task.vgData?.scriptSegments?.length">
- <svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
- <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
- <polyline points="14 2 14 8 20 8"></polyline>
- </svg>
- {{task.vgData?.scriptSegments?.length}} 个片段
- </span>
- <span class="task-row__chip" *ngIf="task.estimatedCredits || task.cost">
- {{getTaskCreditText(task)}}
- </span>
- </div>
- <!-- 错误信息 -->
- <div class="task-row__meta" *ngIf="getTaskCostLines(task).length || getTaskExternalIds(task).length || task.refundReason">
- <span class="task-row__chip" *ngFor="let line of getTaskCostLines(task)">
- {{getTaskCostLineText(line)}}
- </span>
- <span class="task-row__chip" *ngFor="let item of getTaskExternalIds(task)">
- {{item.key}}: {{item.value}}
- </span>
- <span class="task-row__chip" *ngIf="task.refundReason">
- 退款原因:{{task.refundReason}}
- </span>
- </div>
- <div class="task-row__meta" *ngIf="task.steps?.length">
- <span class="task-row__chip" *ngFor="let step of task.steps">
- {{step.label}} · {{step.status}} · {{step.progress || 0}}%
- <ng-container *ngIf="getTaskStepDurationText(step)"> · {{getTaskStepDurationText(step)}}</ng-container>
- </span>
- </div>
- <div class="task-row__error" *ngIf="task.status === 'failed' && task.error_message">
- <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
- <circle cx="12" cy="12" r="10"></circle>
- <line x1="12" y1="8" x2="12" y2="12"></line>
- <line x1="12" y1="16" x2="12.01" y2="16"></line>
- </svg>
- <span>{{task.error_message}}</span>
- </div>
- <div class="task-row__error" *ngIf="task.status === 'failed' && (task.failureSuggestion || task.recoverable)">
- <span>{{getTaskRecoveryText(task)}}</span>
- </div>
- <!-- 操作按钮 -->
- <div class="task-row__actions">
- <a *ngIf="task.status === 'completed' && task.result_url" [href]="task.result_url" target="_blank"
- class="task-btn task-btn--success">
- <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
- <polygon points="5 3 19 12 5 21 5 3"></polygon>
- </svg>
- 查看结果
- </a>
- <button type="button" class="task-btn task-btn--primary"
- *ngIf="task.type === 'video-generation' && task.vgData && task.status !== 'completed'"
- (click)="vgResumeFromTask(task)">
- <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
- <polygon points="5 3 19 12 5 21 5 3"></polygon>
- </svg>
- 继续任务
- </button>
- <button type="button" class="task-btn task-btn--primary"
- *ngIf="task.type === 'video-generation' && task.vgData && task.status === 'completed'"
- (click)="vgResumeFromTask(task)">
- <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
- <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
- <circle cx="12" cy="12" r="3"></circle>
- </svg>
- 查看详情
- </button>
- <button type="button" class="task-btn task-btn--primary" *ngIf="task.status === 'failed' && (task.retryable || task.recoverable)" (click)="retryTask(task)">
- 重试/恢复
- </button>
- <button type="button" class="task-btn task-btn--ghost" *ngIf="task.status === 'processing'" (click)="cancelTask(task)">
- 取消
- </button>
- <button type="button" class="task-btn task-btn--danger" (click)="removeTask(task.id)" title="删除任务">
- <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
- <polyline points="3 6 5 6 21 6"></polyline>
- <path d="M19 6l-1.5 14a2 2 0 0 1-2 1.5H8.5a2 2 0 0 1-2-1.5L5 6"></path>
- <path d="M10 11v6M14 11v6"></path>
- </svg>
- </button>
- </div>
- </article>
- </div>
- <!-- 筛选后无结果 -->
- <div class="task-empty task-empty--inline" *ngIf="getFilteredTasks().length === 0">
- <p>当前筛选条件下没有匹配的任务</p>
- <button type="button" class="task-btn task-btn--ghost" (click)="toggleTasksFilter(tasksFilter)">
- 清除筛选
- </button>
- </div>
- </div>
- <div class="task-empty" *ngIf="activeTasks.length === 0">
- <div class="task-empty__icon">
- <svg viewBox="0 0 24 24" width="40" height="40" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
- <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
- <polyline points="14 2 14 8 20 8"></polyline>
- <line x1="16" y1="13" x2="8" y2="13"></line>
- <line x1="16" y1="17" x2="8" y2="17"></line>
- <polyline points="10 9 9 9 8 9"></polyline>
- </svg>
- </div>
- <h3>暂无任务</h3>
- <p>在视频生成或视频管理中创建任务,任务将显示在这里</p>
- </div>
- </section>
- <!-- 生成历史模块 -->
- <section class="dh-page history-page" *ngIf="currentTab === 'history'">
- <header class="dh-page-header">
- <div class="dh-header-icon">
- <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
- <path d="M3 12a9 9 0 1 0 9-9"></path>
- <polyline points="3 4 3 9 8 9"></polyline>
- <polyline points="12 7 12 12 15 14"></polyline>
- </svg>
- </div>
- <div class="dh-header-text">
- <h1>生成历史</h1>
- <p>所有 AI 生成任务的完整记录</p>
- </div>
- </header>
- <div class="history-toolbar">
- <div class="history-toolbar__filter" role="tablist">
- <button type="button" class="history-pill"
- [class.history-pill--active]="!historyFilter.status"
- (click)="historyFilter.status = ''">
- 全部 <span class="history-pill__count">{{getHistoryStatusCount('')}}</span>
- </button>
- <button type="button" class="history-pill history-pill--success"
- [class.history-pill--active]="historyFilter.status === 'completed'"
- (click)="historyFilter.status = 'completed'">
- 已完成 <span class="history-pill__count">{{getHistoryStatusCount('completed')}}</span>
- </button>
- <button type="button" class="history-pill history-pill--danger"
- [class.history-pill--active]="historyFilter.status === 'failed'"
- (click)="historyFilter.status = 'failed'">
- 失败 <span class="history-pill__count">{{getHistoryStatusCount('failed')}}</span>
- </button>
- </div>
- <button type="button" class="history-export" (click)="exportHistory()" title="导出全部记录为 JSON">
- <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
- <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
- <polyline points="7 10 12 15 17 10"></polyline>
- <line x1="12" y1="15" x2="12" y2="3"></line>
- </svg>
- <span>导出记录</span>
- </button>
- </div>
- <div class="history-list" *ngIf="getFilteredHistoryRecords().length > 0">
- <ng-container *ngFor="let record of getFilteredHistoryRecords()">
- <article class="history-card"
- [class.history-card--success]="record.status === 'completed'"
- [class.history-card--failed]="record.status === 'failed'"
- *ngIf="parseHistoryRecordTitle(record) as parsed">
- <span class="history-card__accent" aria-hidden="true"></span>
- <div class="history-card__body">
- <div class="history-card__head">
- <h4 class="history-card__title">{{parsed.title}}</h4>
- <span class="history-card__date">
- <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
- <rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
- <line x1="16" y1="2" x2="16" y2="6"></line>
- <line x1="8" y1="2" x2="8" y2="6"></line>
- <line x1="3" y1="10" x2="21" y2="10"></line>
- </svg>
- {{record.created_at | date:'yyyy-MM-dd HH:mm'}}
- </span>
- </div>
- <div class="history-card__tags" *ngIf="parsed.tags.length > 0">
- <span class="history-tag" *ngFor="let t of parsed.tags">#{{t}}</span>
- </div>
- <div class="history-card__meta">
- <span class="history-status-pill"
- [class.history-status-pill--success]="record.status === 'completed'"
- [class.history-status-pill--failed]="record.status === 'failed'">
- <span class="history-status-pill__dot"></span>
- {{getStatusText(record.status)}}
- </span>
- <span class="history-chip" *ngIf="record.styleName">风格 · {{record.styleName}}</span>
- <span class="history-chip" *ngIf="record.successCount !== undefined">片段 · {{record.successCount}}/{{record.segmentCount}}</span>
- <span class="history-chip" *ngIf="record.audioSourceLabel">音频 · {{record.audioSourceLabel}}</span>
- <span class="history-chip" *ngIf="record.duration">{{record.duration}}</span>
- </div>
- </div>
- <div class="history-card__cost">
- <span class="history-card__cost-label">费用</span>
- <span class="history-card__cost-value">¥{{record.cost || 0}}</span>
- </div>
- <div class="history-card__actions">
- <button type="button" class="history-btn history-btn--primary" (click)="viewHistoryDetail(record)">
- <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
- <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
- <circle cx="12" cy="12" r="3"></circle>
- </svg>
- 查看详情
- </button>
- <button type="button" class="history-btn history-btn--ghost" (click)="removeHistoryRecord(record.id)" title="删除该条记录">
- <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
- <polyline points="3 6 5 6 21 6"></polyline>
- <path d="M19 6l-1.5 14a2 2 0 0 1-2 1.5H8.5a2 2 0 0 1-2-1.5L5 6"></path>
- <path d="M10 11v6M14 11v6"></path>
- <path d="M9 6V4a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2"></path>
- </svg>
- </button>
- </div>
- </article>
- </ng-container>
- </div>
- <div class="history-empty" *ngIf="getFilteredHistoryRecords().length === 0">
- <div class="history-empty__icon">
- <svg viewBox="0 0 24 24" width="40" height="40" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
- <path d="M3 12a9 9 0 1 0 9-9"></path>
- <polyline points="3 4 3 9 8 9"></polyline>
- <polyline points="12 7 12 12 15 14"></polyline>
- </svg>
- </div>
- <h3>{{historyRecords.length === 0 ? '暂无历史记录' : '没有符合筛选条件的记录'}}</h3>
- <p>{{historyRecords.length === 0 ? '完成第一个 AI 生成任务后,记录将显示在这里' : '试试切换其他状态标签,或清除当前筛选'}}</p>
- <button type="button" class="history-btn history-btn--ghost" *ngIf="historyRecords.length > 0 && historyFilter.status" (click)="historyFilter.status = ''">
- 清除筛选
- </button>
- </div>
- </section>
- <!-- 结果展示模块 -->
- <section class="dh-page results-page" *ngIf="currentTab === 'results'">
- <header class="dh-page-header">
- <div class="dh-header-icon">
- <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
- <rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
- <polygon points="10 8 16 12 10 16 10 8" fill="currentColor" stroke="none"></polygon>
- </svg>
- </div>
- <div class="dh-header-text">
- <h1>结果展示</h1>
- <p>查看和下载您的 AI 生成内容</p>
- </div>
- </header>
- <div class="results-grid" *ngIf="generatedResults.length > 0">
- <div class="result-card" *ngFor="let result of generatedResults">
- <div class="result-media">
- <div class="video-preview" *ngIf="result.type === 'video' && result.url">
- <video controls [src]="result.url" preload="metadata"></video>
- <div class="result-badge">
- <span class="icon">play_circle</span>
- <span>{{result.quality || '视频'}}</span>
- </div>
- </div>
- <div class="image-preview" *ngIf="result.type === 'image'">
- <img [src]="result.url" [alt]="result.title">
- <div class="result-badge">
- <span class="icon">image</span>
- <span>图片</span>
- </div>
- </div>
- <div *ngIf="result.type === 'video' && !result.url" style="padding:24px;text-align:center;color:var(--text-secondary);background:var(--bg-tertiary);border-radius:8px">
- <span class="icon" style="font-size:36px;display:block;margin-bottom:8px">videocam_off</span>
- <span>无主视频(查看下方片段)</span>
- </div>
- </div>
- <div class="result-content">
- <h4 class="result-title">{{result.title}}</h4>
- <div class="result-meta">
- <span class="result-date">{{result.created_at | date:'yyyy-MM-dd HH:mm'}}</span>
- <span class="result-quality">{{result.quality}}</span>
- </div>
- <div class="result-stats">
- <span class="stat-item">
- <span class="icon">video_library</span>
- <span>{{result.duration}}</span>
- </span>
- <span class="stat-item">
- <span class="icon">attach_money</span>
- <span>费用: ¥{{result.cost}}</span>
- </span>
- </div>
- <!-- 片段视频列表 -->
- <div *ngIf="result.segments && result.segments.length > 0" style="margin-top:10px">
- <details>
- <summary style="cursor:pointer;font-size:13px;color:var(--accent-color);user-select:none">
- <span class="icon" style="font-size:14px;vertical-align:middle">expand_more</span>
- 展开全部 {{result.segments.length}} 个片段
- </summary>
- <div style="margin-top:8px;display:flex;flex-direction:column;gap:8px">
- <div *ngFor="let seg of result.segments" style="padding:8px;background:var(--bg-tertiary);border-radius:6px">
- <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px">
- <span style="font-size:12px;font-weight:600">{{seg.id}}</span>
- <span class="status-badge badge-success" style="font-size:11px">成功</span>
- </div>
- <video controls [src]="seg.videoUrl" preload="metadata" style="width:100%;max-height:200px;border-radius:4px;background:#000"></video>
- <a [href]="seg.videoUrl" target="_blank" style="display:inline-block;margin-top:4px;font-size:12px;color:var(--accent-color)">
- <span class="icon" style="font-size:12px">open_in_new</span> 新窗口
- </a>
- </div>
- </div>
- </details>
- </div>
- </div>
- <div class="result-actions">
- <button class="btn" (click)="previewResult(result)" *ngIf="result.url">
- <span class="icon">visibility</span> 预览
- </button>
- <button class="btn btn-primary" (click)="downloadResult(result)" *ngIf="result.url">
- <span class="icon">download</span> 下载
- </button>
- <button class="btn" (click)="removeResult(result.id)">
- <span class="icon">delete</span> 删除
- </button>
- </div>
- </div>
- </div>
- <div class="empty-state" *ngIf="generatedResults.length === 0">
- <span class="icon large">video_library</span>
- <h3>暂无生成结果</h3>
- <p>完成AI生成任务后,结果将显示在这里</p>
- </div>
- </section>
- <!-- 视频管理模块 -->
- <section class="dh-page videos-page" *ngIf="currentTab === 'videos' && auth.isLoggedIn">
- <header class="dh-page-header">
- <div class="dh-header-icon">
- <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
- <path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path>
- </svg>
- </div>
- <div class="dh-header-text">
- <h1>视频管理</h1>
- <p>本地已下载与生成的视频素材库</p>
- </div>
- </header>
- <div class="videos-header videos-toolbar-row">
- <div class="videos-controls">
- <div class="form-field filter-field">
- <label>分类筛选</label>
- <select [(ngModel)]="videoFilter.category">
- <option value="">全部分类</option>
- <option *ngFor="let category of videoCategories" [value]="category.id">
- {{category.name}} ({{category.count}})
- </option>
- </select>
- </div>
- <div class="form-field filter-field">
- <label>来源</label>
- <select [(ngModel)]="videoFilter.source">
- <option value="">全部来源</option>
- <option value="downloaded">下载</option>
- <option value="generated">AI生成</option>
- <option value="uploaded">上传</option>
- </select>
- </div>
- <div class="form-field search-field">
- <label>搜索视频</label>
- <input type="text" placeholder="输入关键词搜索..." [(ngModel)]="videoFilter.searchTerm">
- </div>
- <button class="btn btn-primary" type="button" (click)="openUploadModal()" style="position:relative;z-index:10">
- <span class="icon">upload</span> 上传视频
- </button>
- <button class="btn" (click)="isSelectionMode = !isSelectionMode">
- <span class="icon">{{isSelectionMode ? 'close' : 'check_circle'}}</span>
- {{isSelectionMode ? '取消选择' : '批量操作'}}
- </button>
- </div>
- </div>
- <div class="videos-actions" *ngIf="isSelectionMode && selectedVideos.size > 0">
- <div class="selection-info">
- 已选择 {{selectedVideos.size}} 个视频
- </div>
- <div class="batch-actions">
- <button class="btn" (click)="batchDelete()">
- <span class="icon">delete</span> 批量删除
- </button>
- <button class="btn" (click)="batchExport()">
- <span class="icon">download</span> 批量导出
- </button>
- <button class="btn" (click)="batchCategorize()">
- <span class="icon">folder</span> 批量分类
- </button>
- </div>
- </div>
- <div class="videos-grid" *ngIf="getFilteredVideos().length > 0">
- <div class="video-item" *ngFor="let video of getFilteredVideos()" [class.selected]="selectedVideos.has(video.id)">
- <div class="video-checkbox" *ngIf="isSelectionMode">
- <input type="checkbox" [checked]="selectedVideos.has(video.id)" (change)="toggleVideoSelection(video.id)">
- </div>
- <div class="video-thumbnail">
- <img *ngIf="video.thumbnail" [src]="video.thumbnail" [alt]="video.title">
- <video *ngIf="!video.thumbnail && getVideoPlayUrl(video)" [src]="getVideoPlayUrl(video) + '#t=0.1'" muted preload="metadata" style="width:100%;height:100%;object-fit:cover;pointer-events:none"></video>
- <div *ngIf="!video.thumbnail && !getVideoPlayUrl(video)" class="video-thumb-placeholder">
- <span class="icon" style="font-size:36px;color:var(--text-muted)">movie</span>
- </div>
- <div class="video-duration">{{video.duration | timeFormat}}</div>
- <div class="video-source" [class]="video.source">{{getSourceText(video.source)}}</div>
- <!-- AI重塑进度指示器 -->
- <div class="remix-progress" *ngIf="video.isRemixing">
- <div class="remix-progress-bar">
- <div class="remix-progress-fill" [style.width]="video.remixProgress + '%'"></div>
- </div>
- <div class="remix-style">{{video.remixStyle}}</div>
- </div>
- </div>
- <div class="video-info">
- <div class="video-title-row">
- <h4 class="video-title">{{video.title}}</h4>
- <span class="category-badge" [style.background-color]="getCategoryColor(video.category)">
- {{getCategoryName(video.category)}}
- </span>
- </div>
- <div class="video-meta">
- <span class="meta-item">
- <span class="icon">schedule</span>
- {{video.created_at | date:'yyyy-MM-dd HH:mm'}}
- </span>
- <span class="meta-item">
- <span class="icon">storage</span>
- {{video.size | fileSize}}
- </span>
- <span class="meta-item">
- <span class="icon">hd</span>
- {{video.metadata.resolution}}
- </span>
- </div>
- <div class="video-description" *ngIf="video.description">
- {{video.description}}
- </div>
- <div class="video-tags" *ngIf="video.tags.length > 0">
- <span class="tag" *ngFor="let tag of video.tags">
- #{{tag}}
- </span>
- </div>
- </div>
- <div class="video-actions">
- <button class="btn" (click)="previewVideo(video)">
- <span class="icon">play_arrow</span> 预览
- </button>
- <button class="btn" (click)="editVideo(video)">
- <span class="icon">edit</span> 编辑
- </button>
- <button class="btn {{getRemixStatusColor(video)}}" (click)="openRemixModeChooser(video)" [disabled]="video.isRemixing">
- <span class="icon">
- {{video.isRemixing ? 'hourglass_empty' : 'auto_awesome'}}
- </span>
- {{getRemixStatusText(video)}}
- </button>
- <button class="btn" (click)="viewRemixVersions(video)" *ngIf="video.remixVersions && video.remixVersions.length > 0">
- <span class="icon">history</span> 版本
- </button>
- <button class="btn btn-warn" (click)="deleteVideo(video)">
- <span class="icon">delete</span> 删除
- </button>
- </div>
- </div>
- </div>
- <div class="empty-state" *ngIf="getFilteredVideos().length === 0">
- <span class="icon large">folder_open</span>
- <h3>暂无视频文件</h3>
- <p>下载、生成或上传视频后,它们将显示在这里进行管理</p>
- <button class="btn btn-primary" style="margin-top:12px" (click)="openUploadModal()">
- <span class="icon">upload</span> 上传视频
- </button>
- </div>
- </section>
- <!-- 语音合成模块(重设计:二栏布局) -->
- <section class="dh-page vs-page" *ngIf="currentTab === 'voice-synthesis'">
- <header class="dh-page-header">
- <div class="dh-header-icon">
- <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
- <path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"></path>
- <path d="M19 10v2a7 7 0 0 1-14 0v-2"></path>
- <line x1="12" y1="19" x2="12" y2="23"></line>
- <line x1="8" y1="23" x2="16" y2="23"></line>
- </svg>
- </div>
- <div class="dh-header-text">
- <h1>声音定制</h1>
- <p>上传音频样本训练专属声音模型,或选择已有声音模型生成语音</p>
- </div>
- <button type="button" class="dh-help-btn" (click)="showToast('先在左侧「声音训练」上传样本完成训练,再到右侧「声音合成」选择声音模型生成语音。', 'info', 5000)">
- <span class="dh-help-icon">?</span>
- <span>帮助</span>
- </button>
- </header>
- <div class="vs-two-col">
- <!-- ========== 左:声音训练 ========== -->
- <article class="dh-card vs-subcard">
- <header class="vs-subcard-header">
- <div class="vs-subcard-icon vs-subcard-icon--purple">
- <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
- <path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"></path>
- <path d="M19 10v2a7 7 0 0 1-14 0v-2"></path>
- <line x1="12" y1="19" x2="12" y2="23"></line>
- <line x1="8" y1="23" x2="16" y2="23"></line>
- </svg>
- </div>
- <div>
- <h2>声音训练</h2>
- <p>上传音频样本,训练专属声音模型</p>
- </div>
- <button type="button" class="dh-help-btn">
- <span class="dh-help-icon">?</span>
- <span>帮助</span>
- </button>
- </header>
- <div class="dh-segment vs-tab-header">
- <button type="button" class="dh-segment__item is-active">上传音频</button>
- <button type="button" class="dh-segment__item" disabled title="即将上线">直接录音</button>
- </div>
- <input type="file" id="vsCloneAudioInput"
- accept=".mp3,.wav,.m4a,.ogg,.aac,.flac,.wma,audio/*"
- (change)="onVsCloneAudioSelected($event)"
- hidden
- [disabled]="vsCloneLoading || vsCloneUploading">
- <label for="vsCloneAudioInput" class="dh-upload__zone vs-audio-upload" *ngIf="!vsCloneAudioName">
- <svg class="dh-upload__icon" viewBox="0 0 24 24" width="40" height="40" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
- <path d="M22 12h-6l-2 3h-4l-2-3H2"></path>
- <path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"></path>
- </svg>
- <h3 class="dh-upload__title">点击或拖拽音频文件到此区域</h3>
- <ul class="dh-upload__hints">
- <li>支持 MP3 / WAV / M4A / OGG / AAC / FLAC 等格式</li>
- <li>建议时长 5-30 秒,文件大小不超过 50MB</li>
- <li>单人纯净人声、无背景音乐和噪声效果最佳</li>
- </ul>
- </label>
- <div class="vs-upload-filled" *ngIf="vsCloneAudioName">
- <div style="min-width:0;flex:1">
- <div class="dh-upload__preview-name">{{vsCloneAudioName}}</div>
- <div class="dh-upload__preview-size">
- {{vsCloneAudioFile ? (vsCloneAudioFile.size / 1024 / 1024).toFixed(2) + ' MB' : ''}}
- <span *ngIf="vsCloneAudioUrl" style="color:var(--dh-success)"> · 已上传</span>
- <span *ngIf="vsCloneUploading" style="color:#c4b5fd"> · 正在上传...</span>
- </div>
- </div>
- <div class="dh-upload__preview-actions" *ngIf="!vsCloneLoading && !vsCloneUploading">
- <label for="vsCloneAudioInput" class="dh-chip-btn">更换</label>
- <button type="button" class="dh-chip-btn dh-chip-btn--danger" (click)="clearVsCloneAudio()">移除</button>
- </div>
- </div>
- <div class="dh-field">
- <label class="dh-field__label">
- <span>声音名称 <em>*</em></span>
- <span class="dh-field__counter">{{(vsCloneForm.name || '').length}} / 20</span>
- </label>
- <input class="dh-input" type="text" maxlength="20" [(ngModel)]="vsCloneForm.name"
- placeholder="为这个声音起个名字"
- [disabled]="vsCloneLoading || vsCloneUploading">
- <p class="vs-hint">起个名字方便记忆(20 字以内),例如:我的声音</p>
- </div>
- <div class="dh-field-row">
- <div class="dh-field" style="flex:1;min-width:0">
- <label class="dh-field__label"><span>语言</span></label>
- <select class="dh-input" [(ngModel)]="vsCloneForm.language" [disabled]="vsCloneLoading || vsCloneUploading">
- <option [ngValue]="0">中文</option>
- <option [ngValue]="1">英文</option>
- </select>
- <p class="vs-hint">系统会自动准备声音训练所需信息,无需手动配置</p>
- </div>
- </div>
- <div class="dh-field">
- <label class="dh-field__label"><span>音频对应文本(可选)</span></label>
- <input class="dh-input" type="text" [(ngModel)]="vsCloneForm.audioText"
- placeholder="音频中说的文本内容,有助于提升训练质量"
- [disabled]="vsCloneLoading || vsCloneUploading">
- </div>
- <div class="dh-field">
- <label class="dh-field__label"><span>试听文本(4-80 字) <em>*</em></span></label>
- <input class="dh-input" type="text" [(ngModel)]="vsCloneForm.demoText"
- placeholder="训练成功后用于生成试听音频的文本"
- [disabled]="vsCloneLoading || vsCloneUploading">
- </div>
- <div class="dh-error" *ngIf="vsCloneError">{{vsCloneError}}</div>
- <button type="button" class="dh-cta" (click)="vsStartClone()" [disabled]="vsCloneLoading || vsCloneUploading">
- <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
- <polygon points="5 3 19 12 5 21 5 3"></polygon>
- </svg>
- <span>{{vsCloneUploading ? '上传音频中...' : vsCloneLoading ? '训练中...' : '开始训练'}}</span>
- </button>
- <p class="vs-hint" style="text-align:center"
- *ngIf="!vsCloneLoading && !vsCloneUploading && (!vsCloneForm.name || !vsCloneAudioName)">
- {{ !vsCloneForm.name ? '请输入声音名称' : '请上传或录制音频' }}
- </p>
- <!-- 训练结果 -->
- <div class="vs-result" *ngIf="vsCloneResult?.code === 200 && vsCloneResult?.data?.timbre">
- <div class="vs-result-head">
- <span class="dh-status-banner__check">✓</span>
- <span>训练成功</span>
- </div>
- <div class="vs-result-info">
- <div><strong>声音名称:</strong>{{vsCloneResult.data.timbre.name || vsCloneForm.name}}</div>
- <div *ngIf="vsCloneResult.data.timbre.models?.[0]?.demo_audio">
- <strong>试听:</strong>
- <audio controls [src]="vsCloneResult.data.timbre.models[0].demo_audio" preload="metadata" class="vs-audio-compact"></audio>
- </div>
- </div>
- <button type="button" class="dh-link-btn" style="margin-top:10px"
- (click)="vsSelectClonedTimbre(vsCloneResult.data.timbre.objectId)">
- 使用此声音生成语音 →
- </button>
- </div>
- <!-- 已训练快捷列表 -->
- <div class="vs-cloned-list" *ngIf="vsClonedTimbreList.length > 0">
- <h4 class="vs-subtle-title">我的声音模型 ({{vsClonedTimbreList.length}})</h4>
- <div class="dh-chips">
- <button type="button" class="dh-chip" *ngFor="let t of vsClonedTimbreList"
- (click)="vsSelectClonedTimbre(t.objectId)">
- {{t.name}}
- </button>
- </div>
- </div>
- </article>
- <!-- ========== 右:声音合成 ========== -->
- <article class="dh-card vs-subcard">
- <header class="vs-subcard-header">
- <div class="vs-subcard-icon vs-subcard-icon--pink">
- <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
- <path d="M3 10v3M7 6v11M11 3v17M15 7v9M19 10v3"></path>
- </svg>
- </div>
- <div>
- <h2>声音合成</h2>
- <p>选择声音模型,生成个性化语音</p>
- </div>
- <button type="button" class="dh-help-btn">
- <span class="dh-help-icon">?</span>
- <span>帮助</span>
- </button>
- </header>
- <div class="dh-field">
- <label class="dh-field__label"><span>选择声音模型</span></label>
- <button type="button" class="vs-voice-picker-trigger"
- (click)="openVoicePicker()"
- [disabled]="vsSynthLoading"
- [class.is-empty]="!currentVoiceSelection">
- <ng-container *ngIf="currentVoiceSelection; else emptyVoiceTpl">
- <span class="vs-voice-picker-avatar"
- [class.vs-voice-picker-avatar--mine]="currentVoiceSelection.source === 'cloned'">
- {{currentVoiceSelection.name.charAt(0)}}
- </span>
- <span class="vs-voice-picker-info">
- <span class="vs-voice-picker-name">{{currentVoiceSelection.name}}</span>
- <span class="vs-voice-picker-src">
- {{currentVoiceSelection.source === 'official' ? '声音库' : '我的声音'}}
- </span>
- </span>
- </ng-container>
- <ng-template #emptyVoiceTpl>
- <span class="vs-voice-picker-avatar vs-voice-picker-avatar--empty">+</span>
- <span class="vs-voice-picker-info">
- <span class="vs-voice-picker-name">请选择声音模型</span>
- <span class="vs-voice-picker-src">点击选择声音库 / 我的声音</span>
- </span>
- </ng-template>
- <span class="vs-voice-picker-chevron">›</span>
- </button>
- </div>
- <app-voice-picker
- [open]="voicePickerOpen"
- [clonedList]="vsClonedTimbreList"
- [currentSelection]="currentVoiceSelection"
- (select)="onVoicePicked($event)"
- (close)="closeVoicePicker()">
- </app-voice-picker>
- <div class="dh-field">
- <label class="dh-field__label">
- <span>输入文本</span>
- <span class="dh-field__counter">{{(vsSynthForm.text || '').length}} / 5000</span>
- </label>
- <textarea class="dh-textarea" rows="4" maxlength="5000" [(ngModel)]="vsSynthForm.text"
- placeholder="你好,这是一个声音克隆测试。"
- [disabled]="vsSynthLoading"></textarea>
- </div>
- <div class="dh-field-row">
- <div class="dh-field" style="flex:1;min-width:0">
- <label class="dh-field__label">
- <span>语速</span>
- <span class="dh-field__counter">{{vsSynthForm.speechRate}}</span>
- </label>
- <input type="range" min="-50" max="100" [(ngModel)]="vsSynthForm.speechRate" [disabled]="vsSynthLoading">
- </div>
- <div class="dh-field" style="flex:1;min-width:0">
- <label class="dh-field__label">
- <span>音量</span>
- <span class="dh-field__counter">{{vsSynthForm.loudnessRate}}</span>
- </label>
- <input type="range" min="-50" max="100" [(ngModel)]="vsSynthForm.loudnessRate" [disabled]="vsSynthLoading">
- </div>
- </div>
- <div class="dh-field-row">
- <div class="dh-field" style="flex:1;min-width:0">
- <label class="dh-field__label"><span>音频格式</span></label>
- <select class="dh-input" [(ngModel)]="vsSynthForm.format" [disabled]="vsSynthLoading">
- <option value="mp3">MP3</option>
- <option value="ogg_opus">OGG Opus</option>
- <option value="pcm">PCM</option>
- </select>
- </div>
- <div class="dh-field" style="flex:1;min-width:0">
- <label class="dh-field__label"><span>采样率</span></label>
- <select class="dh-input" [(ngModel)]="vsSynthForm.sampleRate" [disabled]="vsSynthLoading">
- <option [ngValue]="8000">8000 Hz</option>
- <option [ngValue]="16000">16000 Hz</option>
- <option [ngValue]="24000">24000 Hz</option>
- <option [ngValue]="48000">48000 Hz</option>
- </select>
- </div>
- </div>
- <div class="dh-error" *ngIf="vsSynthError">{{vsSynthError}}</div>
- <button type="button" class="dh-cta" (click)="vsStartSynthesize()" [disabled]="vsSynthLoading">
- <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
- <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon>
- <path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"></path>
- </svg>
- <span>{{vsSynthLoading ? '合成中...' : '生成语音'}}</span>
- </button>
- <p class="vs-hint" style="text-align:center"
- *ngIf="!vsSynthLoading && (!currentVoiceSelection || !vsSynthForm.text)">
- {{ !currentVoiceSelection ? '请选择声音模型' : '请输入要合成的文本' }}
- </p>
- <!-- 合成结果 -->
- <div class="vs-result" *ngIf="vsSynthAudioUrl">
- <div class="vs-result-head">
- <span class="dh-status-banner__check">✓</span>
- <span>合成成功</span>
- </div>
- <audio controls [src]="vsSynthAudioUrl" preload="metadata" class="vs-audio-full"></audio>
- <div class="vs-result-info">
- <div *ngIf="vsSynthWorkId"><strong>生成记录:</strong>已保存</div>
- </div>
- <div style="display:flex;gap:8px;margin-top:10px;flex-wrap:wrap">
- <a [href]="vsSynthAudioUrl" target="_blank" class="dh-link-btn dh-link-btn--ghost">新窗口播放</a>
- <a [href]="vsSynthAudioUrl" download class="dh-link-btn">下载音频</a>
- </div>
- </div>
- </article>
- </div>
- </section>
- <!-- LEGACY 语音合成(旧版 *ngIf="false" 死代码,保留作回滚备份) -->
- <section class="section-card" *ngIf="false">
- <div class="search-header">
- <h2>
- <span class="icon">record_voice_over</span> 语音合成
- </h2>
- <p class="subtitle">训练自己的声音模型,或选择已有声音生成自然语音</p>
- </div>
- <!-- 模式切换 -->
- <div class="remix-mode-grid" style="margin-bottom:20px">
- <div class="remix-mode-card"
- [class.active]="vsTtsMode === 'clone'"
- (click)="setVsTtsMode('clone')">
- <div class="remix-mode-title">音色复刻</div>
- <div class="remix-mode-desc">上传音频样本,训练可重复使用的声音模型。</div>
- </div>
- <div class="remix-mode-card"
- [class.active]="vsTtsMode === 'synthesize'"
- (click)="setVsTtsMode('synthesize')">
- <div class="remix-mode-title">语音合成</div>
- <div class="remix-mode-desc">选择声音模型,将文本内容转为语音音频。</div>
- </div>
- </div>
- <!-- 音色复刻表单 -->
- <div *ngIf="vsTtsMode === 'clone'" style="display:flex;flex-direction:column;gap:14px">
- <div class="form-field">
- <label>音色名称 *</label>
- <input type="text" [(ngModel)]="vsCloneForm.name" placeholder="例如:我的专属音色" [disabled]="vsCloneLoading || vsCloneUploading">
- </div>
- <div class="form-field">
- <label>训练通道 *</label>
- <select [(ngModel)]="vsCloneForm.speakerId" [disabled]="vsCloneLoading || vsCloneUploading">
- <option value="S_ULZKhZWZ1">S_ULZKhZWZ1</option>
- <option value="S_TLZKhZWZ1">S_TLZKhZWZ1</option>
- <option value="S_SLZKhZWZ1">S_SLZKhZWZ1</option>
- <option value="S_QLZKhZWZ1">S_QLZKhZWZ1</option>
- <option value="S_PLZKhZWZ1">S_PLZKhZWZ1</option>
- <option value="S_OLZKhZWZ1">S_OLZKhZWZ1</option>
- <option value="S_MLZKhZWZ1">S_MLZKhZWZ1</option>
- <option value="S_RWDjSzXZ1">S_RWDjSzXZ1</option>
- </select>
- </div>
- <div class="form-field">
- <label>上传音频文件 *</label>
- <input type="file" id="vsCloneAudioInput"
- accept=".mp3,.wav,.m4a,.ogg,.aac,.flac,.wma,audio/*"
- (change)="onVsCloneAudioSelected($event)"
- style="display:none"
- [disabled]="vsCloneLoading || vsCloneUploading">
- <div style="border:2px dashed var(--border-color);border-radius:10px;padding:20px;text-align:center;transition:border-color 0.2s ease,background-color 0.2s ease"
- [style.border-color]="vsCloneAudioName ? 'var(--accent-color)' : 'var(--border-color)'"
- [style.background]="vsCloneAudioName ? 'var(--accent-color-alpha, rgba(33,150,243,0.05))' : 'transparent'">
- <div *ngIf="!vsCloneAudioName">
- <span class="icon" style="font-size:36px;color:var(--text-secondary);display:block;margin-bottom:6px">audio_file</span>
- <p style="margin:0 0 6px;color:var(--text-primary);font-size:14px;font-weight:500">点击选择音频文件</p>
- <p style="margin:0;color:var(--text-secondary);font-size:12px">支持 MP3、WAV、M4A、OGG、AAC、FLAC 格式,最大 50MB</p>
- <label for="vsCloneAudioInput" class="btn btn-primary" style="margin-top:10px;cursor:pointer;display:inline-flex;font-size:13px">
- <span class="icon" style="font-size:16px">folder_open</span> 选择音频
- </label>
- </div>
- <div *ngIf="vsCloneAudioName">
- <span class="icon" style="font-size:28px;color:var(--accent-color);display:block;margin-bottom:4px">audio_file</span>
- <p style="margin:0 0 2px;color:var(--text-primary);font-size:14px;font-weight:500">{{vsCloneAudioName}}</p>
- <p *ngIf="vsCloneAudioFile" style="margin:0;color:var(--text-secondary);font-size:12px">
- 文件大小: {{((vsCloneAudioFile?.size || 0) / 1024 / 1024).toFixed(2)}} MB
- </p>
- <p *ngIf="vsCloneAudioUrl" style="margin:4px 0 0;color:var(--success-color);font-size:12px">
- <span class="icon" style="font-size:14px;vertical-align:middle">check_circle</span> 已上传
- </p>
- <div style="margin-top:8px;display:flex;gap:8px;justify-content:center" *ngIf="!vsCloneLoading && !vsCloneUploading">
- <label for="vsCloneAudioInput" class="btn" style="cursor:pointer;display:inline-flex;font-size:12px">
- <span class="icon" style="font-size:14px">swap_horiz</span> 更换文件
- </label>
- <button class="btn" (click)="clearVsCloneAudio()" style="font-size:12px">
- <span class="icon" style="font-size:14px">delete</span> 移除
- </button>
- </div>
- <p *ngIf="vsCloneUploading" style="margin:6px 0 0;color:var(--accent-color);font-size:12px">
- <span class="icon" style="font-size:14px;vertical-align:middle">hourglass_empty</span> 正在上传音频...
- </p>
- </div>
- </div>
- </div>
- <div class="form-field">
- <label>音频对应文本(可选)</label>
- <input type="text" [(ngModel)]="vsCloneForm.audioText"
- placeholder="音频中说的文本内容,有助于提升复刻质量"
- [disabled]="vsCloneLoading || vsCloneUploading">
- </div>
- <div style="display:flex;gap:12px">
- <div class="form-field" style="flex:1">
- <label>语言</label>
- <select [(ngModel)]="vsCloneForm.language" [disabled]="vsCloneLoading || vsCloneUploading">
- <option [ngValue]="0">中文</option>
- <option [ngValue]="1">英文</option>
- </select>
- </div>
- <div class="form-field" style="flex:2">
- <label>试听文本(4-80字)*</label>
- <input type="text" [(ngModel)]="vsCloneForm.demoText"
- placeholder="复刻成功后用于生成试听音频的文本"
- [disabled]="vsCloneLoading || vsCloneUploading">
- </div>
- </div>
- <div style="color:var(--error-color);font-size:13px" *ngIf="vsCloneError">
- <span class="icon" style="font-size:14px;vertical-align:middle">error</span> {{vsCloneError}}
- </div>
- <button class="btn btn-primary" (click)="vsStartClone()" [disabled]="vsCloneLoading || vsCloneUploading" style="align-self:flex-start">
- <span class="icon">{{vsCloneLoading || vsCloneUploading ? 'hourglass_empty' : 'content_copy'}}</span>
- {{vsCloneUploading ? '上传音频中...' : vsCloneLoading ? '训练中...' : '开始训练' }}
- </button>
- <!-- 复刻结果 -->
- <div *ngIf="vsCloneResult?.code === 200 && vsCloneResult?.data?.timbre"
- style="margin-top:8px;padding:16px;background:var(--bg-tertiary);border-radius:10px;border:1px solid var(--border-color)">
- <h4 style="margin:0 0 10px;display:flex;align-items:center;gap:6px">
- <span class="icon" style="color:var(--success-color)">check_circle</span> 复刻成功
- </h4>
- <div style="display:flex;flex-direction:column;gap:6px;font-size:13px">
- <div><strong>声音名称:</strong>{{vsCloneResult.data.timbre.name || vsCloneForm.name}}</div>
- <div *ngIf="vsCloneResult.data.timbre.models?.[0]?.demo_audio">
- <strong>试听音频:</strong>
- <audio controls [src]="vsCloneResult.data.timbre.models[0].demo_audio" preload="metadata" style="height:32px;vertical-align:middle"></audio>
- </div>
- </div>
- <button class="btn btn-primary" style="margin-top:12px"
- (click)="vsSelectClonedTimbre(vsCloneResult.data.timbre.objectId)">
- <span class="icon">arrow_forward</span> 使用此声音生成语音
- </button>
- </div>
- <!-- 已复刻音色快捷列表 -->
- <div *ngIf="vsClonedTimbreList.length > 0" style="margin-top:8px">
- <h4 style="margin:0 0 8px;font-size:14px;color:var(--text-secondary)">我的声音模型</h4>
- <div style="display:flex;flex-wrap:wrap;gap:8px">
- <button class="btn" *ngFor="let t of vsClonedTimbreList"
- (click)="vsSelectClonedTimbre(t.objectId)"
- style="font-size:12px">
- <span class="icon" style="font-size:14px">record_voice_over</span>
- {{t.name}}
- </button>
- </div>
- </div>
- </div>
- <!-- 语音合成表单 -->
- <div *ngIf="vsTtsMode === 'synthesize'" style="display:flex;flex-direction:column;gap:14px">
- <div class="form-field">
- <label>声音模型 *</label>
- <input type="text" [(ngModel)]="vsSynthForm.timbreId"
- placeholder="请选择已训练的声音模型"
- [disabled]="vsSynthLoading">
- </div>
- <!-- 已复刻音色快捷选择 -->
- <div *ngIf="vsClonedTimbreList.length > 0" style="display:flex;flex-wrap:wrap;gap:6px">
- <button class="btn" *ngFor="let t of vsClonedTimbreList"
- [class.btn-primary]="vsSynthForm.timbreId === t.objectId"
- (click)="vsSynthForm.timbreId = t.objectId"
- [disabled]="vsSynthLoading"
- style="font-size:12px">
- {{t.name}}
- </button>
- </div>
- <div class="form-field">
- <label>合成文本 *</label>
- <textarea [(ngModel)]="vsSynthForm.text" rows="4"
- placeholder="请输入要合成的文本内容..."
- [disabled]="vsSynthLoading"
- style="resize:vertical;min-height:80px"></textarea>
- </div>
- <div style="display:flex;gap:12px;flex-wrap:wrap">
- <div class="form-field" style="flex:1;min-width:120px">
- <label>音频格式</label>
- <select [(ngModel)]="vsSynthForm.format" [disabled]="vsSynthLoading">
- <option value="mp3">MP3</option>
- <option value="ogg_opus">OGG Opus</option>
- <option value="pcm">PCM</option>
- </select>
- </div>
- <div class="form-field" style="flex:1;min-width:120px">
- <label>采样率</label>
- <select [(ngModel)]="vsSynthForm.sampleRate" [disabled]="vsSynthLoading">
- <option [ngValue]="8000">8000</option>
- <option [ngValue]="16000">16000</option>
- <option [ngValue]="24000">24000</option>
- <option [ngValue]="48000">48000</option>
- </select>
- </div>
- <div class="form-field" style="flex:1;min-width:120px">
- <label>语速 ({{vsSynthForm.speechRate}})</label>
- <input type="range" min="-50" max="100" [(ngModel)]="vsSynthForm.speechRate" [disabled]="vsSynthLoading">
- </div>
- <div class="form-field" style="flex:1;min-width:120px">
- <label>音量 ({{vsSynthForm.loudnessRate}})</label>
- <input type="range" min="-50" max="100" [(ngModel)]="vsSynthForm.loudnessRate" [disabled]="vsSynthLoading">
- </div>
- </div>
- <div style="color:var(--error-color);font-size:13px" *ngIf="vsSynthError">
- <span class="icon" style="font-size:14px;vertical-align:middle">error</span> {{vsSynthError}}
- </div>
- <button class="btn btn-primary" (click)="vsStartSynthesize()" [disabled]="vsSynthLoading" style="align-self:flex-start">
- <span class="icon">{{vsSynthLoading ? 'hourglass_empty' : 'play_arrow'}}</span>
- {{vsSynthLoading ? '合成中...' : '开始语音合成'}}
- </button>
- <!-- 合成结果 -->
- <div *ngIf="vsSynthAudioUrl"
- style="margin-top:8px;padding:16px;background:var(--bg-tertiary);border-radius:10px;border:1px solid var(--border-color)">
- <h4 style="margin:0 0 10px;display:flex;align-items:center;gap:6px">
- <span class="icon" style="color:var(--success-color)">check_circle</span> 合成成功
- </h4>
- <audio controls [src]="vsSynthAudioUrl" preload="metadata" style="width:100%;margin-bottom:8px"></audio>
- <div style="font-size:13px;display:flex;flex-direction:column;gap:4px">
- <div *ngIf="vsSynthWorkId"><strong>作品ID:</strong><code>{{vsSynthWorkId}}</code></div>
- <div><strong>音频地址:</strong>
- <a [href]="vsSynthAudioUrl" target="_blank" style="color:var(--accent-color);word-break:break-all">{{vsSynthAudioUrl}}</a>
- </div>
- </div>
- </div>
- </div>
- </section>
- <!-- 数字人合成模块 -->
- <section class="dh-page" *ngIf="currentTab === 'digital-human'">
- <!-- 顶部标题栏 -->
- <header class="dh-page-header">
- <div class="dh-header-icon">
- <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
- <line x1="12" y1="5" x2="12" y2="19"></line>
- <line x1="5" y1="12" x2="19" y2="12"></line>
- </svg>
- </div>
- <div class="dh-header-text">
- <h1>添加形象</h1>
- <p>上传形象图与音频,一键生成专属数字人口播视频</p>
- </div>
- <button type="button" class="dh-help-btn" (click)="showToast('可以先在「语音合成」中训练声音模型,再用于数字人视频。', 'info', 5000)">
- <span class="dh-help-icon">?</span>
- <span>帮助</span>
- </button>
- </header>
- <!-- ============================================================ -->
- <!-- 4 栏 Step 流程板:参考视频 → 内容与脚本 → 数字人设置 → 视频预览 -->
- <!-- ============================================================ -->
- <div class="dh-board">
- <!-- ===================== Step 1: 参考视频 ===================== -->
- <article class="dh-step-col">
- <header class="dh-step-col__head">
- <span class="dh-step-col__step">Step 1</span>
- <h3 class="dh-step-col__title">参考视频</h3>
- <button type="button" class="dh-chip-btn dh-step-col__action"
- *ngIf="dhRefVideoUrl || dhRefFile || dhRefTranscript"
- (click)="dhRefClear()">清除</button>
- </header>
- <div class="dh-step-col__body">
- <!-- 未选择文件 -->
- <div *ngIf="!dhRefFile && !dhRefVideoUrl">
- <input type="file" id="dhRefVideoInput" accept="video/*" (change)="dhRefSelectFile($event)" hidden>
- <label for="dhRefVideoInput" class="dh-mini-drop dh-ref-drop">
- <span class="dh-mini-drop__title">点击选择参考视频</span>
- <span class="dh-mini-drop__hint">支持常见视频格式,建议 ≤ 100MB;上传后将自动开始分析</span>
- </label>
- <p class="dh-step-col__hint">上传视频后,AI 会自动识别画面与音频,生成口播文本与提示词。</p>
- </div>
- <!-- 已选择文件 / 已分析 -->
- <div *ngIf="dhRefFile || dhRefVideoUrl" class="dh-step-col__stack">
- <div class="dh-ref-preview-card">
- <div class="dh-ref-preview-card__video">
- <video *ngIf="dhRefVideoUrl" controls muted [src]="dhRefVideoUrl"></video>
- </div>
- <div class="dh-ref-preview-card__meta">
- <div class="dh-ref-preview-card__name" [title]="dhRefFileName">{{dhRefFileName || '参考视频'}}</div>
- <div class="dh-ref-preview-card__size" *ngIf="dhRefFileSize > 0">{{(dhRefFileSize / 1024 / 1024).toFixed(2)}} MB</div>
- </div>
- </div>
- <button type="button" class="dh-cta dh-cta--sm"
- *ngIf="dhRefFile && !dhRefUploading && !dhRefAnalyzing && !dhRefVideoId"
- (click)="dhRefUploadAndAnalyze()">开始分析</button>
- <!-- 上传 / 分析进度 -->
- <div class="dh-ref-progress" *ngIf="dhRefUploading">
- <span class="dh-spinner"></span>
- <span>正在上传...{{dhRefUploadProgress | number:'1.0-0'}}%</span>
- </div>
- <div class="dh-ref-progress" *ngIf="dhRefAnalyzing">
- <span class="dh-spinner"></span>
- <span>{{dhRefAnalysisText || '正在分析视频内容...'}}</span>
- </div>
- <!-- AI 分析结果(已自动填入 Step 2 口播文本) -->
- <div class="dh-substep" *ngIf="dhRefTranscript && !dhRefAnalyzing">
- <header class="dh-substep__head">
- <h4>AI 分析结果</h4>
- <span class="dh-chip-btn dh-chip-btn--ghost" style="cursor:default">已同步到口播</span>
- </header>
- <div class="dh-field">
- <label class="dh-field__label"><span>视频文字稿(可编辑)</span></label>
- <textarea class="dh-textarea" rows="4" [(ngModel)]="dhRefTranscript"
- (ngModelChange)="dhRefSyncToTts($event)"></textarea>
- </div>
- <div class="dh-field" *ngIf="dhRefAnalysisText">
- <label class="dh-field__label"><span>内容分析</span></label>
- <div class="dh-ref-analysis">{{dhRefAnalysisText}}</div>
- </div>
- </div>
- <div class="dh-error" *ngIf="dhRefError">{{dhRefError}}</div>
- </div>
- </div>
- </article>
- <!-- ===================== Step 2: 内容与脚本 ===================== -->
- <article class="dh-step-col">
- <header class="dh-step-col__head">
- <span class="dh-step-col__step">Step 2</span>
- <h3 class="dh-step-col__title">内容与脚本</h3>
- <span class="dh-step-col__action dh-step-col__counter"
- *ngIf="dhTtsText">{{(dhTtsText || '').length}} 字</span>
- </header>
- <div class="dh-step-col__body dh-step-col__stack">
- <div class="dh-field">
- <label class="dh-field__label">
- <span>口播文本 <em>*</em></span>
- <span class="dh-field__counter">{{(dhTtsText || '').length}} / 600</span>
- </label>
- <textarea class="dh-textarea" rows="4" maxlength="600" [(ngModel)]="dhTtsText"
- placeholder="输入数字人要朗读的文本,将使用所选声音模型生成语音"></textarea>
- </div>
- <div class="dh-field">
- <label class="dh-field__label">
- <span>补充提示词(可选)</span>
- <span class="dh-field__counter">{{(dhPrompt || '').length}} / 200</span>
- </label>
- <textarea class="dh-textarea" rows="3" maxlength="200" [(ngModel)]="dhPrompt"
- placeholder="可选:强调产品的易用性,缩短文案至 40 字..."></textarea>
- <!-- 预设风格快捷填入 -->
- <div class="dh-preset-row">
- <button type="button" class="dh-preset-chip"
- *ngFor="let p of dhPromptPresets"
- [class.is-active]="dhPrompt === p.value"
- (click)="dhApplyPromptPreset(p.value)"
- [title]="p.value">{{p.label}}</button>
- </div>
- </div>
- <!-- AI 风格助手(折叠在内容栏底部) -->
- <details class="dh-substep dh-chat-fold" [attr.open]="dhChatMessages.length > 0 ? '' : null">
- <summary class="dh-substep__head dh-chat-fold__summary">
- <h4>
- <span class="dh-chat-dot"></span>
- AI 风格助手
- <span class="dh-chat-fold__badge" *ngIf="dhChatMessages.length > 0">{{dhChatMessages.length}}</span>
- </h4>
- <button type="button" class="dh-chip-btn"
- *ngIf="dhChatMessages.length > 0"
- (click)="$event.preventDefault(); $event.stopPropagation(); dhChatClear()">清空</button>
- </summary>
- <p class="dh-chat-fold__desc">用一句话告诉 AI 你想要的语气或风格,它会基于当前口播文本与提示词给出修改建议。</p>
- <div class="dh-chat-quick">
- <button type="button" class="dh-chat-quick__chip" [disabled]="dhChatLoading"
- (click)="dhChatQuickAsk('请把口播文本改得更口语化、更接地气,像在和朋友聊天')">更口语化</button>
- <button type="button" class="dh-chat-quick__chip" [disabled]="dhChatLoading"
- (click)="dhChatQuickAsk('请把口播文本改得更专业、像新闻播报的语气')">更专业</button>
- <button type="button" class="dh-chat-quick__chip" [disabled]="dhChatLoading"
- (click)="dhChatQuickAsk('请把口播文本压缩到 100 字以内,保留核心信息')">压缩到 100 字</button>
- <button type="button" class="dh-chat-quick__chip" [disabled]="dhChatLoading"
- (click)="dhChatQuickAsk('在保持原意的前提下,开头加一句吸引人的钩子')">加钩子开头</button>
- <button type="button" class="dh-chat-quick__chip" [disabled]="dhChatLoading"
- (click)="dhChatQuickAsk('请优化补充提示词,强调自信、亲和的镜头表现')">优化提示词</button>
- </div>
- <div class="dh-chat-list" *ngIf="dhChatMessages.length > 0">
- <ng-container *ngFor="let msg of dhChatMessages; let i = index">
- <div class="dh-chat-msg dh-chat-msg--user" *ngIf="msg.role === 'user'">
- <div class="dh-chat-bubble dh-chat-bubble--user">{{msg.content}}</div>
- </div>
- <div class="dh-chat-msg dh-chat-msg--ai" *ngIf="msg.role === 'assistant'">
- <div class="dh-chat-avatar">AI</div>
- <div class="dh-chat-bubble dh-chat-bubble--ai">
- <div class="dh-chat-bubble__text">{{msg.content}}</div>
- <div class="dh-chat-suggest" *ngIf="msg.suggestedTts">
- <div class="dh-chat-suggest__label">建议口播文本</div>
- <div class="dh-chat-suggest__text">{{msg.suggestedTts}}</div>
- </div>
- <div class="dh-chat-suggest" *ngIf="msg.suggestedPrompt">
- <div class="dh-chat-suggest__label">建议补充提示词</div>
- <div class="dh-chat-suggest__text">{{msg.suggestedPrompt}}</div>
- </div>
- <div class="dh-chat-actions" *ngIf="msg.suggestedTts || msg.suggestedPrompt">
- <button type="button" class="dh-chat-apply" *ngIf="msg.suggestedTts"
- [disabled]="msg.appliedTts" (click)="dhChatApply(i, 'tts')">
- {{msg.appliedTts ? '✓ 口播已应用' : '应用到口播'}}
- </button>
- <button type="button" class="dh-chat-apply" *ngIf="msg.suggestedPrompt"
- [disabled]="msg.appliedPrompt" (click)="dhChatApply(i, 'prompt')">
- {{msg.appliedPrompt ? '✓ 提示词已应用' : '应用到提示词'}}
- </button>
- <button type="button" class="dh-chat-apply dh-chat-apply--primary"
- *ngIf="msg.suggestedTts && msg.suggestedPrompt"
- [disabled]="msg.appliedTts && msg.appliedPrompt"
- (click)="dhChatApply(i, 'both')">全部应用</button>
- </div>
- </div>
- </div>
- </ng-container>
- <div class="dh-chat-msg dh-chat-msg--ai" *ngIf="dhChatLoading">
- <div class="dh-chat-avatar">AI</div>
- <div class="dh-chat-bubble dh-chat-bubble--ai">
- <span class="dh-spinner"></span>
- <span style="margin-left:8px">AI 正在思考...</span>
- </div>
- </div>
- </div>
- <div class="dh-chat-inputbar">
- <textarea class="dh-textarea dh-chat-textarea" rows="2"
- [(ngModel)]="dhChatInput" [disabled]="dhChatLoading"
- placeholder="例如:把语气改得更轻松一点、加一个反问句..."
- (keydown.enter)="!$any($event).shiftKey && ($any($event).preventDefault() || dhChatSend())"></textarea>
- <button type="button" class="dh-cta dh-cta--sm dh-chat-send"
- [disabled]="dhChatLoading || !dhChatInput.trim()"
- (click)="dhChatSend()">
- <span *ngIf="!dhChatLoading">发送</span>
- <span *ngIf="dhChatLoading" class="dh-cta__spinner-row">
- <span class="dh-spinner"></span><span>发送中</span>
- </span>
- </button>
- </div>
- <div class="dh-error" *ngIf="dhChatError">{{dhChatError}}</div>
- </details>
- </div>
- </article>
- <!-- ===================== Step 3: 数字人设置 ===================== -->
- <article class="dh-step-col">
- <header class="dh-step-col__head">
- <span class="dh-step-col__step">Step 3</span>
- <h3 class="dh-step-col__title">数字人设置</h3>
- </header>
- <div class="dh-step-col__body dh-step-col__stack">
- <!-- 形象图上传 -->
- <div class="dh-substep">
- <header class="dh-substep__head">
- <h4>形象图上传 <em>*</em></h4>
- </header>
- <input type="file" id="dhImageInput"
- accept="image/jpeg,image/png,image/jpg,image/gif,.jpg,.jpeg,.png,.gif"
- (change)="onDhImageSelected($event)" hidden [disabled]="dhLoading">
- <label for="dhImageInput" class="dh-image-card dh-image-card--empty" *ngIf="!dhImagePreviewUrl">
- <svg viewBox="0 0 24 24" width="36" height="36" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
- <path d="M22 12h-6l-2 3h-4l-2-3H2"></path>
- <path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"></path>
- </svg>
- <span class="dh-image-card__title">点击上传形象图</span>
- <ul class="dh-image-card__hints">
- <li>支持 JPG / PNG / GIF</li>
- <li>建议清晰单人正面照</li>
- <li>最大 20MB</li>
- </ul>
- </label>
- <div class="dh-image-card dh-image-card--filled" *ngIf="dhImagePreviewUrl">
- <img [src]="dhImagePreviewUrl" [alt]="dhImageName || '数字人形象图'">
- <div class="dh-image-card__meta">
- <div class="dh-image-card__name">{{dhImageName || '未命名形象'}}</div>
- <div class="dh-image-card__size" *ngIf="dhImageFile">{{(dhImageFile.size / 1024 / 1024).toFixed(2)}} MB</div>
- <div class="dh-image-card__actions">
- <label for="dhImageInput" class="dh-chip-btn">更换</label>
- <button type="button" class="dh-chip-btn dh-chip-btn--danger" (click)="clearDhImage()">移除</button>
- </div>
- </div>
- </div>
- <div class="dh-field" style="margin-top:10px">
- <label class="dh-field__label">
- <span>形象名称</span>
- <span class="dh-field__counter">{{(dhImageName || '').length}} / 60</span>
- </label>
- <input class="dh-input" type="text" [(ngModel)]="dhImageName" maxlength="60" placeholder="请输入数字人名称">
- </div>
- <!-- 形象准备:raw / 自拍优化 / 对标账号重塑(仅当上传图后显示) -->
- <div *ngIf="dhImageFile" style="margin-top:14px;padding-top:14px;border-top:1px dashed var(--border-color)">
- <app-portrait-prepare
- [selfieFile]="dhImageFile"
- [selfiePreviewUrl]="dhImagePreviewUrl"
- (prepared)="onDhPortraitPrepared($event)">
- </app-portrait-prepare>
- </div>
- </div>
- <!-- 音频来源 -->
- <div class="dh-substep">
- <header class="dh-substep__head">
- <h4>音频来源 <em>*</em></h4>
- </header>
- <div class="dh-segment">
- <button type="button" class="dh-segment__item" [class.is-active]="dhAudioSource === 'tts'" (click)="dhAudioSource = 'tts'; dhError = ''">语音合成</button>
- <button type="button" class="dh-segment__item" [class.is-active]="dhAudioSource === 'upload'" (click)="dhAudioSource = 'upload'; dhError = ''">提供音频</button>
- </div>
- <ng-container *ngIf="dhAudioSource === 'tts'">
- <div class="dh-field" style="margin-top:10px">
- <label class="dh-field__label"><span>选择声音模型 <em>*</em></span></label>
- <button type="button" class="vs-voice-picker-trigger"
- [class.is-empty]="!dhCurrentVoiceSelection"
- [disabled]="dhLoading"
- (click)="openDhVoicePicker()">
- <ng-container *ngIf="dhCurrentVoiceSelection; else dhVoiceEmpty">
- <span class="vs-voice-picker-avatar"
- [class.vs-voice-picker-avatar--mine]="dhCurrentVoiceSelection.source === 'cloned'">
- {{dhCurrentVoiceSelection.source === 'official' ? '知' : '我'}}
- </span>
- <span class="vs-voice-picker-info">
- <span class="vs-voice-picker-name">{{dhCurrentVoiceSelection.name}}</span>
- <span class="vs-voice-picker-src">
- {{dhCurrentVoiceSelection.source === 'official' ? '声音库' : '我的声音'}}
- </span>
- </span>
- </ng-container>
- <ng-template #dhVoiceEmpty>
- <span class="vs-voice-picker-avatar vs-voice-picker-avatar--empty">+</span>
- <span class="vs-voice-picker-info">
- <span class="vs-voice-picker-name">请选择声音模型</span>
- <span class="vs-voice-picker-src">点击选择声音库 / 我的声音</span>
- </span>
- </ng-template>
- <span class="vs-voice-picker-chevron">›</span>
- </button>
- </div>
- <app-voice-picker
- [open]="dhVoicePickerOpen"
- [clonedList]="vsClonedTimbreList"
- [currentSelection]="dhCurrentVoiceSelection"
- (select)="onDhVoicePicked($event)"
- (close)="closeDhVoicePicker()">
- </app-voice-picker>
- </ng-container>
- <ng-container *ngIf="dhAudioSource === 'upload'">
- <div class="dh-field" style="margin-top:10px">
- <label class="dh-field__label"><span>音频文件 <em>*</em></span></label>
- <input type="file" id="dhAudioInput" accept=".mp3,.wav,.m4a,.ogg,.aac,.flac,.wma,audio/*" (change)="onDhAudioSelected($event)" hidden>
- <label for="dhAudioInput" class="dh-mini-drop" *ngIf="!dhUploadAudioName">
- <span class="dh-mini-drop__title">点击选择音频文件</span>
- <span class="dh-mini-drop__hint">支持 MP3 / WAV / M4A / OGG / AAC / FLAC,最大 50MB</span>
- </label>
- <div class="dh-mini-drop dh-mini-drop--filled" *ngIf="dhUploadAudioName">
- <div>
- <div class="dh-mini-drop__name">{{dhUploadAudioName}}</div>
- <div class="dh-mini-drop__sub" *ngIf="dhUploadAudioFile">{{(dhUploadAudioFile.size / 1024 / 1024).toFixed(2)}} MB</div>
- </div>
- <div class="dh-mini-drop__actions">
- <label for="dhAudioInput" class="dh-chip-btn">更换</label>
- <button type="button" class="dh-chip-btn dh-chip-btn--danger" (click)="clearDhAudio()">移除</button>
- </div>
- </div>
- </div>
- </ng-container>
- </div>
- <!-- 输出设置 -->
- <div class="dh-substep">
- <header class="dh-substep__head">
- <h4>输出设置</h4>
- </header>
- <div class="dh-field">
- <label class="dh-field__label"><span>分辨率</span></label>
- <div class="dh-segment">
- <button type="button" class="dh-segment__item" [class.is-active]="dhResolution === '720p'" (click)="dhResolution = '720p'">720P</button>
- <button type="button" class="dh-segment__item" [class.is-active]="dhResolution === '1080p'" (click)="dhResolution = '1080p'">1080P</button>
- </div>
- </div>
- <label class="dh-toggle" style="margin-top:10px">
- <input type="checkbox" [(ngModel)]="dhFastMode">
- <span class="dh-toggle__track"><span class="dh-toggle__thumb"></span></span>
- <span class="dh-toggle__label">快速模式<small>720P 自动启用</small></span>
- </label>
- </div>
- </div>
- </article>
- <!-- ===================== Step 4: 视频预览 ===================== -->
- <article class="dh-step-col dh-step-col--preview">
- <header class="dh-step-col__head">
- <span class="dh-step-col__step">Step 4</span>
- <h3 class="dh-step-col__title">视频预览</h3>
- </header>
- <div class="dh-step-col__body dh-step-col__stack">
- <!-- 预览主区 -->
- <div class="dh-preview-stage">
- <!-- 空状态 -->
- <div class="dh-preview-stage__empty" *ngIf="dhStep === 'form' && !dhResultVideoUrl">
- <div class="dh-preview-stage__icon">
- <svg viewBox="0 0 24 24" width="36" height="36" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
- <polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon>
- </svg>
- </div>
- <p class="dh-preview-stage__title">完成以上步骤后</p>
- <p class="dh-preview-stage__sub">点击下方按钮即可生成数字人视频</p>
- </div>
- <!-- 生成中 -->
- <div class="dh-preview-stage__loading" *ngIf="dhStep === 'generating'">
- <span class="dh-spinner dh-spinner--lg"></span>
- <p class="dh-preview-stage__title">{{dhStatusText || '正在生成...'}}</p>
- <div class="dh-progress-bar"><div class="dh-progress-bar__fill" [style.width]="dhProgress + '%'"></div></div>
- <p class="dh-preview-stage__sub">{{dhProgress | number:'1.0-0'}}%</p>
- </div>
- <!-- 完成 -->
- <div class="dh-preview-stage__done" *ngIf="dhStep === 'done' && dhResultVideoUrl">
- <video controls [src]="dhResultVideoUrl"></video>
- </div>
- <!-- 失败 -->
- <div class="dh-preview-stage__error" *ngIf="dhStep === 'done' && !dhResultVideoUrl && dhError">
- <span class="dh-preview-stage__cross">!</span>
- <p class="dh-preview-stage__title">生成失败</p>
- <p class="dh-preview-stage__sub">{{dhError}}</p>
- </div>
- </div>
- <!-- 主 CTA -->
- <button type="button" class="dh-cta dh-cta--xl"
- *ngIf="dhStep !== 'done' || !dhResultVideoUrl"
- (click)="dhStartGeneration()"
- [disabled]="dhLoading || dhStep === 'generating'">
- <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
- <polygon points="5 3 19 12 5 21 5 3"></polygon>
- </svg>
- <span>{{dhStep === 'generating' ? '生成中...' : '立即生成'}}</span>
- </button>
- <div class="dh-error" *ngIf="dhError && dhStep !== 'done'">{{dhError}}</div>
- <!-- 步骤状态清单 -->
- <ul class="dh-checklist">
- <li class="dh-checklist__item" [class.is-done]="!!dhRefTranscript">
- <span class="dh-checklist__dot"></span>
- <span class="dh-checklist__label">分析参考视频</span>
- <span class="dh-checklist__status">{{dhRefTranscript ? '完成' : (dhRefAnalyzing ? '进行中' : '待处理')}}</span>
- </li>
- <li class="dh-checklist__item" [class.is-done]="!!(dhTtsText && dhTtsText.trim())">
- <span class="dh-checklist__dot"></span>
- <span class="dh-checklist__label">提取口播内容</span>
- <span class="dh-checklist__status">{{dhTtsText && dhTtsText.trim() ? '完成' : '待处理'}}</span>
- </li>
- <li class="dh-checklist__item"
- [class.is-done]="dhProgress >= 25 && dhStep !== 'form'"
- [class.is-active]="dhSynthesizing || (dhAudioSource === 'upload' && dhUploadingAudio)">
- <span class="dh-checklist__dot"></span>
- <span class="dh-checklist__label">音频合成中</span>
- <span class="dh-checklist__status">{{(dhSynthesizing || dhUploadingAudio) ? '进行中' : (dhProgress >= 25 ? '完成' : '待处理')}}</span>
- </li>
- <li class="dh-checklist__item"
- [class.is-done]="dhStep === 'done' && !!dhResultVideoUrl"
- [class.is-active]="dhStep === 'generating' && dhProgress >= 50">
- <span class="dh-checklist__dot"></span>
- <span class="dh-checklist__label">渲染数字人</span>
- <span class="dh-checklist__status">{{dhStep === 'done' && dhResultVideoUrl ? '完成' : (dhStep === 'generating' && dhProgress >= 50 ? '进行中' : '待处理')}}</span>
- </li>
- <li class="dh-checklist__item" [class.is-done]="dhStep === 'done' && !!dhResultVideoUrl">
- <span class="dh-checklist__dot"></span>
- <span class="dh-checklist__label">视频生成导出</span>
- <span class="dh-checklist__status">{{dhStep === 'done' && dhResultVideoUrl ? '完成' : '待处理'}}</span>
- </li>
- </ul>
- <!-- 完成后的操作栏 -->
- <div class="dh-preview-actions" *ngIf="dhStep === 'done' && dhResultVideoUrl">
- <a [href]="dhResultVideoUrl" download class="dh-preview-action">
- <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
- <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
- <polyline points="7 10 12 15 17 10"></polyline>
- <line x1="12" y1="15" x2="12" y2="3"></line>
- </svg>
- <span>下载视频</span>
- </a>
- <a [href]="dhResultVideoUrl" target="_blank" class="dh-preview-action">
- <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
- <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>
- <polyline points="15 3 21 3 21 9"></polyline>
- <line x1="10" y1="14" x2="21" y2="3"></line>
- </svg>
- <span>新窗口播放</span>
- </a>
- <button type="button" class="dh-preview-action" (click)="dhReset()">
- <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
- <polyline points="23 4 23 10 17 10"></polyline>
- <path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path>
- </svg>
- <span>重新生成</span>
- </button>
- </div>
- </div>
- </article>
- </div>
- <!-- 我的形象库 -->
- <section class="dh-gallery">
- <header class="dh-gallery__header">
- <div>
- <h2>我的形象库</h2>
- <p>查看您创建的所有数字人作品</p>
- </div>
- <button type="button" class="dh-refresh-btn" (click)="refreshDigitalHumanGallery()">
- <span class="dh-refresh-icon">↻</span>
- <span>刷新</span>
- </button>
- </header>
- <div class="dh-gallery__empty" *ngIf="!getDigitalHumanResults().length">
- <div class="dh-gallery__empty-icon">🎬</div>
- <p>还没有数字人作品,先在上方创建一个吧</p>
- </div>
- <div class="dh-gallery__grid" *ngIf="getDigitalHumanResults().length">
- <article class="dh-gallery__card" *ngFor="let r of getDigitalHumanResults()">
- <div class="dh-gallery__media">
- <video [src]="r.url" preload="metadata" muted playsinline></video>
- <a [href]="r.url" target="_blank" class="dh-gallery__play" title="播放">▶</a>
- </div>
- <div class="dh-gallery__body">
- <div class="dh-gallery__title-row">
- <h4 class="dh-gallery__title" [title]="r.title">{{r.title}}</h4>
- <div class="dh-gallery__icons">
- <a [href]="r.url" download class="dh-gallery__icon-btn" title="下载">⬇</a>
- <button type="button" class="dh-gallery__icon-btn dh-gallery__icon-btn--danger" (click)="removeResult(r.id)" title="删除">🗑</button>
- </div>
- </div>
- <div class="dh-gallery__info">
- <span>{{r.quality || '—'}}</span>
- <span>{{r.created_at | date:'yyyy/M/d HH:mm:ss'}}</span>
- <span class="dh-gallery__chip">MP4</span>
- </div>
- </div>
- </article>
- </div>
- </section>
- </section>
- <!-- 视频生成工作流模块 -->
- <section class="dh-page vg-page" *ngIf="false && currentTab === 'video-generation'">
- <header class="dh-page-header">
- <div class="dh-header-icon">
- <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
- <polygon points="23 7 16 12 23 17 23 7"></polygon>
- <rect x="1" y="5" width="15" height="14" rx="2" ry="2"></rect>
- </svg>
- </div>
- <div class="dh-header-text">
- <h1>视频生成工作流</h1>
- <p>上传视频 → 拆解分析 → 生成脚本 → AI 配图 → 语音配音 → 视频合成</p>
- </div>
- </header>
- <!-- 步骤进度条 -->
- <div class="vg-step-bar">
- <ng-container *ngFor="let label of vgStepLabels; let i = index">
- <div class="vg-step-item"
- [class.is-active]="vgStep === i + 1"
- [class.is-done]="vgStep > i + 1">
- <div class="vg-step-badge">{{vgStep > i + 1 ? '✓' : i + 1}}</div>
- <span class="vg-step-label">{{label}}</span>
- </div>
- <div *ngIf="i < vgStepLabels.length - 1"
- class="vg-step-connector"
- [class.is-done]="vgStep > i + 1"></div>
- </ng-container>
- </div>
- <!-- 错误提示 -->
- <div *ngIf="vgError" style="margin-bottom:16px;padding:12px 16px;background:rgba(244,67,54,0.08);border-radius:8px;color:var(--error-color);font-size:13px;display:flex;align-items:center;gap:8px">
- <span class="icon" style="font-size:16px">error</span>
- {{vgError}}
- <button class="btn btn-sm" style="margin-left:auto" (click)="vgError = ''">
- <span class="icon" style="font-size:14px">close</span>
- </button>
- </div>
- <!-- ===== Step 1: 上传视频 ===== -->
- <section class="vg-step-card" [attr.data-state]="vgGetStepState(1)" [class.is-collapsed]="!vgIsStepExpanded(1)">
- <header class="vg-step-card__header" (click)="vgToggleStepExpand(1)">
- <div class="vg-step-card__num">{{vgStep > 1 ? '✓' : '1'}}</div>
- <div class="vg-step-card__meta">
- <h3>{{vgStepLabels[0]}}</h3>
- <p>{{vgStepSubtitles[0]}}</p>
- </div>
- <div class="vg-step-card__chip" [attr.data-state]="vgGetStepState(1)">{{ vgGetStepState(1) === 'done' ? '已完成' : vgGetStepState(1) === 'active' ? '进行中' : '待解锁' }}</div>
- <button type="button" class="vg-step-card__edit" *ngIf="vgGetStepState(1) === 'done'" (click)="$event.stopPropagation(); vgEditStep(1)" title="返回此步骤编辑">编辑</button>
- <button type="button" class="vg-step-card__toggle" [class.is-disabled]="vgGetStepState(1) !== 'done'" tabindex="-1" aria-label="展开/折叠">
- <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"></polyline></svg>
- </button>
- </header>
- <div class="vg-step-card__body" *ngIf="vgIsStepExpanded(1)">
- <div style="border:2px dashed var(--border-color);border-radius:12px;padding:40px;text-align:center;background:var(--bg-secondary);position:relative"
- [style.borderColor]="vgSourceFile ? 'var(--accent-color)' : 'var(--border-color)'">
- <input type="file" accept="video/*" (change)="vgSelectFile($event)"
- style="position:absolute;inset:0;opacity:0;cursor:pointer">
- <div *ngIf="!vgSourceFile">
- <svg viewBox="0 0 24 24" width="48" height="48" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"
- style="display:block;margin:0 auto 12px;color:var(--text-secondary)">
- <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
- <polyline points="17 8 12 3 7 8"></polyline>
- <line x1="12" y1="3" x2="12" y2="15"></line>
- </svg>
- <p style="margin:0;font-size:15px;color:var(--text-primary)">点击或拖拽上传视频文件</p>
- <p style="margin:6px 0 0;font-size:12px;color:var(--text-secondary)">支持 MP4、MOV、AVI、WebM 等格式</p>
- </div>
- <div *ngIf="vgSourceFile" style="display:flex;align-items:center;gap:16px;justify-content:center">
- <span class="icon" style="font-size:36px;color:var(--accent-color)">movie</span>
- <div style="text-align:left">
- <p style="margin:0;font-size:14px;font-weight:500">{{vgSourceFileName}}</p>
- <p style="margin:4px 0 0;font-size:12px;color:var(--text-secondary)">
- {{(vgSourceFile.size / 1024 / 1024).toFixed(1)}} MB
- </p>
- </div>
- </div>
- </div>
- <!-- 视频预览 -->
- <div *ngIf="vgSourceVideoUrl" style="margin-top:16px;text-align:center">
- <video [src]="vgSourceVideoUrl" controls
- style="max-width:100%;max-height:300px;border-radius:8px;background:#000"></video>
- </div>
- <!-- 上传进度 -->
- <div *ngIf="vgUploading" style="margin-top:16px">
- <div style="display:flex;justify-content:space-between;margin-bottom:6px">
- <span style="font-size:13px">正在上传...</span>
- <span style="font-size:13px;font-weight:600;color:var(--accent-color)">{{vgUploadProgress}}%</span>
- </div>
- <div class="progress-bar" style="height:6px">
- <div class="progress-fill" [style.width]="vgUploadProgress + '%'"></div>
- </div>
- </div>
- <div style="margin-top:20px;display:flex;justify-content:flex-end">
- <button class="btn btn-primary" (click)="vgUploadAndAnalyze()"
- [disabled]="!vgSourceFile || vgUploading">
- <span class="icon">{{vgUploading ? 'hourglass_empty' : 'upload'}}</span>
- {{vgUploading ? '上传中...' : '上传并开始分析'}}
- </button>
- </div>
- </div>
- </section>
- <!-- ===== Step 2: 视频解析 ===== -->
- <section class="vg-step-card" [attr.data-state]="vgGetStepState(2)" [class.is-collapsed]="!vgIsStepExpanded(2)">
- <header class="vg-step-card__header" (click)="vgToggleStepExpand(2)">
- <div class="vg-step-card__num">{{vgStep > 2 ? '✓' : '2'}}</div>
- <div class="vg-step-card__meta">
- <h3>{{vgStepLabels[1]}}</h3>
- <p>{{vgStepSubtitles[1]}}</p>
- </div>
- <div class="vg-step-card__chip" [attr.data-state]="vgGetStepState(2)">{{ vgGetStepState(2) === 'done' ? '已完成' : vgGetStepState(2) === 'active' ? '进行中' : '待解锁' }}</div>
- <button type="button" class="vg-step-card__edit" *ngIf="vgGetStepState(2) === 'done'" (click)="$event.stopPropagation(); vgEditStep(2)" title="返回此步骤编辑">编辑</button>
- <button type="button" class="vg-step-card__toggle" [class.is-disabled]="vgGetStepState(2) !== 'done'" tabindex="-1" aria-label="展开/折叠">
- <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"></polyline></svg>
- </button>
- </header>
- <div class="vg-step-card__body" *ngIf="vgIsStepExpanded(2)">
- <!-- 解析状态 -->
- <div *ngIf="vgAnalyzing" style="padding:24px;text-align:center;background:var(--bg-secondary);border-radius:10px;margin-bottom:16px">
- <div class="spinner" style="margin:0 auto 12px"></div>
- <p style="margin:0;color:var(--text-primary)">{{vgAnalysisText}}</p>
- </div>
- <!-- AI分析结果 -->
- <div *ngIf="!vgAnalyzing && vgAnalysisText" style="padding:16px;background:var(--bg-secondary);border-radius:10px;margin-bottom:16px;border:1px solid var(--border-color)">
- <h4 style="margin:0 0 10px;display:flex;align-items:center;gap:6px">
- <span class="icon" style="color:var(--accent-color)">smart_toy</span> AI 内容分析
- </h4>
- <pre style="margin:0;white-space:pre-wrap;font-size:13px;line-height:1.6;color:var(--text-primary);font-family:inherit">{{vgAnalysisText}}</pre>
- </div>
- <!-- 转录文字稿 -->
- <div style="margin-bottom:16px">
- <label style="display:block;font-size:13px;font-weight:500;margin-bottom:6px">
- <span class="icon" style="font-size:14px;vertical-align:middle">description</span> 转录文字稿
- </label>
- <textarea [(ngModel)]="vgTranscript" rows="8"
- placeholder="视频转录文字稿将在此显示,也可手动输入或修改..."
- style="width:100%;resize:vertical;min-height:120px;border:1px solid var(--border-color);border-radius:8px;padding:12px;font-size:13px;line-height:1.6;background:var(--bg-primary);color:var(--text-primary)"></textarea>
- </div>
- <div style="display:flex;justify-content:space-between">
- <button class="btn" (click)="vgPrevStep()">
- <span class="icon">arrow_back</span> 上一步
- </button>
- <button class="btn btn-primary" (click)="vgConfirmAnalysis()"
- [disabled]="!vgTranscript.trim() || vgAnalyzing">
- <span class="icon">arrow_forward</span> 确认并生成脚本
- </button>
- </div>
- </div>
- </section>
- <!-- ===== Step 3: 脚本生成 ===== -->
- <section class="vg-step-card" [attr.data-state]="vgGetStepState(3)" [class.is-collapsed]="!vgIsStepExpanded(3)">
- <header class="vg-step-card__header" (click)="vgToggleStepExpand(3)">
- <div class="vg-step-card__num">{{vgStep > 3 ? '✓' : '3'}}</div>
- <div class="vg-step-card__meta">
- <h3>{{vgStepLabels[2]}}</h3>
- <p>{{vgStepSubtitles[2]}}</p>
- </div>
- <div class="vg-step-card__chip" [attr.data-state]="vgGetStepState(3)">{{ vgGetStepState(3) === 'done' ? '已完成' : vgGetStepState(3) === 'active' ? '进行中' : '待解锁' }}</div>
- <button type="button" class="vg-step-card__edit" *ngIf="vgGetStepState(3) === 'done'" (click)="$event.stopPropagation(); vgEditStep(3)" title="返回此步骤编辑">编辑</button>
- <button type="button" class="vg-step-card__toggle" [class.is-disabled]="vgGetStepState(3) !== 'done'" tabindex="-1" aria-label="展开/折叠">
- <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"></polyline></svg>
- </button>
- </header>
- <div class="vg-step-card__body" *ngIf="vgIsStepExpanded(3)">
- <div *ngIf="vgGeneratingScript" style="padding:24px;text-align:center;background:var(--bg-secondary);border-radius:10px;margin-bottom:16px">
- <div class="spinner" style="margin:0 auto 12px"></div>
- <p style="margin:0;color:var(--text-primary)">AI 正在生成分镜脚本...</p>
- </div>
- <!-- 脚本片段列表 -->
- <div *ngIf="!vgGeneratingScript && vgScriptSegments.length > 0" style="display:flex;flex-direction:column;gap:12px;margin-bottom:16px">
- <div *ngFor="let seg of vgScriptSegments; let i = index"
- style="padding:14px;background:var(--bg-secondary);border-radius:10px;border:1px solid var(--border-color)">
- <div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
- <span style="background:var(--accent-color);color:#fff;padding:2px 10px;border-radius:10px;font-size:12px;font-weight:600">
- {{seg.id}}
- </span>
- </div>
- <div style="margin-bottom:8px">
- <label style="font-size:11px;color:var(--text-secondary);display:block;margin-bottom:4px">旁白文本</label>
- <textarea [(ngModel)]="seg.narration" rows="2"
- style="width:100%;resize:vertical;border:1px solid var(--border-color);border-radius:6px;padding:8px;font-size:13px;background:var(--bg-primary);color:var(--text-primary)"></textarea>
- </div>
- <div>
- <label style="font-size:11px;color:var(--text-secondary);display:block;margin-bottom:4px">配图提示词(中文,用于 AI 绘画)</label>
- <textarea [(ngModel)]="seg.imagePrompt" rows="2"
- style="width:100%;resize:vertical;border:1px solid var(--border-color);border-radius:6px;padding:8px;font-size:12px;font-family:monospace;background:var(--bg-primary);color:var(--text-primary)"></textarea>
- </div>
- <div *ngIf="seg.imagePromptCn" style="margin-top:6px">
- <label style="font-size:11px;color:var(--text-secondary);display:block;margin-bottom:4px">配图中文说明</label>
- <div style="padding:8px;background:var(--bg-primary);border:1px solid var(--border-color);border-radius:6px;font-size:12px;color:var(--text-secondary)">{{seg.imagePromptCn}}</div>
- </div>
- </div>
- </div>
- <div style="display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px">
- <button class="btn" (click)="vgPrevStep()">
- <span class="icon">arrow_back</span> 上一步
- </button>
- <div style="display:flex;gap:8px">
- <button class="btn" (click)="vgRegenerateScript()" [disabled]="vgGeneratingScript">
- <span class="icon">refresh</span> 重新生成
- </button>
- <button class="btn btn-primary" (click)="vgConfirmScript()"
- [disabled]="vgScriptSegments.length === 0 || vgGeneratingScript">
- <span class="icon">arrow_forward</span> 确认并配图
- </button>
- </div>
- </div>
- </div>
- </section>
- <!-- ===== Step 4: 配图生成 ===== -->
- <section class="vg-step-card" [attr.data-state]="vgGetStepState(4)" [class.is-collapsed]="!vgIsStepExpanded(4)">
- <header class="vg-step-card__header" (click)="vgToggleStepExpand(4)">
- <div class="vg-step-card__num">{{vgStep > 4 ? '✓' : '4'}}</div>
- <div class="vg-step-card__meta">
- <h3>{{vgStepLabels[3]}}</h3>
- <p>{{vgStepSubtitles[3]}}</p>
- </div>
- <div class="vg-step-card__chip" [attr.data-state]="vgGetStepState(4)">{{ vgGetStepState(4) === 'done' ? '已完成' : vgGetStepState(4) === 'active' ? '进行中' : '待解锁' }}</div>
- <button type="button" class="vg-step-card__edit" *ngIf="vgGetStepState(4) === 'done'" (click)="$event.stopPropagation(); vgEditStep(4)" title="返回此步骤编辑">编辑</button>
- <button type="button" class="vg-step-card__toggle" [class.is-disabled]="vgGetStepState(4) !== 'done'" tabindex="-1" aria-label="展开/折叠">
- <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"></polyline></svg>
- </button>
- </header>
- <div class="vg-step-card__body" *ngIf="vgIsStepExpanded(4)">
- <!-- 配图进度 -->
- <div *ngIf="vgGeneratingImages" style="margin-bottom:16px">
- <div style="display:flex;justify-content:space-between;margin-bottom:6px">
- <span style="font-size:13px">正在生成配图...</span>
- <span style="font-size:13px;font-weight:600;color:var(--accent-color)">{{vgImageProgress}}%</span>
- </div>
- <div class="progress-bar" style="height:6px">
- <div class="progress-fill" [style.width]="vgImageProgress + '%'"></div>
- </div>
- </div>
- <!-- 图片网格 -->
- <div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:12px;margin-bottom:16px">
- <div *ngFor="let img of vgImageResults"
- style="background:var(--bg-secondary);border-radius:10px;overflow:hidden;border:1px solid var(--border-color)">
- <div style="aspect-ratio:16/9;display:flex;align-items:center;justify-content:center;background:var(--bg-tertiary)">
- <img *ngIf="img.imageUrl" [src]="img.imageUrl" style="width:100%;height:100%;object-fit:cover">
- <div *ngIf="!img.imageUrl && img.status === 'generating'" class="spinner"></div>
- <span *ngIf="!img.imageUrl && img.status === 'pending'" class="icon" style="font-size:32px;color:var(--text-secondary)">image</span>
- <span *ngIf="!img.imageUrl && img.status === 'failed'" class="icon" style="font-size:32px;color:var(--error-color)">broken_image</span>
- </div>
- <div style="padding:8px">
- <span style="font-size:12px;font-weight:600">{{img.id}}</span>
- <span style="font-size:11px;margin-left:6px;padding:2px 6px;border-radius:4px"
- [style.background]="img.status === 'done' ? 'rgba(76,175,80,0.15)' : img.status === 'failed' ? 'rgba(244,67,54,0.15)' : img.status === 'generating' ? 'rgba(33,150,243,0.15)' : 'var(--bg-tertiary)'"
- [style.color]="img.status === 'done' ? 'var(--success-color)' : img.status === 'failed' ? 'var(--error-color)' : 'var(--text-secondary)'">
- {{img.status === 'done' ? '完成' : img.status === 'failed' ? '失败' : img.status === 'generating' ? '生成中' : '待生成'}}
- </span>
- </div>
- </div>
- </div>
- <div style="display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px">
- <button class="btn" (click)="vgPrevStep()">
- <span class="icon">arrow_back</span> 上一步
- </button>
- <div style="display:flex;gap:8px">
- <button class="btn btn-primary" (click)="vgGenerateImages()"
- [disabled]="vgGeneratingImages"
- *ngIf="vgImageResults.some(r => r.status === 'pending')">
- <span class="icon">auto_fix_high</span>
- {{vgGeneratingImages ? '生成中...' : '开始生成配图'}}
- </button>
- <button class="btn btn-primary" (click)="vgConfirmImages()"
- [disabled]="vgGeneratingImages">
- <span class="icon">arrow_forward</span> 下一步:配音
- </button>
- </div>
- </div>
- </div>
- </section>
- <!-- ===== Step 5: 语音配音 ===== -->
- <section class="vg-step-card" [attr.data-state]="vgGetStepState(5)" [class.is-collapsed]="!vgIsStepExpanded(5)">
- <header class="vg-step-card__header" (click)="vgToggleStepExpand(5)">
- <div class="vg-step-card__num">{{vgStep > 5 ? '✓' : '5'}}</div>
- <div class="vg-step-card__meta">
- <h3>{{vgStepLabels[4]}}</h3>
- <p>{{vgStepSubtitles[4]}}</p>
- </div>
- <div class="vg-step-card__chip" [attr.data-state]="vgGetStepState(5)">{{ vgGetStepState(5) === 'done' ? '已完成' : vgGetStepState(5) === 'active' ? '进行中' : '待解锁' }}</div>
- <button type="button" class="vg-step-card__edit" *ngIf="vgGetStepState(5) === 'done'" (click)="$event.stopPropagation(); vgEditStep(5)" title="返回此步骤编辑">编辑</button>
- <button type="button" class="vg-step-card__toggle" [class.is-disabled]="vgGetStepState(5) !== 'done'" tabindex="-1" aria-label="展开/折叠">
- <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"></polyline></svg>
- </button>
- </header>
- <div class="vg-step-card__body" *ngIf="vgIsStepExpanded(5)">
- <!-- 声音模型选择 -->
- <div style="margin-bottom:20px;padding:16px;background:var(--bg-secondary);border-radius:10px;border:1px solid var(--border-color)">
- <div class="form-field">
- <label>选择声音模型</label>
- <button type="button" class="vs-voice-picker-trigger"
- [class.is-empty]="!vgCurrentVoiceSelection"
- [disabled]="vgSynthesizing"
- (click)="openVgVoicePicker()">
- <ng-container *ngIf="vgCurrentVoiceSelection; else vgVoiceEmpty">
- <span class="vs-voice-picker-avatar"
- [class.vs-voice-picker-avatar--mine]="vgCurrentVoiceSelection.source === 'cloned'">
- {{vgCurrentVoiceSelection.source === 'official' ? '知' : '我'}}
- </span>
- <span class="vs-voice-picker-info">
- <span class="vs-voice-picker-name">{{vgCurrentVoiceSelection.name}}</span>
- <span class="vs-voice-picker-src">
- {{vgCurrentVoiceSelection.source === 'official' ? '声音库' : '我的声音'}}
- </span>
- </span>
- </ng-container>
- <ng-template #vgVoiceEmpty>
- <span class="vs-voice-picker-avatar vs-voice-picker-avatar--empty">+</span>
- <span class="vs-voice-picker-info">
- <span class="vs-voice-picker-name">请选择声音模型</span>
- <span class="vs-voice-picker-src">点击选择声音库 / 我的声音</span>
- </span>
- </ng-template>
- <span class="vs-voice-picker-chevron">›</span>
- </button>
- <app-voice-picker
- [open]="vgVoicePickerOpen"
- [clonedList]="vsClonedTimbreList"
- [currentSelection]="vgCurrentVoiceSelection"
- (select)="onVgVoicePicked($event)"
- (close)="closeVgVoicePicker()">
- </app-voice-picker>
- </div>
- </div>
- <!-- 旁白列表预览 -->
- <div style="margin-bottom:16px">
- <h4 style="margin:0 0 10px;font-size:14px">待配音片段 ({{vgScriptSegments.length}})</h4>
- <div style="display:flex;flex-direction:column;gap:8px;max-height:300px;overflow-y:auto">
- <div *ngFor="let seg of vgScriptSegments; let i = index"
- style="display:flex;align-items:center;gap:10px;padding:10px;background:var(--bg-secondary);border-radius:8px;font-size:13px">
- <span style="background:var(--accent-color);color:#fff;padding:2px 8px;border-radius:8px;font-size:11px;font-weight:600;flex-shrink:0">{{seg.id}}</span>
- <span style="flex:1;color:var(--text-primary)">{{seg.narration}}</span>
- <span *ngIf="vgAudioUrls.length > i" class="icon" style="color:var(--success-color);flex-shrink:0">check_circle</span>
- </div>
- </div>
- </div>
- <!-- 配音进度 -->
- <div *ngIf="vgSynthesizing" style="margin-bottom:16px;padding:16px;background:var(--bg-secondary);border-radius:10px;text-align:center">
- <div class="spinner" style="margin:0 auto 8px"></div>
- <p style="margin:0;font-size:13px">正在逐段合成语音... ({{vgAudioUrls.length}}/{{vgScriptSegments.length}})</p>
- </div>
- <!-- 已合成音频试听 -->
- <div *ngIf="vgAudioUrls.length > 0 && !vgSynthesizing" style="margin-bottom:16px;padding:12px;background:var(--bg-secondary);border-radius:10px">
- <h4 style="margin:0 0 8px;font-size:13px">已合成 {{vgAudioUrls.length}} 段音频</h4>
- <div style="display:flex;flex-wrap:wrap;gap:8px">
- <div *ngFor="let au of vgAudioUrls" style="display:flex;align-items:center;gap:6px">
- <span style="font-size:11px;font-weight:600">{{au.segId}}</span>
- <audio [src]="au.audioUrl" controls style="height:28px"></audio>
- </div>
- </div>
- </div>
- <div style="display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px">
- <button class="btn" (click)="vgPrevStep()">
- <span class="icon">arrow_back</span> 上一步
- </button>
- <div style="display:flex;gap:8px">
- <button class="btn btn-primary" (click)="vgSynthesizeAll()"
- [disabled]="vgSynthesizing || !vgCurrentVoiceSelection">
- <span class="icon">{{vgSynthesizing ? 'hourglass_empty' : 'mic'}}</span>
- {{vgSynthesizing ? '合成中...' : (vgAudioUrls.length > 0 ? '重新合成' : '开始配音')}}
- </button>
- <button class="btn btn-primary" (click)="vgConfirmVoice()" [disabled]="vgSynthesizing">
- <span class="icon">arrow_forward</span> 下一步:视频合成
- </button>
- </div>
- </div>
- </div>
- </section>
- <!-- ===== Step 6: 视频合成 / 结果汇总 ===== -->
- <section class="vg-step-card" [attr.data-state]="vgGetStepState(6)" [class.is-collapsed]="!vgIsStepExpanded(6)">
- <header class="vg-step-card__header" (click)="vgToggleStepExpand(6)">
- <div class="vg-step-card__num">{{vgStep > 6 ? '✓' : '6'}}</div>
- <div class="vg-step-card__meta">
- <h3>{{vgStepLabels[5]}}</h3>
- <p>{{vgStepSubtitles[5]}}</p>
- </div>
- <div class="vg-step-card__chip" [attr.data-state]="vgGetStepState(6)">{{ vgGetStepState(6) === 'done' ? '已完成' : vgGetStepState(6) === 'active' ? '进行中' : '待解锁' }}</div>
- <button type="button" class="vg-step-card__toggle" [class.is-disabled]="vgGetStepState(6) !== 'done'" tabindex="-1" aria-label="展开/折叠">
- <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"></polyline></svg>
- </button>
- </header>
- <div class="vg-step-card__body" *ngIf="vgIsStepExpanded(6)">
- <!-- 合成进度 -->
- <div *ngIf="vgCompositing" style="margin-bottom:20px">
- <div style="display:flex;justify-content:space-between;margin-bottom:6px">
- <span style="font-size:13px">正在合成视频(下载素材 → 逐段合成 → 拼接)...</span>
- <span style="font-size:13px;font-weight:600;color:var(--accent-color)">{{vgCompositingProgress}}%</span>
- </div>
- <div class="progress-bar" style="height:6px">
- <div class="progress-fill" [style.width]="vgCompositingProgress + '%'"></div>
- </div>
- </div>
- <!-- 合成完成:视频播放器 -->
- <div *ngIf="vgFinalVideoUrl" style="margin-bottom:20px;padding:20px;background:var(--bg-secondary);border-radius:12px;border:2px solid var(--success-color)">
- <div style="display:flex;align-items:center;gap:8px;margin-bottom:12px">
- <span class="icon" style="color:var(--success-color);font-size:24px">check_circle</span>
- <h4 style="margin:0;font-size:16px">视频合成完成</h4>
- </div>
- <video controls [src]="vgFinalVideoUrl" style="width:100%;max-height:500px;border-radius:8px;background:#000" preload="metadata"></video>
- <div style="display:flex;gap:8px;margin-top:12px">
- <a [href]="vgFinalVideoUrl" download class="btn btn-primary" style="text-decoration:none">
- <span class="icon">download</span> 下载视频
- </a>
- </div>
- </div>
- <!-- 合成错误提示 -->
- <div *ngIf="vgError && !vgCompositing" style="margin-bottom:16px;padding:12px 16px;background:rgba(244,67,54,0.1);border:1px solid rgba(244,67,54,0.3);border-radius:8px;color:var(--error-color);font-size:13px">
- <span class="icon" style="vertical-align:middle;margin-right:4px">error</span> {{vgError}}
- </div>
- <!-- 结果汇总卡片 -->
- <div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:16px;margin-bottom:20px">
- <!-- 脚本统计 -->
- <div style="padding:16px;background:var(--bg-secondary);border-radius:10px;border:1px solid var(--border-color)">
- <div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
- <span class="icon" style="color:var(--accent-color)">auto_stories</span>
- <strong>分镜脚本</strong>
- </div>
- <p style="margin:0;font-size:24px;font-weight:600">{{vgScriptSegments.length}}</p>
- <p style="margin:2px 0 0;font-size:12px;color:var(--text-secondary)">个场景片段</p>
- </div>
- <!-- 配图统计 -->
- <div style="padding:16px;background:var(--bg-secondary);border-radius:10px;border:1px solid var(--border-color)">
- <div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
- <span class="icon" style="color:#4CAF50">image</span>
- <strong>AI 配图</strong>
- </div>
- <p style="margin:0;font-size:24px;font-weight:600">
- {{vgImageResults.filter(r => r.status === 'done').length}} / {{vgImageResults.length}}
- </p>
- <p style="margin:2px 0 0;font-size:12px;color:var(--text-secondary)">张图片已生成</p>
- </div>
- <!-- 配音统计 -->
- <div style="padding:16px;background:var(--bg-secondary);border-radius:10px;border:1px solid var(--border-color)">
- <div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
- <span class="icon" style="color:#FF9800">record_voice_over</span>
- <strong>语音配音</strong>
- </div>
- <p style="margin:0;font-size:24px;font-weight:600">
- {{vgAudioUrls.length}} / {{vgScriptSegments.length}}
- </p>
- <p style="margin:2px 0 0;font-size:12px;color:var(--text-secondary)">段音频已合成</p>
- </div>
- </div>
- <!-- 素材详情列表 -->
- <div style="margin-bottom:20px">
- <h4 style="margin:0 0 12px;font-size:14px">素材详情</h4>
- <div style="display:flex;flex-direction:column;gap:10px">
- <div *ngFor="let seg of vgScriptSegments; let i = index"
- style="display:flex;gap:12px;padding:12px;background:var(--bg-secondary);border-radius:10px;border:1px solid var(--border-color)">
- <div style="flex-shrink:0;width:120px;height:120px;border-radius:8px;overflow:hidden;background:var(--bg-tertiary);display:flex;align-items:center;justify-content:center">
- <img *ngIf="vgImageResults[i]?.imageUrl" [src]="vgImageResults[i]?.imageUrl"
- style="width:100%;height:100%;object-fit:cover">
- <span *ngIf="!vgImageResults[i]?.imageUrl" class="icon" style="font-size:28px;color:var(--text-secondary)">image</span>
- </div>
- <div style="flex:1;min-width:0">
- <div style="display:flex;align-items:center;gap:6px;margin-bottom:6px">
- <span style="background:var(--accent-color);color:#fff;padding:2px 8px;border-radius:8px;font-size:11px;font-weight:600">{{seg.id}}</span>
- </div>
- <p style="margin:0 0 6px;font-size:13px;color:var(--text-primary);line-height:1.5">{{seg.narration}}</p>
- <div *ngIf="vgAudioUrls[i]?.audioUrl" style="margin-top:4px">
- <audio [src]="vgAudioUrls[i]?.audioUrl" controls style="height:28px;width:100%;max-width:300px"></audio>
- </div>
- </div>
- </div>
- </div>
- </div>
- <div style="display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px">
- <button class="btn" (click)="vgPrevStep()">
- <span class="icon">arrow_back</span> 上一步
- </button>
- <div style="display:flex;gap:8px">
- <button class="btn" (click)="vgReset()">
- <span class="icon">restart_alt</span> 重新开始
- </button>
- <button class="btn btn-primary" (click)="vgComposite()" [disabled]="vgCompositing">
- <span class="icon">{{vgCompositing ? 'hourglass_empty' : 'movie_creation'}}</span>
- {{vgCompositing ? '合成中...' : vgFinalVideoUrl ? '重新合成视频' : '合成视频'}}
- </button>
- </div>
- </div>
- </div>
- </section>
- <!-- 使用说明 -->
- <div class="vg-help-card" style="margin-top:24px;padding:16px;background:var(--bg-secondary);border-radius:10px;border:1px solid var(--border-color)">
- <h4 style="margin:0 0 8px;display:flex;align-items:center;gap:6px;font-size:13px">
- <span class="icon" style="font-size:16px">help_outline</span> 使用说明
- </h4>
- <div style="font-size:12px;color:var(--text-secondary);line-height:1.8">
- <div><strong>步骤1:</strong>上传需要拆解的视频源文件(支持常见视频格式)</div>
- <div><strong>步骤2:</strong>系统自动识别视频内容并整理文字稿</div>
- <div><strong>步骤3:</strong>根据文字稿自动生成分镜脚本,可手动编辑调整</div>
- <div><strong>步骤4:</strong>为每个分镜自动生成画面</div>
- <div><strong>步骤5:</strong>选择声音模型,为旁白自动合成语音</div>
- <div><strong>步骤6:</strong>将画面与配音合成为最终视频</div>
- </div>
- </div>
- </section>
- </ng-container>
- </main>
- </div>
- </div>
- <!-- 视频预览模态框 -->
- <div class="preview-modal-overlay" *ngIf="showPreviewModal" (click)="closePreview()">
- <div class="preview-modal" (click)="$event.stopPropagation()">
- <div class="preview-modal-header">
- <h3>{{previewVideoTitle}}</h3>
- <button class="preview-close-btn" (click)="closePreview()">
- <span class="icon">close</span>
- </button>
- </div>
- <div class="preview-modal-body">
- <div class="preview-player">
- <video *ngIf="previewVideoUrl" controls autoplay [src]="previewVideoUrl" class="preview-video-element">
- 您的浏览器不支持视频播放
- </video>
- <img *ngIf="!previewVideoUrl && previewVideoInfo?.poster" [src]="previewVideoInfo.poster" [alt]="previewVideoTitle" class="preview-video-element">
- <div *ngIf="previewVideoInfo?.loading && !previewVideoUrl" style="padding:12px 0;color:var(--text-secondary);text-align:center;">
- 正在获取可播放视频地址...
- </div>
- </div>
- <div class="preview-info" *ngIf="previewVideoInfo">
- <div class="preview-info-grid">
- <div class="preview-info-item" *ngIf="previewVideoInfo.filename">
- <span class="preview-info-label">文件名</span>
- <span class="preview-info-value">{{previewVideoInfo.filename}}</span>
- </div>
- <div class="preview-info-item" *ngIf="previewVideoInfo.size">
- <span class="preview-info-label">文件大小</span>
- <span class="preview-info-value">{{previewVideoInfo.size | fileSize}}</span>
- </div>
- <div class="preview-info-item" *ngIf="previewVideoInfo.duration">
- <span class="preview-info-label">时长</span>
- <span class="preview-info-value">{{previewVideoInfo.duration | timeFormat}}</span>
- </div>
- <div class="preview-info-item" *ngIf="previewVideoInfo.resolution">
- <span class="preview-info-label">分辨率</span>
- <span class="preview-info-value">{{previewVideoInfo.resolution}}</span>
- </div>
- <div class="preview-info-item" *ngIf="previewVideoInfo.format">
- <span class="preview-info-label">格式</span>
- <span class="preview-info-value">{{previewVideoInfo.format | uppercase}}</span>
- </div>
- <div class="preview-info-item" *ngIf="previewVideoInfo.source">
- <span class="preview-info-label">来源</span>
- <span class="preview-info-value">{{previewVideoInfo.source === 'downloaded' ? '已下载' : previewVideoInfo.source === 'generated' ? 'AI生成' : previewVideoInfo.source === 'douyin' ? '抖音搜索' : '已上传'}}</span>
- </div>
- <div class="preview-info-item" *ngIf="previewVideoInfo.quality">
- <span class="preview-info-label">画质</span>
- <span class="preview-info-value">{{previewVideoInfo.quality}}</span>
- </div>
- <div class="preview-info-item" *ngIf="previewVideoInfo.cost !== undefined">
- <span class="preview-info-label">费用</span>
- <span class="preview-info-value">¥{{previewVideoInfo.cost}}</span>
- </div>
- <div class="preview-info-item" *ngIf="previewVideoInfo.created_at">
- <span class="preview-info-label">创建时间</span>
- <span class="preview-info-value">{{previewVideoInfo.created_at | date:'yyyy-MM-dd HH:mm'}}</span>
- </div>
- <div class="preview-info-item" *ngIf="previewVideoInfo.authorName">
- <span class="preview-info-label">作者</span>
- <span class="preview-info-value">{{previewVideoInfo.authorName}}</span>
- </div>
- <div class="preview-info-item" *ngIf="previewVideoInfo.playCount !== undefined">
- <span class="preview-info-label">播放量</span>
- <span class="preview-info-value">{{previewVideoInfo.playCount | numberFormat}}</span>
- </div>
- <div class="preview-info-item" *ngIf="previewVideoInfo.diggCount !== undefined">
- <span class="preview-info-label">点赞数</span>
- <span class="preview-info-value">{{previewVideoInfo.diggCount | numberFormat}}</span>
- </div>
- <div class="preview-info-item" *ngIf="previewVideoInfo.commentCount !== undefined">
- <span class="preview-info-label">评论数</span>
- <span class="preview-info-value">{{previewVideoInfo.commentCount | numberFormat}}</span>
- </div>
- <div class="preview-info-item" *ngIf="previewVideoInfo.shareCount !== undefined">
- <span class="preview-info-label">分享数</span>
- <span class="preview-info-value">{{previewVideoInfo.shareCount | numberFormat}}</span>
- </div>
- </div>
- <div class="preview-description" *ngIf="previewVideoInfo.description">
- <span class="preview-info-label">描述</span>
- <p>{{previewVideoInfo.description}}</p>
- </div>
- <div class="preview-tags" *ngIf="previewVideoInfo.tags?.length > 0">
- <span class="preview-info-label">标签</span>
- <div class="preview-tag-list">
- <span class="tag" *ngFor="let tag of previewVideoInfo.tags">#{{tag}}</span>
- </div>
- </div>
- </div>
- </div>
- <div class="preview-modal-footer">
- <button class="btn" (click)="closePreview()">
- <span class="icon">close</span> 关闭
- </button>
- <button class="btn btn-primary" *ngIf="previewSourceVideo" (click)="downloadPreviewVideo()" [disabled]="previewSourceVideo.isDownloading">
- <span class="icon">{{ previewSourceVideo.isDownloading ? 'hourglass_empty' : 'download' }}</span>
- {{ previewSourceVideo.isDownloading ? '下载中 ' + previewSourceVideo.downloadProgress + '%' : '下载' }}
- </button>
- <a class="btn btn-primary" *ngIf="!previewSourceVideo && previewDownloadUrl" [href]="previewDownloadUrl" download>
- <span class="icon">download</span> 下载
- </a>
- </div>
- </div>
- </div>
- <!-- AI重塑模态框 — 多步骤流程 -->
- <div class="remix-modal-overlay" *ngIf="showRemixModal" (click)="closeRemixModal()">
- <div class="remix-modal" (click)="$event.stopPropagation()">
- <div class="remix-modal-header">
- <h3>
- <span class="icon">auto_awesome</span>
- AI重塑 — {{remixTargetVideo?.title}}
- </h3>
- <button class="preview-close-btn" (click)="closeRemixModal()" [disabled]="isRemixSubmitting">
- <span class="icon">close</span>
- </button>
- </div>
- <!-- 步骤指示器 -->
- <div class="remix-stepper">
- <div class="remix-step-item" *ngFor="let item of getActiveRemixSteps()"
- [class.active]="remixStep === item.step"
- [class.done]="remixStep > item.step">
- <div class="remix-step-circle">{{remixStep > item.step ? '✓' : item.step}}</div>
- <span class="remix-step-text">{{item.label}}</span>
- </div>
- </div>
- <div class="remix-modal-body">
- <!-- ===== Step 1: 提取文字稿 ===== -->
- <div *ngIf="remixStep === 1">
- <div class="remix-section">
- <label class="remix-label">重塑模式</label>
- <div class="remix-mode-grid">
- <div class="remix-mode-card"
- [class.active]="remixMode === 'standard'"
- (click)="setRemixMode('standard')">
- <div class="remix-mode-title">标准 AI重塑</div>
- <div class="remix-mode-desc">基于文字稿、AI美化和分镜脚本,批量生成多个重塑片段并自动拼接。</div>
- </div>
- <div class="remix-mode-card"
- [class.active]="remixMode === 'digital-human'"
- (click)="setRemixMode('digital-human')">
- <div class="remix-mode-title">数字人生成</div>
- <div class="remix-mode-desc">上传人物形象图,复用原视频音频生成单条数字人口播视频。</div>
- </div>
- </div>
- </div>
- <ng-container *ngIf="remixMode === 'standard'">
- <div class="remix-section">
- <label class="remix-label">视频文字稿</label>
- <!-- 提示:已有分镜数据可跳过 -->
- <div class="remix-whisper-loaded" *ngIf="remixStoryboard.length > 0">
- <span class="icon" style="color:var(--success-color)">check_circle</span>
- 已自动加载 Whisper 处理结果({{remixStoryboard.length}} 个分镜片段),可直接跳到分镜脚本步骤。
- </div>
- <!-- 提示:已加载文字稿 -->
- <div class="remix-whisper-loaded" *ngIf="remixTranscript && remixStoryboard.length === 0 && remixTargetVideo?.whisper">
- <span class="icon" style="color:var(--accent-color)">description</span>
- 已加载该视频的 Whisper 转录文字稿,可编辑后进入下一步。
- </div>
- <!-- Whisper 识别进度 -->
- <div class="remix-whisper-progress-box" *ngIf="isWhisperRunning">
- <div class="remix-stitch-banner">
- <span class="icon spinning" style="color:var(--accent-color)">sync</span>
- <span>{{whisperStatusText}}</span>
- </div>
- </div>
- <!-- Whisper 识别完成提示 -->
- <div class="remix-whisper-loaded" *ngIf="whisperStatusText.startsWith('✅') && !isWhisperRunning">
- <span class="icon" style="color:var(--success-color)">check_circle</span>
- {{whisperStatusText}}
- </div>
- <!-- 生成文字稿操作区(无文字稿且不在识别中时显示) -->
- <div class="remix-no-transcript" *ngIf="!remixTranscript && !isWhisperRunning && !showManualTranscriptInput">
- <div class="remix-no-transcript-icon">
- <span class="icon" style="font-size:36px;color:var(--text-muted)">subtitles_off</span>
- </div>
- <div class="remix-no-transcript-text">该视频暂无文字稿,请选择生成方式</div>
- <div class="remix-no-transcript-actions">
- <button class="btn btn-primary" (click)="generateTranscript()">
- <span class="icon">mic</span> 使用 Whisper 自动识别
- </button>
- <button class="btn" (click)="showManualTranscriptInput = true">
- <span class="icon">edit_note</span> 手动输入文字稿
- </button>
- </div>
- </div>
- <!-- 文字稿输入/编辑区 -->
- <textarea class="remix-textarea"
- *ngIf="remixTranscript || showManualTranscriptInput || remixTargetVideo?.whisper"
- [(ngModel)]="remixTranscript"
- rows="10"
- placeholder="请输入视频的文字稿内容..."></textarea>
- </div>
- </ng-container>
- <ng-container *ngIf="remixMode === 'digital-human'">
- <div class="remix-section">
- <app-digital-human-form
- [audioSourceOptions]="['tts', 'original']"
- [showPortraitPipeline]="true"
- [timbreOptions]="remixTimbreOptions"
- [promptPresets]="digitalHumanPromptPresets"
- [originalAudioVideoId]="remixTargetVideo?.id || ''"
- [originalAudioLabel]="remixTargetVideo?.title || ''"
- [submitting]="isRemixSubmitting"
- submitButtonLabel="开始生成数字人视频"
- (request)="onRemixDhRequest($event)">
- </app-digital-human-form>
- </div>
- </ng-container>
- </div>
- <!-- ===== Step 2: AI美化 ===== -->
- <div *ngIf="remixStep === 2">
- <div class="remix-section">
- <label class="remix-label">原始文字稿</label>
- <div class="remix-original-text">{{remixTranscript}}</div>
- </div>
- <div class="remix-section">
- <label class="remix-label">
- <span class="icon" style="font-size:16px;vertical-align:middle">auto_fix_high</span>
- AI美化后的文字稿
- </label>
- <div class="remix-hint" style="margin-bottom:8px">
- 已自动去除口语化词汇、整理段落结构。可手动修改。
- </div>
- <textarea class="remix-textarea"
- [(ngModel)]="remixBeautifiedTranscript"
- rows="10"
- placeholder="美化后的文字稿"></textarea>
- </div>
- </div>
- <!-- ===== Step 3: 分镜脚本 ===== -->
- <div *ngIf="remixStep === 3">
- <div class="remix-section">
- <label class="remix-label">选择风格</label>
- <div class="remix-style-grid">
- <div class="remix-style-card"
- *ngFor="let style of remixStyleOptions"
- [class.active]="remixStyle === style.id"
- (click)="remixStyle = style.id; generateStoryboard()">
- <span class="remix-style-name">{{style.name}}</span>
- </div>
- </div>
- </div>
- <div class="remix-section">
- <label class="remix-label">分镜脚本 ({{remixStoryboard.length}} 个片段)</label>
- <div class="remix-hint" style="margin-bottom:8px">每段约40-45字,可编辑提示词(Prompt)以调整画面。</div>
- <div class="storyboard-list">
- <div class="storyboard-card" *ngFor="let seg of remixStoryboard; let i = index">
- <div class="storyboard-card-head">
- <span class="storyboard-id">{{seg.id}}</span>
- <span class="storyboard-act">{{seg.act}}</span>
- <span class="storyboard-dur">{{seg.duration}}</span>
- </div>
- <div class="storyboard-narration">{{seg.narration}}</div>
- <textarea class="remix-textarea storyboard-prompt"
- [(ngModel)]="seg.prompt"
- rows="2"
- placeholder="中文视频提示词..."></textarea>
- </div>
- </div>
- </div>
- </div>
- <!-- ===== Step 4: 生成参数 ===== -->
- <div *ngIf="remixStep === 4">
- <ng-container *ngIf="remixMode === 'standard'">
- <div class="remix-section">
- <label class="remix-label">生成参数配置</label>
- <div class="remix-params">
- <div class="remix-param-item">
- <label class="remix-label">视频质量</label>
- <select [(ngModel)]="remixQuality">
- <option value="1080p">1080P</option>
- <option value="pro">Pro 1080P</option>
- <option value="720p">720P</option>
- </select>
- </div>
- <div class="remix-param-item">
- <label class="remix-label">每段时长</label>
- <select [(ngModel)]="remixFrames">
- <option [ngValue]="121">5秒 (121帧)</option>
- <option [ngValue]="241">10秒 (241帧)</option>
- </select>
- </div>
- <div class="remix-param-item">
- <label class="remix-label">画面比例</label>
- <select [(ngModel)]="remixAspectRatio">
- <option value="16:9">16:9 横屏</option>
- <option value="9:16">9:16 竖屏</option>
- <option value="1:1">1:1 正方形</option>
- <option value="4:3">4:3</option>
- </select>
- </div>
- </div>
- </div>
- <div class="remix-cost">
- <span class="icon">attach_money</span>
- 预计生成:<strong>{{remixStoryboard.length}} 个片段</strong>
- </div>
- </ng-container>
-
- </div>
- <!-- ===== Step 5: 生成中 ===== -->
- <div *ngIf="remixStep === 5">
- <div class="remix-section">
- <div class="remix-progress-section">
- <div class="remix-progress-bar-container">
- <div class="remix-progress-bar-fill" [style.width]="(remixTargetVideo?.remixProgress || 0) + '%'"></div>
- </div>
- <div class="remix-progress-text">
- {{remixStatusText}} ({{remixTargetVideo?.remixProgress | number:'1.0-0'}}%)
- </div>
- </div>
- <div class="remix-digital-human-progress-card" *ngIf="remixMode === 'digital-human'">
- <div class="remix-hint">
- {{remixDigitalHumanAudioSource === 'tts'
- ? '正在使用上传形象图与合成语音生成数字人视频,请稍候。'
- : '正在使用上传形象图与原视频提取音频生成数字人视频,请稍候。'}}
- </div>
- <div class="remix-digital-human-inline-preview" *ngIf="remixDigitalHumanImagePreviewUrl">
- <img [src]="remixDigitalHumanImagePreviewUrl" [alt]="remixDigitalHumanImageName || '数字人形象图'">
- <div>
- <div class="remix-digital-human-name">{{remixDigitalHumanImageName}}</div>
- <div class="remix-hint">当前模式:数字人生成 · {{remixDigitalHumanAudioSource === 'tts' ? '语音合成' : '原视频提取音频'}}</div>
- </div>
- </div>
- </div>
- <div class="storyboard-list" style="margin-top:16px">
- <ng-container *ngIf="remixMode === 'standard'">
- <div class="storyboard-card" *ngFor="let seg of remixStoryboard"
- [class.generating]="seg.status === 'generating'"
- [class.completed]="seg.status === 'completed'"
- [class.failed]="seg.status === 'failed'">
- <div class="storyboard-card-head">
- <span class="storyboard-id">{{seg.id}}</span>
- <span class="storyboard-status-icon">
- <span class="icon" *ngIf="seg.status === 'pending'" style="color:var(--text-muted)">hourglass_empty</span>
- <span class="icon" *ngIf="seg.status === 'generating'" style="color:var(--accent-color)">autorenew</span>
- <span class="icon" *ngIf="seg.status === 'completed'" style="color:var(--success-color)">check_circle</span>
- <span class="icon" *ngIf="seg.status === 'failed'" style="color:var(--error-color)">error</span>
- </span>
- <span class="storyboard-dur">{{seg.duration}}</span>
- </div>
- <div class="storyboard-narration">{{seg.narration}}</div>
- <div class="storyboard-error" *ngIf="seg.error">{{seg.error}}</div>
- </div>
- </ng-container>
- </div>
- </div>
- </div>
- <!-- ===== Step 6: 结果汇总 ===== -->
- <div *ngIf="remixStep === 6">
- <div *ngIf="remixMode === 'digital-human' && remixFinalVideoUrl" class="remix-stitch-banner stitch-done">
- <span class="icon" style="color:var(--success-color)">check_circle</span>
- <span>数字人视频生成完成!</span>
- <a [href]="remixFinalVideoUrl" target="_blank" class="storyboard-video-link" style="margin-left:auto">
- <span class="icon">play_circle</span> 播放视频
- </a>
- </div>
- <!-- 一键成片状态 -->
- <div class="remix-stitch-banner" *ngIf="remixMode === 'standard' && (remixStitchStatus === 'stitching' || remixStitchStatus === 'polling')">
- <span class="icon spinning" style="color:var(--accent-color)">sync</span>
- <span>{{remixStatusText}}</span>
- </div>
- <div class="remix-stitch-banner stitch-done" *ngIf="remixMode === 'standard' && remixStitchStatus === 'done'">
- <span class="icon" style="color:var(--success-color)">check_circle</span>
- <span>视频拼接完成!</span>
- <a [href]="remixStitchUrl" target="_blank" class="storyboard-video-link" style="margin-left:auto">
- <span class="icon">play_circle</span> 播放完整视频
- </a>
- </div>
- <div class="remix-stitch-banner stitch-failed" *ngIf="remixStitchStatus === 'failed'">
- <span class="icon" style="color:var(--error-color)">warning</span>
- <span>{{remixMode === 'digital-human' ? remixStitchError : '自动拼接失败:' + remixStitchError + '(各片段仍可单独查看)'}}</span>
- </div>
- <div class="remix-result-summary">
- <div class="remix-result-stat success">
- <span class="remix-result-num">{{remixResults.success}}</span>
- <span class="remix-result-label">成功</span>
- </div>
- <div class="remix-result-stat failed" *ngIf="remixResults.failed > 0">
- <span class="remix-result-num">{{remixResults.failed}}</span>
- <span class="remix-result-label">失败</span>
- </div>
- <div class="remix-result-stat total">
- <span class="remix-result-num">{{remixResults.total}}</span>
- <span class="remix-result-label">总计</span>
- </div>
- </div>
- <div class="storyboard-list" style="margin-top:16px">
- <ng-container *ngIf="remixMode === 'standard'">
- <div class="storyboard-card" *ngFor="let seg of remixStoryboard"
- [class.completed]="seg.status === 'completed'"
- [class.failed]="seg.status === 'failed'">
- <div class="storyboard-card-head">
- <span class="storyboard-id">{{seg.id}}</span>
- <span class="icon" *ngIf="seg.status === 'completed'" style="color:var(--success-color)">check_circle</span>
- <span class="icon" *ngIf="seg.status === 'failed'" style="color:var(--error-color)">error</span>
- </div>
- <div class="storyboard-narration">{{seg.narration}}</div>
- <a *ngIf="seg.videoUrl" [href]="seg.videoUrl" target="_blank" class="storyboard-video-link">
- <span class="icon">play_circle</span> 查看片段
- </a>
- <div class="storyboard-error" *ngIf="seg.error">{{seg.error}}</div>
- </div>
- </ng-container>
- </div>
- </div>
- </div>
- <!-- 底部操作按钮 -->
- <div class="remix-modal-footer">
- <!-- Step 1 -->
- <ng-container *ngIf="remixStep === 1">
- <button class="btn" (click)="closeRemixModal()">取消</button>
- <button class="btn btn-accent" *ngIf="remixMode === 'standard' && remixStoryboard.length > 0" (click)="remixStep = 3">
- <span class="icon">skip_next</span> 跳到分镜脚本
- </button>
- <button class="btn btn-primary"
- *ngIf="remixMode === 'standard'"
- (click)="goToRemixGenerationConfig()"
- [disabled]="!remixTranscript.trim()">
- 下一步:AI美化 <span class="icon">arrow_forward</span>
- </button>
- <div class="remix-footer-status" *ngIf="remixMode === 'digital-human'" style="font-size:12px;color:var(--text-muted)">
- 完成表单后点击右下方「开始生成数字人视频」按钮
- </div>
- </ng-container>
- <!-- Step 2 -->
- <ng-container *ngIf="remixMode === 'standard' && remixStep === 2">
- <button class="btn" (click)="remixStep = 1"><span class="icon">arrow_back</span> 上一步</button>
- <button class="btn btn-primary" (click)="confirmBeautified()" [disabled]="!remixBeautifiedTranscript.trim()">
- 下一步:分镜脚本 <span class="icon">arrow_forward</span>
- </button>
- </ng-container>
- <!-- Step 3 -->
- <ng-container *ngIf="remixMode === 'standard' && remixStep === 3">
- <button class="btn" (click)="remixStep = 2"><span class="icon">arrow_back</span> 上一步</button>
- <button class="btn btn-primary" (click)="confirmStoryboard()" [disabled]="remixStoryboard.length === 0">
- 下一步:生成参数 <span class="icon">arrow_forward</span>
- </button>
- </ng-container>
- <!-- Step 4(仅标准重塑模式)-->
- <ng-container *ngIf="remixStep === 4 && remixMode === 'standard'">
- <button class="btn" (click)="remixStep = 3"><span class="icon">arrow_back</span> 上一步</button>
- <button class="btn btn-primary" (click)="startBatchGeneration()">
- <span class="icon">auto_awesome</span> 开始生成 ({{remixStoryboard.length}} 个片段)
- </button>
- </ng-container>
- <!-- Step 5 -->
- <ng-container *ngIf="remixStep === 5">
- <div class="remix-footer-status">{{remixStatusText}}</div>
- </ng-container>
- <!-- Step 6 -->
- <ng-container *ngIf="remixStep === 6">
- <button class="btn" (click)="closeRemixModal()">关闭</button>
- <button class="btn btn-primary" (click)="finishRemix()">
- <span class="icon">visibility</span> 查看生成结果
- </button>
- </ng-container>
- </div>
- </div>
- </div>
- <!-- AI重塑模式选择弹窗 -->
- <div class="remix-modal-overlay remix-mode-chooser-overlay" *ngIf="showRemixModeChooser" (click)="closeRemixModeChooser()">
- <div class="remix-mode-chooser" (click)="$event.stopPropagation()">
- <div class="remix-mode-chooser__header">
- <h3>
- <span class="icon">auto_awesome</span>
- 选择重塑方式
- </h3>
- <button type="button" class="remix-mode-chooser__close" (click)="closeRemixModeChooser()" aria-label="关闭">
- <span class="icon">close</span>
- </button>
- </div>
- <p class="remix-mode-chooser__subtitle" *ngIf="remixModeChooserVideo">
- 当前视频:{{remixModeChooserVideo.title}}
- </p>
- <div class="remix-mode-chooser__grid">
- <button type="button" class="remix-mode-chooser__card" (click)="selectRemixMode('standard')">
- <div class="remix-mode-chooser__card-title">标准AI重塑</div>
- <div class="remix-mode-chooser__card-desc">基于文字稿、AI美化和分镜脚本,批量生成多个重塑片段并自动拼接。</div>
- </button>
- <button type="button" class="remix-mode-chooser__card" (click)="selectRemixMode('digital-human')">
- <div class="remix-mode-chooser__card-title">数字人生成</div>
- <div class="remix-mode-chooser__card-desc">上传人物形象图,复用原视频内容生成单条数字人口播视频。</div>
- </button>
- </div>
- </div>
- </div>
- <!-- 视频上传模态框 -->
- <div class="remix-modal-overlay" *ngIf="showUploadModal" (click)="closeUploadModal()">
- <div class="remix-modal" style="max-width:560px" (click)="$event.stopPropagation()">
- <div class="remix-modal-header">
- <h3>
- <span class="icon">upload</span> 上传视频
- </h3>
- <button class="icon-btn" (click)="closeUploadModal()" [disabled]="isUploading">
- <span class="icon">close</span>
- </button>
- </div>
- <div class="remix-modal-body">
- <!-- 文件选择区域 -->
- <div style="border:2px dashed var(--border-color);border-radius:12px;padding:32px;text-align:center;margin-bottom:16px;transition:border-color 0.2s ease,background-color 0.2s ease"
- [style.border-color]="uploadFileName ? 'var(--accent-color)' : 'var(--border-color)'"
- [style.background]="uploadFileName ? 'var(--accent-color-alpha, rgba(33,150,243,0.05))' : 'transparent'">
- <input type="file" id="videoFileInput" accept="video/mp4,video/quicktime,video/x-msvideo,video/webm,video/x-matroska,.mp4,.mov,.avi,.webm,.mkv"
- (change)="onFileSelected($event)" style="display:none" [disabled]="isUploading">
- <div *ngIf="!uploadFileName">
- <span class="icon" style="font-size:48px;color:var(--text-secondary);display:block;margin-bottom:8px">cloud_upload</span>
- <p style="margin:0 0 8px;color:var(--text-primary);font-size:15px;font-weight:500">点击选择视频文件</p>
- <p style="margin:0;color:var(--text-secondary);font-size:13px">支持 MP4、MOV、AVI、WebM、MKV 格式,最大 500MB</p>
- <label for="videoFileInput" class="btn btn-primary" style="margin-top:12px;cursor:pointer;display:inline-flex">
- <span class="icon">folder_open</span> 选择文件
- </label>
- </div>
- <div *ngIf="uploadFileName">
- <span class="icon" style="font-size:36px;color:var(--accent-color);display:block;margin-bottom:8px">movie</span>
- <p style="margin:0 0 4px;color:var(--text-primary);font-size:14px;font-weight:500">{{uploadFileName}}</p>
- <p *ngIf="uploadFile" style="margin:0;color:var(--text-secondary);font-size:12px">
- 文件大小: {{(uploadFile.size / 1024 / 1024).toFixed(1)}} MB
- </p>
- <label for="videoFileInput" class="btn" style="margin-top:8px;cursor:pointer;display:inline-flex;font-size:12px" *ngIf="!isUploading">
- <span class="icon" style="font-size:14px">swap_horiz</span> 更换文件
- </label>
- </div>
- </div>
- <!-- 视频信息表单 -->
- <div style="display:flex;flex-direction:column;gap:12px">
- <div class="form-field">
- <label>视频标题</label>
- <input type="text" [(ngModel)]="uploadTitle" placeholder="输入视频标题" [disabled]="isUploading">
- </div>
- <div class="form-field">
- <label>描述</label>
- <textarea [(ngModel)]="uploadDescription" placeholder="输入视频描述(可选)" rows="2"
- style="resize:vertical;min-height:48px" [disabled]="isUploading"></textarea>
- </div>
- <div class="form-field">
- <label>标签</label>
- <input type="text" [(ngModel)]="uploadTags" placeholder="用逗号分隔多个标签(可选)" [disabled]="isUploading">
- </div>
- </div>
- <!-- 上传进度 -->
- <div *ngIf="isUploading" style="margin-top:16px">
- <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px">
- <span style="font-size:13px;color:var(--text-primary)">正在上传...</span>
- <span style="font-size:13px;font-weight:600;color:var(--accent-color)">{{uploadProgress}}%</span>
- </div>
- <div class="progress-bar" style="height:8px">
- <div class="progress-fill" [style.width]="uploadProgress + '%'"
- [class.progress-done]="uploadProgress === 100"
- style="transition:width 0.3s"></div>
- </div>
- <p *ngIf="uploadProgress === 100" style="margin:8px 0 0;font-size:12px;color:var(--text-secondary)">
- 文件已传输完成,正在处理...
- </p>
- </div>
- <!-- 错误信息 -->
- <div *ngIf="uploadError" style="margin-top:12px;padding:10px 14px;background:var(--error-bg, rgba(244,67,54,0.08));border-radius:8px;color:var(--error-color);font-size:13px;display:flex;align-items:center;gap:6px">
- <span class="icon" style="font-size:16px">error</span>
- {{uploadError}}
- </div>
- </div>
- <div class="remix-modal-footer">
- <button class="btn" (click)="closeUploadModal()" [disabled]="isUploading">取消</button>
- <button class="btn btn-primary" (click)="uploadVideo()" [disabled]="!uploadFile || isUploading">
- <span class="icon">{{isUploading ? 'hourglass_empty' : 'upload'}}</span>
- {{isUploading ? '上传中...' : '开始上传'}}
- </button>
- </div>
- </div>
- </div>
- <!-- 历史详情模态框 -->
- <div class="remix-modal-overlay" *ngIf="showHistoryDetailModal" (click)="closeHistoryDetail()">
- <div class="modal remix-modal" style="max-width:800px;max-height:85vh;overflow-y:auto" (click)="$event.stopPropagation()">
- <div class="modal-header">
- <h3>
- <span class="icon">history</span>
- 重塑详情 — {{historyDetailRecord?.original_video_title || historyDetailRecord?.keyword}}
- </h3>
- <button class="icon-btn" (click)="closeHistoryDetail()">
- <span class="icon">close</span>
- </button>
- </div>
- <div class="modal-body" style="padding:16px">
- <div style="display:flex;gap:16px;flex-wrap:wrap;margin-bottom:16px;padding:12px;background:var(--bg-tertiary);border-radius:8px">
- <div *ngIf="historyDetailRecord?.styleName"><strong>风格:</strong>{{historyDetailRecord?.styleName}}</div>
- <div *ngIf="historyDetailRecord?.duration"><strong>片段:</strong>{{historyDetailRecord?.duration}}</div>
- <div *ngIf="historyDetailRecord?.quality"><strong>画质:</strong>{{historyDetailRecord?.quality}}</div>
- <div><strong>费用:</strong>¥{{historyDetailRecord?.cost || 0}}</div>
- <div><strong>状态:</strong>
- <span class="status-badge" [class]="getStatusColor(historyDetailRecord?.status || '')">
- {{getStatusText(historyDetailRecord?.status || '')}}
- </span>
- </div>
- <div><strong>时间:</strong>{{historyDetailRecord?.created_at | date:'yyyy-MM-dd HH:mm'}}</div>
- </div>
- <div class="history-primary-video" *ngIf="historyDetailPrimaryVideoUrl">
- <div class="history-primary-video-head">
- <h4>
- <span class="icon" style="font-size:18px;vertical-align:middle">play_circle</span>
- 生成结果
- </h4>
- <a [href]="historyDetailPrimaryVideoUrl" target="_blank" class="btn btn-sm">
- <span class="icon" style="font-size:14px">open_in_new</span> 新窗口播放
- </a>
- </div>
- <video controls [src]="historyDetailPrimaryVideoUrl" class="history-primary-video-player"></video>
- </div>
- <div *ngIf="historyDetailLoading" class="history-detail-loading">
- 正在加载详情视频与片段数据...
- </div>
- <h4 style="margin-bottom:12px">
- <span class="icon" style="font-size:18px;vertical-align:middle">video_library</span>
- 视频片段 ({{historyDetailSegments.length}})
- </h4>
- <div *ngIf="historyDetailSegments.length === 0 && !historyDetailLoading" style="text-align:center;padding:24px;color:var(--text-secondary)">
- <span class="icon" style="font-size:36px;display:block;margin-bottom:8px">info</span>
- {{historyDetailPrimaryVideoUrl ? '当前记录暂无独立片段数据,已展示主视频结果' : '暂无片段数据(该记录可能未关联重塑数据)'}}
- </div>
- <div class="storyboard-list" style="gap:12px">
- <div class="storyboard-card" *ngFor="let seg of historyDetailSegments"
- [class.completed]="seg.status === 'completed'"
- [class.failed]="seg.status === 'failed'"
- style="margin-bottom:12px">
- <div class="storyboard-card-head">
- <span class="storyboard-id">{{seg.id}}</span>
- <span class="status-badge" [class]="seg.status === 'completed' ? 'badge-success' : seg.status === 'failed' ? 'badge-danger' : 'badge-secondary'">
- {{seg.status === 'completed' ? '成功' : seg.status === 'failed' ? '失败' : seg.status}}
- </span>
- </div>
- <div *ngIf="seg.narration" style="font-size:13px;color:var(--text-secondary);margin:6px 0">
- {{seg.narration}}
- </div>
- <div *ngIf="seg.videoUrl" style="margin-top:8px">
- <video controls [src]="seg.videoUrl" style="width:100%;max-height:240px;border-radius:6px;background:#000"></video>
- </div>
- <div *ngIf="seg.videoUrl" style="margin-top:6px">
- <a [href]="seg.videoUrl" target="_blank" class="btn btn-sm" style="font-size:12px">
- <span class="icon" style="font-size:14px">open_in_new</span> 新窗口播放
- </a>
- </div>
- <div *ngIf="!seg.videoUrl && seg.status === 'failed'" style="color:var(--error-color);font-size:13px;margin-top:6px">
- <span class="icon" style="font-size:14px">error</span> 生成失败
- </div>
- </div>
- </div>
- </div>
- <div class="modal-footer" style="padding:12px 16px;border-top:1px solid var(--border-color);display:flex;justify-content:flex-end">
- <button class="btn" (click)="closeHistoryDetail()">关闭</button>
- </div>
- </div>
- </div>
- <!-- 浮动 mini-pill:最近一次生成(P3)。
- 与 AI 助手 FAB 共用右下角,通过 .has-assistant 类把 pill 抬高到 FAB 上方 -->
- <div class="modal-overlay viral-analysis-overlay vf-modal-backdrop" *ngIf="isViralAnalysisOpen" (click)="closeViralAnalysis()">
- <div class="modal viral-analysis-modal vf-modal" (click)="$event.stopPropagation()">
- <div class="modal-header">
- <h3><span class="icon">insights</span> 爆款分析</h3>
- <button class="icon-btn" type="button" (click)="closeViralAnalysis()" [disabled]="isAnalyzingViralVideo">
- <span class="icon">close</span>
- </button>
- </div>
- <div class="modal-body viral-analysis-body">
- <div class="viral-analysis-loading" *ngIf="isAnalyzingViralVideo">
- <div class="spinner"></div>
- <strong>正在分析视频结构</strong>
- <p>会结合视频信息和评论样本,提炼可复用的选题与脚本框架。</p>
- </div>
- <div class="monitor-error" *ngIf="viralAnalysisError && !isAnalyzingViralVideo">{{viralAnalysisError}}</div>
- <ng-container *ngIf="currentViralAnalysis as insight">
- <section class="viral-analysis-hero">
- <div>
- <span class="viral-analysis-kicker">来源视频</span>
- <h4>{{insight.videoSnapshot.desc || viralAnalysisSourceTitle}}</h4>
- <p>
- {{insight.videoSnapshot.authorName || '未知作者'}}
- · 置信度:{{insight.confidence === 'high' ? '高' : insight.confidence === 'medium' ? '中' : '低'}}
- · 评论 {{insight.commentsSnapshot.length || 0}} 条
- <ng-container *ngIf="insight.repliesSnapshot?.length"> · 回复 {{insight.repliesSnapshot?.length}} 条</ng-container>
- <ng-container *ngIf="insight.transcript"> · 已使用字幕</ng-container>
- </p>
- </div>
- <span class="status-badge">{{insight.source === 'monitor' ? '博主监测' : '视频发现'}}</span>
- </section>
- <section class="viral-analysis-section viral-analysis-section--summary">
- <h4>爆点判断</h4>
- <p>{{insight.analysis.summary}}</p>
- </section>
- <section class="viral-analysis-section viral-analysis-section--evidence">
- <div class="viral-section-heading">
- <div>
- <span class="viral-analysis-kicker">分析依据</span>
- <h4>证据与逐字稿</h4>
- </div>
- <span class="vf-status-pill" *ngIf="insight.transcriptJob">
- {{viralTranscriptStatusLabel(insight.transcriptJob)}}
- </span>
- </div>
- <p>
- 互动:播放 {{insight.videoSnapshot.playCount || 0}} / 点赞 {{insight.videoSnapshot.diggCount || 0}} / 评论 {{insight.videoSnapshot.commentCount || 0}} / 分享 {{insight.videoSnapshot.shareCount || 0}}
- </p>
- <p>
- 样本:评论 {{insight.commentsSnapshot.length || 0}} 条
- <ng-container *ngIf="insight.repliesSnapshot?.length"> / 回复 {{insight.repliesSnapshot?.length}} 条</ng-container>
- / 逐字稿:{{insight.transcript ? '已使用' : '未提供,按结构推断'}}
- </p>
- <p *ngIf="insight.transcriptJob">
- 转写任务:{{viralTranscriptStatusLabel(insight.transcriptJob)}}
- <ng-container *ngIf="insight.transcriptJob.stageLabel"> / {{insight.transcriptJob.stageLabel}}</ng-container>
- <ng-container *ngIf="insight.transcriptJob.orderId"> / {{insight.transcriptJob.orderId}}</ng-container>
- </p>
- <div class="viral-transcript-progress" *ngIf="insight.transcriptJob && viralTranscriptIsProcessing(insight.transcriptJob)">
- <div class="viral-transcript-progress__bar">
- <div class="viral-transcript-progress__fill" [style.width]="(insight.transcriptJob.progress || 0) + '%'"></div>
- </div>
- <span>{{insight.transcriptJob.progress || 0}}%</span>
- </div>
- <div class="viral-transcript-alert" *ngIf="viralTranscriptShouldWarn(insight.transcriptJob)">
- <strong>{{viralTranscriptPrimaryMessage(insight.transcriptJob)}}</strong>
- <p *ngFor="let warning of (insight.transcriptJob?.warnings || [])">{{warning}}</p>
- <p *ngIf="insight.transcriptJob?.errorMessage">{{insight.transcriptJob?.errorMessage}}</p>
- </div>
- <div class="viral-transcript-actions" *ngIf="!insight.transcript">
- <button class="vf-btn vf-btn-primary vf-btn-lg viral-transcript-action--primary" type="button" *ngIf="canStartViralTranscript(insight)"
- (click)="startViralTranscript()" [disabled]="viralTranscriptBusy">
- <span class="icon">subtitles</span>
- {{viralTranscriptBusy ? '提交中...' : (insight.transcriptJob ? '重新提交转写' : '补逐字稿')}}
- </button>
- <button class="vf-btn vf-btn-lg viral-transcript-action--refresh" type="button" *ngIf="insight.transcriptJob && insight.transcriptJob.status !== 'completed'"
- (click)="refreshViralTranscript()" [disabled]="viralTranscriptBusy">
- <span class="icon">refresh</span>
- {{viralTranscriptBusy ? '自动跟踪中' : '刷新转写结果'}}
- </button>
- </div>
- </section>
- <div class="viral-analysis-grid">
- <section class="viral-analysis-section">
- <h4>钩子类型</h4>
- <p>{{insight.analysis.hookType || '待判断'}}</p>
- </section>
- <section class="viral-analysis-section">
- <h4>开头方式</h4>
- <p>{{insight.analysis.openingPattern || '待判断'}}</p>
- </section>
- <section class="viral-analysis-section">
- <h4>核心冲突</h4>
- <p>{{insight.analysis.conflict || '待判断'}}</p>
- </section>
- <section class="viral-analysis-section">
- <h4>用户情绪</h4>
- <p>{{insight.analysis.audienceEmotion || '待判断'}}</p>
- </section>
- <section class="viral-analysis-section">
- <h4>评论触发点</h4>
- <p>{{insight.analysis.commentTrigger || '暂无可用评论触发点'}}</p>
- </section>
- </div>
- <section class="viral-analysis-section">
- <h4>内容节奏</h4>
- <p>{{insight.analysis.contentRhythm}}</p>
- </section>
- <section class="viral-analysis-section">
- <h4>可复用脚本框架</h4>
- <p>{{insight.analysis.reusableFrame}}</p>
- </section>
- <section class="viral-analysis-section" *ngIf="insight.transcript">
- <h4>字幕/口播依据</h4>
- <p>{{insight.transcript | slice:0:220}}{{insight.transcript.length > 220 ? '...' : ''}}</p>
- </section>
- <section class="viral-analysis-section" *ngIf="insight.analysis.reusableAngles.length">
- <h4>同款选题方向</h4>
- <div class="viral-angle-list">
- <span class="viral-angle" *ngFor="let angle of insight.analysis.reusableAngles">{{angle}}</span>
- </div>
- </section>
- <section class="viral-analysis-section" *ngIf="insight.analysis.evidenceRefs.length">
- <h4>证据引用</h4>
- <p *ngFor="let ref of insight.analysis.evidenceRefs">{{ref}}</p>
- </section>
- <section class="viral-analysis-section" *ngIf="insight.analysis.riskNotes.length || insight.confidence !== 'high'">
- <h4>注意事项</h4>
- <p *ngIf="insight.confidence !== 'high'">当前证据不足以判定为高置信,建议补充逐字稿或更多匹配评论后再作为正式脚本。</p>
- <p *ngFor="let note of insight.analysis.riskNotes">{{note}}</p>
- </section>
- </ng-container>
- </div>
- <div class="modal-footer viral-analysis-footer">
- <button class="vf-btn vf-btn-lg" type="button" (click)="closeViralAnalysis()" [disabled]="isAnalyzingViralVideo">关闭</button>
- <button class="vf-btn vf-btn-lg" type="button" (click)="saveCurrentViralAnalysisToTopics()" [disabled]="!currentViralAnalysis || isAnalyzingViralVideo">
- 保存到选题池
- </button>
- <button class="vf-btn vf-btn-primary vf-btn-lg" type="button" (click)="useViralAnalysisForTopicVideo()" [disabled]="!currentViralAnalysis || isAnalyzingViralVideo">
- 进入主题生视频
- </button>
- </div>
- </div>
- </div>
- <div class="modal-overlay daily-report-overlay" *ngIf="isDailyReportOpen" (click)="closeDailyReport()">
- <div class="modal daily-report-modal" (click)="$event.stopPropagation()">
- <div class="modal-header daily-report-header">
- <div>
- <span class="daily-report-eyebrow">博主热点监测</span>
- <h3><span class="icon">summarize</span> 监测日报</h3>
- </div>
- <button class="icon-btn" type="button" (click)="closeDailyReport()">
- <span class="icon">close</span>
- </button>
- </div>
- <div class="modal-body daily-report-body" *ngIf="currentDailyReport as report">
- <section class="daily-report-summary">
- <div>
- <span>日报日期</span>
- <strong>{{report.date}}</strong>
- </div>
- <div>
- <span>监测博主</span>
- <strong>{{getDailyReportMetric(report, 'authorCount', monitorAuthors.length)}}</strong>
- </div>
- <div>
- <span>覆盖作品</span>
- <strong>{{getDailyReportMetric(report, 'worksCount', report.newWorks.length)}}</strong>
- </div>
- <div>
- <span>推荐选题</span>
- <strong>{{report.topTopics.length}}</strong>
- </div>
- </section>
- <section class="daily-report-section">
- <div class="daily-report-section-head">
- <h4>值得关注的作品</h4>
- <span>优先覆盖已加载作品的不同博主,再按互动热度补充</span>
- </div>
- <article class="daily-work" *ngFor="let work of report.newWorks | slice:0:6">
- <div class="daily-work__head">
- <strong>{{$any(work).authorName || '监测博主'}}</strong>
- <span>互动分 {{($any(work).score || 0) | number:'1.0-0'}}</span>
- </div>
- <p>{{$any(work).desc || $any(work).id}}</p>
- <div class="daily-work__stats">
- <span>点赞 {{$any(work).statistics?.digg_count || 0}}</span>
- <span>评论 {{$any(work).statistics?.comment_count || 0}}</span>
- <span>分享 {{$any(work).statistics?.share_count || 0}}</span>
- </div>
- <p *ngIf="$any(work).commentSamples?.length">评论关注:{{$any(work).commentSamples[0]}}</p>
- </article>
- </section>
- <section class="daily-report-section">
- <div class="daily-report-section-head">
- <h4>可保存选题</h4>
- <span>可直接进入主题生视频继续生产</span>
- </div>
- <article class="daily-topic" *ngFor="let topic of report.topTopics">
- <div>
- <strong>{{topic.title}}</strong>
- <p>{{topic.angle}}</p>
- </div>
- <button class="daily-report-topic-action" type="button" (click)="openDailyReportTopic(topic)">进入主题生视频</button>
- </article>
- </section>
- <section class="daily-report-section">
- <h4>明日建议</h4>
- <p>{{report.calibrationPrompt}}</p>
- </section>
- </div>
- <div class="modal-footer daily-report-footer">
- <button class="vf-btn vf-btn-secondary" type="button" (click)="closeDailyReport()">关闭</button>
- <button class="vf-btn vf-btn-primary" type="button" (click)="saveDailyReportTopics()" [disabled]="!currentDailyReport?.topTopics?.length">
- 保存选题到选题池
- </button>
- </div>
- </div>
- </div>
- <app-recent-task-pill
- *ngIf="auth.isLoggedIn"
- [task]="currentRunningTask"
- [hidden]="pillHidden"
- [class.has-assistant]="!assistantExpanded"
- (open)="setCurrentTab('tasks')"
- (dismiss)="dismissCurrentRunningTask()">
- </app-recent-task-pill>
- <!-- 全局 AI 创作助手(永远在场,跨 tab 持久化) -->
- <app-assistant
- *ngIf="auth.isLoggedIn"
- (suggestionPick)="onAssistantSuggestion($event)"
- (expandedChange)="assistantExpanded = $event">
- </app-assistant>
- <app-auth-required-modal
- [open]="authModalOpen"
- [featureName]="authModalFeature"
- (close)="authModalOpen = false"
- (login)="openAuth('login')"
- (register)="openAuth('register')">
- </app-auth-required-modal>
- <!-- Toast 通知 -->
- <div class="toast-container" *ngIf="toastVisible" [ngClass]="['toast-' + toastType]" (click)="toastVisible = false">
- <span class="icon toast-icon">
- {{toastType === 'success' ? 'check_circle' : toastType === 'error' ? 'error' : toastType === 'warn' ? 'warning' : 'info'}}
- </span>
- <span class="toast-text">{{toastMessage}}</span>
- <span class="icon toast-close">close</span>
- </div>
|