index.vue 48 KB

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