index.vue 44 KB

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