HomeView.vue 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736
  1. <script setup lang="ts">
  2. import {ref, watch, onMounted, defineAsyncComponent, computed} from 'vue'
  3. import {BasicApi} from '@/api/basic'
  4. import {RequestError} from '@/utils/request'
  5. import {isMobile, isKioskDevice} from "@/utils/useDeviceDetection.ts";
  6. import {useGlobalStore} from '@/stores/global'
  7. import {ElMessage} from 'element-plus'
  8. const SsHeader = defineAsyncComponent(() => import('@/components/SsHeader.vue'))
  9. const SsFooter = defineAsyncComponent(() => import('@/components/SsFooter.vue'))
  10. const SsNavigation = defineAsyncComponent(() => import('@/components/SsNavigation.vue'))
  11. const SsHeadline = defineAsyncComponent(() => import('@/components/SsHeadline.vue'))
  12. const SsInputBox = defineAsyncComponent(() => import('@/components/SsInputBox.vue'))
  13. const SsPanel = defineAsyncComponent(() => import('@/components/SsPanel.vue'))
  14. const SsCommonProblem = defineAsyncComponent(() => import('@/components/SsCommonProblem.vue'))
  15. const SsService = defineAsyncComponent(() => import('@/components/SsService.vue'))
  16. const SsGridEntrance = defineAsyncComponent(() => import('@/components/SsGridEntrance.vue'))
  17. const SsHotline = defineAsyncComponent(() => import('@/components/SsHotline.vue'))
  18. const SsOpinion = defineAsyncComponent(() => import('@/components/SsOpinion.vue'))
  19. const componentsLoaded = ref(0)
  20. const totalComponents = ref(8)
  21. const componentLoaded = () => {
  22. componentsLoaded.value++
  23. if (componentsLoaded.value >= totalComponents.value) {
  24. let warp = (document.querySelector('.first-loading-wrp') as HTMLElement | null)
  25. if (warp) {
  26. warp.style.display = 'none'
  27. }
  28. }
  29. }
  30. const global = useGlobalStore()
  31. const problemIsRefresh = ref(false)
  32. const mainBoxFlag = ref(true)
  33. const timeoutFlag = ref()
  34. const mainContentClass = ref({})
  35. /**
  36. * 立式一体机模式下的常见问题列表
  37. * 从 global.commonProblem 派生,取前 4 条用于底部展示
  38. */
  39. const kioskFaqList = computed(() => {
  40. return global.commonProblem.slice(0, 4)
  41. })
  42. const inputBox = ref<InstanceType<typeof SsInputBox> | null>(null)
  43. const commonProblem = ref<InstanceType<typeof SsCommonProblem> | null>(null)
  44. const hotlineRef = ref<InstanceType<typeof SsHotline> | null>(null)
  45. const opinionRef = ref<InstanceType<typeof SsOpinion> | null>(null)
  46. const loadBasicData = async () => {
  47. try {
  48. const result = await BasicApi.getWebSet()
  49. global.setBasic(result)
  50. } catch (error) {
  51. const err = error as RequestError
  52. ElMessage.warning(err.message || '请求错误')
  53. }
  54. }
  55. const refreshProblem = () => {
  56. problemIsRefresh.value = true
  57. if (commonProblem.value) {
  58. commonProblem.value.refresh()
  59. }
  60. }
  61. const problemLoadFinish = () => {
  62. problemIsRefresh.value = false
  63. }
  64. const editMessage = (message: string) => {
  65. if (inputBox.value) {
  66. inputBox.value.editMessage(message)
  67. }
  68. }
  69. const setMainContentClass = () => {
  70. mainContentClass.value = {
  71. 'input-line-1': global.spread && global.inputLine == 1,
  72. 'input-line-2': global.spread && global.inputLine == 2,
  73. 'input-line-3': global.spread && global.inputLine >= 3
  74. }
  75. }
  76. const quickSend = (msg: string) => {
  77. if (inputBox.value) {
  78. inputBox.value.quickSend(msg)
  79. }
  80. }
  81. const loadService = (categoryId: number, title: string, avatar: string) => {
  82. if (isMobile()) {
  83. global.setMenuSwitch(false)
  84. }
  85. if (inputBox.value) {
  86. inputBox.value.loadService(categoryId, title, avatar)
  87. }
  88. }
  89. const loadCommonProblem = () => {
  90. if (isMobile()) {
  91. global.setMenuSwitch(false)
  92. }
  93. if (inputBox.value) {
  94. inputBox.value.loadCommonProblem()
  95. }
  96. }
  97. const openHotlineDialog = () => {
  98. hotlineRef.value?.open()
  99. }
  100. const openOpinionDialog = () => {
  101. opinionRef.value?.open()
  102. }
  103. const openPolicyFile = () => {
  104. if (global.basic.policyFileUrl) {
  105. const newWindow = window.open(global.basic.policyFileUrl)
  106. if (newWindow) newWindow.opener = null
  107. }
  108. }
  109. /**
  110. * 返回首页:重置对话状态
  111. */
  112. const goHome = () => {
  113. global.setSpread(false)
  114. global.setMenuSwitch(false)
  115. global.setInReply(false)
  116. }
  117. /**
  118. * 获取服务项样式类(复用 SsService 组件逻辑)
  119. */
  120. const getServiceClass = (index: number) => {
  121. switch (index % 5) {
  122. case 0:
  123. return 'hover-scale'
  124. case 1:
  125. return 'hover-scale yellow'
  126. case 2:
  127. return 'center'
  128. case 3:
  129. return 'hover-scale yellow'
  130. case 4:
  131. return 'hover-scale'
  132. default:
  133. return ''
  134. }
  135. }
  136. const initPage = () => {
  137. let w = window.screen.width
  138. if (w <= 750) {
  139. global.setMenuSwitch(false)
  140. }
  141. setMainContentClass()
  142. }
  143. onMounted(() => {
  144. loadBasicData()
  145. initPage()
  146. window.addEventListener('resize', initPage)
  147. })
  148. watch(() => global.spread, (newValue, oldValue) => {
  149. setMainContentClass()
  150. if (timeoutFlag.value) {
  151. clearTimeout(timeoutFlag.value)
  152. }
  153. if (newValue) {
  154. timeoutFlag.value = setTimeout(() => {
  155. mainBoxFlag.value = false
  156. }, 50)
  157. } else {
  158. timeoutFlag.value = setTimeout(() => {
  159. mainBoxFlag.value = true
  160. }, 300)
  161. }
  162. })
  163. watch(() => global.inputLine, () => {
  164. setMainContentClass()
  165. })
  166. </script>
  167. <template>
  168. <div class="root">
  169. <!-- 1080x1920 立式一体机对话模式:顶部导航栏替代 Header -->
  170. <div v-if="global.spread && isKioskDevice()" class="kiosk-top-nav">
  171. <div class="back-home-btn" @click="goHome">
  172. <span class="back-icon">←</span>
  173. <span>返回首页</span>
  174. </div>
  175. <h1 class="app-title">商小川<span>智能助手</span></h1>
  176. <div class="nav-spacer"></div>
  177. </div>
  178. <!-- 立式一体机对话模式:服务快捷入口栏(复用原有设计) -->
  179. <div v-if="global.spread && isKioskDevice()" class="kiosk-service-bar">
  180. <div class="service-bar-container">
  181. <div
  182. v-for="(item, index) in global.categories"
  183. :key="item.id"
  184. class="service-quick-item"
  185. :class="getServiceClass(index)"
  186. @click="loadService(item.id, item.categoryName, item.imgUrl)"
  187. >
  188. <div class="service-item-content">
  189. <div class="service-title">{{ item.categoryName }}</div>
  190. </div>
  191. <el-image class="service-item-image" fit="contain" :src="getImageUrl(item.imgUrl)"/>
  192. </div>
  193. </div>
  194. </div>
  195. <!-- 默认 Header(非立式一体机模式) -->
  196. <ss-header v-else @loaded="componentLoaded"/>
  197. <!-- 侧边栏导航:立式一体机对话模式下隐藏 -->
  198. <ss-navigation
  199. v-if="!(global.spread && isKioskDevice())"
  200. @open-hotline="openHotlineDialog"
  201. @open-opinion="openOpinionDialog"
  202. @quick-send="quickSend"
  203. @load-service="loadService"
  204. @load-common-problem="loadCommonProblem"
  205. @loaded="componentLoaded"
  206. />
  207. <div class="container" :class="{spread: global.spread, 'menu-close': !global.menuSwitch, 'kiosk-mode': isKioskDevice() && global.spread}">
  208. <div class="main">
  209. <div class="main-content" :class="mainContentClass">
  210. <ss-headline v-if="mainBoxFlag"/>
  211. <ss-input-box ref="inputBox" @loaded="componentLoaded"/>
  212. <!-- 立式一体机对话模式:底部常见问题展示区 -->
  213. <div v-if="global.spread && isKioskDevice()" class="kiosk-bottom-faq">
  214. <div class="faq-card">
  215. <div class="faq-title">热门问题</div>
  216. <div class="faq-list">
  217. <div
  218. v-for="(item, index) in kioskFaqList"
  219. :key="index"
  220. class="faq-item"
  221. @click="quickSend(item.questionContent)"
  222. >
  223. {{ item.questionContent }}
  224. </div>
  225. </div>
  226. </div>
  227. </div>
  228. <!-- 默认布局:服务导航 + 常见问题 -->
  229. <div v-if="mainBoxFlag" class="main-box">
  230. <div class="ss-row ss-row-vertical">
  231. <div class="ss-col ss-col-service is-mobile">
  232. <ss-panel title="服务导航" :show-refresh="false">
  233. <ss-service @load-service="loadService" @loaded="componentLoaded"/>
  234. </ss-panel>
  235. </div>
  236. <div class="ss-col ss-col-service is-pc">
  237. <ss-panel title="服务导航" :show-refresh="false">
  238. <ss-service @load-service="loadService" @loaded="componentLoaded"/>
  239. </ss-panel>
  240. </div>
  241. <div class="ss-col ss-col-problem">
  242. <ss-panel title="常见问题" :show-refresh="true" :is-refresh="problemIsRefresh" @refresh="refreshProblem">
  243. <ss-common-problem ref="commonProblem" @quick-send="quickSend" @finish="problemLoadFinish" @loaded="componentLoaded"/>
  244. </ss-panel>
  245. </div>
  246. </div>
  247. <div class="ss-row ss-flex no-margin-bottom">
  248. <div class="ss-col">
  249. <ss-grid-entrance title="意见反馈" subject="诚谢建言,共筑精品" image="/images/bg07.png" @click="openOpinionDialog" @loaded="componentLoaded"/>
  250. </div>
  251. <div class="ss-col">
  252. <ss-grid-entrance title="政策文件" subject="便捷获取最新政策信息" image="/images/bg08.png" sub-class="yellow" @click="openPolicyFile" @loaded="componentLoaded"/>
  253. </div>
  254. </div>
  255. </div>
  256. </div>
  257. <!-- 立式一体机对话模式下隐藏 Footer,避免与底部 FAQ 区域重叠 -->
  258. <ss-footer v-if="!(global.spread && isKioskDevice())" @loaded="componentLoaded"/>
  259. </div>
  260. </div>
  261. <ss-hotline ref="hotlineRef"/>
  262. <ss-opinion ref="opinionRef"/>
  263. </div>
  264. </template>
  265. <style lang="scss">
  266. .root {
  267. position: fixed;
  268. z-index: 1;
  269. top: 0;
  270. left: 0;
  271. right: 0;
  272. bottom: 0;
  273. .container {
  274. display: flex;
  275. position: absolute;
  276. top: 0;
  277. left: 0;
  278. right: 0;
  279. bottom: 0;
  280. z-index: 1;
  281. background: #ffffff url("/images/backgroup.png") no-repeat scroll center center;
  282. background-size: cover;
  283. .main {
  284. width: 100%;
  285. overflow: hidden auto;
  286. position: relative;
  287. .main-content {
  288. height: 100vh;
  289. padding: 4rem 0 2rem 0;
  290. overflow: hidden auto;
  291. position: relative;
  292. .main-box {
  293. max-width: calc(1200px + 2rem);
  294. margin: 0 auto 1.69rem auto;
  295. padding: 0 1rem;
  296. transition: all .3s linear;
  297. .ss-row {
  298. display: flex;
  299. justify-content: space-between;
  300. margin-bottom: 1.68rem;
  301. &.no-margin-bottom {
  302. margin-bottom: 0;
  303. }
  304. .ss-col {
  305. width: calc(50% - 0.9rem);
  306. }
  307. }
  308. }
  309. &.input-line-1 {
  310. padding-bottom: 9rem;
  311. }
  312. &.input-line-2 {
  313. padding-bottom: 10.5rem;
  314. }
  315. &.input-line-3 {
  316. padding-bottom: 12rem;
  317. }
  318. }
  319. }
  320. &.spread {
  321. padding-left: 18rem;
  322. background-image: none;
  323. .navigation {
  324. display: block;
  325. }
  326. .main {
  327. .main-content {
  328. padding-top: 3rem;
  329. padding-right: 0;
  330. padding-left: 0;
  331. }
  332. }
  333. &.menu-close {
  334. padding-left: 4.5rem;
  335. }
  336. }
  337. }
  338. }
  339. /* 1080x1920 立式一体机专用布局优化 */
  340. @media screen and (min-width: 751px) and (max-width: 1200px) and (min-height: 1700px) {
  341. .root {
  342. /* 定义统一的内容区域宽度:屏幕宽度减去左右 padding */
  343. --kiosk-content-padding: 2rem;
  344. --kiosk-content-width: calc(100vw - var(--kiosk-content-padding) * 2);
  345. .container {
  346. .main {
  347. .main-content {
  348. padding: 5rem 0 5rem 0;
  349. .main-box {
  350. max-width: calc(1100px + 2rem);
  351. margin: 0 auto 2rem auto;
  352. padding: 0 1.5rem;
  353. .ss-row {
  354. margin-bottom: 2rem;
  355. /* 垂直布局:服务导航在上,常见问题在下 */
  356. &.ss-row-vertical {
  357. display: block;
  358. .ss-col-service {
  359. width: 100%;
  360. margin-bottom: 1.5rem;
  361. }
  362. .ss-col-problem {
  363. width: 100%;
  364. }
  365. }
  366. .ss-col {
  367. width: calc(50% - 1rem);
  368. }
  369. }
  370. }
  371. &.input-line-1 {
  372. padding-bottom: 10rem;
  373. }
  374. &.input-line-2 {
  375. padding-bottom: 11.5rem;
  376. }
  377. &.input-line-3 {
  378. padding-bottom: 13rem;
  379. }
  380. }
  381. }
  382. /* 立式一体机对话模式:三段式布局 */
  383. &.kiosk-mode {
  384. padding-left: 0 !important;
  385. .main {
  386. .main-content {
  387. display: flex;
  388. flex-direction: column;
  389. height: 100vh;
  390. padding: 200px 0 0 0; /* 顶部留出导航栏(100px) + 服务栏(100px)空间 */
  391. /* 对话区域:占据主要空间 */
  392. .chat-container {
  393. flex: 1;
  394. display: flex;
  395. flex-direction: column;
  396. min-height: 0; /* 允许 flex 子元素收缩 */
  397. /* 聊天消息区域:保持原有宽度(768px),无需覆盖 */
  398. /* 输入框:保持原有宽度(768px),无需覆盖 */
  399. }
  400. /* 底部常见问题展示区:固定 420px,与输入框宽度对齐 */
  401. .kiosk-bottom-faq {
  402. flex-shrink: 0;
  403. height: 420px;
  404. background: transparent;
  405. padding: 0;
  406. margin-top: -2rem;
  407. overflow: hidden;
  408. display: flex;
  409. flex-direction: column;
  410. align-items: center;
  411. .faq-card {
  412. width: var(--kiosk-content-width); /* 与顶部导航栏内容区域对齐 */
  413. max-width: 100%;
  414. padding: 2rem;
  415. background: #ffffff; /* 白色卡片背景 */
  416. .faq-title {
  417. font-size: 1.75rem;
  418. font-weight: 600;
  419. color: #101333;
  420. margin-bottom: 1.5rem;
  421. text-align: left; /* 左对齐 */
  422. }
  423. .faq-list {
  424. display: flex;
  425. flex-direction: column;
  426. gap: 1rem;
  427. .faq-item {
  428. font-size: 1.25rem;
  429. color: #545764;
  430. line-height: 1.6;
  431. padding: 0.75rem 1rem;
  432. background: #ffffff;
  433. border-radius: 0.75rem;
  434. border: 1px solid #D5D6D8;
  435. cursor: pointer;
  436. transition: all 0.2s ease;
  437. &:hover {
  438. background: #F6F7FB;
  439. border-color: #2943D6;
  440. transform: translateX(0.25rem);
  441. }
  442. &:active {
  443. background: #EAEDFB;
  444. transform: translateX(0.125rem);
  445. }
  446. }
  447. }
  448. }
  449. }
  450. }
  451. }
  452. }
  453. }
  454. /* 立式一体机顶部导航栏:固定 100px */
  455. .kiosk-top-nav {
  456. position: absolute;
  457. top: 0;
  458. left: 0;
  459. right: 0;
  460. height: 100px;
  461. background: #ffffff;
  462. border-bottom: 1px solid #E5E7EB;
  463. display: flex;
  464. align-items: center;
  465. justify-content: space-between;
  466. padding: 0 2rem;
  467. z-index: 1000;
  468. .back-home-btn {
  469. display: flex;
  470. align-items: center;
  471. gap: 0.5rem;
  472. padding: 0.75rem 1.5rem;
  473. border-radius: 2.5rem; /* 胶囊形状 */
  474. border: 1px solid #e5e5e5;
  475. background: #ffffff;
  476. color: #333333;
  477. font-size: 1rem;
  478. font-weight: 400;
  479. cursor: pointer;
  480. transition: all 0.2s ease;
  481. box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
  482. &:hover {
  483. background: #f7f7f7;
  484. border-color: #d5d5d5;
  485. box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
  486. }
  487. &:active {
  488. background: #eeeeee;
  489. border-color: #cccccc;
  490. box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
  491. transform: scale(0.98);
  492. }
  493. .back-icon {
  494. font-size: 1.125rem;
  495. font-weight: 500;
  496. }
  497. }
  498. .app-title {
  499. font-size: 3rem;
  500. font-weight: 700;
  501. color: #555964;
  502. letter-spacing: 0.125rem;
  503. margin: 0;
  504. /* 核心:文字双重阴影营造新拟态效果 */
  505. text-shadow:
  506. 4px 4px 8px #d1d9e6, /* 右下深色阴影 */
  507. -4px -4px 8px #ffffff; /* 左上高光 */
  508. /* 让"智能助手"四个字稍微淡一点,突出"商小川" */
  509. span {
  510. font-weight: 300;
  511. color: #8990a0;
  512. font-size: 0.8em; /* 稍微小一点 */
  513. }
  514. }
  515. .nav-spacer {
  516. width: 120px; /* 占位,保持标题居中 */
  517. }
  518. }
  519. /* 立式一体机服务快捷入口栏:紧贴顶部导航栏下方,复用原有设计 */
  520. .kiosk-service-bar {
  521. position: absolute;
  522. top: 100px;
  523. left: 0;
  524. right: 0;
  525. height: 100px; /* 从 120px 减小到 100px */
  526. background: #ffffff;
  527. display: flex;
  528. align-items: center;
  529. justify-content: center;
  530. padding: 0.75rem 0; /* 从 1rem 减小到 0.75rem */
  531. z-index: 999;
  532. overflow: hidden;
  533. .service-bar-container {
  534. width: var(--kiosk-content-width); /* 与顶部导航栏内容区域对齐 */
  535. max-width: 100%;
  536. margin: 0 auto; /* 居中对齐 */
  537. display: flex;
  538. justify-content: space-between; /* 均匀分布 */
  539. gap: 1rem;
  540. overflow: hidden;
  541. padding: 0;
  542. .service-quick-item {
  543. position: relative;
  544. flex: 1; /* 弹性宽度,均匀分布 */
  545. min-width: 140px; /* 最小宽度保证可读性 */
  546. height: 70px; /* 从 90px 减小到 70px */
  547. border-radius: 0.75rem;
  548. overflow: hidden;
  549. cursor: pointer;
  550. transition: transform 0.3s ease;
  551. /* 复用原有渐变背景 */
  552. background: linear-gradient(180deg, rgba(250, 255, 253, 0.8), rgba(225, 245, 248, 0.8));
  553. box-shadow: 0 0.25rem 1.25rem 0 rgba(52, 149, 239, 0.4);
  554. &.yellow {
  555. background: linear-gradient(180deg, rgba(255, 255, 255, 0.7), rgba(246, 240, 239, 0.7));
  556. }
  557. &.center {
  558. background: linear-gradient(90deg, #c2dff9, #c6e2fa);
  559. }
  560. &.hover-scale:hover {
  561. transform: scale(1.05);
  562. }
  563. .service-item-content {
  564. position: absolute;
  565. top: 0;
  566. left: 0;
  567. right: 0;
  568. bottom: 0;
  569. z-index: 2;
  570. padding: 1rem;
  571. display: flex;
  572. align-items: center;
  573. .service-title {
  574. color: #101333;
  575. font-size: 1.125rem; /* 1rem → 1.125rem */
  576. font-weight: 600;
  577. line-height: 1.2;
  578. }
  579. }
  580. .service-item-image {
  581. position: absolute;
  582. bottom: 0;
  583. right: 0.5rem;
  584. z-index: 1;
  585. width: 4rem;
  586. height: 3rem;
  587. opacity: 0.2;
  588. }
  589. }
  590. }
  591. }
  592. }
  593. }
  594. @media screen and (max-width: 750px) {
  595. .root {
  596. .container {
  597. .main {
  598. .main-content {
  599. padding-bottom: 0;
  600. .main-box {
  601. .ss-row {
  602. display: block;
  603. .ss-col {
  604. width: 100%;
  605. margin-bottom: 1rem;
  606. }
  607. &.ss-flex {
  608. display: flex;
  609. .ss-col {
  610. width: calc(50% - 0.47rem);
  611. margin-bottom: 0;
  612. }
  613. }
  614. }
  615. }
  616. &.input-line-1 {
  617. padding-bottom: 7rem;
  618. }
  619. &.input-line-2 {
  620. padding-bottom: 8.5rem;
  621. }
  622. &.input-line-3 {
  623. padding-bottom: 10rem;
  624. }
  625. }
  626. }
  627. &.spread {
  628. padding: 0;
  629. .main {
  630. .main-content {
  631. padding-top: 4rem;
  632. }
  633. }
  634. &.menu-close {
  635. padding-left: 0;
  636. }
  637. }
  638. }
  639. }
  640. }
  641. </style>