index.vue 43 KB

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