index.vue 46 KB

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