灵活用工项目

EnterpriseHome.vue 67KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148
  1. <template>
  2. <view class="chat-page">
  3. <view class="chat-header">
  4. <view class="chat-header__row">
  5. <view class="fe-nav-btn guide-target guide-target--sidebar" @tap="openSidebar">
  6. <text class="fe-nav-icon">☰</text>
  7. </view>
  8. <text class="chat-header__title">智能用工助手</text>
  9. <view class="chat-header__actions">
  10. <view class="fe-nav-btn guide-target guide-target--new-chat" @tap="newChat">
  11. <text class="fe-nav-icon fe-nav-icon--plus">+</text>
  12. </view>
  13. <view class="fe-nav-btn guide-target guide-target--message" @tap="openDrawer('message')">
  14. <text class="fe-nav-icon">🔔</text>
  15. <view class="fe-nav-badge">2</view>
  16. </view>
  17. <view class="fe-nav-avatar guide-target guide-target--avatar" @tap="openDrawer('profile')">{{ user.avatarText }}</view>
  18. </view>
  19. </view>
  20. </view>
  21. <!-- Sidebar -->
  22. <view class="fe-sidebar" :class="{ 'fe-sidebar--open': sidebarOpen }">
  23. <view class="sidebar-head">
  24. <text class="sidebar-head__title">对话列表</text>
  25. <view class="sidebar-head__add" @tap="newChat">+</view>
  26. </view>
  27. <scroll-view scroll-y class="sidebar-list" :show-scrollbar="false">
  28. <view v-if="sessionsLoading" class="sidebar-status">
  29. <text class="sidebar-status__text">加载中...</text>
  30. </view>
  31. <view v-else-if="!sessions.length" class="sidebar-status sidebar-status--action" @tap="newChat">
  32. <text class="sidebar-status__text sidebar-status__text--action">开启新对话</text>
  33. </view>
  34. <block v-else>
  35. <view
  36. v-for="(s, sessionIdx) in sessions"
  37. :key="s.conversationNo"
  38. class="fe-sidebar-item"
  39. :class="{ 'fe-sidebar-item--active': currentSession === s.conversationNo }"
  40. @tap="switchSessionByIndex(sessionIdx)"
  41. >
  42. <view class="fe-sidebar-item__title">{{ s.title }}</view>
  43. <view class="fe-sidebar-item__time">{{ s.time }}</view>
  44. </view>
  45. </block>
  46. </scroll-view>
  47. </view>
  48. <!-- Chat Area -->
  49. <scroll-view
  50. class="chat-area"
  51. scroll-y
  52. :scroll-top="scrollTop"
  53. :scroll-into-view="scrollIntoView"
  54. :scroll-with-animation="scrollWithAnimation"
  55. :show-scrollbar="false"
  56. :enhanced="true"
  57. :upper-threshold="80"
  58. @scroll="onChatScroll"
  59. @scrolltoupper="onChatScrollToUpper"
  60. >
  61. <view class="chat-area__inner">
  62. <view v-if="showNewChatEmpty" class="chat-empty">
  63. <view class="chat-empty__icon">💬</view>
  64. <text class="chat-empty__title">{{ newChatGreeting }}</text>
  65. <view class="chat-empty__btn" @tap="newChat">开启新对话</view>
  66. </view>
  67. <block v-else>
  68. <view v-if="messagesLoading" class="chat-status">
  69. <text class="chat-status__text">加载消息中...</text>
  70. </view>
  71. <block v-else>
  72. <view v-if="messagesLoadingMore" class="chat-status chat-status--top">
  73. <text class="chat-status__text">加载历史消息...</text>
  74. </view>
  75. <view v-else-if="!hasMoreHistory && hasMessageHistory" class="chat-status chat-status--top">
  76. <text class="chat-status__text chat-status__text--muted">没有更多消息了</text>
  77. </view>
  78. <view v-for="(msg, idx) in messages" :key="msg.msgKey" class="chat-msg-wrap">
  79. <!-- Plain text -->
  80. <view v-if="msg.type === 'text'" class="fe-chat-msg" :class="'fe-chat-msg--' + msg.role" :id="'msg-' + idx">
  81. <view v-if="msg.role === 'ai'" class="fe-chat-avatar fe-chat-avatar--ai">AI</view>
  82. <view v-if="msg.role === 'user'" class="fe-chat-avatar fe-chat-avatar--user">{{ user.avatarText }}</view>
  83. <view class="fe-chat-bubble">
  84. <view v-if="msg.streaming && !msg.content" class="fe-thinking-dots fe-thinking-dots--inline">
  85. <view class="fe-thinking-dots__dot" />
  86. <view class="fe-thinking-dots__dot" />
  87. <view class="fe-thinking-dots__dot" />
  88. </view>
  89. <view v-else class="fe-chat-bubble__text-wrap">
  90. <FeChatMarkdown v-if="msg.role === 'ai'" :content="msg.content" />
  91. <text v-else class="fe-chat-bubble__text" user-select>{{ msg.content }}</text>
  92. </view>
  93. </view>
  94. </view>
  95. <!-- Report card -->
  96. <view v-else-if="msg.type === 'report'" class="fe-chat-msg fe-chat-msg--ai report-card-anchor" :id="'msg-' + idx">
  97. <view class="fe-chat-avatar fe-chat-avatar--ai">AI</view>
  98. <view class="fe-chat-bubble">
  99. <view class="fe-biz-card fe-biz-card--report" @tap="openSheet('report')">
  100. <view class="fe-biz-card__header">📊 今日汇报</view>
  101. <view class="fe-biz-card__row"><text>1. 浦东仓库</text><text class="fe-biz-card__val">8/10 人已登记</text></view>
  102. <view class="fe-biz-card__row"><text>2. 王五实名失败</text><text class="fe-biz-card__val fe-biz-card__val--danger">已自动提醒</text></view>
  103. <view class="fe-biz-card__row"><text>3. 结算单待确认</text><text class="fe-biz-card__val">¥12,800</text></view>
  104. <view class="fe-biz-card__actions">
  105. <view class="fe-biz-btn" @tap.stop="toast('已发送催办')">一键催办</view>
  106. <view class="fe-biz-btn" @tap.stop="openProgressOverview">查看进度</view>
  107. </view>
  108. </view>
  109. </view>
  110. </view>
  111. <!-- Draft card -->
  112. <view v-else-if="msg.type === 'draft'" class="fe-chat-msg fe-chat-msg--ai" :id="'msg-' + idx">
  113. <view class="fe-chat-avatar fe-chat-avatar--ai">AI</view>
  114. <view class="fe-chat-bubble">
  115. <view
  116. class="fe-biz-card fe-biz-card--draft"
  117. :class="msg.draftRootClass"
  118. @tap="openDraftByIndex(idx)"
  119. >
  120. <view class="fe-biz-card__header">
  121. <text class="fe-biz-card__header-title">📄 {{ (msg.draft && msg.draft.title) || '用工草稿' }}</text>
  122. <text class="fe-draft-card-status" :class="msg.draftStatusClass">{{ msg.draftStatusLabel }}</text>
  123. </view>
  124. <view class="fe-biz-card__row">
  125. <text>岗位类型</text>
  126. <text class="fe-biz-card__val">{{ (msg.draft && msg.draft.workType) || '待补充' }}</text>
  127. </view>
  128. <view class="fe-biz-card__row">
  129. <text>工作地点</text>
  130. <text class="fe-biz-card__val">{{ (msg.draft && msg.draft.workLocation) || '待补充' }}</text>
  131. </view>
  132. <view class="fe-biz-card__row">
  133. <text>用工人数</text>
  134. <text class="fe-biz-card__val">{{ (msg.draft && msg.draft.headcountText) || '待补充' }}</text>
  135. </view>
  136. <view class="fe-draft-price">
  137. {{ (msg.draft && msg.draft.estimatedTotalText) || '—' }}
  138. <text style="font-size:26rpx;font-weight:500;color:#64748B;">预估总价</text>
  139. </view>
  140. <view class="fe-biz-card__actions fe-biz-card__actions--draft">
  141. <view class="fe-biz-btn fe-biz-btn--primary" @tap.stop="openDraftByIndex(idx)">查看详情</view>
  142. <text class="fe-biz-card__hint">如需修改草稿,请直接在对话中说明</text>
  143. </view>
  144. <view v-if="msg.voided" class="fe-draft-void-stamp">已作废</view>
  145. </view>
  146. </view>
  147. </view>
  148. <!-- QR card(确认方案后) -->
  149. <view v-else-if="msg.type === 'qr'" class="fe-chat-msg fe-chat-msg--ai" :id="'msg-' + idx">
  150. <view class="fe-chat-avatar fe-chat-avatar--ai">AI</view>
  151. <view class="fe-chat-bubble">
  152. <view class="fe-biz-card fe-biz-card--qr" @tap="openQrByIndex(idx)">
  153. <view class="fe-biz-card__header">
  154. <text class="fe-biz-card__header-title">工人登记二维码</text>
  155. </view>
  156. <text v-if="qrSubtitleByIndex(idx)" class="fe-qr-card__subtitle">{{ qrSubtitleByIndex(idx) }}</text>
  157. <image
  158. v-if="qrImageUrlByIndex(idx)"
  159. class="fe-qr-card__img"
  160. :src="qrImageUrlByIndex(idx)"
  161. mode="aspectFit"
  162. @tap.stop="openQrByIndex(idx)"
  163. />
  164. <view v-else class="fe-qr-card__empty">
  165. <text class="fe-qr-card__empty-text">二维码加载中,可点此刷新</text>
  166. </view>
  167. <text class="fe-qr-card__hint">{{ qrHintByIndex(idx) }}</text>
  168. <view class="fe-biz-card__actions">
  169. <view class="fe-biz-btn" @tap.stop="openProgressByQrIndex(idx)">查看进度</view>
  170. <view class="fe-biz-btn fe-biz-btn--primary" @tap.stop="openQrByIndex(idx)">查看 / 分享</view>
  171. </view>
  172. </view>
  173. </view>
  174. </view>
  175. <!-- Orders -->
  176. <view v-else-if="msg.type === 'orders'" class="fe-chat-msg fe-chat-msg--ai" :id="'msg-' + idx">
  177. <view class="fe-chat-avatar fe-chat-avatar--ai">AI</view>
  178. <view class="fe-chat-bubble">
  179. <view class="fe-biz-card" style="margin-bottom:16rpx;">
  180. <view class="fe-biz-card__header">📋 订单 #2024-001 · 活动执行</view>
  181. <view class="fe-biz-card__row"><text>状态</text><text class="fe-biz-card__val" style="color:#10B981;">进行中</text></view>
  182. <view class="fe-biz-card__row"><text>人数/时间</text><text class="fe-biz-card__val">10人 · 01-20至21</text></view>
  183. <view class="fe-biz-card__row"><text>已登记</text><text class="fe-biz-card__val">7/10人</text></view>
  184. </view>
  185. <view class="fe-biz-card">
  186. <view class="fe-biz-card__header">📋 订单 #2024-002 · 促销推广</view>
  187. <view class="fe-biz-card__row"><text>状态</text><text class="fe-biz-card__val" style="color:#F59E0B;">待确认</text></view>
  188. <view class="fe-biz-card__row"><text>人数/时间</text><text class="fe-biz-card__val">5人 · 01-25至26</text></view>
  189. </view>
  190. </view>
  191. </view>
  192. <!-- Summary bubbles -->
  193. <view v-else-if="msg.type === 'summary1'" class="fe-summary-bubble" @tap="openSheet('draft')" :id="'msg-' + idx">
  194. <text class="fe-summary-bubble__check">✓</text>
  195. <text><text class="fe-summary-bubble__text">方案A已确认</text> · 合规优先 · ¥12,800</text>
  196. </view>
  197. <view v-else-if="msg.type === 'summary2'" class="fe-summary-bubble" @tap="openSheet('settlement')" :id="'msg-' + idx">
  198. <text class="fe-summary-bubble__check">✓</text>
  199. <text><text class="fe-summary-bubble__text">结算已支付</text> · ¥50,400 · BATCH-001</text>
  200. </view>
  201. <!-- Knowledge actions -->
  202. <view v-else-if="msg.type === 'knowledge_actions'" class="fe-chat-msg fe-chat-msg--ai" :id="'msg-' + idx">
  203. <view class="fe-chat-avatar fe-chat-avatar--ai">AI</view>
  204. <view class="fe-chat-bubble">
  205. <view class="fe-quick-actions">
  206. <view class="fe-quick-action" @tap="triggerAI('政策法规')">
  207. <view class="fe-quick-action__icon" style="background:#DBEAFE;">📜</view>
  208. <text class="fe-quick-action__label">政策法规</text>
  209. </view>
  210. <view class="fe-quick-action" @tap="triggerAI('税务处理')">
  211. <view class="fe-quick-action__icon" style="background:#FEF3C7;">💰</view>
  212. <text class="fe-quick-action__label">税务处理</text>
  213. </view>
  214. <view class="fe-quick-action" @tap="triggerAI('合同管理')">
  215. <view class="fe-quick-action__icon" style="background:#D1FAE5;">📄</view>
  216. <text class="fe-quick-action__label">合同管理</text>
  217. </view>
  218. <view class="fe-quick-action" @tap="triggerAI('社保保险')">
  219. <view class="fe-quick-action__icon" style="background:#FEE2E2;">🛡</view>
  220. <text class="fe-quick-action__label">社保保险</text>
  221. </view>
  222. </view>
  223. </view>
  224. </view>
  225. </view>
  226. <view id="chat-bottom-anchor" class="chat-bottom-anchor" />
  227. </block>
  228. </block>
  229. </view>
  230. </scroll-view>
  231. <FeReportFloat :visible="showReportFloat" @open="openReportSheet" />
  232. <!-- Footer: Quick Actions + Input -->
  233. <view class="chat-footer">
  234. <view class="fe-quick-actions fe-quick-actions--dock guide-target guide-target--quick-bar">
  235. <view class="fe-quick-action" @tap="triggerAI('新用工需求')">
  236. <view class="fe-quick-action__icon" style="background:#FEF3C7;">➕</view>
  237. <text class="fe-quick-action__label">发布需求</text>
  238. </view>
  239. <view class="fe-quick-action" @tap="openConversationCost">
  240. <view class="fe-quick-action__icon" style="background:#D1FAE5;">💰</view>
  241. <text class="fe-quick-action__label">成本测算</text>
  242. </view>
  243. <view class="fe-quick-action" @tap="openProgressOverview">
  244. <view class="fe-quick-action__icon" style="background:#DBEAFE;">📈</view>
  245. <text class="fe-quick-action__label">进度总览</text>
  246. </view>
  247. <view class="fe-quick-action" @tap="openSheet('settlement')">
  248. <view class="fe-quick-action__icon" style="background:#FEE2E2;">💳</view>
  249. <text class="fe-quick-action__label">历史订单</text>
  250. </view>
  251. </view>
  252. <view
  253. class="fe-input-bar fe-input-bar--embedded"
  254. :class="{ 'fe-input-bar--disabled': inputDisabled }"
  255. >
  256. <view
  257. class="fe-input-bar__btn fe-input-bar__btn--voice"
  258. @tap="onVoiceTap"
  259. >🎤</view>
  260. <input
  261. class="fe-input-bar__field"
  262. v-model="inputText"
  263. :disabled="inputDisabled"
  264. :placeholder="inputPlaceholder"
  265. confirm-type="send"
  266. @input="onInput"
  267. @confirm="sendMsg"
  268. />
  269. <view
  270. class="fe-input-bar__btn fe-input-bar__btn--send"
  271. :class="{ active: canSend }"
  272. @tap="sendMsg"
  273. >➤</view>
  274. </view>
  275. </view>
  276. <!-- Overlay -->
  277. <view class="fe-overlay" :class="{ 'fe-overlay--active': overlayActive, 'fe-overlay--elevated': sheetElevated }" @tap="closeAll" />
  278. <!-- Sheets -->
  279. <FeDraftSheet
  280. :active="activeSheet === 'draft'"
  281. :draft="activeDraft"
  282. @confirm="confirmPlan"
  283. @updated="onDraftUpdated"
  284. @view-qr="openDraftQr"
  285. />
  286. <FeConversationCostSheet
  287. :active="activeSheet === 'cost'"
  288. :conversation-id="currentConversationId"
  289. :draft-metas="costDraftMetas"
  290. @confirm="onConversationCostConfirm"
  291. @updated="onDraftUpdated($event, { sinkToBottom: false })"
  292. @view-qr="openDraftQr"
  293. />
  294. <FeReportSheet :active="activeSheet === 'report'" @progress="openProgressOverview" />
  295. <FeConversationProgressSheet
  296. :active="activeSheet === 'progressOverview'"
  297. :conversation-id="currentConversationId"
  298. />
  299. <FeProgressSheet
  300. :active="activeSheet === 'progress'"
  301. :elevated="sheetElevated"
  302. :draft-id="progressDraftId"
  303. />
  304. <FeSettlementSheet :active="activeSheet === 'settlement'" @pay="showPayment" />
  305. <!-- Drawer: Message -->
  306. <view class="fe-side-drawer" :class="{ 'fe-side-drawer--active': activeDrawer === 'message' }">
  307. <view class="fe-drawer-header"><view class="fe-drawer-title">🔔 消息与待办</view></view>
  308. <scroll-view scroll-y class="fe-drawer-body" :show-scrollbar="false">
  309. <view class="fe-drawer-body__inner">
  310. <view v-for="(m, i) in inbox" :key="i" class="fe-msg-item">
  311. <view style="display:flex;justify-content:space-between;margin-bottom:8rpx;">
  312. <text class="fe-msg-type" :class="'fe-msg-type--' + m.type">{{ m.type === 'pending' ? '待办' : '异常' }}</text>
  313. <text style="font-size:22rpx;color:#94A3B8;">{{ m.time }}</text>
  314. </view>
  315. <view style="font-weight:600;font-size:28rpx;">{{ m.title }}</view>
  316. <view style="font-size:26rpx;color:#64748B;margin-top:4rpx;">{{ m.desc }}</view>
  317. </view>
  318. </view>
  319. </scroll-view>
  320. </view>
  321. <!-- Drawer: 企业管理中枢 -->
  322. <view class="fe-side-drawer" :class="{ 'fe-side-drawer--active': activeDrawer === 'profile' }">
  323. <view class="fe-drawer-header"><view class="fe-drawer-title">企业管理中枢</view></view>
  324. <scroll-view scroll-y class="fe-drawer-body" :show-scrollbar="false">
  325. <view class="fe-drawer-body__inner">
  326. <EnterpriseHubPanel ref="hubPanel" show-logout @logout="logout" />
  327. </view>
  328. </scroll-view>
  329. </view>
  330. <!-- Modal Confirm -->
  331. <view class="fe-modal-overlay" :class="{ 'fe-modal-overlay--active': modal === 'confirm' || modal === 'payment' || modal === 'paySuccess' }" @tap="closeModal" />
  332. <view class="fe-modal" :class="{ 'fe-modal--active': modal === 'confirm' }">
  333. <view class="fe-modal-body">
  334. <view class="fe-modal-icon fe-modal-icon--success">✓</view>
  335. <view class="fe-modal-title">{{ confirmModalTitle }}</view>
  336. <view class="fe-modal-desc">
  337. <view>{{ confirmModalDescMain }}</view>
  338. <view>{{ confirmModalDescSub }}</view>
  339. </view>
  340. </view>
  341. <view class="fe-modal-footer">
  342. <view class="fe-modal-btn fe-modal-btn--cancel" @tap="closeModal">稍后查看</view>
  343. <view class="fe-modal-btn fe-modal-btn--confirm" @tap="showQR">查看二维码</view>
  344. </view>
  345. </view>
  346. <FeBatchQrModal
  347. :active="modal === 'qr'"
  348. :batch="qrBatch"
  349. @close="closeModal"
  350. @progress="openQrProgress"
  351. @progress-loaded="onQrProgressLoaded"
  352. />
  353. <!-- Modal Payment -->
  354. <view class="fe-modal" :class="{ 'fe-modal--active': modal === 'payment' }">
  355. <view class="fe-modal-body">
  356. <view class="fe-modal-title">确认支付 ¥52,800</view>
  357. <view class="fe-modal-desc">将从企业对公账户扣款\n浦东仓库搬运 BATCH-001</view>
  358. </view>
  359. <view class="fe-modal-footer">
  360. <view class="fe-modal-btn fe-modal-btn--cancel" @tap="closeModal">取消</view>
  361. <view class="fe-modal-btn fe-modal-btn--confirm" @tap="showPaySuccess">确认支付</view>
  362. </view>
  363. </view>
  364. <!-- Modal Pay Success -->
  365. <view class="fe-modal" :class="{ 'fe-modal--active': modal === 'paySuccess' }">
  366. <view class="fe-modal-body">
  367. <view class="fe-modal-icon fe-modal-icon--success">✓</view>
  368. <view class="fe-modal-title">支付成功</view>
  369. <view class="fe-modal-desc">¥52,800 已支付\n结算单 BATCH-001</view>
  370. </view>
  371. <view class="fe-modal-footer">
  372. <view class="fe-modal-btn fe-modal-btn--confirm" style="flex:1;" @tap="closeModal">完成</view>
  373. </view>
  374. </view>
  375. </view>
  376. </template>
  377. <script>
  378. import EnterpriseHubPanel from '@/packageA/components/EnterpriseHubPanel.vue'
  379. import FeDraftSheet from '@/packageA/components/chat/FeDraftSheet.vue'
  380. import FeConversationCostSheet from '@/packageA/components/chat/FeConversationCostSheet.vue'
  381. import FeConversationProgressSheet from '@/packageA/components/chat/FeConversationProgressSheet.vue'
  382. import FeProgressSheet from '@/packageA/components/chat/FeProgressSheet.vue'
  383. import FeSettlementSheet from '@/packageA/components/chat/FeSettlementSheet.vue'
  384. import FeReportSheet from '@/packageA/components/chat/FeReportSheet.vue'
  385. import FeReportFloat from '@/packageA/components/chat/FeReportFloat.vue'
  386. import FeBatchQrModal from '@/packageA/components/chat/FeBatchQrModal.vue'
  387. import FeChatMarkdown from '@/packageA/components/chat/FeChatMarkdown.vue'
  388. import store from '@/common/store.js'
  389. import { MESSAGES, BATCH_QR, showToast } from '@/common/chat-data.js'
  390. import { listConversations, createConversation, streamConversationChat, listConversationMessages } from '@/api/conversation.js'
  391. import { getDraftDetail } from '@/api/draft.js'
  392. import {
  393. getNewChatGreeting,
  394. mapConversationItem,
  395. mapConversationMessage,
  396. calcLastMessagePage,
  397. CONVERSATION_MESSAGE_PAGE_SIZE
  398. } from '@/utils/conversation.js'
  399. import {
  400. shouldFetchDraftCard,
  401. shouldShowOrderQrcode,
  402. isDraftCardContentType,
  403. buildDraftCardMessage,
  404. mapDraftDetailToCard,
  405. syncDraftCardViewMeta,
  406. getDraftStatus,
  407. pickDraftQrCodeUrl,
  408. pickDraftQrToken,
  409. parseChatCardPayload,
  410. formatDraftMoney
  411. } from '@/utils/draft.js'
  412. import {
  413. buildJobOfferFromDraft,
  414. saveJobOffer,
  415. buildJobOfferPageUrl,
  416. buildJobOfferShareMessage
  417. } from '@/utils/job-offer.js'
  418. import { resolveApiAssetUrl } from '@/common/config.js'
  419. export default {
  420. name: 'EnterpriseHome',
  421. options: {
  422. styleIsolation: 'shared'
  423. },
  424. props: {
  425. active: { type: Boolean, default: true }
  426. },
  427. components: {
  428. EnterpriseHubPanel,
  429. FeDraftSheet,
  430. FeConversationCostSheet,
  431. FeConversationProgressSheet,
  432. FeProgressSheet,
  433. FeSettlementSheet,
  434. FeReportSheet,
  435. FeReportFloat,
  436. FeBatchQrModal,
  437. FeChatMarkdown
  438. },
  439. data() {
  440. return {
  441. user: {},
  442. enterprise: {},
  443. sessions: [],
  444. sessionsLoading: false,
  445. creatingSession: false,
  446. currentSession: '',
  447. currentConversationId: null,
  448. messages: [],
  449. messagesLoading: false,
  450. messagesLoadingMore: false,
  451. historyPage: 1,
  452. hasMoreHistory: false,
  453. hasMessageHistory: false,
  454. messagePageSize: CONVERSATION_MESSAGE_PAGE_SIZE,
  455. inputText: '',
  456. canSend: false,
  457. chatStreaming: false,
  458. abortChat: null,
  459. scrollTop: 0,
  460. scrollIntoView: '',
  461. scrollWithAnimation: true,
  462. stickToBottom: true,
  463. sidebarOpen: false,
  464. activeSheet: '',
  465. activeDraft: null,
  466. activeDrawer: '',
  467. modal: '',
  468. confirmInfo: null,
  469. qrBatch: { ...BATCH_QR },
  470. /** 已处理过「方案已确认 + 二维码」的草稿 id,同会话内只执行一次 */
  471. orderConfirmHandledIds: {},
  472. inbox: MESSAGES,
  473. reportCardVisible: true,
  474. reportObserver: null,
  475. _localMsgSeq: 0,
  476. progressDraftId: null,
  477. costDraftMetas: []
  478. }
  479. },
  480. computed: {
  481. overlayActive() {
  482. return !!(this.sidebarOpen || this.activeSheet || this.activeDrawer)
  483. },
  484. confirmModalTitle() {
  485. const info = this.confirmInfo
  486. if (!info || !info.title) return '方案已确认'
  487. return `已确认:${info.title}`
  488. },
  489. confirmModalDescMain() {
  490. const info = this.confirmInfo
  491. if (!info) return '方案确认成功'
  492. const parts = []
  493. if (info.tagsText) parts.push(info.tagsText)
  494. if (info.total) parts.push(`总支出 ${info.total}`)
  495. if (info.avgCost) parts.push(`人均 ${info.avgCost}`)
  496. return parts.length ? parts.join(' · ') : '方案确认成功'
  497. },
  498. confirmModalDescSub() {
  499. const info = this.confirmInfo
  500. const orderId = info && info.orderId
  501. if (orderId) return `订单 ${orderId} 已生成,可查看批次二维码`
  502. return '确认后可查看并分享批次二维码'
  503. },
  504. hasReportMessage() {
  505. return this.messages.some((m) => m.type === 'report')
  506. },
  507. showReportFloat() {
  508. return this.hasReportMessage && !this.reportCardVisible
  509. },
  510. showNewChatEmpty() {
  511. return !this.sessionsLoading && !this.currentSession && !this.sessions.length
  512. },
  513. /** 未开启/选中会话时禁止输入 */
  514. inputDisabled() {
  515. return !this.currentConversationId || this.chatStreaming
  516. },
  517. inputPlaceholder() {
  518. if (!this.currentConversationId) return '请先开启新对话'
  519. if (this.chatStreaming) return 'AI 正在回复...'
  520. return '输入需求或按住说话...'
  521. },
  522. newChatGreeting() {
  523. return getNewChatGreeting()
  524. },
  525. sheetElevated() {
  526. return this.modal === 'qr' && this.activeSheet === 'progress'
  527. }
  528. },
  529. onShow() {
  530. if (!this.active) return
  531. this.initHome()
  532. },
  533. mounted() {
  534. if (this.active) this.initHome()
  535. },
  536. watch: {
  537. active(val) {
  538. if (val) this.initHome()
  539. }
  540. },
  541. onReady() {
  542. this.setupReportObserver()
  543. },
  544. beforeDestroy() {
  545. this.disconnectReportObserver()
  546. this.stopChatStream()
  547. if (this._scrollTimer) clearTimeout(this._scrollTimer)
  548. },
  549. methods: {
  550. toast: showToast,
  551. async initHome() {
  552. const s = store.getState()
  553. this.user = s.user
  554. this.enterprise = s.enterprise
  555. await this.fetchSessions({ autoLoad: true })
  556. },
  557. async fetchSessions(options = {}) {
  558. const { autoLoad = false, showError = false } = options
  559. this.sessionsLoading = true
  560. try {
  561. const list = await listConversations({ status: 'active' }, { showError })
  562. this.sessions = (list || []).map(mapConversationItem)
  563. if (autoLoad) {
  564. if (this.sessions.length) {
  565. const current = this.sessions.find((item) => item.conversationNo === this.currentSession)
  566. const target = current || this.sessions[0]
  567. await this.loadSession(target.conversationNo, target.chatType)
  568. } else if (!this.currentSession) {
  569. this.messages = []
  570. }
  571. }
  572. } catch (e) {
  573. if (autoLoad && !this.currentSession) {
  574. this.messages = []
  575. }
  576. } finally {
  577. this.sessionsLoading = false
  578. }
  579. },
  580. buildWelcomeMessages() {
  581. return [{
  582. type: 'text',
  583. role: 'ai',
  584. content: getNewChatGreeting(),
  585. msgKey: this.nextLocalMsgKey()
  586. }]
  587. },
  588. nextLocalMsgKey() {
  589. this._localMsgSeq += 1
  590. return -this._localMsgSeq
  591. },
  592. resetMessagePagination() {
  593. this.historyPage = 1
  594. this.hasMoreHistory = false
  595. this.hasMessageHistory = false
  596. this.messagesLoading = false
  597. this.messagesLoadingMore = false
  598. },
  599. async loadSession(conversationNo, chatType) {
  600. this.abortChatStream()
  601. this.currentSession = conversationNo
  602. const session = this.sessions.find((item) => item.conversationNo === conversationNo)
  603. this.currentConversationId = session ? session.id : null
  604. this.resetMessagePagination()
  605. this.messages = []
  606. this.orderConfirmHandledIds = {}
  607. this.reportCardVisible = true
  608. await this.fetchLatestMessages({ scrollBottom: true })
  609. this.markOrderConfirmHandledFromMessages()
  610. this.$nextTick(() => {
  611. this.setupReportObserver()
  612. setTimeout(() => this.checkReportVisibility(), 120)
  613. })
  614. },
  615. async fetchLatestMessages(options = {}) {
  616. const { scrollBottom = false } = options
  617. if (!this.currentConversationId) {
  618. this.messages = this.buildWelcomeMessages()
  619. return
  620. }
  621. this.messagesLoading = true
  622. try {
  623. const probe = await listConversationMessages(
  624. this.currentConversationId,
  625. { page: 1, size: this.messagePageSize },
  626. { showError: true }
  627. )
  628. const total = probe.total || 0
  629. if (!total) {
  630. this.messages = this.buildWelcomeMessages()
  631. this.hasMoreHistory = false
  632. this.hasMessageHistory = false
  633. return
  634. }
  635. const lastPage = calcLastMessagePage(total, this.messagePageSize)
  636. let pageData = probe
  637. if (lastPage > 1) {
  638. pageData = await listConversationMessages(
  639. this.currentConversationId,
  640. { page: lastPage, size: this.messagePageSize },
  641. { showError: true }
  642. )
  643. }
  644. this.historyPage = lastPage
  645. this.hasMoreHistory = lastPage > 1
  646. this.hasMessageHistory = true
  647. this.messages = (pageData.items || []).map(mapConversationMessage)
  648. await this.hydrateDraftCardsFromMessages()
  649. if (scrollBottom) {
  650. this.stickToBottom = true
  651. this.ensureChatBottom()
  652. }
  653. } catch (e) {
  654. this.messages = this.buildWelcomeMessages()
  655. this.hasMoreHistory = false
  656. this.hasMessageHistory = false
  657. } finally {
  658. this.messagesLoading = false
  659. }
  660. },
  661. measureChatContentHeight() {
  662. return new Promise((resolve) => {
  663. const query = uni.createSelectorQuery().in(this)
  664. query.select('.chat-area__inner').boundingClientRect()
  665. query.exec((res) => {
  666. resolve((res[0] && res[0].height) || 0)
  667. })
  668. })
  669. },
  670. restoreScrollAfterPrepend(beforeHeight, beforeTop) {
  671. return new Promise((resolve) => {
  672. this.$nextTick(() => {
  673. setTimeout(async () => {
  674. const afterHeight = await this.measureChatContentHeight()
  675. const delta = afterHeight - beforeHeight
  676. this._programmaticScroll = true
  677. this.scrollWithAnimation = false
  678. this._scrollBump = (this._scrollBump || 0) + 1
  679. this.scrollTop = Math.max(0, beforeTop + delta) + this._scrollBump
  680. setTimeout(() => {
  681. this._programmaticScroll = false
  682. resolve()
  683. }, 60)
  684. }, 50)
  685. })
  686. })
  687. },
  688. async loadMoreHistory() {
  689. if (
  690. !this.hasMoreHistory ||
  691. this.messagesLoadingMore ||
  692. this.messagesLoading ||
  693. this.chatStreaming ||
  694. !this.currentConversationId
  695. ) {
  696. return
  697. }
  698. const olderPage = this.historyPage - 1
  699. if (olderPage < 1) {
  700. this.hasMoreHistory = false
  701. return
  702. }
  703. this.messagesLoadingMore = true
  704. try {
  705. const beforeHeight = await this.measureChatContentHeight()
  706. const beforeTop = this._lastScrollTop || 0
  707. const pageData = await listConversationMessages(
  708. this.currentConversationId,
  709. { page: olderPage, size: this.messagePageSize },
  710. { showError: false }
  711. )
  712. const older = (pageData.items || []).map(mapConversationMessage)
  713. if (!older.length) {
  714. this.hasMoreHistory = false
  715. return
  716. }
  717. this.messages = [...older, ...this.messages]
  718. this.historyPage = olderPage
  719. this.hasMoreHistory = olderPage > 1
  720. await this.hydrateDraftCardsFromMessages(older)
  721. await this.restoreScrollAfterPrepend(beforeHeight, beforeTop)
  722. } catch (e) {
  723. // 错误提示由 request.js 统一处理
  724. } finally {
  725. this.messagesLoadingMore = false
  726. }
  727. },
  728. onChatScrollToUpper() {
  729. this.loadMoreHistory()
  730. },
  731. switchSession(conversationNo) {
  732. if (this.currentSession === conversationNo) {
  733. this.sidebarOpen = false
  734. return
  735. }
  736. this.sidebarOpen = false
  737. const session = this.sessions.find((item) => item.conversationNo === conversationNo)
  738. this.loadSession(conversationNo, session && session.chatType)
  739. },
  740. switchSessionByIndex(index) {
  741. const session = this.sessions[index]
  742. if (!session) return
  743. this.switchSession(session.conversationNo)
  744. },
  745. async newChat() {
  746. if (this.creatingSession) return
  747. this.creatingSession = true
  748. this.sidebarOpen = false
  749. try {
  750. const created = await createConversation({
  751. chatType: 'employment',
  752. title: '新对话'
  753. })
  754. const item = mapConversationItem(created)
  755. this.sessions = [
  756. item,
  757. ...this.sessions.filter((session) => session.conversationNo !== item.conversationNo)
  758. ]
  759. this.loadSession(item.conversationNo, item.chatType)
  760. this.toast('已开启新对话')
  761. } catch (e) {
  762. // 错误提示由 request.js 统一处理
  763. } finally {
  764. this.creatingSession = false
  765. }
  766. },
  767. scrollBottom(animated = true, force = false) {
  768. if (!force && !this.stickToBottom && !this.chatStreaming) return
  769. if (this._scrollTimer) clearTimeout(this._scrollTimer)
  770. this._scrollTimer = setTimeout(() => {
  771. this._scrollTimer = null
  772. this.scrollWithAnimation = !!animated
  773. this._programmaticScroll = true
  774. this._scrollBump = (this._scrollBump || 0) + 1
  775. this.scrollTop = 100000 + this._scrollBump
  776. setTimeout(() => {
  777. this._programmaticScroll = false
  778. }, animated ? 300 : 60)
  779. setTimeout(() => this.checkReportVisibility(), 120)
  780. }, animated ? 40 : 0)
  781. },
  782. scrollToBottomAnchor() {
  783. this.scrollIntoView = ''
  784. this.$nextTick(() => {
  785. this.scrollIntoView = 'chat-bottom-anchor'
  786. })
  787. },
  788. ensureChatBottom() {
  789. this.stickToBottom = true
  790. const scroll = () => {
  791. this.scrollBottom(false, true)
  792. this.scrollToBottomAnchor()
  793. }
  794. this.$nextTick(() => {
  795. scroll()
  796. setTimeout(scroll, 60)
  797. setTimeout(scroll, 180)
  798. setTimeout(scroll, 360)
  799. })
  800. },
  801. onChatScroll(e) {
  802. const detail = e.detail || {}
  803. this._lastScrollTop = detail.scrollTop || 0
  804. if (this._programmaticScroll) {
  805. this.checkReportVisibility()
  806. return
  807. }
  808. const gap = (detail.scrollHeight || 0) - (detail.scrollTop || 0) - (detail.clientHeight || 0)
  809. if (this.chatStreaming) {
  810. // 流式输出时内容可能突增,仅当明显上滑才取消贴底
  811. if (gap > 280) this.stickToBottom = false
  812. } else {
  813. this.stickToBottom = gap < 120
  814. }
  815. if (
  816. !this.chatStreaming &&
  817. !this.messagesLoading &&
  818. !this.messagesLoadingMore &&
  819. this._lastScrollTop <= 20
  820. ) {
  821. this.loadMoreHistory()
  822. }
  823. this.checkReportVisibility()
  824. },
  825. setupReportObserver() {
  826. this.disconnectReportObserver()
  827. if (!this.hasReportMessage) return
  828. this.$nextTick(() => {
  829. this.reportObserver = uni.createIntersectionObserver(this, { thresholds: [0, 0.01, 1] })
  830. this.reportObserver
  831. .relativeTo('.chat-area', { top: 0, bottom: 0 })
  832. .observe('.report-card-anchor', (res) => {
  833. this.reportCardVisible = res.intersectionRatio > 0
  834. })
  835. })
  836. },
  837. disconnectReportObserver() {
  838. if (this.reportObserver) {
  839. this.reportObserver.disconnect()
  840. this.reportObserver = null
  841. }
  842. },
  843. checkReportVisibility() {
  844. if (!this.hasReportMessage) {
  845. this.reportCardVisible = true
  846. return
  847. }
  848. const query = uni.createSelectorQuery().in(this)
  849. query.select('.chat-area').boundingClientRect()
  850. query.select('.report-card-anchor').boundingClientRect()
  851. query.exec((res) => {
  852. const area = res[0]
  853. const card = res[1]
  854. if (!area || !card) return
  855. this.reportCardVisible = card.bottom > area.top && card.top < area.bottom
  856. })
  857. },
  858. openReportSheet() {
  859. this.openSheet('report')
  860. },
  861. onInput() {
  862. this.canSend =
  863. !this.inputDisabled &&
  864. !!this.currentConversationId &&
  865. this.inputText.trim().length > 0 &&
  866. !this.chatStreaming
  867. },
  868. onVoiceTap() {
  869. if (this.inputDisabled) {
  870. if (!this.currentConversationId) this.toast('请先开启新对话')
  871. return
  872. }
  873. this.toast('按住说话...')
  874. },
  875. stopChatStream() {
  876. if (this.abortChat) {
  877. this.abortChat()
  878. this.abortChat = null
  879. }
  880. this.chatStreaming = false
  881. },
  882. abortChatStream() {
  883. if (this.abortChat) {
  884. this.abortChat()
  885. this.abortChat = null
  886. }
  887. },
  888. sendMsg() {
  889. if (this.inputDisabled && !this.chatStreaming) {
  890. if (!this.currentConversationId) this.toast('请先开启新对话')
  891. return
  892. }
  893. const text = this.inputText.trim()
  894. if (!text || this.chatStreaming) return
  895. if (!this.currentConversationId) {
  896. this.toast('请先开启新对话')
  897. return
  898. }
  899. this.abortChatStream()
  900. this.stickToBottom = true
  901. this.messages.push({ type: 'text', role: 'user', content: text, msgKey: this.nextLocalMsgKey() })
  902. const aiIdx = this.messages.length
  903. this.messages.push({
  904. type: 'text',
  905. role: 'ai',
  906. content: '',
  907. streaming: true,
  908. msgKey: this.nextLocalMsgKey()
  909. })
  910. this.inputText = ''
  911. this.canSend = false
  912. this.chatStreaming = true
  913. this.scrollBottom()
  914. this.abortChat = streamConversationChat({
  915. conversationId: this.currentConversationId,
  916. message: text,
  917. onDelta: (assembledText) => {
  918. const prev = this.messages[aiIdx].content || ''
  919. this.$set(this.messages[aiIdx], 'content', assembledText || '')
  920. const force = !!(assembledText && assembledText.length - prev.length > 80)
  921. this.scrollBottom(false, force)
  922. if (force) {
  923. this.$nextTick(() => this.scrollBottom(false, true))
  924. }
  925. },
  926. onDone: async (assembledText, cardMeta) => {
  927. this.$set(this.messages[aiIdx], 'streaming', false)
  928. if (assembledText) {
  929. this.$set(this.messages[aiIdx], 'content', assembledText)
  930. } else if (!this.messages[aiIdx].content) {
  931. this.$set(this.messages[aiIdx], 'content', '暂无回复,请稍后重试')
  932. }
  933. // 本轮 SSE 带草稿卡:card_draft 作废旧卡并末尾新卡;card_order 仅首次补卡
  934. if (shouldFetchDraftCard(cardMeta)) {
  935. await this.handleDraftCardFromChat(cardMeta)
  936. }
  937. // next_action=show_order_qrcode:同草稿只生成一次确认文案 + 二维码
  938. if (shouldShowOrderQrcode(cardMeta)) {
  939. await this.showOrderConfirmFromChat(cardMeta)
  940. }
  941. this.ensureChatBottom()
  942. this.stopChatStream()
  943. this.fetchSessions({ showError: false })
  944. },
  945. onError: (err) => {
  946. this.$set(this.messages[aiIdx], 'streaming', false)
  947. const message = (err && err.message) || '对话失败,请稍后重试'
  948. if (!this.messages[aiIdx].content) {
  949. this.$set(this.messages[aiIdx], 'content', message)
  950. } else {
  951. this.toast(message)
  952. }
  953. this.ensureChatBottom()
  954. this.stopChatStream()
  955. }
  956. })
  957. },
  958. triggerAI(text) {
  959. if (!this.currentConversationId) {
  960. this.toast('请先开启新对话')
  961. return
  962. }
  963. if (this.chatStreaming) return
  964. this.inputText = text
  965. this.canSend = true
  966. this.sendMsg()
  967. },
  968. isDraftVoidedByIndex(idx) {
  969. const msg = this.messages[idx]
  970. return !!(msg && msg.type === 'draft' && msg.voided)
  971. },
  972. isDraftConfirmedByIndex(idx) {
  973. const msg = this.messages[idx]
  974. if (!msg || msg.voided) return false
  975. return !!(msg.draft && getDraftStatus(msg.draft) === 'confirmed')
  976. },
  977. draftCardStatusLabelByIndex(idx) {
  978. if (this.isDraftVoidedByIndex(idx)) return '已作废'
  979. return this.isDraftConfirmedByIndex(idx) ? '已确认' : '未确认'
  980. },
  981. draftCardStatusClassByIndex(idx) {
  982. if (this.isDraftVoidedByIndex(idx)) return 'fe-draft-card-status--cancelled'
  983. return this.isDraftConfirmedByIndex(idx)
  984. ? 'fe-draft-card-status--confirmed'
  985. : 'fe-draft-card-status--pending'
  986. },
  987. async openDraftByIndex(idx) {
  988. const msg = this.messages[idx]
  989. if (!msg || msg.type !== 'draft' || !msg.draft) return
  990. if (msg.voided) {
  991. this.toast('该草稿已作废')
  992. return
  993. }
  994. let draft = msg.draft
  995. this.closeAll()
  996. this.activeDraft = draft
  997. setTimeout(() => { this.activeSheet = 'draft' }, 50)
  998. // 回源刷新状态(确认后缺省字段仍能正确展示)
  999. const draftId = draft.draftId
  1000. if (draftId == null) return
  1001. try {
  1002. const detail = await getDraftDetail(draftId, { showError: false })
  1003. if (!detail || detail.id == null) return
  1004. const card = mapDraftDetailToCard(detail)
  1005. // 保留本地已确认方案快照,避免刷新后丢失成本展示
  1006. if (draft.costPlans) card.costPlans = draft.costPlans
  1007. if (draft.confirmedPlan) card.confirmedPlan = draft.confirmedPlan
  1008. if (draft.confirmedPlanTitle) card.confirmedPlanTitle = draft.confirmedPlanTitle
  1009. if (draft.orderId && !card.orderId) card.orderId = draft.orderId
  1010. if (draft.qrToken && !card.qrToken) card.qrToken = draft.qrToken
  1011. if (draft.scene && !card.scene) card.scene = draft.scene
  1012. // qrCodeUrl 以详情接口为准;详情暂无时才回退本地
  1013. if (!card.qrCodeUrl && draft.qrCodeUrl) card.qrCodeUrl = draft.qrCodeUrl
  1014. if (card.qrToken && !card.scene) card.scene = card.qrToken
  1015. if (draft.selectedSchemeCode && !card.selectedSchemeCode) {
  1016. card.selectedSchemeCode = draft.selectedSchemeCode
  1017. }
  1018. this.onDraftUpdated(card, { sinkToBottom: false })
  1019. } catch (e) {
  1020. // 静默失败,沿用本地草稿
  1021. }
  1022. },
  1023. /**
  1024. * 移除会话中指定草稿卡片(保留对话文本)
  1025. */
  1026. removeDraftCardById(draftId) {
  1027. if (draftId == null) return
  1028. const id = Number(draftId)
  1029. if (Number.isNaN(id)) return
  1030. const key = String(id)
  1031. for (let i = this.messages.length - 1; i >= 0; i--) {
  1032. const msg = this.messages[i]
  1033. if (msg && msg.type === 'draft' && String(msg.draftId) === key) {
  1034. this.messages.splice(i, 1)
  1035. }
  1036. }
  1037. },
  1038. /**
  1039. * 更新草稿卡片(默认原地更新,不沉底)
  1040. * @param {object} draftCard
  1041. * @param {{ sinkToBottom?: boolean }} [options] 仅显式传 true 时才沉底
  1042. */
  1043. onDraftUpdated(draftCard, options = {}) {
  1044. if (!draftCard || draftCard.draftId == null) return
  1045. this.activeDraft = draftCard
  1046. const sinkToBottom = options.sinkToBottom === true
  1047. const draftKey = String(draftCard.draftId)
  1048. if (!sinkToBottom) {
  1049. for (let i = this.messages.length - 1; i >= 0; i--) {
  1050. const msg = this.messages[i]
  1051. if (
  1052. msg.type === 'draft'
  1053. && String(msg.draftId) === draftKey
  1054. && !msg.voided
  1055. ) {
  1056. this.$set(this.messages[i], 'draft', draftCard)
  1057. syncDraftCardViewMeta(this.messages[i])
  1058. return
  1059. }
  1060. }
  1061. // 仅有已作废同草稿卡时不「复活」新卡(避免普通对话末尾再插一张)
  1062. const hasSameDraft = this.messages.some(
  1063. (m) => m && m.type === 'draft' && String(m.draftId) === draftKey
  1064. )
  1065. if (hasSameDraft) return
  1066. // 会话中尚无该草稿卡时追加一次
  1067. this.messages.push(syncDraftCardViewMeta({
  1068. type: 'draft',
  1069. role: 'ai',
  1070. msgKey: this.nextLocalMsgKey(),
  1071. draftId: draftCard.draftId,
  1072. voided: false,
  1073. draft: draftCard
  1074. }))
  1075. return
  1076. }
  1077. this.removeDraftCardById(draftCard.draftId)
  1078. this.messages.push(syncDraftCardViewMeta({
  1079. type: 'draft',
  1080. role: 'ai',
  1081. msgKey: this.nextLocalMsgKey(),
  1082. draftId: draftCard.draftId,
  1083. voided: false,
  1084. draft: draftCard
  1085. }))
  1086. this.ensureChatBottom()
  1087. },
  1088. /**
  1089. * SSE 草稿卡:
  1090. * - card_draft:作废旧卡并在末尾生成新卡(对话改草稿)
  1091. * - card_order:后端在「已有确认订单+二维码」时每轮都会注入,已有有效卡则跳过,避免普通问答反复插卡
  1092. */
  1093. async handleDraftCardFromChat(cardMeta) {
  1094. if (!shouldFetchDraftCard(cardMeta)) return null
  1095. const draftId = cardMeta.relatedDraftId
  1096. if (cardMeta.contentType === 'card_order') {
  1097. const exists = this.messages.some(
  1098. (m) => m
  1099. && m.type === 'draft'
  1100. && !m.voided
  1101. && String(m.draftId) === String(draftId)
  1102. )
  1103. if (exists) return null
  1104. }
  1105. return this.appendDraftCardFromChat(draftId)
  1106. },
  1107. /**
  1108. * 历史消息:每个 related_draft_id 对应一张草稿卡,插到「提到该 id」的文本之后;其它草稿互不影响
  1109. * @param {Array} [sourceMessages]
  1110. */
  1111. async hydrateDraftCardsFromMessages(sourceMessages) {
  1112. const list = Array.isArray(sourceMessages) ? sourceMessages : this.messages
  1113. const draftIds = []
  1114. for (let i = 0; i < list.length; i++) {
  1115. const msg = list[i]
  1116. if (!msg || msg.type !== 'text') continue
  1117. if (!isDraftCardContentType(msg.contentType) || msg.relatedDraftId == null) continue
  1118. const id = msg.relatedDraftId
  1119. const prev = draftIds.indexOf(id)
  1120. if (prev >= 0) draftIds.splice(prev, 1)
  1121. draftIds.push(id)
  1122. }
  1123. for (let i = 0; i < draftIds.length; i++) {
  1124. const id = draftIds[i]
  1125. this.removeDraftCardById(id)
  1126. await this.appendDraftCardById(id, { afterRelatedText: true })
  1127. }
  1128. },
  1129. /**
  1130. * 根据当前消息中已有的二维码卡 / 已确认草稿,标记对应草稿已处理过确认流程
  1131. */
  1132. markOrderConfirmHandledFromMessages() {
  1133. const map = Object.assign({}, this.orderConfirmHandledIds)
  1134. for (let i = 0; i < this.messages.length; i++) {
  1135. const msg = this.messages[i]
  1136. if (!msg) continue
  1137. if (msg.type === 'qr' && msg.draftId != null) {
  1138. map[String(msg.draftId)] = true
  1139. }
  1140. if (
  1141. msg.type === 'draft'
  1142. && !msg.voided
  1143. && msg.draftId != null
  1144. && msg.draft
  1145. && getDraftStatus(msg.draft) === 'confirmed'
  1146. ) {
  1147. map[String(msg.draftId)] = true
  1148. }
  1149. }
  1150. this.orderConfirmHandledIds = map
  1151. },
  1152. isOrderConfirmHandled(draftId) {
  1153. if (draftId == null) return false
  1154. return !!this.orderConfirmHandledIds[String(draftId)]
  1155. },
  1156. markOrderConfirmHandled(draftId) {
  1157. if (draftId == null) return
  1158. this.$set(this.orderConfirmHandledIds, String(draftId), true)
  1159. },
  1160. /**
  1161. * 将会话中同草稿 / 同订单的有效草稿卡标记为已作废
  1162. */
  1163. voidRelatedDraftCards(options = {}) {
  1164. const draftId = options.draftId
  1165. const orderId = options.orderId
  1166. const draftKey = draftId != null ? String(draftId) : ''
  1167. const orderKey = orderId != null && orderId !== '' ? String(orderId) : ''
  1168. if (!draftKey && !orderKey) return
  1169. for (let i = 0; i < this.messages.length; i++) {
  1170. const msg = this.messages[i]
  1171. if (!msg || msg.type !== 'draft' || msg.voided) continue
  1172. const sameDraft = draftKey && String(msg.draftId) === draftKey
  1173. const msgOrderId = msg.draft && (msg.draft.orderId || msg.draft.order_id)
  1174. const sameOrder = orderKey && msgOrderId != null && String(msgOrderId) === orderKey
  1175. if (sameDraft || sameOrder) {
  1176. this.$set(this.messages[i], 'voided', true)
  1177. syncDraftCardViewMeta(this.messages[i])
  1178. }
  1179. }
  1180. },
  1181. /**
  1182. * SSE 返回草稿卡:作废上方同订单/同草稿旧卡,并在会话末尾生成新卡
  1183. */
  1184. async appendDraftCardFromChat(draftId) {
  1185. if (draftId == null) return null
  1186. const id = Number(draftId)
  1187. if (Number.isNaN(id)) return null
  1188. try {
  1189. const detail = await getDraftDetail(id, { showError: false })
  1190. if (!detail || detail.id == null) return null
  1191. const orderId = detail.orderId || detail.order_id || ''
  1192. this.voidRelatedDraftCards({ draftId: id, orderId })
  1193. const cardMsg = buildDraftCardMessage(detail, this.nextLocalMsgKey())
  1194. cardMsg.voided = false
  1195. this.messages.push(cardMsg)
  1196. this.ensureChatBottom()
  1197. return cardMsg
  1198. } catch (e) {
  1199. return null
  1200. }
  1201. },
  1202. /**
  1203. * 历史消息还原:插到提到该草稿 id 的文本后(不作废链路)
  1204. * @param {number} draftId
  1205. * @param {{ afterRelatedText?: boolean }} [options]
  1206. */
  1207. async appendDraftCardById(draftId, options = {}) {
  1208. if (draftId == null) return null
  1209. const id = Number(draftId)
  1210. if (Number.isNaN(id)) return null
  1211. const exists = this.messages.some((m) => m.type === 'draft' && m.draftId === id && !m.voided)
  1212. if (exists) return null
  1213. try {
  1214. const detail = await getDraftDetail(id, { showError: false })
  1215. if (!detail || detail.id == null) return null
  1216. const cardMsg = buildDraftCardMessage(detail, this.nextLocalMsgKey())
  1217. cardMsg.voided = false
  1218. if (options.afterRelatedText) {
  1219. let insertAt = -1
  1220. for (let i = this.messages.length - 1; i >= 0; i--) {
  1221. const msg = this.messages[i]
  1222. if (
  1223. msg.type === 'text'
  1224. && msg.relatedDraftId === id
  1225. && isDraftCardContentType(msg.contentType)
  1226. ) {
  1227. insertAt = i + 1
  1228. break
  1229. }
  1230. }
  1231. if (insertAt < 0) {
  1232. for (let i = this.messages.length - 1; i >= 0; i--) {
  1233. const msg = this.messages[i]
  1234. if (msg.type === 'text' && msg.relatedDraftId === id) {
  1235. insertAt = i + 1
  1236. break
  1237. }
  1238. }
  1239. }
  1240. if (insertAt < 0) return null
  1241. this.messages.splice(insertAt, 0, cardMsg)
  1242. } else {
  1243. this.messages.push(cardMsg)
  1244. }
  1245. this.ensureChatBottom()
  1246. return cardMsg
  1247. } catch (e) {
  1248. return null
  1249. }
  1250. },
  1251. openSidebar() {
  1252. if (this.sidebarOpen) {
  1253. this.sidebarOpen = false
  1254. return
  1255. }
  1256. this.activeSheet = ''
  1257. this.activeDrawer = ''
  1258. this.sidebarOpen = true
  1259. this.fetchSessions()
  1260. },
  1261. openSheet(name) {
  1262. this.closeAll()
  1263. setTimeout(() => { this.activeSheet = name }, 50)
  1264. },
  1265. openDrawer(name) {
  1266. this.closeAll()
  1267. setTimeout(() => {
  1268. this.activeDrawer = name
  1269. if (name === 'profile' && this.$refs.hubPanel) {
  1270. this.$refs.hubPanel.refreshPasswordStatus()
  1271. }
  1272. }, 50)
  1273. },
  1274. closeAll() {
  1275. this.sidebarOpen = false
  1276. this.activeSheet = ''
  1277. this.activeDrawer = ''
  1278. this.activeDraft = null
  1279. },
  1280. closeModal() {
  1281. this.modal = ''
  1282. this.confirmInfo = null
  1283. },
  1284. /**
  1285. * 用工场景标题:地点+岗位 / 草稿标题
  1286. */
  1287. resolveDraftSceneTitle(draft, fallback) {
  1288. if (!draft) return fallback || '用工批次'
  1289. const title = draft.title && draft.title !== '用工草稿' ? draft.title : ''
  1290. if (title) return title
  1291. const loc = draft.workLocation && draft.workLocation !== '待补充' ? draft.workLocation : ''
  1292. const type = draft.workType && draft.workType !== '待补充' ? draft.workType : ''
  1293. const combined = `${loc}${type}`
  1294. return combined || fallback || '用工批次'
  1295. },
  1296. buildQrBatch(draft, options = {}) {
  1297. const draftId = options.draftId
  1298. || (draft && (draft.draftId != null ? draft.draftId : (draft.detail && draft.detail.id)))
  1299. || null
  1300. const batchId = (draft && draft.draftNo) || (draftId != null ? `draft_${draftId}` : '')
  1301. const sceneTitle = this.resolveDraftSceneTitle(draft, options.schemeTitle)
  1302. const workerCount = draft && draft.workerCount != null ? Number(draft.workerCount) : 0
  1303. const subtitle = batchId ? `${sceneTitle} ${batchId}` : sceneTitle
  1304. const enterpriseName = (this.enterprise && this.enterprise.name) || ''
  1305. const scheme = options.scheme || (draft && draft.confirmedPlan) || null
  1306. const qrCodeUrl = options.qrCodeUrl || pickDraftQrCodeUrl(draft) || ''
  1307. const qrToken = options.qrToken
  1308. || pickDraftQrToken(draft)
  1309. || options.orderId
  1310. || (draft && draft.orderId)
  1311. || ''
  1312. const offer = buildJobOfferFromDraft(draft, {
  1313. batchId,
  1314. draftId,
  1315. scheme,
  1316. schemeTitle: options.schemeTitle || (scheme && scheme.title),
  1317. enterpriseName,
  1318. orderId: options.orderId || (draft && draft.orderId) || '',
  1319. qrToken,
  1320. scene: qrToken || options.scene || (draft && draft.scene) || ''
  1321. })
  1322. saveJobOffer(offer)
  1323. return Object.assign({}, BATCH_QR, {
  1324. modalTitle: '方案已确认',
  1325. title: sceneTitle,
  1326. batchId,
  1327. draftId: offer.draftId,
  1328. orderId: offer.orderId || '',
  1329. qrToken: offer.qrToken || qrToken || '',
  1330. scene: offer.scene || offer.qrToken || qrToken || '',
  1331. subtitle,
  1332. qrCodeUrl,
  1333. total: workerCount > 0 ? workerCount : BATCH_QR.total,
  1334. registered: 0,
  1335. hint: '分享给临时工扫码即可登记',
  1336. shareLink: buildJobOfferPageUrl(offer)
  1337. })
  1338. },
  1339. /**
  1340. * 从草稿详情拉取最新 qrCodeUrl / qrToken,并回写 activeDraft
  1341. */
  1342. async refreshDraftQrFromDetail(draftId) {
  1343. if (draftId == null) return { qrCodeUrl: '', qrToken: '' }
  1344. try {
  1345. const detail = await getDraftDetail(draftId, { showError: false })
  1346. if (!detail || detail.id == null) return { qrCodeUrl: '', qrToken: '' }
  1347. const qrCodeUrl = pickDraftQrCodeUrl(detail)
  1348. const qrToken = pickDraftQrToken(detail)
  1349. const card = mapDraftDetailToCard(detail)
  1350. const prev = this.activeDraft
  1351. if (prev && prev.draftId === detail.id) {
  1352. const merged = Object.assign({}, prev, card, {
  1353. qrCodeUrl,
  1354. qrToken,
  1355. scene: qrToken || prev.scene || '',
  1356. confirmedPlan: prev.confirmedPlan || card.confirmedPlan,
  1357. confirmedPlanTitle: prev.confirmedPlanTitle || card.confirmedPlanTitle,
  1358. costPlans: prev.costPlans || card.costPlans
  1359. })
  1360. this.onDraftUpdated(merged, { sinkToBottom: false })
  1361. }
  1362. return { qrCodeUrl, qrToken }
  1363. } catch (e) {
  1364. return { qrCodeUrl: '', qrToken: '' }
  1365. }
  1366. },
  1367. /**
  1368. * 确认方案后在对话框追加二维码卡片(同 draft 已有则不再追加)
  1369. */
  1370. appendQrCardMessage(batch) {
  1371. if (!batch) return
  1372. const draftId = batch.draftId
  1373. if (draftId != null) {
  1374. const exists = this.messages.some((m) => m && m.type === 'qr' && m.draftId === draftId)
  1375. if (exists) return
  1376. }
  1377. this.messages.push({
  1378. type: 'qr',
  1379. role: 'ai',
  1380. msgKey: this.nextLocalMsgKey(),
  1381. draftId: draftId != null ? draftId : null,
  1382. batch: Object.assign({}, batch)
  1383. })
  1384. this.ensureChatBottom()
  1385. },
  1386. syncQrCardMessage(batch) {
  1387. if (!batch || batch.draftId == null) return
  1388. for (let i = this.messages.length - 1; i >= 0; i--) {
  1389. const m = this.messages[i]
  1390. if (m && m.type === 'qr' && m.draftId === batch.draftId) {
  1391. this.$set(this.messages, i, Object.assign({}, m, {
  1392. batch: Object.assign({}, batch)
  1393. }))
  1394. return
  1395. }
  1396. }
  1397. },
  1398. qrImageUrlByIndex(idx) {
  1399. const msg = this.messages[idx]
  1400. if (!msg || msg.type !== 'qr' || !msg.batch) return ''
  1401. const raw = msg.batch.qrCodeUrl || msg.batch.qr_code_url || ''
  1402. return resolveApiAssetUrl(raw)
  1403. },
  1404. qrSubtitleByIndex(idx) {
  1405. const msg = this.messages[idx]
  1406. if (!msg || !msg.batch) return ''
  1407. return msg.batch.subtitle || msg.batch.title || ''
  1408. },
  1409. qrHintByIndex(idx) {
  1410. const msg = this.messages[idx]
  1411. if (!msg || !msg.batch) return '分享给临时工扫码即可登记'
  1412. return msg.batch.hint || '分享给临时工扫码即可登记'
  1413. },
  1414. openQrByIndex(idx) {
  1415. const msg = this.messages[idx]
  1416. if (!msg || msg.type !== 'qr' || !msg.batch) return
  1417. this.qrBatch = Object.assign({}, msg.batch)
  1418. this.showQR()
  1419. },
  1420. openProgressByQrIndex(idx) {
  1421. const msg = this.messages[idx]
  1422. if (!msg || msg.type !== 'qr') return
  1423. const draftId = msg.draftId
  1424. || (msg.batch && msg.batch.draftId)
  1425. this.openProgressDetail(draftId)
  1426. },
  1427. /**
  1428. * AI 对话 next_action=show_order_qrcode:
  1429. * 同草稿只执行一次:写确认文案 + 二维码卡,草稿卡原地标已确认(不沉底)
  1430. */
  1431. async showOrderConfirmFromChat(cardMeta) {
  1432. const payload = parseChatCardPayload(cardMeta && cardMeta.cardPayload)
  1433. const draftId = (cardMeta && cardMeta.relatedDraftId != null)
  1434. ? cardMeta.relatedDraftId
  1435. : (payload && (payload.draft_id != null ? payload.draft_id : payload.draftId))
  1436. if (this.isOrderConfirmHandled(draftId)) return
  1437. let draft = null
  1438. if (this.activeDraft && String(this.activeDraft.draftId) === String(draftId)) {
  1439. draft = this.activeDraft
  1440. } else if (draftId != null) {
  1441. for (let i = this.messages.length - 1; i >= 0; i--) {
  1442. const msg = this.messages[i]
  1443. if (msg && msg.type === 'draft' && String(msg.draftId) === String(draftId) && msg.draft) {
  1444. draft = msg.draft
  1445. break
  1446. }
  1447. }
  1448. }
  1449. if (!draft && draftId != null) {
  1450. try {
  1451. const detail = await getDraftDetail(draftId, { showError: false })
  1452. if (detail) draft = mapDraftDetailToCard(detail)
  1453. } catch (e) {
  1454. // ignore
  1455. }
  1456. }
  1457. const orderId = (payload && (payload.order_id != null ? payload.order_id : payload.orderId))
  1458. || (draft && draft.orderId)
  1459. || ''
  1460. const title = (payload && payload.title)
  1461. || (draft && draft.title)
  1462. || (draft && draft.confirmedPlanTitle)
  1463. || ''
  1464. let qrCodeUrl = (payload && (payload.qr_code_url || payload.qrCodeUrl))
  1465. || pickDraftQrCodeUrl(draft)
  1466. || ''
  1467. let qrToken = (payload && (payload.qr_token || payload.qrToken))
  1468. || pickDraftQrToken(draft)
  1469. || ''
  1470. const scene = qrToken || orderId || ''
  1471. this.qrBatch = this.buildQrBatch(draft, {
  1472. draftId,
  1473. scheme: draft && draft.confirmedPlan,
  1474. schemeTitle: title,
  1475. qrCodeUrl,
  1476. orderId,
  1477. qrToken,
  1478. scene
  1479. })
  1480. if (draftId != null) {
  1481. const fromDetail = await this.refreshDraftQrFromDetail(draftId)
  1482. if (fromDetail && fromDetail.qrCodeUrl) {
  1483. qrCodeUrl = fromDetail.qrCodeUrl
  1484. this.$set(this.qrBatch, 'qrCodeUrl', fromDetail.qrCodeUrl)
  1485. }
  1486. if (fromDetail && fromDetail.qrToken) {
  1487. qrToken = fromDetail.qrToken
  1488. this.$set(this.qrBatch, 'qrToken', fromDetail.qrToken)
  1489. this.$set(this.qrBatch, 'scene', fromDetail.qrToken)
  1490. }
  1491. }
  1492. this.markOrderConfirmHandled(draftId)
  1493. if (draft && draftId != null) {
  1494. this.onDraftUpdated(Object.assign({}, draft, {
  1495. status: 'confirmed',
  1496. orderId,
  1497. qrToken,
  1498. qrCodeUrl,
  1499. scene: qrToken || orderId || ''
  1500. }), { sinkToBottom: false })
  1501. }
  1502. this.closeAll()
  1503. this.messages.push({
  1504. type: 'text',
  1505. role: 'ai',
  1506. msgKey: this.nextLocalMsgKey(),
  1507. content: '方案已确认。请将下方二维码分享给工人扫码登记。'
  1508. })
  1509. this.appendQrCardMessage(this.qrBatch)
  1510. this.ensureChatBottom()
  1511. },
  1512. async confirmPlan(payload) {
  1513. const scheme = (payload && payload.scheme) || null
  1514. const result = (payload && payload.result) || {}
  1515. const draft = this.activeDraft
  1516. const orderId = result.orderId || result.order_id || ''
  1517. let qrCodeUrl = pickDraftQrCodeUrl(draft)
  1518. let qrToken = pickDraftQrToken(result) || pickDraftQrToken(draft) || ''
  1519. const tags = (scheme && Array.isArray(scheme.compliance) ? scheme.compliance : [])
  1520. .filter(Boolean)
  1521. const draftId = draft && draft.draftId
  1522. const scene = qrToken || orderId || ''
  1523. this.confirmInfo = {
  1524. title: (scheme && scheme.title) || (payload && payload.schemeCode) || '',
  1525. code: (payload && payload.schemeCode) || (scheme && scheme.code) || '',
  1526. total: (scheme && scheme.total) || '',
  1527. avgCost: (scheme && scheme.avgCost) || '',
  1528. tagsText: tags.join('、'),
  1529. orderId,
  1530. qrToken,
  1531. qrCodeUrl,
  1532. draftId
  1533. }
  1534. this.qrBatch = this.buildQrBatch(draft, {
  1535. scheme,
  1536. schemeTitle: scheme && scheme.title,
  1537. qrCodeUrl,
  1538. orderId,
  1539. qrToken,
  1540. scene
  1541. })
  1542. if (draft && draftId != null) {
  1543. this.onDraftUpdated(Object.assign({}, draft, {
  1544. orderId,
  1545. qrToken,
  1546. qrCodeUrl,
  1547. scene,
  1548. status: 'confirmed',
  1549. confirmedPlan: (scheme && {
  1550. id: scheme.id || scheme.code,
  1551. code: scheme.code,
  1552. title: scheme.title,
  1553. total: scheme.total,
  1554. avgCost: scheme.avgCost,
  1555. rows: scheme.rows,
  1556. breakdown: scheme.breakdown,
  1557. compliance: scheme.compliance,
  1558. raw: scheme.raw
  1559. }) || draft.confirmedPlan
  1560. }))
  1561. }
  1562. this.closeAll()
  1563. // 拉最新二维码 / qrToken 后插入对话
  1564. if (draftId != null) {
  1565. const fromDetail = await this.refreshDraftQrFromDetail(draftId)
  1566. if (fromDetail && fromDetail.qrCodeUrl) {
  1567. qrCodeUrl = fromDetail.qrCodeUrl
  1568. this.$set(this.qrBatch, 'qrCodeUrl', fromDetail.qrCodeUrl)
  1569. this.$set(this.confirmInfo, 'qrCodeUrl', fromDetail.qrCodeUrl)
  1570. }
  1571. if (fromDetail && fromDetail.qrToken) {
  1572. qrToken = fromDetail.qrToken
  1573. this.$set(this.qrBatch, 'qrToken', fromDetail.qrToken)
  1574. this.$set(this.qrBatch, 'scene', fromDetail.qrToken)
  1575. this.$set(this.confirmInfo, 'qrToken', fromDetail.qrToken)
  1576. }
  1577. }
  1578. this.messages.push({
  1579. type: 'text',
  1580. role: 'ai',
  1581. msgKey: this.nextLocalMsgKey(),
  1582. content: '方案已确认。请将下方二维码分享给工人扫码登记。'
  1583. })
  1584. this.markOrderConfirmHandled(draftId)
  1585. this.appendQrCardMessage(this.qrBatch)
  1586. setTimeout(() => { this.modal = 'confirm' }, 400)
  1587. },
  1588. async showQR() {
  1589. const draftId = (this.qrBatch && this.qrBatch.draftId)
  1590. || (this.confirmInfo && this.confirmInfo.draftId)
  1591. || (this.activeDraft && this.activeDraft.draftId)
  1592. let qrCodeUrl = pickDraftQrCodeUrl(this.qrBatch)
  1593. || pickDraftQrCodeUrl(this.activeDraft)
  1594. || (this.confirmInfo && this.confirmInfo.qrCodeUrl)
  1595. || ''
  1596. let qrToken = pickDraftQrToken(this.qrBatch)
  1597. || pickDraftQrToken(this.activeDraft)
  1598. || (this.confirmInfo && this.confirmInfo.qrToken)
  1599. || ''
  1600. if (draftId != null) {
  1601. const fromDetail = await this.refreshDraftQrFromDetail(draftId)
  1602. if (fromDetail && fromDetail.qrCodeUrl) qrCodeUrl = fromDetail.qrCodeUrl
  1603. if (fromDetail && fromDetail.qrToken) qrToken = fromDetail.qrToken
  1604. }
  1605. if (!this.qrBatch || !this.qrBatch.subtitle) {
  1606. this.qrBatch = this.buildQrBatch(this.activeDraft, {
  1607. schemeTitle: this.confirmInfo && this.confirmInfo.title,
  1608. draftId,
  1609. qrCodeUrl,
  1610. qrToken
  1611. })
  1612. } else {
  1613. this.$set(this.qrBatch, 'qrCodeUrl', qrCodeUrl)
  1614. if (qrToken) {
  1615. this.$set(this.qrBatch, 'qrToken', qrToken)
  1616. this.$set(this.qrBatch, 'scene', qrToken)
  1617. }
  1618. if (draftId != null && this.qrBatch.draftId == null) {
  1619. this.$set(this.qrBatch, 'draftId', draftId)
  1620. }
  1621. }
  1622. this.syncQrCardMessage(this.qrBatch)
  1623. this.modal = 'qr'
  1624. },
  1625. async openDraftQr(payload) {
  1626. let draft = this.activeDraft
  1627. const payloadDraftId = payload && payload.draftId
  1628. if (payloadDraftId != null) {
  1629. if (!draft || draft.draftId !== payloadDraftId) {
  1630. if (this.activeDraft && this.activeDraft.draftId === payloadDraftId) {
  1631. draft = this.activeDraft
  1632. } else {
  1633. for (let i = this.messages.length - 1; i >= 0; i--) {
  1634. const msg = this.messages[i]
  1635. if (msg && msg.type === 'draft' && msg.draftId === payloadDraftId && msg.draft) {
  1636. draft = msg.draft
  1637. break
  1638. }
  1639. }
  1640. }
  1641. if (draft) this.activeDraft = draft
  1642. }
  1643. }
  1644. const draftId = (draft && draft.draftId) || payloadDraftId
  1645. let qrCodeUrl = pickDraftQrCodeUrl(draft)
  1646. let qrToken = pickDraftQrToken(draft)
  1647. if (draftId != null) {
  1648. const fromDetail = await this.refreshDraftQrFromDetail(draftId)
  1649. if (fromDetail && fromDetail.qrCodeUrl) qrCodeUrl = fromDetail.qrCodeUrl
  1650. if (fromDetail && fromDetail.qrToken) qrToken = fromDetail.qrToken
  1651. }
  1652. this.qrBatch = this.buildQrBatch(this.activeDraft || draft, {
  1653. scheme: (this.activeDraft || draft) && (this.activeDraft || draft).confirmedPlan,
  1654. schemeTitle: (this.activeDraft || draft) && (this.activeDraft || draft).confirmedPlanTitle,
  1655. qrCodeUrl,
  1656. qrToken
  1657. })
  1658. this.modal = 'qr'
  1659. },
  1660. /**
  1661. * 供首页 onShareAppMessage 读取
  1662. * path 参数名仍为 scene,值为 qrToken
  1663. */
  1664. getShareAppMessage() {
  1665. const draftId = (this.qrBatch && this.qrBatch.draftId)
  1666. || (this.activeDraft && this.activeDraft.draftId)
  1667. || ''
  1668. const orderId = (this.qrBatch && this.qrBatch.orderId)
  1669. || (this.activeDraft && this.activeDraft.orderId)
  1670. || (this.confirmInfo && this.confirmInfo.orderId)
  1671. || ''
  1672. const qrToken = (this.qrBatch && this.qrBatch.qrToken)
  1673. || (this.activeDraft && this.activeDraft.qrToken)
  1674. || (this.confirmInfo && this.confirmInfo.qrToken)
  1675. || ''
  1676. const scene = qrToken
  1677. || (this.qrBatch && this.qrBatch.scene)
  1678. || (this.activeDraft && this.activeDraft.scene)
  1679. || orderId
  1680. || ''
  1681. return buildJobOfferShareMessage({ draftId, orderId, scene, qrToken })
  1682. },
  1683. openQrProgress() {
  1684. const draftId = (this.qrBatch && this.qrBatch.draftId)
  1685. || (this.activeDraft && this.activeDraft.draftId)
  1686. || (this.confirmInfo && this.confirmInfo.draftId)
  1687. this.openProgressDetail(draftId)
  1688. },
  1689. onQrProgressLoaded(payload) {
  1690. if (!payload || !this.qrBatch) return
  1691. if (payload.registered != null) {
  1692. this.$set(this.qrBatch, 'registered', payload.registered)
  1693. }
  1694. if (payload.total != null) {
  1695. this.$set(this.qrBatch, 'total', payload.total)
  1696. }
  1697. this.syncQrCardMessage(this.qrBatch)
  1698. },
  1699. openProgressOverview() {
  1700. if (!this.currentConversationId) {
  1701. this.toast('请先开启新对话')
  1702. return
  1703. }
  1704. this.openSheet('progressOverview')
  1705. },
  1706. collectCostDraftMetas() {
  1707. const metas = []
  1708. const seen = {}
  1709. const pushMeta = (draftId, title, workerCount) => {
  1710. if (draftId == null || draftId === '') return
  1711. const key = String(draftId)
  1712. if (seen[key]) return
  1713. seen[key] = true
  1714. metas.push({
  1715. draftId,
  1716. title: title || `用工草稿 #${draftId}`,
  1717. workerCount: workerCount != null ? Number(workerCount) : null
  1718. })
  1719. }
  1720. for (let i = 0; i < this.messages.length; i++) {
  1721. const msg = this.messages[i]
  1722. if (!msg || msg.type !== 'draft' || msg.draftId == null) continue
  1723. const d = msg.draft || {}
  1724. pushMeta(msg.draftId, d.title, d.workerCount)
  1725. }
  1726. if (this.activeDraft && this.activeDraft.draftId != null) {
  1727. pushMeta(
  1728. this.activeDraft.draftId,
  1729. this.activeDraft.title,
  1730. this.activeDraft.workerCount
  1731. )
  1732. }
  1733. return metas
  1734. },
  1735. openConversationCost() {
  1736. if (!this.currentConversationId) {
  1737. this.toast('请先开启新对话')
  1738. return
  1739. }
  1740. this.costDraftMetas = this.collectCostDraftMetas()
  1741. this.openSheet('cost')
  1742. },
  1743. async onConversationCostConfirm(payload) {
  1744. const draftId = payload && payload.draftId
  1745. if (draftId == null) {
  1746. this.confirmPlan(payload)
  1747. return
  1748. }
  1749. let draft = null
  1750. if (this.activeDraft && this.activeDraft.draftId === draftId) {
  1751. draft = this.activeDraft
  1752. } else {
  1753. for (let i = this.messages.length - 1; i >= 0; i--) {
  1754. const msg = this.messages[i]
  1755. if (msg && msg.type === 'draft' && msg.draftId === draftId && msg.draft) {
  1756. draft = msg.draft
  1757. break
  1758. }
  1759. }
  1760. }
  1761. if (!draft) {
  1762. try {
  1763. const detail = await getDraftDetail(draftId, { showError: false })
  1764. if (detail) draft = mapDraftDetailToCard(detail)
  1765. } catch (e) {
  1766. // ignore
  1767. }
  1768. }
  1769. if (draft) {
  1770. this.activeDraft = draft
  1771. this.onDraftUpdated(Object.assign({}, draft, {
  1772. status: 'confirmed',
  1773. selectedSchemeCode: (payload && payload.schemeCode) || draft.selectedSchemeCode,
  1774. confirmedPlan: (payload && payload.scheme) || draft.confirmedPlan
  1775. }), { sinkToBottom: false })
  1776. }
  1777. this.confirmPlan(payload)
  1778. },
  1779. openProgressDetail(draftId) {
  1780. if (draftId == null || draftId === '') {
  1781. showToast('缺少草稿信息,无法查看进度')
  1782. return
  1783. }
  1784. this.progressDraftId = draftId
  1785. this.openSheet('progress')
  1786. },
  1787. showPayment() {
  1788. this.closeAll()
  1789. setTimeout(() => { this.modal = 'payment' }, 300)
  1790. },
  1791. showPaySuccess() {
  1792. this.modal = 'paySuccess'
  1793. },
  1794. logout() {
  1795. store.logout()
  1796. uni.reLaunch({ url: '/packageA/auth/login' })
  1797. }
  1798. }
  1799. }
  1800. </script>
  1801. <style lang="scss" scoped>
  1802. .chat-page {
  1803. height: 100%;
  1804. overflow: hidden;
  1805. background: $fe-bg;
  1806. }
  1807. .chat-header {
  1808. position: fixed;
  1809. top: 0;
  1810. left: 0;
  1811. right: 0;
  1812. z-index: 99;
  1813. background: #ffffff;
  1814. border-bottom: 1rpx solid $fe-border-light;
  1815. box-sizing: border-box;
  1816. }
  1817. .chat-header__row {
  1818. height: $fe-h-page-bar;
  1819. display: flex;
  1820. flex-direction: row;
  1821. align-items: center;
  1822. flex-wrap: nowrap;
  1823. padding: 0 24rpx;
  1824. box-sizing: border-box;
  1825. }
  1826. .chat-header__title {
  1827. flex: 1;
  1828. min-width: 0;
  1829. margin: 0 12rpx;
  1830. font-weight: 700;
  1831. font-size: 34rpx;
  1832. color: $fe-foreground;
  1833. overflow: hidden;
  1834. text-overflow: ellipsis;
  1835. white-space: nowrap;
  1836. line-height: $fe-h-page-bar;
  1837. }
  1838. .chat-header__actions {
  1839. display: flex;
  1840. flex-direction: row;
  1841. align-items: center;
  1842. flex-wrap: nowrap;
  1843. flex-shrink: 0;
  1844. gap: 4rpx;
  1845. }
  1846. .fe-nav-icon {
  1847. font-size: 36rpx;
  1848. line-height: 1;
  1849. color: $fe-gray-500;
  1850. }
  1851. .fe-nav-icon--plus {
  1852. font-size: 44rpx;
  1853. font-weight: 300;
  1854. color: $fe-gray-600;
  1855. }
  1856. .chat-area {
  1857. position: fixed;
  1858. top: $fe-h-page-bar;
  1859. left: 0;
  1860. right: 0;
  1861. bottom: calc(#{$fe-h-input-bar} + #{$fe-h-quick-bar} + #{$fe-h-safe-bottom});
  1862. box-sizing: border-box;
  1863. }
  1864. .chat-footer {
  1865. position: fixed;
  1866. left: 0;
  1867. right: 0;
  1868. bottom: 0;
  1869. z-index: 99;
  1870. background: rgba(255, 255, 255, 0.92);
  1871. border-top: 1rpx solid $fe-border-light;
  1872. padding-bottom: $fe-h-safe-bottom;
  1873. box-sizing: border-box;
  1874. }
  1875. .fe-input-bar--embedded {
  1876. position: relative;
  1877. left: auto;
  1878. right: auto;
  1879. bottom: auto;
  1880. border-top: 1rpx solid $fe-border-light;
  1881. background: transparent;
  1882. padding-bottom: 20rpx;
  1883. }
  1884. .fe-input-bar--disabled {
  1885. opacity: 0.55;
  1886. }
  1887. .fe-input-bar--disabled .fe-input-bar__field {
  1888. background: $fe-gray-100;
  1889. color: $fe-gray-400;
  1890. }
  1891. .fe-input-bar--disabled .fe-input-bar__btn {
  1892. opacity: 0.7;
  1893. }
  1894. .chat-area__inner {
  1895. padding: 32rpx;
  1896. padding-bottom: 48rpx;
  1897. }
  1898. .chat-msg-wrap {
  1899. width: 100%;
  1900. }
  1901. .chat-status {
  1902. display: flex;
  1903. justify-content: center;
  1904. padding: 24rpx 0 8rpx;
  1905. }
  1906. .chat-status--top {
  1907. padding: 8rpx 0 24rpx;
  1908. }
  1909. .chat-status__text {
  1910. font-size: 24rpx;
  1911. color: $fe-gray-500;
  1912. }
  1913. .chat-status__text--muted {
  1914. color: $fe-gray-400;
  1915. }
  1916. .chat-bottom-anchor {
  1917. height: 2rpx;
  1918. width: 100%;
  1919. }
  1920. .sidebar-head {
  1921. display: flex;
  1922. align-items: center;
  1923. justify-content: space-between;
  1924. padding: 28rpx 28rpx 20rpx;
  1925. border-bottom: 1rpx solid $fe-border-light;
  1926. }
  1927. .sidebar-head__title {
  1928. font-size: 30rpx;
  1929. font-weight: 700;
  1930. }
  1931. .sidebar-head__add {
  1932. width: 64rpx;
  1933. height: 64rpx;
  1934. border-radius: $fe-radius-sm;
  1935. background: $fe-primary;
  1936. color: #fff;
  1937. display: flex;
  1938. align-items: center;
  1939. justify-content: center;
  1940. font-size: 32rpx;
  1941. }
  1942. .sidebar-list {
  1943. height: calc(100% - 120rpx);
  1944. }
  1945. .sidebar-status {
  1946. padding: 48rpx 28rpx;
  1947. text-align: center;
  1948. }
  1949. .sidebar-status__text {
  1950. font-size: 26rpx;
  1951. color: $fe-gray-500;
  1952. }
  1953. .sidebar-status--action {
  1954. cursor: pointer;
  1955. }
  1956. .sidebar-status__text--action {
  1957. color: $fe-primary;
  1958. font-weight: 600;
  1959. }
  1960. .chat-empty {
  1961. min-height: 100%;
  1962. display: flex;
  1963. flex-direction: column;
  1964. align-items: center;
  1965. justify-content: center;
  1966. padding: 80rpx 48rpx;
  1967. text-align: center;
  1968. box-sizing: border-box;
  1969. }
  1970. .chat-empty__icon {
  1971. width: 120rpx;
  1972. height: 120rpx;
  1973. border-radius: 50%;
  1974. background: $fe-primary-bg;
  1975. display: flex;
  1976. align-items: center;
  1977. justify-content: center;
  1978. font-size: 56rpx;
  1979. margin-bottom: 32rpx;
  1980. }
  1981. .chat-empty__title {
  1982. font-size: 36rpx;
  1983. font-weight: 700;
  1984. color: $fe-foreground;
  1985. margin-bottom: 48rpx;
  1986. line-height: 1.5;
  1987. max-width: 520rpx;
  1988. }
  1989. .chat-empty__btn {
  1990. padding: 24rpx 64rpx;
  1991. border-radius: $fe-radius-full;
  1992. background: $fe-primary;
  1993. color: #fff;
  1994. font-size: 30rpx;
  1995. font-weight: 600;
  1996. }
  1997. /* H5:避开浏览器/WebView 顶栏与安全区,避免头部被遮住 */
  1998. /* #ifdef H5 */
  1999. .chat-header {
  2000. padding-top: constant(safe-area-inset-top);
  2001. padding-top: env(safe-area-inset-top);
  2002. z-index: 200;
  2003. }
  2004. .chat-header__row {
  2005. height: 96rpx;
  2006. padding: 0 32rpx;
  2007. }
  2008. .chat-header__title {
  2009. font-size: 32rpx;
  2010. line-height: 96rpx;
  2011. }
  2012. .chat-area {
  2013. top: calc(96rpx + constant(safe-area-inset-top));
  2014. top: calc(96rpx + env(safe-area-inset-top));
  2015. }
  2016. .fe-sidebar {
  2017. top: calc(96rpx + constant(safe-area-inset-top));
  2018. top: calc(96rpx + env(safe-area-inset-top));
  2019. }
  2020. /* #endif */
  2021. .fe-biz-card--qr {
  2022. border-left: 6rpx solid $fe-primary;
  2023. }
  2024. .fe-qr-card__subtitle {
  2025. display: block;
  2026. font-size: 24rpx;
  2027. color: $fe-muted;
  2028. margin-bottom: 20rpx;
  2029. line-height: 1.4;
  2030. }
  2031. .fe-qr-card__img {
  2032. display: block;
  2033. width: 360rpx;
  2034. height: 360rpx;
  2035. margin: 0 auto 20rpx;
  2036. background: #fff;
  2037. }
  2038. .fe-qr-card__empty {
  2039. display: flex;
  2040. align-items: center;
  2041. justify-content: center;
  2042. width: 360rpx;
  2043. height: 360rpx;
  2044. margin: 0 auto 20rpx;
  2045. background: $fe-primary-bg;
  2046. border-radius: 16rpx;
  2047. }
  2048. .fe-qr-card__empty-text {
  2049. font-size: 24rpx;
  2050. color: $fe-muted;
  2051. text-align: center;
  2052. padding: 0 24rpx;
  2053. }
  2054. .fe-qr-card__hint {
  2055. display: block;
  2056. font-size: 24rpx;
  2057. color: $fe-muted;
  2058. text-align: center;
  2059. margin-bottom: 20rpx;
  2060. line-height: 1.4;
  2061. }
  2062. </style>