Kaynağa Gözat

AI解读页输入栏固定底部 + 首页添加快捷提问标签

Ultraworked with Sisyphus (https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
liaoxg 3 ay önce
ebeveyn
işleme
eebb57ca53
2 değiştirilmiş dosya ile 681 ekleme ve 0 silme
  1. 335 0
      client/pages/chart/index.vue
  2. 346 0
      client/pages/index/index.vue

+ 335 - 0
client/pages/chart/index.vue

@@ -0,0 +1,335 @@
+<template>
+  <view class="page-chart">
+    <!-- Triangle chart -->
+    <view class="chart-section">
+      <TriangleChart
+        :chartData="store.currentChart"
+        :birthYear="store.currentBirthYear"
+        :birthMonth="store.currentBirthMonth"
+        :birthDay="store.currentBirthDay"
+      />
+    </view>
+
+    <!-- AI Chat -->
+    <view class="chat-section">
+      <view class="section-title">
+        <text class="title-icon">◈</text>
+        AI 解读
+        <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" class="msg-row" :class="msg.role">
+          <view class="msg-bubble">
+            <text>{{ msg.content }}</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>
+        <button class="upgrade-btn" @click="goSubscribe">立即开通 →</button>
+      </view>
+
+      <!-- Input area (disabled when quota exceeded) -->
+      <template v-if="!quotaExceeded">
+        <view class="input-bar">
+          <input
+            class="chat-input"
+            v-model="chatInput"
+            placeholder="输入您的问题..."
+            @confirm="sendQuestion(chatInput)"
+            :disabled="loading"
+          />
+          <button class="send-btn" @click="sendQuestion(chatInput)" :disabled="loading">
+            {{ loading ? '···' : '发送' }}
+          </button>
+        </view>
+      </template>
+    </view>
+  </view>
+</template>
+
+<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 } 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)
+
+const quotaExceeded = computed(() => {
+  if (userStore.isVipActive) return false
+  return maxChats.value > 0 && usedChats.value >= maxChats.value
+})
+
+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
+
+    // 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
+      }))
+    } 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
+        }))
+        const userMsgCount = history.filter(m => m.role === 'user').length
+        usedChats.value = Math.max(usedChats.value, userMsgCount)
+      } catch (e) {
+        // fallback to generic greeting
+      }
+    }
+
+    // Ensure at least a greeting message if still empty
+    if (messages.value.length === 0) {
+      messages.value = [{
+        role: 'ai',
+        content: `已为您生成命盘。主性格数字为${store.currentChart.O}。请点击下方问题或输入您想了解的内容。`
+      }]
+    }
+  } else {
+    messages.value = [{
+      role: 'ai',
+      content: '暂无命盘数据,请先返回首页输入出生信息。'
+    }]
+  }
+})
+
+async function sendQuestion(q) {
+  if (!q.trim() || loading.value) return
+  if (quotaExceeded.value) return
+
+  messages.value.push({ role: 'user', content: q })
+  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 })
+      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: '今日解读次数已用完,开通会员可享无限次解读。'
+      })
+    } else {
+      messages.value.push({
+        role: 'ai',
+        content: e.message || '解读服务暂时不可用,请稍后再试。'
+      })
+    }
+  }
+
+  loading.value = false
+  scrollTop.value = 99999
+}
+
+function goSubscribe() {
+  uni.navigateTo({ url: '/pages/subscribe/index' })
+}
+</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%);
+}
+
+.chart-section {
+  flex-shrink: 0;
+  margin: 0 14px;
+}
+
+.chat-section {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+  min-height: 0;
+  margin: 0 14px;
+}
+
+.section-title {
+  font-size: 17px;
+  font-weight: 600;
+  color: rgba(255, 255, 255, 0.9);
+  margin: 20px 0 14px;
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.title-icon {
+  font-size: 14px;
+  color: #fbbf24;
+  opacity: 0.7;
+}
+
+.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;
+}
+
+.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);
+}
+
+.msg-row.user .msg-bubble {
+  background: rgba(251, 191, 36, 0.12);
+  border-color: rgba(251, 191, 36, 0.15);
+  color: #fbbf24;
+}
+
+.input-bar {
+  display: flex;
+  gap: 8px;
+}
+
+.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;
+}
+
+.send-btn[disabled] {
+  opacity: 0.4;
+}
+
+/* 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-btn {
+  background: linear-gradient(135deg, #f59e0b, #d97706);
+  color: #fff;
+  border: none;
+  border-radius: 10px;
+  padding: 10px 36px;
+  font-size: 15px;
+  font-weight: 500;
+}
+</style>

+ 346 - 0
client/pages/index/index.vue

@@ -0,0 +1,346 @@
+<template>
+  <view class="page-index">
+    <!-- Hero header -->
+    <view class="hero">
+      <text class="hero-title">数字能量</text>
+      <text class="hero-sub">输入出生信息,解锁专属命盘</text>
+    </view>
+
+    <!-- Birth input section -->
+    <view class="input-section">
+      <view class="input-card">
+        <text class="label">姓名(选填)</text>
+        <input
+          class="input-field"
+          v-model="name"
+          placeholder="请输入姓名"
+        />
+        <text class="label mt-16">出生年份</text>
+        <input
+          class="input-field"
+          type="digit"
+          maxlength="4"
+          v-model="birthYear"
+          placeholder="例如 1990"
+        />
+        <view class="row-inputs">
+          <view class="half-input">
+            <text class="label">月份</text>
+            <picker class="picker-wrap" mode="selector" :range="monthRange" @change="onMonthChange">
+              <view class="input-field picker-field">
+                <text :class="birthMonth ? 'picker-val' : 'picker-ph'">{{ birthMonth || '选择' }}</text>
+                <text class="picker-arrow">▼</text>
+              </view>
+            </picker>
+          </view>
+          <view class="half-input">
+            <text class="label">日期</text>
+            <picker class="picker-wrap" mode="selector" :range="dayRange" @change="onDayChange">
+              <view class="input-field picker-field">
+                <text :class="birthDay ? 'picker-val' : 'picker-ph'">{{ birthDay || '选择' }}</text>
+                <text class="picker-arrow">▼</text>
+              </view>
+            </picker>
+          </view>
+        </view>
+        <text class="label mt-16">想了解的问题(选填)</text>
+        <view class="question-wrap">
+          <textarea
+            class="question-input"
+            v-model="questions"
+            maxlength="100"
+            placeholder="例如:我的财运如何?最近适合换工作吗?感情方面有什么建议?"
+          />
+          <text class="char-counter">{{ questions.length }}/100</text>
+        </view>
+        <view class="quick-chips">
+          <view
+            v-for="q in quickQuestions"
+            :key="q"
+            class="chip"
+            :class="{ active: questions === q }"
+            @click="selectQuick(q)"
+          >
+            <text>{{ q }}</text>
+          </view>
+        </view>
+        <button class="analyze-btn" @click="onAnalyze">开始咨询</button>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script setup>
+import { ref, computed } from 'vue'
+import { useChartStore } from '@/stores/chart'
+import { consultationApi } from '@/utils/api'
+
+const store = useChartStore()
+const name = ref('')
+const birthYear = ref('')
+const birthMonth = ref('')
+const birthDay = ref('')
+const questions = ref('')
+const confirming = ref(false) // prevent double-click
+const quickQuestions = ['职业发展', '财务分析', '感情运势', '健康建议']
+
+function selectQuick(q) {
+  questions.value = questions.value === q ? '' : q
+}
+
+const monthRange = Array.from({ length: 12 }, (_, i) => String(i + 1))
+const dayRange = Array.from({ length: 31 }, (_, i) => String(i + 1))
+
+function onMonthChange(e) {
+  birthMonth.value = monthRange[e.detail.value]
+}
+function onDayChange(e) {
+  birthDay.value = dayRange[e.detail.value]
+}
+
+function formatBirthday(year, month, day) {
+  return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`
+}
+
+async function onAnalyze() {
+  if (confirming.value) return
+  const year = parseInt(birthYear.value, 10)
+  const month = parseInt(birthMonth.value, 10)
+  const day = parseInt(birthDay.value, 10)
+
+  if (!year || !month || !day) {
+    uni.showToast({ title: '请输入完整的出生年月日', icon: 'none' })
+    return
+  }
+  if (year < 1900 || year > new Date().getFullYear()) {
+    uni.showToast({ title: '请输入正确的出生年份', icon: 'none' })
+    return
+  }
+  if (month < 1 || month > 12) {
+    uni.showToast({ title: '请输入正确的月份(1-12)', icon: 'none' })
+    return
+  }
+  if (day < 1 || day > 31) {
+    uni.showToast({ title: '请输入正确的日期(1-31)', icon: 'none' })
+    return
+  }
+
+  const birthday = formatBirthday(year, month, day)
+  confirming.value = true
+
+  try {
+    // Check if this birthday has existing records — US-1.1 §7-9
+    const isNew = await checkNewBirthday(birthday)
+    if (!isNew) {
+      // Existing birthday — proceed directly (US-1.1 §7-8: returns existing record)
+      confirming.value = false
+      await proceedConsultation(year, month, day, birthday)
+      return
+    }
+  } catch (_e) {
+    // If check fails (e.g. network), allow proceed without confirm
+  }
+
+  // New birthday — show confirm dialog (US-1.1 §9-11)
+  uni.showModal({
+    title: '确认咨询',
+    content: '此生日将开启全新的命盘咨询,确认吗?',
+    confirmText: '确认开启',
+    cancelText: '再想想',
+    success: async (res) => {
+      confirming.value = false
+      if (res.confirm) {
+        await proceedConsultation(year, month, day, birthday)
+      }
+      // res.cancel → stay on page, do nothing (US-1.1 §11)
+    },
+    fail: () => { confirming.value = false }
+  })
+}
+
+async function checkNewBirthday(birthday) {
+  try {
+    const result = await consultationApi.check({ birthday })
+    return !result.exists
+  } catch {
+    return false
+  }
+}
+
+async function proceedConsultation(year, month, day, birthday) {
+  const result = await store.startConsultation(name.value, birthday, questions.value)
+
+  // Parse backend chart data into birth props for TriangleChart
+  if (store.currentChart) {
+    store.currentBirthYear = year
+    store.currentBirthMonth = month
+    store.currentBirthDay = day
+  }
+
+  // Navigate with recordId for URL shareability (US-1.1 §14)
+  const recordId = store.currentRecordId || (result.record && result.record.id)
+  const url = recordId
+    ? `/pages/chart/index?recordId=${recordId}`
+    : '/pages/chart/index'
+  uni.navigateTo({ url })
+}
+</script>
+
+<style scoped lang="scss">
+.page-index {
+  min-height: 100vh;
+  padding-bottom: 40px;
+  background: linear-gradient(180deg, #0c0a1a 0%, #120d28 60%, #0f0c1e 100%);
+}
+
+/* Hero */
+.hero {
+  padding: 40px 20px 20px;
+  text-align: center;
+}
+
+.hero-title {
+  font-size: 32px;
+  font-weight: 700;
+  color: #fbbf24;
+  text-shadow: 0 0 30px rgba(251, 191, 36, 0.2);
+  letter-spacing: 4px;
+}
+
+.hero-sub {
+  display: block;
+  margin-top: 10px;
+  font-size: 14px;
+  color: rgba(255, 255, 255, 0.4);
+  letter-spacing: 2px;
+}
+
+/* Input card */
+.input-section {
+  padding: 0 16px;
+}
+
+.input-card {
+  background: rgba(255, 255, 255, 0.03);
+  border: 1px solid rgba(255, 255, 255, 0.06);
+  border-radius: 16px;
+  padding: 24px 20px;
+  backdrop-filter: blur(10px);
+}
+
+.label {
+  font-size: 13px;
+  color: rgba(255, 255, 255, 0.5);
+  margin-bottom: 8px;
+  display: block;
+  letter-spacing: 1px;
+}
+
+.input-field {
+  height: 44px;
+  border: 1px solid rgba(255, 255, 255, 0.08);
+  border-radius: 10px;
+  padding: 0 14px;
+  font-size: 16px;
+  width: 100%;
+  color: rgba(255, 255, 255, 0.85);
+  background: rgba(255, 255, 255, 0.04);
+  box-sizing: border-box;
+}
+
+.mt-16 {
+  margin-top: 16px;
+  display: block;
+}
+
+.row-inputs {
+  display: flex;
+  gap: 12px;
+}
+
+.half-input {
+  flex: 1;
+}
+
+/* Picker */
+.picker-wrap {
+  width: 100%;
+}
+.picker-field {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+.picker-val {
+  color: rgba(255, 255, 255, 0.85);
+  font-size: 16px;
+}
+.picker-ph {
+  color: rgba(255, 255, 255, 0.25);
+  font-size: 16px;
+}
+.picker-arrow {
+  font-size: 10px;
+  color: rgba(255, 255, 255, 0.3);
+  margin-left: 4px;
+}
+
+.question-wrap {
+  position: relative;
+}
+
+.question-input {
+  width: 100%;
+  min-height: 80px;
+  border: 1px solid rgba(255, 255, 255, 0.08);
+  border-radius: 10px;
+  padding: 12px 14px 28px;
+  font-size: 14px;
+  line-height: 1.5;
+  box-sizing: border-box;
+  color: rgba(255, 255, 255, 0.85);
+  background: rgba(255, 255, 255, 0.04);
+}
+
+.char-counter {
+  position: absolute;
+  right: 10px;
+  bottom: 8px;
+  font-size: 11px;
+  color: rgba(255, 255, 255, 0.3);
+}
+
+.quick-chips {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+  margin: 12px 0 0;
+}
+.chip {
+  background: rgba(255, 255, 255, 0.04);
+  border: 1px solid rgba(255, 255, 255, 0.08);
+  border-radius: 18px;
+  padding: 6px 18px;
+  font-size: 13px;
+  color: rgba(255, 255, 255, 0.6);
+}
+.chip.active {
+  background: rgba(251, 191, 36, 0.12);
+  border-color: rgba(251, 191, 36, 0.25);
+  color: #fbbf24;
+}
+
+.analyze-btn {
+  width: 100%;
+  height: 46px;
+  background: linear-gradient(135deg, #f59e0b, #d97706);
+  color: #fff;
+  border: none;
+  border-radius: 12px;
+  font-size: 16px;
+  font-weight: 600;
+  line-height: 46px;
+  letter-spacing: 2px;
+  margin-top: 4px;
+}
+</style>