index.vue 40 KB

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