index.vue 46 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636
  1. <template>
  2. <view class="page-chart">
  3. <!-- Top operation bar -->
  4. <view class="top-bar">
  5. <view class="spacer"></view>
  6. <view v-if="userStore.isVip" class="export-btn" @click="exportPdf">
  7. 导出PDF
  8. </view>
  9. </view>
  10. <!-- Triangle chart (collapsible) -->
  11. <view class="chart-section" :class="{ 'chart-section--collapsed': chartCollapsed }">
  12. <view v-show="!chartCollapsed" class="chart-section__body">
  13. <TriangleChart
  14. :chartData="store.currentChart"
  15. :birthYear="store.currentBirthYear"
  16. :birthMonth="store.currentBirthMonth"
  17. :birthDay="store.currentBirthDay"
  18. :recordId="store.currentRecordId"
  19. :annotations="annotations"
  20. :tags="allTags"
  21. @annotation-add="onAnnotationAdd"
  22. @annotation-delete="onAnnotationDelete"
  23. @tag-create="onTagCreate"
  24. />
  25. </view>
  26. <view class="chart-section__toggle" @click="chartCollapsed = !chartCollapsed">
  27. <text class="chart-section__toggle-label">{{ chartCollapsed ? '展开命盘图' : '收起命盘图' }}</text>
  28. <text class="chart-section__toggle-arrow">{{ chartCollapsed ? '▾' : '▴' }}</text>
  29. </view>
  30. </view>
  31. <!-- Menu button (regenerate + share info) -->
  32. <view class="menu-bar">
  33. <text class="menu-btn" @click="showMenu">···</text>
  34. </view>
  35. <!-- AI Chat -->
  36. <view class="chat-section">
  37. <view class="section-title">
  38. <text class="title-icon">◈</text>
  39. 命盘解读
  40. <text v-if="!userStore.isVipActive && maxChats > 0" class="quota-badge">
  41. {{ usedChats }}/{{ maxChats }}
  42. </text>
  43. <text v-if="userStore.isVipActive" class="quota-badge vip">无限</text>
  44. </view>
  45. <scroll-view class="chat-box" scroll-y :scroll-top="scrollTop">
  46. <view v-for="(msg, idx) in messages" :key="idx">
  47. <!-- System messages: centered italic text, no bubble -->
  48. <view v-if="msg.senderType === 'system'" class="msg-system" @longpress.stop="onLongPressMsg(msg)">
  49. <text>{{ msg.content }}</text>
  50. </view>
  51. <!-- Practitioner messages: green bubble with label -->
  52. <view v-else-if="msg.senderType === 'practitioner'" class="msg-row practitioner" @longpress.stop="onLongPressMsg(msg)">
  53. <view class="msg-practitioner-label">👤 能量师</view>
  54. <view class="msg-bubble msg-practitioner-bubble">
  55. <text>{{ msg.content }}</text>
  56. </view>
  57. </view>
  58. <!-- Voice message — playable audio bubble -->
  59. <view v-else-if="msg.audioUrl" class="msg-row" :class="msg.role">
  60. <view class="msg-bubble msg-bubble--audio" @click="playAudio(msg.audioUrl, idx)">
  61. <text class="msg-audio__icon">{{ audioPlayingIdx === idx ? '🔊' : '🔈' }}</text>
  62. <view class="msg-audio__wave" :class="{ 'msg-audio__wave--playing': audioPlayingIdx === idx }">
  63. <view v-for="i in 4" :key="i" class="msg-audio__bar"></view>
  64. </view>
  65. <text class="msg-audio__label">语音</text>
  66. </view>
  67. </view>
  68. <!-- Default (AI/User): text bubble -->
  69. <view v-else class="msg-row" :class="msg.role">
  70. <view class="msg-bubble">
  71. <text>{{ msg.content }}</text>
  72. </view>
  73. </view>
  74. </view>
  75. </scroll-view>
  76. <!-- Quota exceeded prompt for non-VIP -->
  77. <view v-if="quotaExceeded" class="upgrade-prompt">
  78. <text class="upgrade-text">今日 AI 解读次数已用完</text>
  79. <text class="upgrade-desc">选择适合的会员方案,解锁无限次解读</text>
  80. <view class="upgrade-actions">
  81. <button class="upgrade-btn secondary" @click="goSubscribeC">C端会员 ¥131/年</button>
  82. <button class="upgrade-btn" @click="goSubscribe">能量师 ¥1,314/年</button>
  83. </view>
  84. </view>
  85. <!-- Input area — text/voice toggle (WeChat style) -->
  86. <view class="input-bar">
  87. <!-- Text input mode -->
  88. <view v-if="inputMode === 'text'" class="input-bar__row">
  89. <view class="input-bar__toggle" @click.stop="toggleInputMode">
  90. <text class="input-bar__toggle-icon">🎤</text>
  91. </view>
  92. <input
  93. class="chat-input"
  94. v-model="chatInput"
  95. :placeholder="quotaExceeded ? '已用完,开通会员继续' : '输入您的问题...'"
  96. @confirm="sendQuestion(chatInput)"
  97. :disabled="loading"
  98. />
  99. <button class="send-btn" @click="sendQuestion(chatInput)" :disabled="loading || !chatInput.trim()">
  100. {{ loading ? '···' : '发送' }}
  101. </button>
  102. </view>
  103. <!-- Voice input mode -->
  104. <view v-else class="input-bar__row">
  105. <view class="input-bar__toggle" @click.stop="toggleInputMode">
  106. <text class="input-bar__toggle-icon">⌨️</text>
  107. </view>
  108. <view
  109. class="voice-btn"
  110. :class="{ 'voice-btn--recording': isRecording, 'voice-btn--release': willCancel }"
  111. @touchstart="onRecordStart"
  112. @touchend.prevent="onRecordEnd"
  113. @touchmove="onRecordMove"
  114. @touchcancel="onRecordCancel"
  115. >
  116. <view class="voice-btn__ripple" v-if="isRecording"></view>
  117. <text class="voice-btn__text">
  118. <template v-if="!isRecording">按住 说话</template>
  119. <template v-else-if="willCancel">松开 取消</template>
  120. <template v-else>松开 发送</template>
  121. </text>
  122. <text class="voice-btn__hint" v-if="isRecording && !willCancel">上滑取消</text>
  123. </view>
  124. </view>
  125. </view>
  126. </view>
  127. <!-- US-9.4: Proposal card for negotiating state -->
  128. <view v-if="planRequest && planRequest.status === 'negotiating'" class="proposal-section">
  129. <view class="proposal-title">📋 人工方案</view>
  130. <view class="proposal-card">
  131. <view class="proposal-price">¥{{ planRequest.price }}</view>
  132. <view v-if="proposalMessage" class="proposal-message">
  133. <text class="proposal-msg-label">能量师留言:</text>
  134. <text class="proposal-msg-content">{{ proposalMessage }}</text>
  135. </view>
  136. <view v-if="logs.length > 0" class="negotiation-history">
  137. <view class="history-title">协商历史</view>
  138. <view class="history-timeline">
  139. <view v-for="(log, idx) in logs" :key="log.id" class="history-item" :class="log.action">
  140. <view class="timeline-dot" :class="log.action"></view>
  141. <view class="timeline-line" v-if="idx < logs.length - 1"></view>
  142. <view class="timeline-content">
  143. <text v-if="log.action === 'proposal'">能量师提交方案: ¥{{ log.newPrice }}</text>
  144. <text v-else-if="log.action === 'counter' && log.operatorId === currentUserId">您还价: ¥{{ log.newPrice }}</text>
  145. <text v-else-if="log.action === 'counter'">能量师还价: ¥{{ log.newPrice }}</text>
  146. <text v-else-if="log.action === 'accept'">✓ 已接受方案</text>
  147. <text v-else>{{ log.action }}</text>
  148. </view>
  149. </view>
  150. </view>
  151. </view>
  152. <view class="proposal-actions">
  153. <button class="proposal-btn btn-accept" :disabled="acceptingOffer" @click="acceptOffer">接受方案</button>
  154. <button class="proposal-btn btn-counter" @click="showCounterInput = !showCounterInput">还价</button>
  155. <button class="proposal-btn btn-reject" :disabled="rejecting" @click="rejectOffer">拒绝</button>
  156. </view>
  157. <view v-if="showCounterInput" class="counter-input-wrap">
  158. <view class="counter-input-row">
  159. <input class="counter-input" v-model="counterPrice" type="number" placeholder="输入您的报价..." />
  160. <button class="counter-submit" :disabled="countering || !counterPrice" @click="submitCounter">提交还价</button>
  161. </view>
  162. <text class="counter-hint">双方最多协商5轮</text>
  163. </view>
  164. </view>
  165. </view>
  166. <!-- US-9.5: Pending payment — show pay button -->
  167. <view v-if="planRequest && planRequest.status === 'pending_payment' && planOrder" class="proposal-section">
  168. <view class="proposal-title">📋 方案已确认</view>
  169. <view class="proposal-card pending-payment-card">
  170. <view class="proposal-price">¥{{ planRequest.price }}</view>
  171. <text class="payment-hint">请在24小时内完成支付</text>
  172. <button class="payment-btn" @click="goPay">去支付 ¥{{ planRequest.price }}</button>
  173. </view>
  174. </view>
  175. <!-- US-9.5: Paid — show delivery -->
  176. <view v-else-if="planRequest && planRequest.status === 'paid'" class="proposal-section">
  177. <view class="proposal-card paid-card">
  178. <view v-if="deliveryData && deliveryData.length > 0" class="delivery-content">
  179. <view class="delivery-title">📋 方案交付</view>
  180. <view v-for="d in deliveryData" :key="d.id" class="delivery-item">
  181. <view class="delivery-header">
  182. <text class="delivery-type">{{ d.deliveryType === 'text' ? '文字方案' : d.deliveryType === 'file' ? '文件方案' : '综合方案' }}</text>
  183. <text class="delivery-time">{{ d.createdAt?.slice(0, 10) }}</text>
  184. </view>
  185. <text v-if="d.textContent" class="delivery-text">{{ d.textContent }}</text>
  186. </view>
  187. </view>
  188. <view v-else class="paid-waiting">
  189. <text class="paid-waiting-text">方案已确认,等待能量师交付...</text>
  190. </view>
  191. </view>
  192. </view>
  193. </view>
  194. </template>
  195. <script>
  196. // Page lifecycle hooks (needed for WeChat share)
  197. export default {
  198. onShareAppMessage() {
  199. const pages = getCurrentPages()
  200. const page = pages[pages.length - 1]
  201. const recordId = page?.options?.recordId || ''
  202. const store = this.$scope?.store
  203. const title = store?.currentChart?.O
  204. ? `我的命盘主性格数字是${store.currentChart.O},快来测测你的!`
  205. : '数字能量命盘分析'
  206. return {
  207. title,
  208. path: `/pages/chart/index?recordId=${recordId}`,
  209. imageUrl: '/static/share-card.png'
  210. }
  211. },
  212. onShareTimeline() {
  213. const pages = getCurrentPages()
  214. const page = pages[pages.length - 1]
  215. const recordId = page?.options?.recordId || ''
  216. return {
  217. title: '数字能量命盘分析',
  218. query: `recordId=${recordId}`,
  219. imageUrl: '/static/share-card.png'
  220. }
  221. }
  222. }
  223. </script>
  224. <script setup>
  225. import { ref, computed, onMounted } from 'vue'
  226. import { useChartStore } from '@/stores/chart'
  227. import { useUserStore } from '@/stores/user'
  228. import TriangleChart from '@/components/TriangleChart.vue'
  229. import { chatApi, consultationApi, profileApi, annotationApi, exportApi, interventionApi, planApi, proposalApi, deliveryApi, chartApi } from '@/utils/api'
  230. const store = useChartStore()
  231. const userStore = useUserStore()
  232. const chatInput = ref('')
  233. const scrollTop = ref(0)
  234. const messages = ref([])
  235. const loading = ref(false)
  236. const usedChats = ref(0)
  237. const maxChats = ref(0)
  238. // US-9.6: Chart collapse state
  239. const chartCollapsed = ref(false)
  240. // US-9.6: Voice input state
  241. const inputMode = ref('text') // 'text' | 'voice'
  242. const isRecording = ref(false)
  243. const willCancel = ref(false)
  244. const audioPlayingIdx = ref(-1)
  245. const recorderManager = uni.getRecorderManager()
  246. let innerAudioContext = null
  247. // US-9.3: Intervention state
  248. const hasActiveIntervention = ref(false)
  249. const activeInterventionId = ref(null)
  250. // US-9.4: Plan proposal state
  251. const planRequest = ref(null)
  252. const logs = ref([])
  253. const counterPrice = ref(null)
  254. const showCounterInput = ref(false)
  255. const acceptingOffer = ref(false)
  256. const countering = ref(false)
  257. const rejecting = ref(false)
  258. // US-9.5: Payment and delivery state
  259. const planOrder = ref(null)
  260. const deliveryData = ref([])
  261. // Annotation / Tag state (US-7.1 / US-7.2)
  262. const annotations = ref([])
  263. const allTags = ref([])
  264. const quotaExceeded = computed(() => {
  265. if (userStore.isVipActive) return false
  266. return maxChats.value > 0 && usedChats.value >= maxChats.value
  267. })
  268. const shareRemaining = computed(() => {
  269. if (userStore.isVipActive) return -1
  270. const limit = userStore.shareLimit || 1
  271. const count = userStore.shareCount || 0
  272. return limit > 0 ? Math.max(0, limit - count) : 0
  273. })
  274. // US-9.4: Computed proposal message from logs
  275. const proposalMessage = computed(() => {
  276. const proposalLog = logs.value.find(l => l.action === 'proposal')
  277. return proposalLog?.message || ''
  278. })
  279. // US-9.4: Current user ID for comparison in negotiation history
  280. const currentUserId = computed(() => userStore.user?.id || userStore.userId || '')
  281. onMounted(async () => {
  282. try {
  283. await userStore.fetchQuota()
  284. } catch (e) { /* ignore */ }
  285. // URL shareability — load from recordId if no store state (US-1.1 §14)
  286. const pages = getCurrentPages()
  287. const currentPage = pages[pages.length - 1]
  288. const recordId = currentPage?.options?.recordId
  289. if (!store.currentChart && recordId) {
  290. try {
  291. await store.loadChart(Number(recordId))
  292. } catch (e) {
  293. // fall through to "no data" message
  294. }
  295. }
  296. if (store.currentChart) {
  297. maxChats.value = userStore.maxChats || 3
  298. usedChats.value = userStore.usedChats || 0
  299. // Load annotations and tags for this record (US-7.1/7.2)
  300. if (store.currentRecordId) {
  301. loadAnnotations()
  302. }
  303. // US-9.4: Load plan request for this chart
  304. loadPlanRequest()
  305. // Use consultationMessages from store (set during startConsultation) if available
  306. if (store.consultationMessages && store.consultationMessages.length > 0) {
  307. messages.value = store.consultationMessages.map(m => ({
  308. role: m.role === 'ai' ? 'ai' : 'user',
  309. content: m.content,
  310. senderType: m.senderType || null,
  311. interventionId: m.interventionId || null
  312. }))
  313. } else if (store.currentRecordId) {
  314. try {
  315. const history = await chatApi.history(store.currentRecordId)
  316. messages.value = history.map(m => ({
  317. role: m.role === 'ai' ? 'ai' : 'user',
  318. content: m.content,
  319. senderType: m.senderType || null,
  320. interventionId: m.interventionId || null
  321. }))
  322. const userMsgCount = history.filter(m => m.role === 'user').length
  323. usedChats.value = Math.max(usedChats.value, userMsgCount)
  324. } catch (e) {
  325. // fallback to generic greeting
  326. }
  327. }
  328. // US-9.3: Check intervention status from loaded messages
  329. checkInterventionStatus(messages.value)
  330. // Ensure at least a greeting message if still empty
  331. if (messages.value.length === 0) {
  332. messages.value = [{
  333. role: 'ai',
  334. content: `已为您生成命盘。主性格数字为${store.currentChart.O}。请点击下方问题或输入您想了解的内容。`
  335. }]
  336. }
  337. } else {
  338. messages.value = [{
  339. role: 'ai',
  340. content: '暂无命盘数据,请先返回首页输入出生信息。'
  341. }]
  342. }
  343. })
  344. // US-9.3: Check if intervention is active from message history
  345. function checkInterventionStatus(messages) {
  346. let active = false
  347. let lastId = null
  348. for (const msg of messages) {
  349. if (msg.senderType === 'system') {
  350. if (msg.content && msg.content.includes('能量师') && msg.content.includes('进入')) {
  351. active = true
  352. lastId = msg.interventionId
  353. }
  354. if (msg.content && (msg.content.includes('退出') || msg.content.includes('结束'))) {
  355. active = false
  356. }
  357. }
  358. }
  359. hasActiveIntervention.value = active
  360. activeInterventionId.value = lastId
  361. }
  362. // US-9.3: Long-press handler for practitioner / entry system messages
  363. function onLongPressMsg(msg) {
  364. // Only show action if intervention is active
  365. if (!hasActiveIntervention.value) return
  366. // Only on practitioner messages or entry system messages
  367. if (msg.senderType === 'practitioner' || (msg.senderType === 'system' && msg.content && msg.content.includes('能量师'))) {
  368. uni.showActionSheet({
  369. itemList: ['结束能量师介入'],
  370. success: async (res) => {
  371. if (res.tapIndex === 0) {
  372. await endIntervention()
  373. }
  374. }
  375. })
  376. }
  377. }
  378. // US-9.3: End intervention by user's request
  379. async function endIntervention() {
  380. uni.showLoading({ title: '处理中...' })
  381. try {
  382. await interventionApi.endByUser(activeInterventionId.value)
  383. uni.hideLoading()
  384. uni.showToast({ title: '已结束能量师介入', icon: 'success' })
  385. hasActiveIntervention.value = false
  386. activeInterventionId.value = null
  387. } catch (e) {
  388. uni.hideLoading()
  389. uni.showToast({ title: e.message || '操作失败', icon: 'none' })
  390. }
  391. }
  392. // ─── Voice Input ─────────────────────────────────────────
  393. // Setup recorder event listeners
  394. if (recorderManager) {
  395. recorderManager.onStart(() => {
  396. isRecording.value = true
  397. })
  398. recorderManager.onStop(async (res) => {
  399. isRecording.value = false
  400. willCancel.value = false
  401. if (res.tempFilePath && !cancelledRecording) {
  402. await uploadVoice(res.tempFilePath)
  403. }
  404. cancelledRecording = false
  405. })
  406. recorderManager.onError(() => {
  407. isRecording.value = false
  408. willCancel.value = false
  409. uni.showToast({ title: '录音失败', icon: 'none' })
  410. })
  411. }
  412. let cancelledRecording = false
  413. let recordStartY = 0
  414. function toggleInputMode() {
  415. inputMode.value = inputMode.value === 'text' ? 'voice' : 'text'
  416. }
  417. function onRecordStart(e) {
  418. if (loading.value) return
  419. cancelledRecording = false
  420. willCancel.value = false
  421. recordStartY = e.touches[0]?.clientY || 0
  422. // Start recording
  423. recorderManager.start({
  424. format: 'mp3',
  425. sampleRate: 16000,
  426. numberOfChannels: 1,
  427. encodeBitRate: 48000
  428. })
  429. }
  430. function onRecordMove(e) {
  431. if (!isRecording.value) return
  432. const touch = e.touches[0]
  433. if (touch && recordStartY > 0) {
  434. // Slide up more than 50px → show cancel
  435. willCancel.value = (recordStartY - touch.clientY) > 50
  436. }
  437. }
  438. function onRecordEnd() {
  439. if (isRecording.value) {
  440. if (willCancel.value) {
  441. cancelledRecording = true
  442. recorderManager.stop()
  443. } else {
  444. recorderManager.stop()
  445. }
  446. }
  447. }
  448. function onRecordCancel() {
  449. if (isRecording.value) {
  450. cancelledRecording = true
  451. willCancel.value = false
  452. isRecording.value = false
  453. try { recorderManager.stop() } catch (e) {}
  454. }
  455. }
  456. async function uploadVoice(filePath) {
  457. try {
  458. uni.showLoading({ title: '语音处理中...', mask: true })
  459. const result = await chatApi.voiceUpload(filePath, store.currentRecordId)
  460. uni.hideLoading()
  461. if (result && result.url) {
  462. await sendVoiceMessage(result.url)
  463. }
  464. } catch (e) {
  465. uni.hideLoading()
  466. uni.showToast({ title: '语音上传失败', icon: 'none' })
  467. }
  468. }
  469. async function sendVoiceMessage(audioUrl) {
  470. // Add voice message locally
  471. messages.value.push({ role: 'user', content: '[语音消息]', audioUrl })
  472. scrollTop.value = 99999
  473. loading.value = true
  474. try {
  475. if (store.currentRecordId) {
  476. const res = await chatApi.send({
  477. chartRecordId: store.currentRecordId,
  478. query: '[语音消息]',
  479. audioUrl
  480. })
  481. messages.value.push({ role: 'ai', content: res.reply })
  482. usedChats.value++
  483. }
  484. } catch (e) {
  485. if (e.code === 1008) {
  486. usedChats.value = maxChats.value
  487. messages.value.push({
  488. role: 'ai',
  489. content: '今日解读次数已用完,开通会员可享无限次解读。'
  490. })
  491. } else {
  492. messages.value.push({
  493. role: 'ai',
  494. content: e.message || '解读服务暂时不可用,请稍后再试。'
  495. })
  496. }
  497. }
  498. loading.value = false
  499. scrollTop.value = 99999
  500. }
  501. function getServerBase() {
  502. // Derive server base from API_BASE_URL pattern (strip trailing /api)
  503. // In api.js: const API_BASE_URL = 'http://127.0.0.1:8186/api'
  504. const token = uni.getStorageSync('token')
  505. // We don't have direct access to API_BASE_URL, so we construct it
  506. // This works in dev; production should use relative URLs
  507. return 'http://127.0.0.1:8186'
  508. }
  509. function playAudio(audioUrl, idx) {
  510. if (audioPlayingIdx.value === idx) {
  511. if (innerAudioContext) {
  512. innerAudioContext.stop()
  513. innerAudioContext.destroy()
  514. innerAudioContext = null
  515. }
  516. audioPlayingIdx.value = -1
  517. return
  518. }
  519. if (innerAudioContext) {
  520. innerAudioContext.stop()
  521. innerAudioContext.destroy()
  522. innerAudioContext = null
  523. }
  524. audioPlayingIdx.value = idx
  525. innerAudioContext = uni.createInnerAudioContext()
  526. innerAudioContext.src = getServerBase() + audioUrl
  527. innerAudioContext.onPlay(() => {})
  528. innerAudioContext.onError(() => {
  529. audioPlayingIdx.value = -1
  530. uni.showToast({ title: '播放失败', icon: 'none' })
  531. })
  532. innerAudioContext.onEnded(() => {
  533. audioPlayingIdx.value = -1
  534. innerAudioContext.destroy()
  535. innerAudioContext = null
  536. })
  537. innerAudioContext.play()
  538. }
  539. async function sendQuestion(q) {
  540. if (!q.trim() || loading.value) return
  541. if (quotaExceeded.value) {
  542. // Show upgrade prompt instead of sending
  543. uni.showModal({
  544. title: '今日解读次数已用完',
  545. content: '开通 VIP 会员享无限次 AI 解读,或分享好友获取额外次数',
  546. confirmText: '开通会员',
  547. cancelText: '分享好友',
  548. success: (res) => {
  549. if (res.confirm) {
  550. goSubscribeC()
  551. } else {
  552. triggerShare()
  553. }
  554. }
  555. })
  556. return
  557. }
  558. messages.value.push({ role: 'user', content: q })
  559. chatInput.value = ''
  560. loading.value = true
  561. try {
  562. if (store.currentRecordId) {
  563. const res = await chatApi.send({ chartRecordId: store.currentRecordId, query: q })
  564. messages.value.push({ role: 'ai', content: res.reply })
  565. usedChats.value++
  566. } else {
  567. messages.value.push({
  568. role: 'ai',
  569. content: `数字能量分析显示,${q}方面您的能量组合表明有良好的发展潜力,请结合自身情况理性决策。`
  570. })
  571. }
  572. } catch (e) {
  573. if (e.code === 1008) {
  574. usedChats.value = maxChats.value
  575. messages.value.push({
  576. role: 'ai',
  577. content: '今日解读次数已用完,开通会员可享无限次解读。'
  578. })
  579. } else {
  580. messages.value.push({
  581. role: 'ai',
  582. content: e.message || '解读服务暂时不可用,请稍后再试。'
  583. })
  584. }
  585. }
  586. loading.value = false
  587. scrollTop.value = 99999
  588. }
  589. // US-3.3: Regenerate interpretation via action sheet menu
  590. function showMenu() {
  591. const items = ['🔄 重新开始']
  592. // US-9.1: Academic orientation AI (paid users only)
  593. if (userStore.isVipActive) {
  594. items.push('🎓 学业方向AI')
  595. }
  596. // US-9.2: Apply for manual plan (only when no active plan request)
  597. if (!planRequest.value) {
  598. items.push('📝 申请人工方案')
  599. }
  600. // US-2.2: PDF export for paid users only
  601. if (userStore.isVipActive) {
  602. items.push('📄 导出 PDF')
  603. }
  604. if (!userStore.isVipActive && shareRemaining.value > 0) {
  605. items.push('📤 分享给好友')
  606. }
  607. items.push('❌ 取消')
  608. uni.showActionSheet({
  609. itemList: items,
  610. success: async (res) => {
  611. if (res.tapIndex === 0) {
  612. await confirmRegenerate()
  613. } else if (items[res.tapIndex] === '🎓 学业方向AI') {
  614. await handleAcademicOrientation()
  615. } else if (items[res.tapIndex] === '📝 申请人工方案') {
  616. await handlePlanApply()
  617. } else if (items[res.tapIndex] === '📄 导出 PDF') {
  618. await exportPdf()
  619. } else if (items[res.tapIndex] === '📤 分享给好友') {
  620. await triggerShare()
  621. }
  622. }
  623. })
  624. }
  625. async function confirmRegenerate() {
  626. uni.showModal({
  627. title: '重新开始',
  628. content: '将清除当前对话记录并重新生成命盘解读,确定吗?',
  629. success: async (confirm) => {
  630. if (!confirm.confirm) return
  631. if (!store.currentRecordId) {
  632. uni.showToast({ title: '暂无命盘数据', icon: 'none' })
  633. return
  634. }
  635. try {
  636. uni.showLoading({ title: '重新生成中...' })
  637. const newMessages = await consultationApi.regenerate(store.currentRecordId)
  638. messages.value = (newMessages || []).map(m => ({
  639. role: m.role === 'ai' ? 'ai' : 'user',
  640. content: m.content
  641. }))
  642. // Reset chat counters
  643. usedChats.value = 0
  644. uni.hideLoading()
  645. uni.showToast({ title: '解读已重新生成', icon: 'success' })
  646. scrollTop.value = 99999
  647. } catch (e) {
  648. uni.hideLoading()
  649. uni.showToast({ title: '重新生成失败,请重试', icon: 'none' })
  650. }
  651. }
  652. })
  653. }
  654. // US-9.1: Academic orientation AI interpretation
  655. async function handleAcademicOrientation() {
  656. if (!store.currentRecordId) {
  657. uni.showToast({ title: '暂无命盘数据', icon: 'none' })
  658. return
  659. }
  660. try {
  661. uni.showLoading({ title: 'AI 分析中...' })
  662. const result = await chartApi.academicOrientation(store.currentRecordId)
  663. uni.hideLoading()
  664. if (result && result.content) {
  665. // Append academic orientation as a system message in the chat
  666. messages.value.push({
  667. senderType: 'system',
  668. content: '🎓 学业方向AI分析',
  669. id: Date.now()
  670. })
  671. messages.value.push({
  672. senderType: 'ai',
  673. content: result.content,
  674. id: Date.now() + 1
  675. })
  676. scrollTop.value = 99999
  677. } else {
  678. uni.showToast({ title: '暂无学业方向分析结果', icon: 'none' })
  679. }
  680. } catch (e) {
  681. uni.hideLoading()
  682. uni.showToast({ title: e.message || '学业方向分析失败', icon: 'none' })
  683. }
  684. }
  685. // US-9.2: Navigate to plan application page
  686. function handlePlanApply() {
  687. if (!store.currentRecordId) {
  688. uni.showToast({ title: '暂无命盘数据', icon: 'none' })
  689. return
  690. }
  691. uni.navigateTo({ url: `/pages/plan/apply?chartRecordId=${store.currentRecordId}` })
  692. }
  693. // US-2.1: Track share and update quota
  694. async function triggerShare() {
  695. uni.showShareMenu({
  696. withShareTicket: true,
  697. menus: ['shareAppMessage', 'shareTimeline'],
  698. success: async () => {
  699. // Call backend to track share
  700. try {
  701. const result = await profileApi.trackShare()
  702. shareCount.value = result.dailyShareCount || shareCount.value + 1
  703. } catch (e) { /* best-effort */ }
  704. }
  705. })
  706. }
  707. function goSubscribe() {
  708. uni.navigateTo({ url: '/pages/subscribe/index' })
  709. }
  710. function goSubscribeC() {
  711. uni.navigateTo({ url: '/pages/payment/index?product=annual' })
  712. }
  713. // US-2.2: Export PDF (paid users only)
  714. async function exportPdf() {
  715. if (!store.currentRecordId) {
  716. uni.showToast({ title: '暂无命盘记录', icon: 'none' })
  717. return
  718. }
  719. uni.showLoading({ title: '生成中...' })
  720. try {
  721. const filePath = await exportApi.pdf(store.currentRecordId)
  722. uni.hideLoading()
  723. // Open PDF document
  724. uni.openDocument({
  725. filePath,
  726. success: () => {
  727. uni.showToast({ title: 'PDF 已生成', icon: 'success' })
  728. },
  729. fail: () => {
  730. uni.showToast({ title: '打开文件失败', icon: 'none' })
  731. }
  732. })
  733. } catch (e) {
  734. uni.hideLoading()
  735. uni.showToast({ title: e.message || '导出失败', icon: 'none' })
  736. }
  737. }
  738. // === Annotation / Tag handlers (US-7.1 / US-7.2) ===
  739. async function loadAnnotations() {
  740. if (!store.currentRecordId) return
  741. try {
  742. const [annoList, tagList] = await Promise.all([
  743. annotationApi.list(store.currentRecordId),
  744. annotationApi.listTags(store.currentRecordId)
  745. ])
  746. annotations.value = annoList || []
  747. allTags.value = tagList || []
  748. } catch (e) { /* best-effort */ }
  749. }
  750. async function onAnnotationAdd(data) {
  751. try {
  752. const saved = await annotationApi.add(data)
  753. // Reload annotations to include the new one with server-generated ID
  754. if (store.currentRecordId) {
  755. const annoList = await annotationApi.list(store.currentRecordId)
  756. annotations.value = annoList || []
  757. }
  758. uni.showToast({ title: '批注已保存', icon: 'success' })
  759. } catch (e) {
  760. uni.showToast({ title: '保存失败', icon: 'none' })
  761. }
  762. }
  763. async function onAnnotationDelete(annoId) {
  764. try {
  765. await annotationApi.delete(annoId)
  766. annotations.value = annotations.value.filter(a => a.id !== annoId)
  767. uni.showToast({ title: '批注已删除', icon: 'success' })
  768. } catch (e) {
  769. uni.showToast({ title: '删除失败', icon: 'none' })
  770. }
  771. }
  772. async function onTagCreate(tagData) {
  773. try {
  774. const saved = await annotationApi.addTag({ ...tagData, recordId: store.currentRecordId })
  775. if (store.currentRecordId) {
  776. const tagList = await annotationApi.listTags(store.currentRecordId)
  777. allTags.value = tagList || []
  778. }
  779. uni.showToast({ title: '标签已创建', icon: 'success' })
  780. } catch (e) {
  781. uni.showToast({ title: '创建失败', icon: 'none' })
  782. }
  783. }
  784. // US-9.4: Load plan request for current chart
  785. async function loadPlanRequest() {
  786. if (!store.currentRecordId) return
  787. try {
  788. const requests = await planApi.myRequests()
  789. const match = requests.find(r => r.chartRecordId === store.currentRecordId && (r.status === 'negotiating' || r.status === 'pending_payment' || r.status === 'paid'))
  790. if (match) {
  791. planRequest.value = match
  792. const logList = await proposalApi.logs(match.id)
  793. logs.value = logList || []
  794. // US-9.5: Load delivery data when status is paid
  795. if (match.status === 'paid') {
  796. try {
  797. const deliveries = await deliveryApi.list(match.id)
  798. deliveryData.value = deliveries || []
  799. } catch (e) { /* best-effort */ }
  800. }
  801. }
  802. } catch (e) {
  803. // No plan request found — that's fine
  804. }
  805. }
  806. // US-9.4: Accept offer
  807. async function acceptOffer() {
  808. uni.showModal({
  809. title: '接受方案',
  810. content: `确定以 ¥${planRequest.value.price} 接受此方案吗?`,
  811. success: async (res) => {
  812. if (!res.confirm) return
  813. acceptingOffer.value = true
  814. try {
  815. const result = await proposalApi.accept({ requestId: planRequest.value.id })
  816. planRequest.value = result.planRequest
  817. planOrder.value = result.order
  818. const logList = await proposalApi.logs(planRequest.value.id)
  819. logs.value = logList || []
  820. uni.showToast({ title: '已接受方案,请完成支付', icon: 'success' })
  821. } catch (e) {
  822. uni.showToast({ title: e.message || '操作失败', icon: 'none' })
  823. } finally {
  824. acceptingOffer.value = false
  825. }
  826. }
  827. })
  828. }
  829. // US-9.5: Navigate to payment page
  830. function goPay() {
  831. if (!planOrder.value || !planRequest.value) return
  832. uni.navigateTo({
  833. url: `/pages/payment/index?product=practitioner_plan&totalFee=${planRequest.value.price}&outTradeNo=${planOrder.value.outTradeNo}`
  834. })
  835. }
  836. // US-9.4: Reject offer
  837. async function rejectOffer() {
  838. uni.showModal({
  839. title: '拒绝方案',
  840. content: '确定拒绝此方案吗?',
  841. success: async (res) => {
  842. if (!res.confirm) return
  843. rejecting.value = true
  844. try {
  845. await proposalApi.reject({ requestId: planRequest.value.id })
  846. planRequest.value.status = 'rejected'
  847. uni.showToast({ title: '已拒绝', icon: 'success' })
  848. } catch (e) {
  849. uni.showToast({ title: e.message || '操作失败', icon: 'none' })
  850. } finally {
  851. rejecting.value = false
  852. }
  853. }
  854. })
  855. }
  856. // US-9.4: Submit counter-offer
  857. async function submitCounter() {
  858. const price = Number(counterPrice.value)
  859. if (!price || price <= 0) {
  860. uni.showToast({ title: '请输入有效价格', icon: 'none' })
  861. return
  862. }
  863. countering.value = true
  864. try {
  865. await proposalApi.counter({ requestId: planRequest.value.id, newPrice: price })
  866. const logList = await proposalApi.logs(planRequest.value.id)
  867. logs.value = logList || []
  868. showCounterInput.value = false
  869. counterPrice.value = null
  870. uni.showToast({ title: '已提交还价', icon: 'success' })
  871. } catch (e) {
  872. uni.showToast({ title: e.message || '还价失败', icon: 'none' })
  873. } finally {
  874. countering.value = false
  875. }
  876. }
  877. </script>
  878. <style scoped lang="scss">
  879. .page-chart {
  880. height: 100vh;
  881. display: flex;
  882. flex-direction: column;
  883. overflow: hidden;
  884. background: linear-gradient(180deg, #0c0a1a 0%, #120d28 50%, #0f0c1e 100%);
  885. }
  886. /* Top operation bar */
  887. .top-bar {
  888. display: flex;
  889. justify-content: flex-end;
  890. align-items: center;
  891. padding: 8px 12px;
  892. background: rgba(0,0,0,0.15);
  893. }
  894. .spacer {
  895. flex: 1;
  896. }
  897. .export-btn {
  898. font-size: 13px;
  899. color: #fbbf24;
  900. padding: 6px 12px;
  901. border: 1px solid rgba(251,191,36,0.3);
  902. border-radius: 8px;
  903. }
  904. .chart-section {
  905. flex-shrink: 0;
  906. margin: 0 14px;
  907. overflow: hidden;
  908. transition: flex-shrink 0.25s;
  909. &__body {
  910. overflow: hidden;
  911. transition: max-height 0.3s ease, opacity 0.25s ease;
  912. }
  913. &--collapsed {
  914. flex-shrink: 1;
  915. }
  916. &__toggle {
  917. display: flex;
  918. align-items: center;
  919. justify-content: center;
  920. gap: 6px;
  921. padding: 6px 0 4px;
  922. cursor: pointer;
  923. -webkit-tap-highlight-color: transparent;
  924. &:active {
  925. opacity: 0.6;
  926. }
  927. &-label {
  928. font-size: 11px;
  929. color: rgba(255, 255, 255, 0.3);
  930. }
  931. &-arrow {
  932. font-size: 10px;
  933. color: rgba(255, 255, 255, 0.25);
  934. }
  935. }
  936. }
  937. /* Menu bar — three-dot button */
  938. .menu-bar {
  939. display: flex;
  940. justify-content: flex-end;
  941. padding: 0 14px;
  942. margin: -6px 0 8px;
  943. }
  944. .menu-btn {
  945. width: 32px;
  946. height: 28px;
  947. display: flex;
  948. align-items: center;
  949. justify-content: center;
  950. font-size: 20px;
  951. font-weight: 700;
  952. color: rgba(255, 255, 255, 0.4);
  953. letter-spacing: 2px;
  954. border-radius: 6px;
  955. background: rgba(255, 255, 255, 0.04);
  956. }
  957. .menu-btn:active {
  958. background: rgba(255, 255, 255, 0.08);
  959. }
  960. .chat-section {
  961. flex: 1;
  962. display: flex;
  963. flex-direction: column;
  964. overflow: hidden;
  965. min-height: 0;
  966. margin: 0 14px;
  967. }
  968. .section-title {
  969. font-size: 15px;
  970. font-weight: 600;
  971. color: rgba(255, 255, 255, 0.85);
  972. margin-bottom: 10px;
  973. display: flex;
  974. align-items: center;
  975. gap: 6px;
  976. }
  977. .title-icon {
  978. font-size: 12px;
  979. color: #fbbf24;
  980. opacity: 0.6;
  981. }
  982. .quota-badge {
  983. font-size: 11px;
  984. font-weight: 400;
  985. color: #fbbf24;
  986. background: rgba(251, 191, 36, 0.12);
  987. padding: 2px 10px;
  988. border-radius: 10px;
  989. border: 1px solid rgba(251, 191, 36, 0.15);
  990. }
  991. .quota-badge.vip {
  992. color: #34d399;
  993. background: rgba(52, 211, 153, 0.1);
  994. border-color: rgba(52, 211, 153, 0.15);
  995. }
  996. .chat-box {
  997. flex: 1;
  998. height: auto;
  999. overflow-y: auto;
  1000. background: rgba(255, 255, 255, 0.04);
  1001. border: 1px solid rgba(255, 255, 255, 0.06);
  1002. border-radius: 14px;
  1003. padding: 14px;
  1004. margin-bottom: 10px;
  1005. }
  1006. .msg-row {
  1007. margin-bottom: 14px;
  1008. display: flex;
  1009. }
  1010. .msg-row.user {
  1011. justify-content: flex-end;
  1012. }
  1013. .msg-bubble {
  1014. max-width: 80%;
  1015. padding: 10px 16px;
  1016. border-radius: 14px;
  1017. font-size: 14px;
  1018. line-height: 1.5;
  1019. color: rgba(255, 255, 255, 0.85);
  1020. background: rgba(255, 255, 255, 0.06);
  1021. border: 1px solid rgba(255, 255, 255, 0.06);
  1022. }
  1023. .msg-row.user .msg-bubble {
  1024. background: rgba(251, 191, 36, 0.12);
  1025. border-color: rgba(251, 191, 36, 0.15);
  1026. color: #fbbf24;
  1027. }
  1028. /* US-9.3: Practitioner message bubble */
  1029. .msg-row.practitioner {
  1030. justify-content: flex-start;
  1031. flex-direction: column;
  1032. align-items: flex-start;
  1033. }
  1034. .msg-practitioner-label {
  1035. font-size: 12px;
  1036. color: #10b981;
  1037. margin-bottom: 4px;
  1038. font-weight: 500;
  1039. }
  1040. .msg-practitioner-bubble {
  1041. background: rgba(16, 185, 129, 0.15);
  1042. border: 1px solid rgba(16, 185, 129, 0.3);
  1043. color: rgba(255, 255, 255, 0.85);
  1044. }
  1045. /* US-9.3: System message — centered italic text */
  1046. .msg-system {
  1047. text-align: center;
  1048. padding: 8px 0;
  1049. color: rgba(255, 255, 255, 0.4);
  1050. font-style: italic;
  1051. font-size: 12px;
  1052. line-height: 1.6;
  1053. }
  1054. .input-bar {
  1055. &__row {
  1056. display: flex;
  1057. align-items: center;
  1058. gap: 8px;
  1059. width: 100%;
  1060. }
  1061. &__toggle {
  1062. width: 38px;
  1063. height: 38px;
  1064. border-radius: 50%;
  1065. background: rgba(255, 255, 255, 0.06);
  1066. display: flex;
  1067. align-items: center;
  1068. justify-content: center;
  1069. flex-shrink: 0;
  1070. cursor: pointer;
  1071. &:active {
  1072. background: rgba(255, 255, 255, 0.12);
  1073. }
  1074. &-icon {
  1075. font-size: 18px;
  1076. line-height: 1;
  1077. }
  1078. }
  1079. }
  1080. .chat-input {
  1081. flex: 1;
  1082. height: 42px;
  1083. border: 1px solid rgba(255, 255, 255, 0.1);
  1084. border-radius: 10px;
  1085. padding: 0 14px;
  1086. font-size: 14px;
  1087. color: rgba(255, 255, 255, 0.85);
  1088. background: rgba(255, 255, 255, 0.04);
  1089. }
  1090. .send-btn {
  1091. height: 42px;
  1092. padding: 0 24px;
  1093. background: linear-gradient(135deg, #f59e0b, #d97706);
  1094. color: #fff;
  1095. border: none;
  1096. border-radius: 10px;
  1097. line-height: 42px;
  1098. font-size: 14px;
  1099. font-weight: 500;
  1100. white-space: nowrap;
  1101. }
  1102. .send-btn[disabled] {
  1103. opacity: 0.4;
  1104. }
  1105. /* ─── Voice recording button ─── */
  1106. .voice-btn {
  1107. flex: 1;
  1108. height: 48px;
  1109. border-radius: 24px;
  1110. background: rgba(255, 255, 255, 0.06);
  1111. border: 1px solid rgba(255, 255, 255, 0.1);
  1112. display: flex;
  1113. align-items: center;
  1114. justify-content: center;
  1115. position: relative;
  1116. overflow: hidden;
  1117. user-select: none;
  1118. -webkit-tap-highlight-color: transparent;
  1119. &:active {
  1120. background: rgba(255, 255, 255, 0.1);
  1121. }
  1122. &--recording {
  1123. background: rgba(251, 191, 36, 0.1);
  1124. border-color: rgba(251, 191, 36, 0.3);
  1125. }
  1126. &--release {
  1127. background: rgba(239, 68, 68, 0.12);
  1128. border-color: rgba(239, 68, 68, 0.3);
  1129. }
  1130. &__text {
  1131. font-size: 15px;
  1132. font-weight: 500;
  1133. color: rgba(255, 255, 255, 0.75);
  1134. position: relative;
  1135. z-index: 1;
  1136. }
  1137. &--recording &__text {
  1138. color: #fbbf24;
  1139. }
  1140. &--release &__text {
  1141. color: #ef4444;
  1142. }
  1143. &__hint {
  1144. position: absolute;
  1145. right: 14px;
  1146. top: 50%;
  1147. transform: translateY(-50%);
  1148. font-size: 10px;
  1149. color: rgba(255, 255, 255, 0.25);
  1150. z-index: 1;
  1151. }
  1152. &__ripple {
  1153. position: absolute;
  1154. inset: -20px;
  1155. border-radius: 50%;
  1156. background: radial-gradient(circle, rgba(251, 191, 36, 0.08) 0%, transparent 70%);
  1157. animation: voice-pulse 1s ease-in-out infinite;
  1158. }
  1159. }
  1160. @keyframes voice-pulse {
  1161. 0%, 100% { transform: scale(1); opacity: 0.6; }
  1162. 50% { transform: scale(1.15); opacity: 1; }
  1163. }
  1164. /* ─── Audio message bubble ─── */
  1165. .msg-bubble--audio {
  1166. display: flex;
  1167. align-items: center;
  1168. gap: 8px;
  1169. min-width: 100px;
  1170. cursor: pointer;
  1171. &:active {
  1172. opacity: 0.7;
  1173. }
  1174. }
  1175. .msg-audio {
  1176. &__icon {
  1177. font-size: 20px;
  1178. flex-shrink: 0;
  1179. }
  1180. &__wave {
  1181. display: flex;
  1182. align-items: center;
  1183. gap: 2px;
  1184. height: 20px;
  1185. &--playing .msg-audio__bar {
  1186. background: #fbbf24;
  1187. animation: audio-bar 0.6s ease-in-out infinite alternate;
  1188. }
  1189. }
  1190. &__bar {
  1191. width: 3px;
  1192. border-radius: 2px;
  1193. background: rgba(255, 255, 255, 0.3);
  1194. &:nth-child(1) { height: 8px; }
  1195. &:nth-child(2) { height: 14px; animation-delay: 0.2s; }
  1196. &:nth-child(3) { height: 10px; animation-delay: 0.4s; }
  1197. &:nth-child(4) { height: 16px; animation-delay: 0.6s; }
  1198. }
  1199. &__label {
  1200. font-size: 12px;
  1201. color: rgba(255, 255, 255, 0.5);
  1202. flex-shrink: 0;
  1203. }
  1204. }
  1205. @keyframes audio-bar {
  1206. 0% { transform: scaleY(0.5); }
  1207. 100% { transform: scaleY(1.2); }
  1208. }
  1209. .msg-row.user .msg-audio__bar {
  1210. background: rgba(251, 191, 36, 0.35);
  1211. }
  1212. .msg-row.user .msg-audio__label {
  1213. color: rgba(251, 191, 36, 0.5);
  1214. }
  1215. /* Upgrade prompt */
  1216. .upgrade-prompt {
  1217. background: linear-gradient(135deg, rgba(251, 191, 36, 0.08), rgba(217, 119, 6, 0.06));
  1218. border: 1px solid rgba(251, 191, 36, 0.12);
  1219. border-radius: 14px;
  1220. padding: 22px;
  1221. text-align: center;
  1222. margin-bottom: 12px;
  1223. }
  1224. .upgrade-text {
  1225. display: block;
  1226. font-size: 16px;
  1227. font-weight: 600;
  1228. color: #fbbf24;
  1229. }
  1230. .upgrade-desc {
  1231. display: block;
  1232. font-size: 13px;
  1233. color: rgba(255, 255, 255, 0.45);
  1234. margin: 8px 0 16px;
  1235. }
  1236. .upgrade-actions {
  1237. display: flex;
  1238. gap: 10px;
  1239. justify-content: center;
  1240. }
  1241. .upgrade-btn {
  1242. flex: 1;
  1243. background: linear-gradient(135deg, #f59e0b, #d97706);
  1244. color: #fff;
  1245. border: none;
  1246. border-radius: 10px;
  1247. padding: 10px 0;
  1248. font-size: 14px;
  1249. font-weight: 500;
  1250. line-height: 1.4;
  1251. }
  1252. .upgrade-btn.secondary {
  1253. background: rgba(255, 255, 255, 0.06);
  1254. border: 1px solid rgba(251, 191, 36, 0.2);
  1255. color: #fbbf24;
  1256. }
  1257. /* US-9.4: Proposal section */
  1258. .proposal-section {
  1259. flex-shrink: 0;
  1260. margin: 0 14px 10px;
  1261. }
  1262. .proposal-title {
  1263. font-size: 16px;
  1264. font-weight: 700;
  1265. color: #a78bfa;
  1266. margin-bottom: 10px;
  1267. padding-left: 2px;
  1268. }
  1269. .proposal-card {
  1270. background: rgba(124,58,237,0.06);
  1271. border: 1px solid rgba(124,58,237,0.12);
  1272. border-radius: 14px;
  1273. padding: 16px;
  1274. }
  1275. .proposal-price {
  1276. text-align: center;
  1277. font-size: 24px;
  1278. font-weight: 700;
  1279. color: #fbbf24;
  1280. margin-bottom: 12px;
  1281. }
  1282. .proposal-message {
  1283. background: rgba(255,255,255,0.03);
  1284. border-radius: 8px;
  1285. padding: 10px 12px;
  1286. margin-bottom: 12px;
  1287. }
  1288. .proposal-msg-label {
  1289. display: block;
  1290. font-size: 12px;
  1291. color: rgba(255,255,255,0.4);
  1292. margin-bottom: 4px;
  1293. }
  1294. .proposal-msg-content {
  1295. display: block;
  1296. font-size: 13px;
  1297. color: rgba(255,255,255,0.75);
  1298. line-height: 1.5;
  1299. }
  1300. .negotiation-history {
  1301. margin-bottom: 14px;
  1302. }
  1303. .history-title {
  1304. font-size: 13px;
  1305. font-weight: 600;
  1306. color: rgba(255,255,255,0.5);
  1307. margin-bottom: 10px;
  1308. }
  1309. .history-timeline {
  1310. padding-left: 6px;
  1311. }
  1312. .history-item {
  1313. display: flex;
  1314. align-items: flex-start;
  1315. position: relative;
  1316. padding-bottom: 6px;
  1317. }
  1318. .timeline-dot {
  1319. width: 8px;
  1320. height: 8px;
  1321. border-radius: 50%;
  1322. background: rgba(255,255,255,0.2);
  1323. flex-shrink: 0;
  1324. margin-top: 4px;
  1325. margin-right: 10px;
  1326. }
  1327. .timeline-dot.accept {
  1328. background: #10b981;
  1329. }
  1330. .timeline-line {
  1331. position: absolute;
  1332. left: 3px;
  1333. top: 12px;
  1334. width: 2px;
  1335. height: calc(100% - 6px);
  1336. background: rgba(255,255,255,0.08);
  1337. }
  1338. .timeline-content {
  1339. flex: 1;
  1340. font-size: 13px;
  1341. color: rgba(255,255,255,0.6);
  1342. line-height: 1.5;
  1343. }
  1344. .proposal-actions {
  1345. display: flex;
  1346. gap: 10px;
  1347. }
  1348. .proposal-btn {
  1349. flex: 1;
  1350. height: 40px;
  1351. border: none;
  1352. border-radius: 10px;
  1353. font-size: 14px;
  1354. font-weight: 600;
  1355. line-height: 40px;
  1356. text-align: center;
  1357. }
  1358. .proposal-btn.btn-accept {
  1359. background: linear-gradient(135deg, #10b981, #059669);
  1360. color: #fff;
  1361. }
  1362. .proposal-btn.btn-accept[disabled] {
  1363. opacity: 0.5;
  1364. }
  1365. .proposal-btn.btn-counter {
  1366. background: transparent;
  1367. border: 1px solid #3b82f6;
  1368. color: #3b82f6;
  1369. }
  1370. .proposal-btn.btn-reject {
  1371. background: transparent;
  1372. border: 1px solid #ef4444;
  1373. color: #ef4444;
  1374. }
  1375. .proposal-btn.btn-reject[disabled] {
  1376. opacity: 0.5;
  1377. }
  1378. .counter-input-wrap {
  1379. margin-top: 12px;
  1380. padding-top: 12px;
  1381. border-top: 1px solid rgba(255,255,255,0.06);
  1382. }
  1383. .counter-input-row {
  1384. display: flex;
  1385. gap: 8px;
  1386. }
  1387. .counter-input {
  1388. flex: 1;
  1389. height: 42px;
  1390. border: 1px solid rgba(255,255,255,0.1);
  1391. border-radius: 10px;
  1392. padding: 0 14px;
  1393. font-size: 14px;
  1394. color: rgba(255,255,255,0.85);
  1395. background: rgba(255,255,255,0.04);
  1396. }
  1397. .counter-submit {
  1398. height: 42px;
  1399. padding: 0 20px;
  1400. background: linear-gradient(135deg, #3b82f6, #2563eb);
  1401. color: #fff;
  1402. border: none;
  1403. border-radius: 10px;
  1404. line-height: 42px;
  1405. font-size: 14px;
  1406. font-weight: 500;
  1407. }
  1408. .counter-submit[disabled] {
  1409. opacity: 0.4;
  1410. }
  1411. .counter-hint {
  1412. display: block;
  1413. font-size: 11px;
  1414. color: rgba(255,255,255,0.3);
  1415. margin-top: 6px;
  1416. text-align: center;
  1417. }
  1418. .proposal-card.paid-card {
  1419. text-align: center;
  1420. padding: 16px;
  1421. }
  1422. .paid-text {
  1423. font-size: 14px;
  1424. color: #10b981;
  1425. font-weight: 600;
  1426. }
  1427. /* US-9.5: Pending payment card */
  1428. .pending-payment-card {
  1429. background: linear-gradient(135deg, rgba(167, 139, 250, 0.08), rgba(124, 58, 237, 0.05));
  1430. border: 1px solid rgba(167, 139, 250, 0.2);
  1431. border-radius: 14px;
  1432. padding: 20px;
  1433. text-align: center;
  1434. }
  1435. .pending-payment-card .proposal-price {
  1436. font-size: 28px;
  1437. font-weight: 700;
  1438. color: #fbbf24;
  1439. margin-bottom: 8px;
  1440. }
  1441. .payment-hint {
  1442. font-size: 12px;
  1443. color: rgba(255, 255, 255, 0.4);
  1444. display: block;
  1445. margin-bottom: 14px;
  1446. }
  1447. .payment-btn {
  1448. width: 100%;
  1449. height: 46px;
  1450. background: linear-gradient(135deg, #10b981, #059669);
  1451. color: #fff;
  1452. border: none;
  1453. border-radius: 12px;
  1454. line-height: 46px;
  1455. font-size: 16px;
  1456. font-weight: 600;
  1457. box-shadow: 0 4px 12px rgba(16, 185, 129, 0.25);
  1458. }
  1459. /* US-9.5: Delivery content display */
  1460. .delivery-content {
  1461. padding: 0;
  1462. }
  1463. .delivery-title {
  1464. font-size: 15px;
  1465. font-weight: 600;
  1466. color: rgba(255, 255, 255, 0.85);
  1467. margin-bottom: 12px;
  1468. }
  1469. .delivery-item {
  1470. background: rgba(255, 255, 255, 0.03);
  1471. border-radius: 10px;
  1472. padding: 14px;
  1473. margin-bottom: 10px;
  1474. }
  1475. .delivery-header {
  1476. display: flex;
  1477. justify-content: space-between;
  1478. align-items: center;
  1479. margin-bottom: 8px;
  1480. }
  1481. .delivery-type {
  1482. font-size: 13px;
  1483. font-weight: 600;
  1484. color: #10b981;
  1485. }
  1486. .delivery-time {
  1487. font-size: 11px;
  1488. color: rgba(255, 255, 255, 0.3);
  1489. }
  1490. .delivery-text {
  1491. display: block;
  1492. font-size: 13px;
  1493. color: rgba(255, 255, 255, 0.65);
  1494. line-height: 1.6;
  1495. white-space: pre-wrap;
  1496. }
  1497. /* US-9.5: Paid waiting state */
  1498. .paid-waiting {
  1499. text-align: center;
  1500. padding: 10px 0;
  1501. }
  1502. .paid-waiting-text {
  1503. font-size: 14px;
  1504. color: rgba(255, 255, 255, 0.4);
  1505. }
  1506. </style>