| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708 |
- <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>
- <!-- Super member entry point (non-super users who have seen AI interpretation) -->
- <view v-if="messages.length >= 2 && !isSuperMember" class="super-entry" @click="goSuperMember">
- <view class="super-entry__icon">🏆</view>
- <view class="super-entry__body">
- <text class="super-entry__title">超级会员专属</text>
- <text class="super-entry__desc">关系能量分析 · 八星号码解读</text>
- </view>
- <view class="super-entry__action">开通</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 isSuperMember = computed(() => {
- if (!userStore.isVip) return false
- return userStore.vipType === 'super_annual' || userStore.vipType === 'practitioner'
- })
- 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' })
- }
- function goSuperMember() {
- uni.navigateTo({ url: '/pages/payment/index?product=super_member' })
- }
- // 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;
- }
- /* Super member entry point */
- .super-entry {
- display: flex;
- align-items: center;
- gap: 10px;
- background: linear-gradient(135deg, rgba(30, 58, 95, 0.08), rgba(201, 168, 76, 0.06));
- border: 1px solid rgba(201, 168, 76, 0.2);
- border-radius: 12px;
- padding: 12px 14px;
- margin-top: 12px;
- cursor: pointer;
- }
- .super-entry:active {
- transform: scale(0.98);
- opacity: 0.9;
- }
- .super-entry__icon {
- font-size: 28px;
- flex-shrink: 0;
- }
- .super-entry__body {
- flex: 1;
- }
- .super-entry__title {
- font-size: 14px;
- font-weight: 600;
- color: #1E3A5F;
- display: block;
- }
- .super-entry__desc {
- font-size: 12px;
- color: #A0AEC0;
- display: block;
- margin-top: 1px;
- }
- .super-entry__action {
- flex-shrink: 0;
- font-size: 13px;
- font-weight: 600;
- color: #C9A84C;
- background: #1E3A5F;
- padding: 6px 14px;
- border-radius: 8px;
- }
- /* 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>
|