| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644 |
- <template>
- <view class="page-chart">
- <!-- Top operation bar -->
- <view class="top-bar">
- <view class="spacer"></view>
- <view class="collapse-btn" @click="chartCollapsed = !chartCollapsed">
- {{ chartCollapsed ? '▾ 展开命盘图' : '▴ 折叠命盘图' }}
- </view>
- </view>
- <!-- Triangle chart (collapsible) -->
- <view class="chart-section" :class="{ 'chart-section--collapsed': chartCollapsed }">
- <view v-show="!chartCollapsed" class="chart-section__body">
- <TriangleChart
- :chartData="store.currentChart"
- :birthYear="store.currentBirthYear"
- :birthMonth="store.currentBirthMonth"
- :birthDay="store.currentBirthDay"
- :recordId="store.currentRecordId"
- :annotations="annotations"
- :tags="allTags"
- @annotation-add="onAnnotationAdd"
- @annotation-delete="onAnnotationDelete"
- @tag-create="onTagCreate"
- />
- </view>
- <view class="chart-section__toggle" @click="chartCollapsed = !chartCollapsed">
- <text class="chart-section__toggle-label">{{ chartCollapsed ? '展开命盘图' : '收起命盘图' }}</text>
- <text class="chart-section__toggle-arrow">{{ chartCollapsed ? '▾' : '▴' }}</text>
- </view>
- </view>
- <!-- Menu button (regenerate + share info) -->
- <view class="menu-bar">
- <text class="menu-btn" @click="showMenu">···</text>
- </view>
- <!-- AI Chat -->
- <view class="chat-section">
- <view class="section-title">
- <text class="title-icon">◈</text>
- 命盘解读
- <text v-if="!userStore.isVipActive && maxChats > 0" class="quota-badge">
- {{ usedChats }}/{{ maxChats }}
- </text>
- <text v-if="userStore.isVipActive" class="quota-badge vip">无限</text>
- </view>
- <scroll-view class="chat-box" scroll-y :scroll-top="scrollTop">
- <view v-for="(msg, idx) in messages" :key="idx">
- <!-- System messages: centered italic text, no bubble -->
- <view v-if="msg.senderType === 'system'" class="msg-system" @longpress.stop="onLongPressMsg(msg)">
- <text>{{ msg.content }}</text>
- <text class="msg-time msg-time--system">{{ formatTime(msg.createdAt) }}</text>
- </view>
- <!-- Practitioner messages: green bubble with label -->
- <view v-else-if="msg.senderType === 'practitioner'" class="msg-row practitioner" @longpress.stop="onLongPressMsg(msg)">
- <view class="msg-practitioner-label">👤 能量师</view>
- <view class="msg-bubble msg-practitioner-bubble">
- <text>{{ msg.content }}</text>
- </view>
- <text class="msg-time">{{ formatTime(msg.createdAt) }}</text>
- </view>
- <!-- Voice message — playable audio bubble -->
- <view v-else-if="msg.audioUrl" class="msg-row" :class="msg.role">
- <view class="msg-bubble msg-bubble--audio" @click="playAudio(msg.audioUrl, idx)">
- <text class="msg-audio__icon">{{ audioPlayingIdx === idx ? '🔊' : '🔈' }}</text>
- <view class="msg-audio__wave" :class="{ 'msg-audio__wave--playing': audioPlayingIdx === idx }">
- <view v-for="i in 4" :key="i" class="msg-audio__bar"></view>
- </view>
- <text class="msg-audio__label">语音</text>
- </view>
- <text class="msg-time">{{ formatTime(msg.createdAt) }}</text>
- </view>
- <!-- Default (AI/User): text bubble -->
- <view v-else class="msg-row" :class="msg.role">
- <view class="msg-bubble">
- <text>{{ msg.content }}</text>
- </view>
- <text class="msg-time">{{ formatTime(msg.createdAt) }}</text>
- </view>
- </view>
- </scroll-view>
- <!-- Quota exceeded prompt for non-VIP -->
- <view v-if="quotaExceeded" class="upgrade-prompt">
- <text class="upgrade-text">今日 AI 解读次数已用完</text>
- <text class="upgrade-desc">选择适合的会员方案,解锁无限次解读</text>
- <view class="upgrade-actions">
- <button class="upgrade-btn secondary" @click="goSubscribeC">C端会员 ¥131/年</button>
- <button class="upgrade-btn" @click="goSubscribe">能量师 ¥1,314/年</button>
- </view>
- </view>
- <!-- Input area — text/voice toggle (WeChat style) -->
- <view class="input-bar">
- <!-- Text input mode -->
- <view v-if="inputMode === 'text'" class="input-bar__row">
- <view class="input-bar__toggle" @click.stop="toggleInputMode">
- <text class="input-bar__toggle-icon">🎤</text>
- </view>
- <input
- class="chat-input"
- v-model="chatInput"
- :placeholder="quotaExceeded ? '已用完,开通会员继续' : '输入您的问题...'"
- @confirm="sendQuestion(chatInput)"
- :disabled="loading"
- />
- <button class="send-btn" @click="sendQuestion(chatInput)" :disabled="loading || !chatInput.trim()">
- {{ loading ? '···' : '发送' }}
- </button>
- </view>
- <!-- Voice input mode -->
- <view v-else class="input-bar__row">
- <view class="input-bar__toggle" @click.stop="toggleInputMode">
- <text class="input-bar__toggle-icon">⌨️</text>
- </view>
- <view
- class="voice-btn"
- :class="{ 'voice-btn--recording': isRecording, 'voice-btn--release': willCancel }"
- @touchstart="onRecordStart"
- @touchend.prevent="onRecordEnd"
- @touchmove="onRecordMove"
- @touchcancel="onRecordCancel"
- >
- <view class="voice-btn__ripple" v-if="isRecording"></view>
- <text class="voice-btn__text">
- <template v-if="!isRecording">按住 说话</template>
- <template v-else-if="willCancel">松开 取消</template>
- <template v-else>松开 发送</template>
- </text>
- <text class="voice-btn__hint" v-if="isRecording && !willCancel">上滑取消</text>
- </view>
- </view>
- </view>
- </view>
- <!-- US-9.4: Proposal card for negotiating state -->
- <view v-if="planRequest && planRequest.status === 'negotiating'" class="proposal-section">
- <view class="proposal-title">📋 人工方案</view>
- <view class="proposal-card">
- <view class="proposal-price">¥{{ planRequest.price }}</view>
- <view v-if="proposalMessage" class="proposal-message">
- <text class="proposal-msg-label">能量师留言:</text>
- <text class="proposal-msg-content">{{ proposalMessage }}</text>
- </view>
- <view v-if="logs.length > 0" class="negotiation-history">
- <view class="history-title">协商历史</view>
- <view class="history-timeline">
- <view v-for="(log, idx) in logs" :key="log.id" class="history-item" :class="log.action">
- <view class="timeline-dot" :class="log.action"></view>
- <view class="timeline-line" v-if="idx < logs.length - 1"></view>
- <view class="timeline-content">
- <text v-if="log.action === 'proposal'">能量师提交方案: ¥{{ log.newPrice }}</text>
- <text v-else-if="log.action === 'counter' && log.operatorId === currentUserId">您还价: ¥{{ log.newPrice }}</text>
- <text v-else-if="log.action === 'counter'">能量师还价: ¥{{ log.newPrice }}</text>
- <text v-else-if="log.action === 'accept'">✓ 已接受方案</text>
- <text v-else>{{ log.action }}</text>
- </view>
- </view>
- </view>
- </view>
- <view class="proposal-actions">
- <button class="proposal-btn btn-accept" :disabled="acceptingOffer" @click="acceptOffer">接受方案</button>
- <button class="proposal-btn btn-counter" @click="showCounterInput = !showCounterInput">还价</button>
- <button class="proposal-btn btn-reject" :disabled="rejecting" @click="rejectOffer">拒绝</button>
- </view>
- <view v-if="showCounterInput" class="counter-input-wrap">
- <view class="counter-input-row">
- <input class="counter-input" v-model="counterPrice" type="number" placeholder="输入您的报价..." />
- <button class="counter-submit" :disabled="countering || !counterPrice" @click="submitCounter">提交还价</button>
- </view>
- <text class="counter-hint">双方最多协商5轮</text>
- </view>
- </view>
- </view>
- <!-- US-9.5: Pending payment — show pay button -->
- <view v-if="planRequest && planRequest.status === 'pending_payment' && planOrder" class="proposal-section">
- <view class="proposal-title">📋 方案已确认</view>
- <view class="proposal-card pending-payment-card">
- <view class="proposal-price">¥{{ planRequest.price }}</view>
- <text class="payment-hint">请在24小时内完成支付</text>
- <button class="payment-btn" @click="goPay">去支付 ¥{{ planRequest.price }}</button>
- </view>
- </view>
- <!-- US-9.5: Paid — show delivery -->
- <view v-else-if="planRequest && planRequest.status === 'paid'" class="proposal-section">
- <view class="proposal-card paid-card">
- <view v-if="deliveryData && deliveryData.length > 0" class="delivery-content">
- <view class="delivery-title">📋 方案交付</view>
- <view v-for="d in deliveryData" :key="d.id" class="delivery-item">
- <view class="delivery-header">
- <text class="delivery-type">{{ d.deliveryType === 'text' ? '文字方案' : d.deliveryType === 'file' ? '文件方案' : '综合方案' }}</text>
- <text class="delivery-time">{{ d.createdAt?.slice(0, 10) }}</text>
- </view>
- <text v-if="d.textContent" class="delivery-text">{{ d.textContent }}</text>
- </view>
- </view>
- <view v-else class="paid-waiting">
- <text class="paid-waiting-text">方案已确认,等待能量师交付...</text>
- </view>
- </view>
- </view>
- </view>
- </template>
- <script>
- // Page lifecycle hooks (needed for WeChat share)
- export default {
- onShareAppMessage() {
- const pages = getCurrentPages()
- const page = pages[pages.length - 1]
- const recordId = page?.options?.recordId || ''
- const store = this.$scope?.store
- const title = store?.currentChart?.O
- ? `我的命盘主性格数字是${store.currentChart.O},快来测测你的!`
- : '数字能量命盘分析'
- return {
- title,
- path: `/pages/chart/index?recordId=${recordId}`,
- imageUrl: '/static/share-card.png'
- }
- },
- onShareTimeline() {
- const pages = getCurrentPages()
- const page = pages[pages.length - 1]
- const recordId = page?.options?.recordId || ''
- return {
- title: '数字能量命盘分析',
- query: `recordId=${recordId}`,
- imageUrl: '/static/share-card.png'
- }
- }
- }
- </script>
- <script setup>
- import { ref, computed, onMounted } from 'vue'
- import { useChartStore } from '@/stores/chart'
- import { useUserStore } from '@/stores/user'
- import TriangleChart from '@/components/TriangleChart.vue'
- import { chatApi, consultationApi, profileApi, annotationApi, exportApi, interventionApi, planApi, proposalApi, deliveryApi } from '@/utils/api'
- const store = useChartStore()
- const userStore = useUserStore()
- const chatInput = ref('')
- const scrollTop = ref(0)
- const messages = ref([])
- const loading = ref(false)
- const usedChats = ref(0)
- const maxChats = ref(0)
- // US-9.6: Chart collapse state
- const chartCollapsed = ref(false)
- // US-9.6: Voice input state
- const inputMode = ref('text') // 'text' | 'voice'
- const isRecording = ref(false)
- const willCancel = ref(false)
- const audioPlayingIdx = ref(-1)
- const recorderManager = uni.getRecorderManager()
- let innerAudioContext = null
- // US-9.3: Intervention state
- const hasActiveIntervention = ref(false)
- const activeInterventionId = ref(null)
- // US-9.4: Plan proposal state
- const planRequest = ref(null)
- const logs = ref([])
- const counterPrice = ref(null)
- const showCounterInput = ref(false)
- const acceptingOffer = ref(false)
- const countering = ref(false)
- const rejecting = ref(false)
- // US-9.5: Payment and delivery state
- const planOrder = ref(null)
- const deliveryData = ref([])
- // Annotation / Tag state (US-7.1 / US-7.2)
- const annotations = ref([])
- const allTags = ref([])
- const quotaExceeded = computed(() => {
- if (userStore.isVipActive) return false
- return maxChats.value > 0 && usedChats.value >= maxChats.value
- })
- const shareRemaining = computed(() => {
- if (userStore.isVipActive) return -1
- const limit = userStore.shareLimit || 1
- const count = userStore.shareCount || 0
- return limit > 0 ? Math.max(0, limit - count) : 0
- })
- // US-9.4: Computed proposal message from logs
- const proposalMessage = computed(() => {
- const proposalLog = logs.value.find(l => l.action === 'proposal')
- return proposalLog?.message || ''
- })
- // US-9.4: Current user ID for comparison in negotiation history
- const currentUserId = computed(() => userStore.user?.id || userStore.userId || '')
- onMounted(async () => {
- try {
- await userStore.fetchQuota()
- } catch (e) { /* ignore */ }
- // URL shareability — load from recordId if no store state (US-1.1 §14)
- const pages = getCurrentPages()
- const currentPage = pages[pages.length - 1]
- const recordId = currentPage?.options?.recordId
- if (!store.currentChart && recordId) {
- try {
- await store.loadChart(Number(recordId))
- } catch (e) {
- // fall through to "no data" message
- }
- }
- if (store.currentChart) {
- maxChats.value = userStore.maxChats || 3
- usedChats.value = userStore.usedChats || 0
- // Load annotations and tags for this record (US-7.1/7.2)
- if (store.currentRecordId) {
- loadAnnotations()
- }
- // US-9.4: Load plan request for this chart
- loadPlanRequest()
- // Use consultationMessages from store (set during startConsultation) if available
- if (store.consultationMessages && store.consultationMessages.length > 0) {
- messages.value = store.consultationMessages.map(m => ({
- role: m.role === 'ai' ? 'ai' : 'user',
- content: m.content,
- senderType: m.senderType || null,
- interventionId: m.interventionId || null,
- createdAt: m.createdAt
- }))
- } else if (store.currentRecordId) {
- try {
- const history = await chatApi.history(store.currentRecordId)
- messages.value = history.map(m => ({
- role: m.role === 'ai' ? 'ai' : 'user',
- content: m.content,
- senderType: m.senderType || null,
- interventionId: m.interventionId || null,
- createdAt: m.createdAt
- }))
- const userMsgCount = history.filter(m => m.role === 'user').length
- usedChats.value = Math.max(usedChats.value, userMsgCount)
- } catch (e) {
- // fallback to generic greeting
- }
- }
- // US-9.3: Check intervention status from loaded messages
- checkInterventionStatus(messages.value)
- // Ensure at least a greeting message if still empty
- if (messages.value.length === 0) {
- messages.value = [{
- role: 'ai',
- content: `已为您生成命盘。主性格数字为${store.currentChart.O}。请点击下方问题或输入您想了解的内容。`,
- createdAt: new Date().toISOString()
- }]
- }
- } else {
- messages.value = [{
- role: 'ai',
- content: '暂无命盘数据,请先返回首页输入出生信息。',
- createdAt: new Date().toISOString()
- }]
- }
- })
- // US-9.3: Check if intervention is active from message history
- function checkInterventionStatus(messages) {
- let active = false
- let lastId = null
- for (const msg of messages) {
- if (msg.senderType === 'system') {
- if (msg.content && msg.content.includes('能量师') && msg.content.includes('进入')) {
- active = true
- lastId = msg.interventionId
- }
- if (msg.content && (msg.content.includes('退出') || msg.content.includes('结束'))) {
- active = false
- }
- }
- }
- hasActiveIntervention.value = active
- activeInterventionId.value = lastId
- }
- // US-9.3: Long-press handler for practitioner / entry system messages
- function onLongPressMsg(msg) {
- // Only show action if intervention is active
- if (!hasActiveIntervention.value) return
- // Only on practitioner messages or entry system messages
- if (msg.senderType === 'practitioner' || (msg.senderType === 'system' && msg.content && msg.content.includes('能量师'))) {
- uni.showActionSheet({
- itemList: ['结束能量师介入'],
- success: async (res) => {
- if (res.tapIndex === 0) {
- await endIntervention()
- }
- }
- })
- }
- }
- // US-9.3: End intervention by user's request
- async function endIntervention() {
- uni.showLoading({ title: '处理中...' })
- try {
- await interventionApi.endByUser(activeInterventionId.value)
- uni.hideLoading()
- uni.showToast({ title: '已结束能量师介入', icon: 'success' })
- hasActiveIntervention.value = false
- activeInterventionId.value = null
- } catch (e) {
- uni.hideLoading()
- uni.showToast({ title: e.message || '操作失败', icon: 'none' })
- }
- }
- // ─── Voice Input ─────────────────────────────────────────
- // Setup recorder event listeners
- if (recorderManager) {
- recorderManager.onStart(() => {
- isRecording.value = true
- })
- recorderManager.onStop(async (res) => {
- isRecording.value = false
- willCancel.value = false
- if (res.tempFilePath && !cancelledRecording) {
- await uploadVoice(res.tempFilePath)
- }
- cancelledRecording = false
- })
- recorderManager.onError(() => {
- isRecording.value = false
- willCancel.value = false
- uni.showToast({ title: '录音失败', icon: 'none' })
- })
- }
- let cancelledRecording = false
- let recordStartY = 0
- function toggleInputMode() {
- inputMode.value = inputMode.value === 'text' ? 'voice' : 'text'
- }
- function onRecordStart(e) {
- if (loading.value) return
- cancelledRecording = false
- willCancel.value = false
- recordStartY = e.touches[0]?.clientY || 0
- // Start recording
- recorderManager.start({
- format: 'mp3',
- sampleRate: 16000,
- numberOfChannels: 1,
- encodeBitRate: 48000
- })
- }
- function onRecordMove(e) {
- if (!isRecording.value) return
- const touch = e.touches[0]
- if (touch && recordStartY > 0) {
- // Slide up more than 50px → show cancel
- willCancel.value = (recordStartY - touch.clientY) > 50
- }
- }
- function onRecordEnd() {
- if (isRecording.value) {
- if (willCancel.value) {
- cancelledRecording = true
- recorderManager.stop()
- } else {
- recorderManager.stop()
- }
- }
- }
- function onRecordCancel() {
- if (isRecording.value) {
- cancelledRecording = true
- willCancel.value = false
- isRecording.value = false
- try { recorderManager.stop() } catch (e) {}
- }
- }
- async function uploadVoice(filePath) {
- try {
- uni.showLoading({ title: '语音处理中...', mask: true })
- const result = await chatApi.voiceUpload(filePath, store.currentRecordId)
- uni.hideLoading()
- if (result && result.url) {
- await sendVoiceMessage(result.url)
- }
- } catch (e) {
- uni.hideLoading()
- uni.showToast({ title: '语音上传失败', icon: 'none' })
- }
- }
- async function sendVoiceMessage(audioUrl) {
- // Add voice message locally
- const now = new Date().toISOString()
- messages.value.push({ role: 'user', content: '[语音消息]', audioUrl, createdAt: now })
- scrollTop.value = 99999
- loading.value = true
- try {
- if (store.currentRecordId) {
- const res = await chatApi.send({
- chartRecordId: store.currentRecordId,
- query: '[语音消息]',
- audioUrl
- })
- messages.value.push({ role: 'ai', content: res.reply, createdAt: new Date().toISOString() })
- usedChats.value++
- }
- } catch (e) {
- if (e.code === 1008) {
- usedChats.value = maxChats.value
- messages.value.push({
- role: 'ai',
- content: '今日解读次数已用完,开通会员可享无限次解读。',
- createdAt: new Date().toISOString()
- })
- } else {
- messages.value.push({
- role: 'ai',
- content: e.message || '解读服务暂时不可用,请稍后再试。',
- createdAt: new Date().toISOString()
- })
- }
- }
- loading.value = false
- scrollTop.value = 99999
- }
- function getServerBase() {
- // Derive server base from API_BASE_URL pattern (strip trailing /api)
- // In api.js: const API_BASE_URL = 'http://127.0.0.1:8186/api'
- const token = uni.getStorageSync('token')
- // We don't have direct access to API_BASE_URL, so we construct it
- // This works in dev; production should use relative URLs
- return 'http://127.0.0.1:8186'
- }
- function playAudio(audioUrl, idx) {
- if (audioPlayingIdx.value === idx) {
- if (innerAudioContext) {
- innerAudioContext.stop()
- innerAudioContext.destroy()
- innerAudioContext = null
- }
- audioPlayingIdx.value = -1
- return
- }
- if (innerAudioContext) {
- innerAudioContext.stop()
- innerAudioContext.destroy()
- innerAudioContext = null
- }
- audioPlayingIdx.value = idx
- innerAudioContext = uni.createInnerAudioContext()
- innerAudioContext.src = getServerBase() + audioUrl
- innerAudioContext.onPlay(() => {})
- innerAudioContext.onError(() => {
- audioPlayingIdx.value = -1
- uni.showToast({ title: '播放失败', icon: 'none' })
- })
- innerAudioContext.onEnded(() => {
- audioPlayingIdx.value = -1
- innerAudioContext.destroy()
- innerAudioContext = null
- })
- innerAudioContext.play()
- }
- async function sendQuestion(q) {
- if (!q.trim() || loading.value) return
- if (quotaExceeded.value) {
- // Show upgrade prompt instead of sending
- uni.showModal({
- title: '今日解读次数已用完',
- content: '开通 VIP 会员享无限次 AI 解读,或分享好友获取额外次数',
- confirmText: '开通会员',
- cancelText: '分享好友',
- success: (res) => {
- if (res.confirm) {
- goSubscribeC()
- } else {
- triggerShare()
- }
- }
- })
- return
- }
- const now = new Date().toISOString()
- messages.value.push({ role: 'user', content: q, createdAt: now })
- chatInput.value = ''
- loading.value = true
- try {
- if (store.currentRecordId) {
- const res = await chatApi.send({ chartRecordId: store.currentRecordId, query: q })
- messages.value.push({ role: 'ai', content: res.reply, createdAt: new Date().toISOString() })
- usedChats.value++
- } else {
- messages.value.push({
- role: 'ai',
- content: `数字能量分析显示,${q}方面您的能量组合表明有良好的发展潜力,请结合自身情况理性决策。`
- })
- }
- } catch (e) {
- if (e.code === 1008) {
- usedChats.value = maxChats.value
- messages.value.push({
- role: 'ai',
- content: '今日解读次数已用完,开通会员可享无限次解读。',
- createdAt: new Date().toISOString()
- })
- } else {
- messages.value.push({
- role: 'ai',
- content: e.message || '解读服务暂时不可用,请稍后再试。',
- createdAt: new Date().toISOString()
- })
- }
- }
- loading.value = false
- scrollTop.value = 99999
- }
- function formatTime(isoStr) {
- if (!isoStr) return ''
- const d = new Date(isoStr)
- const now = new Date()
- const pad = (n) => String(n).padStart(2, '0')
- const hhmm = `${pad(d.getHours())}:${pad(d.getMinutes())}`
- // Same day → show time only; older → show date + time
- if (d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate()) {
- return hhmm
- }
- return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${hhmm}`
- }
- // US-3.3: Regenerate interpretation via action sheet menu
- function showMenu() {
- const items = ['🔄 重新开始']
- // US-9.2: Apply for manual plan (only when no active plan request)
- if (!planRequest.value) {
- items.push('📝 申请人工方案')
- }
- // US-2.2: PDF export for paid users only
- if (userStore.isVipActive) {
- items.push('📄 导出 PDF')
- }
- if (!userStore.isVipActive && shareRemaining.value > 0) {
- items.push('📤 分享给好友')
- }
- uni.showActionSheet({
- itemList: items,
- success: async (res) => {
- if (res.tapIndex === 0) {
- await confirmRegenerate()
- } else if (items[res.tapIndex] === '📝 申请人工方案') {
- await handlePlanApply()
- } else if (items[res.tapIndex] === '📄 导出 PDF') {
- await exportPdf()
- } else if (items[res.tapIndex] === '📤 分享给好友') {
- await triggerShare()
- }
- }
- })
- }
- async function confirmRegenerate() {
- uni.showModal({
- title: '重新开始',
- content: '将清除当前对话记录并重新生成命盘解读,确定吗?',
- success: async (confirm) => {
- if (!confirm.confirm) return
- if (!store.currentRecordId) {
- uni.showToast({ title: '暂无命盘数据', icon: 'none' })
- return
- }
- try {
- uni.showLoading({ title: '重新生成中...' })
- const newMessages = await consultationApi.regenerate(store.currentRecordId)
- messages.value = (newMessages || []).map(m => ({
- role: m.role === 'ai' ? 'ai' : 'user',
- content: m.content
- }))
- // Reset chat counters
- usedChats.value = 0
- uni.hideLoading()
- uni.showToast({ title: '解读已重新生成', icon: 'success' })
- scrollTop.value = 99999
- } catch (e) {
- uni.hideLoading()
- uni.showToast({ title: '重新生成失败,请重试', icon: 'none' })
- }
- }
- })
- }
- // US-9.2: Navigate to plan application page
- function handlePlanApply() {
- if (!store.currentRecordId) {
- uni.showToast({ title: '暂无命盘数据', icon: 'none' })
- return
- }
- uni.navigateTo({ url: `/pages/plan/apply?chartRecordId=${store.currentRecordId}` })
- }
- // US-2.1: Track share and update quota
- async function triggerShare() {
- uni.showShareMenu({
- withShareTicket: true,
- menus: ['shareAppMessage', 'shareTimeline'],
- success: async () => {
- // Call backend to track share
- try {
- const result = await profileApi.trackShare()
- shareCount.value = result.dailyShareCount || shareCount.value + 1
- } catch (e) { /* best-effort */ }
- }
- })
- }
- function goSubscribe() {
- uni.navigateTo({ url: '/pages/subscribe/index' })
- }
- function goSubscribeC() {
- uni.navigateTo({ url: '/pages/payment/index?product=annual' })
- }
- // US-2.2: Export PDF (paid users only)
- async function exportPdf() {
- if (!store.currentRecordId) {
- uni.showToast({ title: '暂无命盘记录', icon: 'none' })
- return
- }
- uni.showLoading({ title: '生成中...' })
- try {
- const filePath = await exportApi.pdf(store.currentRecordId)
- uni.hideLoading()
- // Open PDF document
- uni.openDocument({
- filePath,
- success: () => {
- uni.showToast({ title: 'PDF 已生成', icon: 'success' })
- },
- fail: () => {
- uni.showToast({ title: '打开文件失败', icon: 'none' })
- }
- })
- } catch (e) {
- uni.hideLoading()
- uni.showToast({ title: e.message || '导出失败', icon: 'none' })
- }
- }
- // === Annotation / Tag handlers (US-7.1 / US-7.2) ===
- async function loadAnnotations() {
- if (!store.currentRecordId) return
- try {
- const [annoList, tagList] = await Promise.all([
- annotationApi.list(store.currentRecordId),
- annotationApi.listTags(store.currentRecordId)
- ])
- annotations.value = annoList || []
- allTags.value = tagList || []
- } catch (e) { /* best-effort */ }
- }
- async function onAnnotationAdd(data) {
- try {
- const saved = await annotationApi.add(data)
- // Reload annotations to include the new one with server-generated ID
- if (store.currentRecordId) {
- const annoList = await annotationApi.list(store.currentRecordId)
- annotations.value = annoList || []
- }
- uni.showToast({ title: '批注已保存', icon: 'success' })
- } catch (e) {
- uni.showToast({ title: '保存失败', icon: 'none' })
- }
- }
- async function onAnnotationDelete(annoId) {
- try {
- await annotationApi.delete(annoId)
- annotations.value = annotations.value.filter(a => a.id !== annoId)
- uni.showToast({ title: '批注已删除', icon: 'success' })
- } catch (e) {
- uni.showToast({ title: '删除失败', icon: 'none' })
- }
- }
- async function onTagCreate(tagData) {
- try {
- const saved = await annotationApi.addTag({ ...tagData, recordId: store.currentRecordId })
- if (store.currentRecordId) {
- const tagList = await annotationApi.listTags(store.currentRecordId)
- allTags.value = tagList || []
- }
- uni.showToast({ title: '标签已创建', icon: 'success' })
- } catch (e) {
- uni.showToast({ title: '创建失败', icon: 'none' })
- }
- }
- // US-9.4: Load plan request for current chart
- async function loadPlanRequest() {
- if (!store.currentRecordId) return
- try {
- const requests = await planApi.myRequests()
- const match = requests.find(r => r.chartRecordId === store.currentRecordId && (r.status === 'negotiating' || r.status === 'pending_payment' || r.status === 'paid'))
- if (match) {
- planRequest.value = match
- const logList = await proposalApi.logs(match.id)
- logs.value = logList || []
- // US-9.5: Load delivery data when status is paid
- if (match.status === 'paid') {
- try {
- const deliveries = await deliveryApi.list(match.id)
- deliveryData.value = deliveries || []
- } catch (e) { /* best-effort */ }
- }
- }
- } catch (e) {
- // No plan request found — that's fine
- }
- }
- // US-9.4: Accept offer
- async function acceptOffer() {
- uni.showModal({
- title: '接受方案',
- content: `确定以 ¥${planRequest.value.price} 接受此方案吗?`,
- success: async (res) => {
- if (!res.confirm) return
- acceptingOffer.value = true
- try {
- const result = await proposalApi.accept({ requestId: planRequest.value.id })
- planRequest.value = result.planRequest
- planOrder.value = result.order
- const logList = await proposalApi.logs(planRequest.value.id)
- logs.value = logList || []
- uni.showToast({ title: '已接受方案,请完成支付', icon: 'success' })
- } catch (e) {
- uni.showToast({ title: e.message || '操作失败', icon: 'none' })
- } finally {
- acceptingOffer.value = false
- }
- }
- })
- }
- // US-9.5: Navigate to payment page
- function goPay() {
- if (!planOrder.value || !planRequest.value) return
- uni.navigateTo({
- url: `/pages/payment/index?product=practitioner_plan&totalFee=${planRequest.value.price}&outTradeNo=${planOrder.value.outTradeNo}`
- })
- }
- // US-9.4: Reject offer
- async function rejectOffer() {
- uni.showModal({
- title: '拒绝方案',
- content: '确定拒绝此方案吗?',
- success: async (res) => {
- if (!res.confirm) return
- rejecting.value = true
- try {
- await proposalApi.reject({ requestId: planRequest.value.id })
- planRequest.value.status = 'rejected'
- uni.showToast({ title: '已拒绝', icon: 'success' })
- } catch (e) {
- uni.showToast({ title: e.message || '操作失败', icon: 'none' })
- } finally {
- rejecting.value = false
- }
- }
- })
- }
- // US-9.4: Submit counter-offer
- async function submitCounter() {
- const price = Number(counterPrice.value)
- if (!price || price <= 0) {
- uni.showToast({ title: '请输入有效价格', icon: 'none' })
- return
- }
- countering.value = true
- try {
- await proposalApi.counter({ requestId: planRequest.value.id, newPrice: price })
- const logList = await proposalApi.logs(planRequest.value.id)
- logs.value = logList || []
- showCounterInput.value = false
- counterPrice.value = null
- uni.showToast({ title: '已提交还价', icon: 'success' })
- } catch (e) {
- uni.showToast({ title: e.message || '还价失败', icon: 'none' })
- } finally {
- countering.value = false
- }
- }
- </script>
- <style scoped lang="scss">
- .page-chart {
- height: 100vh;
- display: flex;
- flex-direction: column;
- overflow: hidden;
- background: linear-gradient(180deg, #0c0a1a 0%, #120d28 50%, #0f0c1e 100%);
- }
- /* Top operation bar */
- .top-bar {
- display: flex;
- justify-content: flex-end;
- align-items: center;
- padding: 8px 12px;
- background: rgba(0,0,0,0.15);
- }
- .spacer {
- flex: 1;
- }
- .collapse-btn {
- font-size: 13px;
- color: #fbbf24;
- padding: 6px 12px;
- border: 1px solid rgba(251,191,36,0.3);
- border-radius: 8px;
- }
- .chart-section {
- flex-shrink: 0;
- margin: 0 14px;
- overflow: hidden;
- transition: flex-shrink 0.25s;
- &__body {
- overflow: hidden;
- transition: max-height 0.3s ease, opacity 0.25s ease;
- }
- &--collapsed {
- flex-shrink: 1;
- }
- &__toggle {
- display: flex;
- align-items: center;
- justify-content: center;
- gap: 6px;
- padding: 6px 0 4px;
- cursor: pointer;
- -webkit-tap-highlight-color: transparent;
- &:active {
- opacity: 0.6;
- }
- &-label {
- font-size: 11px;
- color: rgba(255, 255, 255, 0.3);
- }
- &-arrow {
- font-size: 10px;
- color: rgba(255, 255, 255, 0.25);
- }
- }
- }
- /* Menu bar — three-dot button */
- .menu-bar {
- display: flex;
- justify-content: flex-end;
- padding: 0 14px;
- margin: -6px 0 8px;
- }
- .menu-btn {
- width: 32px;
- height: 28px;
- display: flex;
- align-items: center;
- justify-content: center;
- font-size: 20px;
- font-weight: 700;
- color: rgba(255, 255, 255, 0.4);
- letter-spacing: 2px;
- border-radius: 6px;
- background: rgba(255, 255, 255, 0.04);
- }
- .menu-btn:active {
- background: rgba(255, 255, 255, 0.08);
- }
- .chat-section {
- flex: 1;
- display: flex;
- flex-direction: column;
- overflow: hidden;
- min-height: 0;
- margin: 0 14px;
- }
- .section-title {
- font-size: 15px;
- font-weight: 600;
- color: rgba(255, 255, 255, 0.85);
- margin-bottom: 10px;
- display: flex;
- align-items: center;
- gap: 6px;
- }
- .title-icon {
- font-size: 12px;
- color: #fbbf24;
- opacity: 0.6;
- }
- .quota-badge {
- font-size: 11px;
- font-weight: 400;
- color: #fbbf24;
- background: rgba(251, 191, 36, 0.12);
- padding: 2px 10px;
- border-radius: 10px;
- border: 1px solid rgba(251, 191, 36, 0.15);
- }
- .quota-badge.vip {
- color: #34d399;
- background: rgba(52, 211, 153, 0.1);
- border-color: rgba(52, 211, 153, 0.15);
- }
- .chat-box {
- flex: 1;
- height: auto;
- overflow-y: auto;
- background: rgba(255, 255, 255, 0.04);
- border: 1px solid rgba(255, 255, 255, 0.06);
- border-radius: 14px;
- padding: 14px;
- margin-bottom: 10px;
- }
- .msg-row {
- margin-bottom: 14px;
- display: flex;
- }
- .msg-row.user {
- justify-content: flex-end;
- padding-right: 4px;
- }
- .msg-bubble {
- max-width: 80%;
- padding: 10px 16px;
- border-radius: 14px;
- font-size: 14px;
- line-height: 1.5;
- color: rgba(255, 255, 255, 0.85);
- background: rgba(255, 255, 255, 0.06);
- border: 1px solid rgba(255, 255, 255, 0.06);
- word-break: break-word;
- overflow-wrap: break-word;
- }
- .msg-row.user .msg-bubble {
- background: rgba(251, 191, 36, 0.12);
- border-color: rgba(251, 191, 36, 0.15);
- color: #fbbf24;
- margin-left: auto;
- }
- /* US-9.3: Practitioner message bubble */
- .msg-row.practitioner {
- justify-content: flex-start;
- flex-direction: column;
- align-items: flex-start;
- }
- .msg-practitioner-label {
- font-size: 12px;
- color: #10b981;
- margin-bottom: 4px;
- font-weight: 500;
- }
- .msg-practitioner-bubble {
- background: rgba(16, 185, 129, 0.15);
- border: 1px solid rgba(16, 185, 129, 0.3);
- color: rgba(255, 255, 255, 0.85);
- }
- /* US-9.3: System message — centered italic text */
- .msg-time {
- font-size: 11px;
- color: rgba(255, 255, 255, 0.25);
- margin-top: 4px;
- display: block;
- }
- .msg-row.user .msg-time {
- text-align: right;
- }
- .msg-time--system {
- margin-top: 2px;
- font-style: normal;
- }
- .msg-system {
- text-align: center;
- padding: 8px 0;
- color: rgba(255, 255, 255, 0.4);
- font-style: italic;
- font-size: 12px;
- line-height: 1.6;
- }
- .input-bar {
- &__row {
- display: flex;
- align-items: center;
- gap: 8px;
- width: 100%;
- }
- &__toggle {
- width: 38px;
- height: 38px;
- border-radius: 50%;
- background: rgba(255, 255, 255, 0.06);
- display: flex;
- align-items: center;
- justify-content: center;
- flex-shrink: 0;
- cursor: pointer;
- &:active {
- background: rgba(255, 255, 255, 0.12);
- }
- &-icon {
- font-size: 18px;
- line-height: 1;
- }
- }
- }
- .chat-input {
- flex: 1;
- height: 42px;
- border: 1px solid rgba(255, 255, 255, 0.1);
- border-radius: 10px;
- padding: 0 14px;
- font-size: 14px;
- color: rgba(255, 255, 255, 0.85);
- background: rgba(255, 255, 255, 0.04);
- }
- .send-btn {
- height: 42px;
- padding: 0 24px;
- background: linear-gradient(135deg, #f59e0b, #d97706);
- color: #fff;
- border: none;
- border-radius: 10px;
- line-height: 42px;
- font-size: 14px;
- font-weight: 500;
- white-space: nowrap;
- }
- .send-btn[disabled] {
- opacity: 0.4;
- }
- /* ─── Voice recording button ─── */
- .voice-btn {
- flex: 1;
- height: 48px;
- border-radius: 24px;
- background: rgba(255, 255, 255, 0.06);
- border: 1px solid rgba(255, 255, 255, 0.1);
- display: flex;
- align-items: center;
- justify-content: center;
- position: relative;
- overflow: hidden;
- user-select: none;
- -webkit-tap-highlight-color: transparent;
- &:active {
- background: rgba(255, 255, 255, 0.1);
- }
- &--recording {
- background: rgba(251, 191, 36, 0.1);
- border-color: rgba(251, 191, 36, 0.3);
- }
- &--release {
- background: rgba(239, 68, 68, 0.12);
- border-color: rgba(239, 68, 68, 0.3);
- }
- &__text {
- font-size: 15px;
- font-weight: 500;
- color: rgba(255, 255, 255, 0.75);
- position: relative;
- z-index: 1;
- }
- &--recording &__text {
- color: #fbbf24;
- }
- &--release &__text {
- color: #ef4444;
- }
- &__hint {
- position: absolute;
- right: 14px;
- top: 50%;
- transform: translateY(-50%);
- font-size: 10px;
- color: rgba(255, 255, 255, 0.25);
- z-index: 1;
- }
- &__ripple {
- position: absolute;
- inset: -20px;
- border-radius: 50%;
- background: radial-gradient(circle, rgba(251, 191, 36, 0.08) 0%, transparent 70%);
- animation: voice-pulse 1s ease-in-out infinite;
- }
- }
- @keyframes voice-pulse {
- 0%, 100% { transform: scale(1); opacity: 0.6; }
- 50% { transform: scale(1.15); opacity: 1; }
- }
- /* ─── Audio message bubble ─── */
- .msg-bubble--audio {
- display: flex;
- align-items: center;
- gap: 8px;
- min-width: 100px;
- cursor: pointer;
- &:active {
- opacity: 0.7;
- }
- }
- .msg-audio {
- &__icon {
- font-size: 20px;
- flex-shrink: 0;
- }
- &__wave {
- display: flex;
- align-items: center;
- gap: 2px;
- height: 20px;
- &--playing .msg-audio__bar {
- background: #fbbf24;
- animation: audio-bar 0.6s ease-in-out infinite alternate;
- }
- }
- &__bar {
- width: 3px;
- border-radius: 2px;
- background: rgba(255, 255, 255, 0.3);
- &:nth-child(1) { height: 8px; }
- &:nth-child(2) { height: 14px; animation-delay: 0.2s; }
- &:nth-child(3) { height: 10px; animation-delay: 0.4s; }
- &:nth-child(4) { height: 16px; animation-delay: 0.6s; }
- }
- &__label {
- font-size: 12px;
- color: rgba(255, 255, 255, 0.5);
- flex-shrink: 0;
- }
- }
- @keyframes audio-bar {
- 0% { transform: scaleY(0.5); }
- 100% { transform: scaleY(1.2); }
- }
- .msg-row.user .msg-audio__bar {
- background: rgba(251, 191, 36, 0.35);
- }
- .msg-row.user .msg-audio__label {
- color: rgba(251, 191, 36, 0.5);
- }
- /* Upgrade prompt */
- .upgrade-prompt {
- background: linear-gradient(135deg, rgba(251, 191, 36, 0.08), rgba(217, 119, 6, 0.06));
- border: 1px solid rgba(251, 191, 36, 0.12);
- border-radius: 14px;
- padding: 22px;
- text-align: center;
- margin-bottom: 12px;
- }
- .upgrade-text {
- display: block;
- font-size: 16px;
- font-weight: 600;
- color: #fbbf24;
- }
- .upgrade-desc {
- display: block;
- font-size: 13px;
- color: rgba(255, 255, 255, 0.45);
- margin: 8px 0 16px;
- }
- .upgrade-actions {
- display: flex;
- gap: 10px;
- justify-content: center;
- }
- .upgrade-btn {
- flex: 1;
- background: linear-gradient(135deg, #f59e0b, #d97706);
- color: #fff;
- border: none;
- border-radius: 10px;
- padding: 10px 0;
- font-size: 14px;
- font-weight: 500;
- line-height: 1.4;
- }
- .upgrade-btn.secondary {
- background: rgba(255, 255, 255, 0.06);
- border: 1px solid rgba(251, 191, 36, 0.2);
- color: #fbbf24;
- }
- /* US-9.4: Proposal section */
- .proposal-section {
- flex-shrink: 0;
- margin: 0 14px 10px;
- }
- .proposal-title {
- font-size: 16px;
- font-weight: 700;
- color: #a78bfa;
- margin-bottom: 10px;
- padding-left: 2px;
- }
- .proposal-card {
- background: rgba(124,58,237,0.06);
- border: 1px solid rgba(124,58,237,0.12);
- border-radius: 14px;
- padding: 16px;
- }
- .proposal-price {
- text-align: center;
- font-size: 24px;
- font-weight: 700;
- color: #fbbf24;
- margin-bottom: 12px;
- }
- .proposal-message {
- background: rgba(255,255,255,0.03);
- border-radius: 8px;
- padding: 10px 12px;
- margin-bottom: 12px;
- }
- .proposal-msg-label {
- display: block;
- font-size: 12px;
- color: rgba(255,255,255,0.4);
- margin-bottom: 4px;
- }
- .proposal-msg-content {
- display: block;
- font-size: 13px;
- color: rgba(255,255,255,0.75);
- line-height: 1.5;
- }
- .negotiation-history {
- margin-bottom: 14px;
- }
- .history-title {
- font-size: 13px;
- font-weight: 600;
- color: rgba(255,255,255,0.5);
- margin-bottom: 10px;
- }
- .history-timeline {
- padding-left: 6px;
- }
- .history-item {
- display: flex;
- align-items: flex-start;
- position: relative;
- padding-bottom: 6px;
- }
- .timeline-dot {
- width: 8px;
- height: 8px;
- border-radius: 50%;
- background: rgba(255,255,255,0.2);
- flex-shrink: 0;
- margin-top: 4px;
- margin-right: 10px;
- }
- .timeline-dot.accept {
- background: #10b981;
- }
- .timeline-line {
- position: absolute;
- left: 3px;
- top: 12px;
- width: 2px;
- height: calc(100% - 6px);
- background: rgba(255,255,255,0.08);
- }
- .timeline-content {
- flex: 1;
- font-size: 13px;
- color: rgba(255,255,255,0.6);
- line-height: 1.5;
- }
- .proposal-actions {
- display: flex;
- gap: 10px;
- }
- .proposal-btn {
- flex: 1;
- height: 40px;
- border: none;
- border-radius: 10px;
- font-size: 14px;
- font-weight: 600;
- line-height: 40px;
- text-align: center;
- }
- .proposal-btn.btn-accept {
- background: linear-gradient(135deg, #10b981, #059669);
- color: #fff;
- }
- .proposal-btn.btn-accept[disabled] {
- opacity: 0.5;
- }
- .proposal-btn.btn-counter {
- background: transparent;
- border: 1px solid #3b82f6;
- color: #3b82f6;
- }
- .proposal-btn.btn-reject {
- background: transparent;
- border: 1px solid #ef4444;
- color: #ef4444;
- }
- .proposal-btn.btn-reject[disabled] {
- opacity: 0.5;
- }
- .counter-input-wrap {
- margin-top: 12px;
- padding-top: 12px;
- border-top: 1px solid rgba(255,255,255,0.06);
- }
- .counter-input-row {
- display: flex;
- gap: 8px;
- }
- .counter-input {
- flex: 1;
- height: 42px;
- border: 1px solid rgba(255,255,255,0.1);
- border-radius: 10px;
- padding: 0 14px;
- font-size: 14px;
- color: rgba(255,255,255,0.85);
- background: rgba(255,255,255,0.04);
- }
- .counter-submit {
- height: 42px;
- padding: 0 20px;
- background: linear-gradient(135deg, #3b82f6, #2563eb);
- color: #fff;
- border: none;
- border-radius: 10px;
- line-height: 42px;
- font-size: 14px;
- font-weight: 500;
- }
- .counter-submit[disabled] {
- opacity: 0.4;
- }
- .counter-hint {
- display: block;
- font-size: 11px;
- color: rgba(255,255,255,0.3);
- margin-top: 6px;
- text-align: center;
- }
- .proposal-card.paid-card {
- text-align: center;
- padding: 16px;
- }
- .paid-text {
- font-size: 14px;
- color: #10b981;
- font-weight: 600;
- }
- /* US-9.5: Pending payment card */
- .pending-payment-card {
- background: linear-gradient(135deg, rgba(167, 139, 250, 0.08), rgba(124, 58, 237, 0.05));
- border: 1px solid rgba(167, 139, 250, 0.2);
- border-radius: 14px;
- padding: 20px;
- text-align: center;
- }
- .pending-payment-card .proposal-price {
- font-size: 28px;
- font-weight: 700;
- color: #fbbf24;
- margin-bottom: 8px;
- }
- .payment-hint {
- font-size: 12px;
- color: rgba(255, 255, 255, 0.4);
- display: block;
- margin-bottom: 14px;
- }
- .payment-btn {
- width: 100%;
- height: 46px;
- background: linear-gradient(135deg, #10b981, #059669);
- color: #fff;
- border: none;
- border-radius: 12px;
- line-height: 46px;
- font-size: 16px;
- font-weight: 600;
- box-shadow: 0 4px 12px rgba(16, 185, 129, 0.25);
- }
- /* US-9.5: Delivery content display */
- .delivery-content {
- padding: 0;
- }
- .delivery-title {
- font-size: 15px;
- font-weight: 600;
- color: rgba(255, 255, 255, 0.85);
- margin-bottom: 12px;
- }
- .delivery-item {
- background: rgba(255, 255, 255, 0.03);
- border-radius: 10px;
- padding: 14px;
- margin-bottom: 10px;
- }
- .delivery-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 8px;
- }
- .delivery-type {
- font-size: 13px;
- font-weight: 600;
- color: #10b981;
- }
- .delivery-time {
- font-size: 11px;
- color: rgba(255, 255, 255, 0.3);
- }
- .delivery-text {
- display: block;
- font-size: 13px;
- color: rgba(255, 255, 255, 0.65);
- line-height: 1.6;
- white-space: pre-wrap;
- }
- /* US-9.5: Paid waiting state */
- .paid-waiting {
- text-align: center;
- padding: 10px 0;
- }
- .paid-waiting-text {
- font-size: 14px;
- color: rgba(255, 255, 255, 0.4);
- }
- </style>
|