Просмотр исходного кода

feat(frontend): 改版v1 C类前端 - 意图选择弹层(F2) + 旅程进度条(F3)

IntentPicker: A/B/C三入口卡片,C类可展开问题域列表选择。JourneyCard: 按A/B/C类型机渲染旅程进度条+下一步按钮,支持C类7步/A类4步/B类2步状态机。

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
E2E Test Bot 1 месяц назад
Родитель
Сommit
d1591159f9
2 измененных файлов с 617 добавлено и 0 удалено
  1. 311 0
      cfc-frontend/components/intent-picker.vue
  2. 306 0
      cfc-frontend/components/journey-card.vue

+ 311 - 0
cfc-frontend/components/intent-picker.vue

@@ -0,0 +1,311 @@
+<template>
+  <view class="ip-mask" v-if="show" @click="onMaskTap">
+    <view class="ip-panel" @click.stop>
+      <view class="ip-header">
+        <text class="ip-title">告诉我,你想怎么开始?</text>
+        <text class="ip-sub">浠艾福为你定制专属家庭成长方案</text>
+      </view>
+
+      <!-- A 直达 -->
+      <view class="ip-card" @click="emitSelect('A')">
+        <view class="ip-card-icon" style="background: rgba(249,115,22,0.12);"><text class="ip-card-emoji">🎯</text></view>
+        <view class="ip-card-body">
+          <text class="ip-card-title">直达目标</text>
+          <text class="ip-card-desc">我有明确需求,直接选购测评或检测服务</text>
+        </view>
+        <text class="ip-card-arrow">›</text>
+      </view>
+
+      <!-- B 问卷 -->
+      <view class="ip-card" @click="emitSelect('B')">
+        <view class="ip-card-icon" style="background: rgba(14,165,233,0.12);"><text class="ip-card-emoji">📋</text></view>
+        <view class="ip-card-body">
+          <text class="ip-card-title">智能问卷</text>
+          <text class="ip-card-desc">先答几道题,系统为你匹配推荐方案</text>
+        </view>
+        <text class="ip-card-arrow">›</text>
+      </view>
+
+      <!-- C 问题域 -->
+      <view class="ip-card" @click="onDomainTap">
+        <view class="ip-card-icon" style="background: rgba(16,185,129,0.12);"><text class="ip-card-emoji">🔍</text></view>
+        <view class="ip-card-body">
+          <text class="ip-card-title">解决问题</text>
+          <text class="ip-card-desc">选择你关心的问题,获得完整解决方案</text>
+        </view>
+        <text class="ip-card-arrow">›</text>
+      </view>
+
+      <!-- C 子级:问题域列表 -->
+      <view class="ip-domain-wrap" v-if="showDomains">
+        <view
+          v-for="d in domains"
+          :key="getKey(d)"
+          class="ip-domain-item"
+          :class="{ 'ip-domain-item-active': d.code === selectedCode }"
+          @click="selectDomain(d)">
+          <text class="ip-domain-icon">{{ d.icon || '🧭' }}</text>
+          <text class="ip-domain-name">{{ d.name }}</text>
+          <text class="ip-domain-check" v-if="d.code === selectedCode">✓</text>
+        </view>
+        <view class="ip-domain-confirm" @click="confirmDomain">
+          <text class="ip-domain-confirm-text">开始我的方案</text>
+        </view>
+      </view>
+
+      <view class="ip-skip" @click="onSkip">
+        <text class="ip-skip-text">暂时跳过</text>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+import { getProblemDomainList } from '../utils/api.js'
+
+export default {
+  name: 'IntentPicker',
+  props: {
+    show: {
+      type: Boolean,
+      default: false
+    }
+  },
+  data() {
+    return {
+      showDomains: false,
+      domains: [],
+      selectedCode: '',
+      selectedName: '',
+      loading: false
+    }
+  },
+  watch: {
+    show(val) {
+      if (val) {
+        this.showDomains = false
+        this.selectedCode = ''
+        this.selectedName = ''
+        this.loadDomains()
+      }
+    }
+  },
+  methods: {
+    getKey(d) {
+      return d.id || d.code
+    },
+    loadDomains() {
+      if (this.domains.length > 0 || this.loading) return
+      this.loading = true
+      var self = this
+      getProblemDomainList().then(function(res) {
+        self.loading = false
+        var list = res && res.data
+        self.domains = Array.isArray(list) ? list : []
+      }).catch(function() {
+        self.loading = false
+        self.domains = []
+      })
+    },
+    onMaskTap() {
+      // 点击遮罩不关闭,防止误触跳过
+    },
+    onDomainTap() {
+      this.showDomains = !this.showDomains
+      this.loadDomains()
+    },
+    selectDomain(d) {
+      this.selectedCode = d.code
+      this.selectedName = d.name
+    },
+    confirmDomain() {
+      if (!this.selectedCode) {
+        uni.showToast({ title: '请先选择一个关注的问题', icon: 'none' })
+        return
+      }
+      this.$emit('select', {
+        intentType: 'C',
+        problemDomainCode: this.selectedCode,
+        problemDomainName: this.selectedName
+      })
+    },
+    emitSelect(intentType) {
+      this.$emit('select', { intentType: intentType })
+    },
+    onSkip() {
+      this.$emit('close')
+    }
+  }
+}
+</script>
+
+<style scoped>
+.ip-mask {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background: rgba(0, 0, 0, 0.55);
+  display: flex;
+  align-items: flex-end;
+  justify-content: center;
+  z-index: 9999;
+}
+
+.ip-panel {
+  width: 100%;
+  background: #FFF7ED;
+  border-radius: 32rpx 32rpx 0 0;
+  padding: 48rpx 40rpx 60rpx;
+  box-sizing: border-box;
+  max-height: 88vh;
+  overflow-y: auto;
+}
+
+.ip-header {
+  margin-bottom: 32rpx;
+}
+
+.ip-title {
+  display: block;
+  font-size: 38rpx;
+  font-weight: bold;
+  color: #333;
+  text-align: center;
+}
+
+.ip-sub {
+  display: block;
+  font-size: 26rpx;
+  color: #999;
+  text-align: center;
+  margin-top: 12rpx;
+}
+
+.ip-card {
+  display: flex;
+  align-items: center;
+  background: #fff;
+  border-radius: 20rpx;
+  padding: 28rpx 30rpx;
+  margin-bottom: 20rpx;
+  box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.06);
+}
+
+.ip-card:active {
+  transform: scale(0.98);
+}
+
+.ip-card-icon {
+  width: 88rpx;
+  height: 88rpx;
+  border-radius: 22rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-right: 24rpx;
+  flex-shrink: 0;
+}
+
+.ip-card-emoji {
+  font-size: 44rpx;
+}
+
+.ip-card-body {
+  flex: 1;
+}
+
+.ip-card-title {
+  display: block;
+  font-size: 30rpx;
+  font-weight: 600;
+  color: #333;
+}
+
+.ip-card-desc {
+  display: block;
+  font-size: 24rpx;
+  color: #999;
+  margin-top: 6rpx;
+}
+
+.ip-card-arrow {
+  font-size: 44rpx;
+  color: #CCC;
+  margin-left: 12rpx;
+}
+
+.ip-domain-wrap {
+  background: #fff;
+  border-radius: 20rpx;
+  padding: 24rpx;
+  margin-bottom: 20rpx;
+  box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.06);
+}
+
+.ip-domain-item {
+  display: flex;
+  align-items: center;
+  padding: 20rpx 16rpx;
+  border-radius: 16rpx;
+  border: 2rpx solid transparent;
+  margin-bottom: 12rpx;
+}
+
+.ip-domain-item:last-child {
+  margin-bottom: 0;
+}
+
+.ip-domain-item-active {
+  border-color: #F97316;
+  background: #FFF7ED;
+}
+
+.ip-domain-icon {
+  font-size: 36rpx;
+  margin-right: 16rpx;
+}
+
+.ip-domain-name {
+  flex: 1;
+  font-size: 28rpx;
+  color: #333;
+}
+
+.ip-domain-check {
+  color: #F97316;
+  font-size: 32rpx;
+  font-weight: bold;
+}
+
+.ip-domain-confirm {
+  margin-top: 16rpx;
+  background: linear-gradient(135deg, #F97316, #FB923C);
+  border-radius: 40rpx;
+  padding: 22rpx 0;
+  text-align: center;
+}
+
+.ip-domain-confirm:active {
+  opacity: 0.9;
+}
+
+.ip-domain-confirm-text {
+  color: #fff;
+  font-size: 30rpx;
+  font-weight: 600;
+}
+
+.ip-skip {
+  text-align: center;
+  margin-top: 20rpx;
+  padding: 16rpx 0;
+}
+
+.ip-skip-text {
+  font-size: 26rpx;
+  color: #999;
+  text-decoration: underline;
+}
+</style>

+ 306 - 0
cfc-frontend/components/journey-card.vue

@@ -0,0 +1,306 @@
+<template>
+  <view class="jc-wrap" v-if="intent">
+    <view class="jc-header">
+      <text class="jc-title">{{ domainLabel }}</text>
+      <text class="jc-stage-label">{{ stageLabel }}</text>
+    </view>
+    <view class="jc-progress">
+      <view class="jc-bar">
+        <view class="jc-bar-fill" :style="'width:' + progressPct + '%'"></view>
+      </view>
+      <text class="jc-pct">{{ progressPct }}%</text>
+    </view>
+    <view class="jc-steps" v-if="intentType === 'C'">
+      <view class="jc-step" v-for="(s, i) in cSteps" :key="s.key" :class="{ 'jc-step-done': i <= stage, 'jc-step-cur': i === stage }">
+        <view class="jc-step-dot" :class="{ 'jc-step-dot-done': i <= stage }">
+          <text class="jc-step-num" v-if="i < stage">✓</text>
+          <text class="jc-step-num" v-else>{{ i + 1 }}</text>
+        </view>
+        <text class="jc-step-label">{{ s }}</text>
+      </view>
+    </view>
+    <view class="jc-action" v-if="nextAction && nextAction !== 'done'" @click="onAction">
+      <text class="jc-action-text">{{ nextLabel }}</text>
+      <text class="jc-action-arrow">›</text>
+    </view>
+    <view class="jc-done" v-else-if="nextAction === 'done'">
+      <text class="jc-done-text">✅ 全部完成,奖励已发放!</text>
+    </view>
+  </view>
+</template>
+
+<script>
+import { getProblemDomainList } from '../utils/api.js'
+
+var C_STEPS = ['选问题域', '看流程', '调研', '选购方案', '物流', '出报告', '确认方案', '任务完成']
+var A_STEPS = ['选购', '出报告', '确认方案', '任务完成']
+var B_STEPS = ['问卷', '推荐']
+
+var C_ACTIONS = ['flow-intro', 'survey', 'package', 'logistics', 'report', 'plan-confirm', 'tasks', 'done']
+var A_ACTIONS = ['purchase', 'report', 'plan-confirm', 'tasks', 'done']
+var B_ACTIONS = ['survey', 'recommend', 'done']
+
+var C_LABELS = ['查看服务流程', '填写调研', '选购方案', '查看物流', '等待报告', '确认方案', '查看任务', '已完成']
+var A_LABELS = ['去选购', '查看报告', '确认方案', '查看任务', '已完成']
+var B_LABELS = ['填写问卷', '查看推荐', '已完成']
+
+export default {
+  name: 'JourneyCard',
+  props: {
+    intent: {
+      type: Object,
+      default: null
+    }
+  },
+  data() {
+    return {
+      domainMap: {}
+    }
+  },
+  computed: {
+    intentType() {
+      return this.intent && this.intent.intentType
+    },
+    stage() {
+      return this.intent ? (this.intent.journeyStage || 0) : 0
+    },
+    maxStage() {
+      if (this.intentType === 'C') return 7
+      if (this.intentType === 'A') return 4
+      if (this.intentType === 'B') return 2
+      return 0
+    },
+    progressPct() {
+      if (this.maxStage === 0) return 0
+      return Math.round((this.stage / this.maxStage) * 100)
+    },
+    steps() {
+      if (this.intentType === 'C') return C_STEPS
+      if (this.intentType === 'A') return A_STEPS
+      if (this.intentType === 'B') return B_STEPS
+      return []
+    },
+    actions() {
+      if (this.intentType === 'C') return C_ACTIONS
+      if (this.intentType === 'A') return A_ACTIONS
+      if (this.intentType === 'B') return B_ACTIONS
+      return []
+    },
+    labels() {
+      if (this.intentType === 'C') return C_LABELS
+      if (this.intentType === 'A') return A_LABELS
+      if (this.intentType === 'B') return B_LABELS
+      return []
+    },
+    cSteps() {
+      if (this.intentType !== 'C') return []
+      var sliced = this.steps.slice(0, Math.max(this.stage + 1, 3))
+      return sliced.map(function(s, i) {
+        return { key: 's' + i, label: s }
+      })
+    },
+    stageLabel() {
+      var idx = Math.min(this.stage, this.steps.length - 1)
+      if (this.stage >= this.steps.length) return '已完成'
+      return '第' + (this.stage + 1) + '步:' + this.steps[idx]
+    },
+    nextAction() {
+      var idx = Math.min(this.stage, this.actions.length - 1)
+      return this.actions[idx] || 'done'
+    },
+    nextLabel() {
+      var idx = Math.min(this.stage, this.labels.length - 1)
+      return this.labels[idx] || '已完成'
+    },
+    domainLabel() {
+      var code = this.intent && this.intent.problemDomainCode
+      if (code && this.domainMap[code]) {
+        return this.domainMap[code]
+      }
+      if (this.intentType === 'A') return 'A类 · 直达目标'
+      if (this.intentType === 'B') return 'B类 · 智能问卷'
+      if (this.intentType === 'C') return 'C类 · 解决问题'
+      return ''
+    }
+  },
+  created() {
+    this.loadDomainMap()
+  },
+  methods: {
+    loadDomainMap() {
+      var self = this
+      getProblemDomainList().then(function(res) {
+        var list = res && res.data
+        if (Array.isArray(list)) {
+          var map = {}
+          list.forEach(function(d) {
+            map[d.code] = d.name
+          })
+          self.domainMap = map
+        }
+      }).catch(function() {
+        // 静默失败
+      })
+    },
+    onAction() {
+      this.$emit('action', {
+        action: this.nextAction,
+        code: this.intent && this.intent.problemDomainCode
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.jc-wrap {
+  background: linear-gradient(135deg, #FFF7ED, #FFEDD5);
+  border-radius: 24rpx;
+  padding: 28rpx 30rpx;
+  margin: 20rpx 24rpx 0;
+  box-shadow: 0 4rpx 20rpx rgba(249, 115, 22, 0.12);
+}
+
+.jc-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 16rpx;
+}
+
+.jc-title {
+  font-size: 30rpx;
+  font-weight: bold;
+  color: #333;
+}
+
+.jc-stage-label {
+  font-size: 22rpx;
+  color: #F97316;
+  background: rgba(249, 115, 22, 0.12);
+  padding: 4rpx 14rpx;
+  border-radius: 20rpx;
+}
+
+.jc-progress {
+  display: flex;
+  align-items: center;
+  margin-bottom: 20rpx;
+}
+
+.jc-bar {
+  flex: 1;
+  height: 12rpx;
+  background: #FED7AA;
+  border-radius: 6rpx;
+  overflow: hidden;
+}
+
+.jc-bar-fill {
+  height: 100%;
+  background: linear-gradient(90deg, #F97316, #FB923C);
+  border-radius: 6rpx;
+  transition: width 0.3s;
+}
+
+.jc-pct {
+  font-size: 22rpx;
+  color: #F97316;
+  margin-left: 12rpx;
+  min-width: 48rpx;
+  text-align: right;
+}
+
+.jc-steps {
+  display: flex;
+  flex-wrap: nowrap;
+  overflow-x: auto;
+  margin-bottom: 20rpx;
+  padding-bottom: 8rpx;
+}
+
+.jc-step {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  margin-right: 24rpx;
+  flex-shrink: 0;
+}
+
+.jc-step:last-child {
+  margin-right: 0;
+}
+
+.jc-step-dot {
+  width: 36rpx;
+  height: 36rpx;
+  border-radius: 50%;
+  background: #FED7AA;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-bottom: 8rpx;
+}
+
+.jc-step-dot-done {
+  background: #F97316;
+}
+
+.jc-step-num {
+  font-size: 20rpx;
+  color: #fff;
+  font-weight: bold;
+}
+
+.jc-step-label {
+  font-size: 20rpx;
+  color: #999;
+  white-space: nowrap;
+}
+
+.jc-step-cur .jc-step-label {
+  color: #F97316;
+  font-weight: 600;
+}
+
+.jc-step-done .jc-step-label {
+  color: #666;
+}
+
+.jc-action {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: #F97316;
+  border-radius: 40rpx;
+  padding: 22rpx 0;
+  margin-top: 8rpx;
+}
+
+.jc-action:active {
+  opacity: 0.9;
+}
+
+.jc-action-text {
+  color: #fff;
+  font-size: 30rpx;
+  font-weight: 600;
+}
+
+.jc-action-arrow {
+  color: #fff;
+  font-size: 36rpx;
+  margin-left: 8rpx;
+}
+
+.jc-done {
+  text-align: center;
+  padding: 16rpx 0;
+  margin-top: 8rpx;
+}
+
+.jc-done-text {
+  font-size: 28rpx;
+  color: #16A34A;
+}
+</style>