فهرست منبع

feat(W3): 规划师认证闸门前端 - 小程序培训认证中心页 + 管理端5视图/API/路由/菜单

E2E Test Bot 1 هفته پیش
والد
کامیت
3366cff224

+ 634 - 0
cfc-frontend/pages/teacher/certification-center.vue

@@ -0,0 +1,634 @@
+<template>
+  <view class="container" :style="{ '--font-scale': fontScale }">
+    <!-- 顶部导航 -->
+    <view class="nav-bar">
+      <text class="nav-title">培训认证中心</text>
+      <view class="nav-sub">按类别独立认证,通过后方可接单</view>
+    </view>
+
+    <BaseLoading :loading="loading" text="加载认证状态..." />
+
+    <template v-if="!loading">
+      <!-- 类别Tab -->
+      <view class="tab-bar" v-if="certifications.length > 0">
+        <scroll-view scroll-x :enhanced="true" show-scrollbar="false" class="tab-scroll">
+          <view class="tab-inner">
+            <view
+              v-for="(cert, idx) in certifications"
+              :key="cert.categoryId"
+              class="tab-item"
+              :class="{ active: activeTabIdx === idx }"
+              @click="switchTab(idx)">
+              <text class="tab-text">{{ cert.categoryName }}</text>
+              <view v-if="cert.isBase" class="base-badge">基础</view>
+              <view v-if="cert.canService" class="service-badge">可接单</view>
+              <view v-else-if="cert.canExam" class="exam-badge">可考核</view>
+              <view v-else class="learning-badge">学习中</view>
+            </view>
+          </view>
+        </scroll-view>
+      </view>
+
+      <!-- 当前类别详情 -->
+      <view v-if="currentCert" class="cert-detail">
+        <!-- 进度概览卡片 -->
+        <view class="progress-card">
+          <view class="progress-header">
+            <text class="progress-title">{{ currentCert.categoryName }} 认证进度</text>
+            <view class="progress-status" :class="statusClass">
+              {{ statusText }}
+            </view>
+          </view>
+
+          <view class="progress-bars">
+            <!-- 课时进度 -->
+            <view class="progress-item">
+              <view class="progress-label">
+                <text>课时进度</text>
+                <text>{{ formatHours(currentCert.completedHours) }} / {{ formatHours(currentCert.requiredHours) }}</text>
+              </view>
+              <view class="progress-bar-track">
+                <view class="progress-bar-fill" :style="{ width: hoursPercent + '%' }"></view>
+              </view>
+            </view>
+
+            <!-- 考核状态 -->
+            <view class="progress-item" v-if="currentCert.examStatus !== 'none'">
+              <view class="progress-label">
+                <text>考核状态</text>
+                <text>{{ examStatusText }}</text>
+              </view>
+            </view>
+          </view>
+        </view>
+
+        <!-- 线上课程 -->
+        <view class="section">
+          <view class="section-title">
+            <text>📚 线上课程(小鹅通)</text>
+          </view>
+          <view class="course-list" v-if="courses.length > 0">
+            <view class="course-item" v-for="course in courses" :key="course.id" @click="goToCourse(course)">
+              <view class="course-info">
+                <text class="course-title">{{ course.title }}</text>
+                <view class="course-meta">
+                  <text v-if="course.isRequired" class="required-tag">必修</text>
+                  <text>{{ formatHours(course.requiredHours) }} 课时</text>
+                </view>
+              </view>
+              <view class="course-progress">
+                <text class="course-open-tag">去学习</text>
+              </view>
+            </view>
+          </view>
+          <view class="empty-state" v-else>
+            <text>暂无线上课程</text>
+          </view>
+        </view>
+
+        <!-- 线下培训活动 -->
+        <view class="section">
+          <view class="section-title">
+            <text>🏫 线下培训课程</text>
+          </view>
+          <view class="activity-list" v-if="activities.length > 0">
+            <view class="activity-item" v-for="act in activities" :key="act.id">
+              <view class="activity-info">
+                <text class="activity-title">{{ act.title }}</text>
+                <view class="activity-meta">
+                  <text>{{ formatTime(act.startTime) }} · {{ act.location }}</text>
+                  <text class="hours-tag">{{ formatHours(act.durationHours) }} 课时</text>
+                </view>
+              </view>
+              <view class="activity-actions">
+                <button
+                  class="btn-checkin"
+                  v-if="!act.checkedIn"
+                  @click.stop="doCheckIn(act)"
+                  :loading="checkingIn === act.id">
+                  扫码签到
+                </button>
+                <view v-else class="checked-tag">已签到</view>
+              </view>
+            </view>
+          </view>
+          <view class="empty-state" v-else>
+            <text>暂无线下培训课程</text>
+          </view>
+        </view>
+
+        <!-- 考核入口 -->
+        <view class="section exam-section">
+          <view class="section-title">
+            <text>📝 考核测试</text>
+          </view>
+          <view class="exam-card">
+            <view class="exam-status" v-if="currentCert.examStatus !== 'none'">
+              <text>{{ examStatusText }}</text>
+              <text v-if="currentCert.examScore != null" class="exam-score">分数:{{ currentCert.examScore }}</text>
+            </view>
+            <view class="exam-actions">
+              <button
+                class="btn-exam"
+                v-if="currentCert.canExam && currentCert.examStatus !== 'exam_passed'"
+                @click="startExam"
+                :loading="examLoading">
+                {{ currentCert.examStatus === 'exam_failed' ? '重新考核' : '开始考核' }}
+              </button>
+              <button
+                class="btn-result"
+                v-if="currentCert.examStatus === 'exam_passed'"
+                @click="viewResult">
+                查看证书
+              </button>
+              <view v-else-if="!currentCert.canExam" class="exam-locked">
+                完成课时要求({{ formatHours(currentCert.completedHours) }}/{{ formatHours(currentCert.requiredHours) }})后开放考核
+              </view>
+            </view>
+          </view>
+        </view>
+
+        <!-- 证书展示 -->
+        <view class="section" v-if="currentCert.status === 'certified' && currentCert.certNo">
+          <view class="section-title">
+            <text>🏆 证书信息</text>
+          </view>
+          <view class="cert-card">
+            <view class="cert-row">
+              <text class="cert-label">证书编号</text>
+              <text class="cert-value">{{ currentCert.certNo }}</text>
+            </view>
+            <view class="cert-row">
+              <text class="cert-label">获得时间</text>
+              <text class="cert-value">{{ formatTime(currentCert.certifiedAt) }}</text>
+            </view>
+            <view class="cert-row" v-if="currentCert.expireAt">
+              <text class="cert-label">有效期至</text>
+              <text class="cert-value">{{ formatTime(currentCert.expireAt) }}</text>
+            </view>
+          </view>
+        </view>
+      </view>
+
+      <!-- 空态 -->
+      <view class="empty-main" v-else>
+        <text class="empty-icon">📋</text>
+        <text class="empty-text">暂无认证类别,请联系管理员配置</text>
+      </view>
+    </template>
+
+    <!-- 考核弹窗 -->
+    <view class="modal-mask" v-if="showExamModal" @click="closeExamModal">
+      <view class="modal-exam" @click.stop>
+        <view class="modal-header">
+          <text class="modal-title">{{ currentExamPaper.title || '考核测试' }}</text>
+          <view class="modal-timer" v-if="examTimeLeft > 0">
+            ⏱ 剩余 {{ formatExamTime(examTimeLeft) }}
+          </view>
+        </view>
+
+        <view class="exam-questions">
+          <view v-for="(q, idx) in examQuestions" :key="q.id" class="question-item">
+            <view class="question-header">
+              <text class="question-num">{{ idx + 1 }}.</text>
+              <text class="question-score">({{ q.score }}分)</text>
+            </view>
+            <text class="question-text">{{ q.question }}</text>
+            <view class="options">
+              <view
+                v-for="(opt, oi) in parseOptions(q.options)"
+                :key="oi"
+                class="option"
+                :class="{ selected: userAnswers[q.id] === opt.value }"
+                @click="selectOption(q.id, opt.value)">
+                <text class="opt-label">{{ opt.label }}.</text>
+                <text class="opt-text">{{ opt.text }}</text>
+              </view>
+            </view>
+          </view>
+        </view>
+
+        <view class="modal-footer">
+          <button class="btn-submit" @click="submitExam" :loading="submittingExam" :disabled="!canSubmit">
+            提交试卷
+          </button>
+          <button class="btn-cancel" @click="closeExamModal">取消</button>
+        </view>
+      </view>
+    </view>
+
+    <!-- 证书查看弹窗 -->
+    <view class="modal-mask" v-if="showCertModal" @click="closeCertModal">
+      <view class="modal-cert" @click.stop>
+        <view class="cert-modal-header">
+          <text class="cert-modal-title">{{ currentCert.categoryName }} 认证证书</text>
+        </view>
+        <view class="cert-modal-body">
+          <view class="cert-modal-no">{{ currentCert.certNo }}</view>
+          <view class="cert-modal-name">{{ userNickname }}</view>
+          <view class="cert-modal-date">颁发日期:{{ formatTime(currentCert.certifiedAt) }}</view>
+        </view>
+        <button class="btn-close-modal" @click="closeCertModal">关闭</button>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+import fontScaleMixin from '@/mixins/font-scale.js'
+import BaseLoading from '@/components/BaseLoading.vue'
+import {
+  getPlannerCertifications,
+  getPlannerCourses,
+  getPlannerTrainingActivities,
+  plannerTrainingCheckIn,
+  plannerExamStart,
+  plannerExamSubmit
+} from '@/utils/api.js'
+
+export default {
+  mixins: [fontScaleMixin],
+  components: {
+    BaseLoading
+  },
+  data() {
+    return {
+      loading: true,
+      certifications: [],
+      activeTabIdx: 0,
+      courses: [],
+      activities: [],
+      currentExamPaper: null,
+      examQuestions: [],
+      userAnswers: {},
+      examTimeLeft: 0,
+      examTimer: null,
+      showExamModal: false,
+      showCertModal: false,
+      examLoading: false,
+      submittingExam: false,
+      checkingIn: null,
+      userNickname: ''
+    }
+  },
+  computed: {
+    currentCert() {
+      return this.certifications[this.activeTabIdx] || null
+    },
+    statusClass() {
+      if (!this.currentCert) return ''
+      const status = this.currentCert.status
+      if (status === 'certified') return 'status-certified'
+      if (status === 'learning_completed') return 'status-ready-exam'
+      if (status === 'exam_failed') return 'status-exam-failed'
+      return 'status-learning'
+    },
+    statusText() {
+      if (!this.currentCert) return ''
+      const map = {
+        'pending_cert': '待认证',
+        'pending_learning': '学习中',
+        'learning_completed': '可参加考核',
+        'certified': '已认证',
+        'suspended': '已暂停',
+        'exam_failed': '考核未通过'
+      }
+      return map[this.currentCert.status] || this.currentCert.status
+    },
+    examStatusText() {
+      if (!this.currentCert) return ''
+      const map = {
+        'none': '未参加',
+        'exam_failed': '未通过',
+        'exam_passed': '已通过'
+      }
+      return map[this.currentCert.examStatus] || this.currentCert.examStatus
+    },
+    hoursPercent() {
+      if (!this.currentCert || !this.currentCert.requiredHours) return 0
+      const completed = parseFloat(this.currentCert.completedHours) || 0
+      const required = parseFloat(this.currentCert.requiredHours) || 1
+      return Math.min(100, Math.round((completed / required) * 100))
+    },
+    canSubmit() {
+      return Object.keys(this.userAnswers).length === this.examQuestions.length
+    }
+  },
+  onLoad() {
+    this.loadUserInfo()
+    this.loadCertifications()
+  },
+  onShow() {
+    this.loadUserInfo()
+  },
+  onUnload() {
+    this.clearExamTimer()
+  },
+  methods: {
+    loadUserInfo() {
+      const userInfo = uni.getStorageSync('userInfo')
+      this.userNickname = (userInfo && userInfo.nickname) || ''
+    },
+    async loadCertifications() {
+      this.loading = true
+      try {
+        const res = await getPlannerCertifications()
+        if (res.code === 200 && Array.isArray(res.data)) {
+          this.certifications = res.data
+          this.activeTabIdx = 0
+          await this.loadCategoryData(this.certifications[0])
+        }
+      } catch (e) {
+        console.error('加载认证状态失败', e)
+        uni.showToast({ title: '加载失败', icon: 'none' })
+      } finally {
+        this.loading = false
+      }
+    },
+    async loadCategoryData(cert) {
+      if (!cert) return
+      // 并行加载课程和活动
+      await Promise.all([
+        this.loadCourses(cert.categoryId),
+        this.loadActivities()
+      ])
+    },
+    async loadCourses(categoryId) {
+      try {
+        const res = await getPlannerCourses(categoryId)
+        if (res.code === 200 && Array.isArray(res.data)) {
+          this.courses = res.data
+        }
+      } catch (e) {
+        console.error('加载课程失败', e)
+      }
+    },
+    async loadActivities() {
+      try {
+        const res = await getPlannerTrainingActivities()
+        if (res.code === 200 && Array.isArray(res.data)) {
+          this.activities = res.data
+        }
+      } catch (e) {
+        console.error('加载活动失败', e)
+      }
+    },
+    switchTab(idx) {
+      if (idx === this.activeTabIdx) return
+      this.activeTabIdx = idx
+      this.loadCategoryData(this.certifications[idx])
+    },
+    formatHours(val) {
+      if (val == null) return '0'
+      const num = parseFloat(val)
+      return num === Math.floor(num) ? num.toString() : num.toFixed(1)
+    },
+    formatTime(str) {
+      if (!str) return '-'
+      return str.replace('T', ' ').substring(0, 16)
+    },
+    goToCourse(course) {
+      if (course.xiaoeResourceId) {
+        // TODO: 跳转小鹅通小程序/H5
+        uni.showToast({ title: '即将跳转小鹅通', icon: 'none' })
+      }
+    },
+    async doCheckIn(activity) {
+      this.checkingIn = activity.id
+      try {
+        const res = await plannerTrainingCheckIn(activity.id)
+        if (res.code === 200) {
+          uni.showToast({ title: '签到成功,课时已计入', icon: 'success' })
+          activity.checkedIn = true
+          // 刷新当前类别进度
+          const cert = this.certifications[this.activeTabIdx]
+          if (cert && cert.categoryId === activity.categoryId) {
+            await this.loadCategoryData(cert)
+          }
+        } else {
+          uni.showToast({ title: res.message || '签到失败', icon: 'none' })
+        }
+      } catch (e) {
+        console.error('签到失败', e)
+        uni.showToast({ title: '签到失败', icon: 'none' })
+      } finally {
+        this.checkingIn = null
+      }
+    },
+    async startExam() {
+      if (!this.currentCert) return
+      this.examLoading = true
+      try {
+        const res = await plannerExamStart(this.currentCert.categoryId)
+        if (res.code === 200 && res.data) {
+          const data = res.data
+          this.currentExamPaper = { id: data.paperId, title: data.paperTitle || '考核试卷', durationMinutes: data.durationMinutes, passScore: data.passScore, attemptId: data.attemptId }
+          this.examQuestions = data.questions || []
+          this.userAnswers = {}
+          this.examTimeLeft = (data.durationMinutes || 30) * 60
+          this.showExamModal = true
+          this.startExamTimer()
+        } else {
+          uni.showToast({ title: res.message || '获取试卷失败', icon: 'none' })
+        }
+      } catch (e) {
+        console.error('开始考核失败', e)
+        uni.showToast({ title: '开始考核失败', icon: 'none' })
+      } finally {
+        this.examLoading = false
+      }
+    },
+    startExamTimer() {
+      this.clearExamTimer()
+      this.examTimer = setInterval(() => {
+        if (this.examTimeLeft > 0) {
+          this.examTimeLeft--
+        } else {
+          this.clearExamTimer()
+          this.submitExam()
+        }
+      }, 1000)
+    },
+    clearExamTimer() {
+      if (this.examTimer) {
+        clearInterval(this.examTimer)
+        this.examTimer = null
+      }
+    },
+    parseOptions(optionsStr) {
+      try {
+        const arr = JSON.parse(optionsStr || '[]')
+        return arr.map((opt, i) => ({ label: String.fromCharCode(65 + i), value: String.fromCharCode(65 + i), text: opt }))
+      } catch (e) {
+        return []
+      }
+    },
+    selectOption(questionId, value) {
+      this.$set(this.userAnswers, questionId, value)
+    },
+    async submitExam() {
+      this.clearExamTimer()
+      if (!this.currentExamPaper) return
+      this.submittingExam = true
+      try {
+        const res = await plannerExamSubmit(
+          this.currentExamPaper.attemptId || this.currentExamPaper.id,
+          JSON.stringify(this.userAnswers)
+        )
+        if (res.code === 200) {
+          uni.showToast({ title: res.data.passed ? '考核通过!' : '考核未通过,请重新学习', icon: res.data.passed ? 'success' : 'none' })
+          this.showExamModal = false
+          this.examQuestions = []
+          this.userAnswers = {}
+          // 刷新认证状态
+          await this.loadCertifications()
+          if (res.data.passed && this.currentCert) {
+            this.showCertModal = true
+          }
+        } else {
+          uni.showToast({ title: res.message || '提交失败', icon: 'none' })
+        }
+      } catch (e) {
+        console.error('提交考核失败', e)
+        uni.showToast({ title: '提交失败', icon: 'none' })
+      } finally {
+        this.submittingExam = false
+      }
+    },
+    viewResult() {
+      this.showCertModal = true
+    },
+    closeExamModal() {
+      this.clearExamTimer()
+      this.showExamModal = false
+    },
+    closeCertModal() {
+      this.showCertModal = false
+    }
+  }
+}
+</script>
+
+<style scoped lang="scss">
+.container {
+  background: $tint-sky-ghost;
+  min-height: 100vh;
+  padding-bottom: 80rpx;
+}
+
+.nav-bar {
+  background: linear-gradient(135deg, $brand-primary, $brand-secondary);
+  padding: 32rpx 24rpx;
+  color: $app-surface;
+}
+.nav-title { font-size: calc(36rpx * var(--font-scale, 1)); font-weight: bold; display: block; margin-bottom: 8rpx; }
+.nav-sub { font-size: calc(24rpx * var(--font-scale, 1)); opacity: 0.9; }
+
+.tab-bar { background: $app-surface; border-bottom: 1rpx solid $facade-border-soft; position: sticky; top: 0; z-index: 10; }
+.tab-scroll { padding: 0 24rpx; }
+.tab-inner { display: inline-flex; gap: 16rpx; padding: 16rpx 0; }
+.tab-item { display: flex; flex-direction: column; align-items: center; padding: 8rpx 16rpx; border-radius: 20rpx; background: $facade-fill; transition: background-color 0.2s, color 0.2s; min-width: 120rpx; }
+.tab-item.active { background: $brand-primary; color: $app-surface; }
+.tab-text { font-size: calc(26rpx * var(--font-scale, 1)); font-weight: 500; }
+.tab-item .base-badge { font-size: calc(20rpx * var(--font-scale, 1)); padding: 2rpx 8rpx; border-radius: 10rpx; background: $status-warning-soft; color: $status-warning-deep; margin-top: 4rpx; }
+.tab-item .service-badge { font-size: calc(20rpx * var(--font-scale, 1)); padding: 2rpx 8rpx; border-radius: 10rpx; background: $status-success-tint; color: $status-success-deep; margin-top: 4rpx; }
+.tab-item .exam-badge { font-size: calc(20rpx * var(--font-scale, 1)); padding: 2rpx 8rpx; border-radius: 10rpx; background: $status-info-soft; color: $status-info-deep; margin-top: 4rpx; }
+.tab-item .learning-badge { font-size: calc(20rpx * var(--font-scale, 1)); padding: 2rpx 8rpx; border-radius: 10rpx; background: $facade-fill-2; color: $facade-text-muted; margin-top: 4rpx; }
+
+.cert-detail { padding: 24rpx; }
+
+.progress-card { background: $app-surface; border-radius: 16rpx; padding: 24rpx; margin-bottom: 24rpx; box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.04); }
+.progress-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20rpx; }
+.progress-title { font-size: calc(30rpx * var(--font-scale, 1)); font-weight: 600; color: $facade-text-strong; }
+.progress-status { font-size: calc(24rpx * var(--font-scale, 1)); padding: 4rpx 16rpx; border-radius: 16rpx; font-weight: 500; }
+.status-certified { background: $status-success-tint; color: $status-success-deep; }
+.status-ready-exam { background: $status-info-soft; color: $status-info-deep; }
+.status-exam-failed { background: $status-danger-tint-2; color: $status-danger-deep; }
+.status-learning { background: $status-warning-soft; color: $status-warning-deep; }
+
+.progress-bars { display: flex; flex-direction: column; gap: 16rpx; }
+.progress-item { }
+.progress-label { display: flex; justify-content: space-between; font-size: calc(24rpx * var(--font-scale, 1)); margin-bottom: 8rpx; color: $facade-text-muted; }
+.progress-bar-track { height: 8rpx; background: $facade-border-soft; border-radius: 4rpx; overflow: hidden; }
+.progress-bar-fill { height: 100%; background: linear-gradient(90deg, $brand-primary, $brand-secondary); border-radius: 4rpx; transition: width 0.3s; }
+.mini-bar { height: 4rpx; background: $facade-border-soft; border-radius: 2rpx; margin-top: 4rpx; overflow: hidden; }
+.mini-fill { height: 100%; background: $brand-primary; border-radius: 2rpx; }
+
+.section { background: $app-surface; border-radius: 16rpx; padding: 24rpx; margin-bottom: 24rpx; box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.04); }
+.section-title { font-size: calc(28rpx * var(--font-scale, 1)); font-weight: 600; color: $facade-text-strong; margin-bottom: 16rpx; }
+
+.course-list { display: flex; flex-direction: column; gap: 12rpx; }
+.course-item { display: flex; justify-content: space-between; align-items: center; padding: 16rpx; background: $facade-fill; border-radius: 12rpx; }
+.course-info { flex: 1; }
+.course-title { display: block; font-size: calc(28rpx * var(--font-scale, 1)); font-weight: 500; color: $facade-text-strong; margin-bottom: 4rpx; }
+.course-meta { display: flex; gap: 12rpx; font-size: calc(22rpx * var(--font-scale, 1)); color: $facade-text-muted; }
+.required-tag { background: $status-warning-soft; color: $status-warning-deep; padding: 2rpx 6rpx; border-radius: 4rpx; font-size: calc(20rpx * var(--font-scale, 1)); }
+.course-open-tag { font-size: calc(24rpx * var(--font-scale, 1)); color: $brand-primary; font-weight: 500; }
+.mini-bar { height: 4rpx; background: $facade-border-soft; border-radius: 2rpx; margin-top: 4rpx; }
+.mini-fill { height: 100%; background: $brand-primary; border-radius: 2rpx; }
+
+.activity-list { display: flex; flex-direction: column; gap: 12rpx; }
+.activity-item { display: flex; justify-content: space-between; align-items: center; padding: 16rpx; background: $facade-fill; border-radius: 12rpx; flex-wrap: wrap; gap: 12rpx; }
+.activity-info { flex: 1; min-width: 200rpx; }
+.activity-title { display: block; font-size: calc(28rpx * var(--font-scale, 1)); font-weight: 500; color: $facade-text-strong; margin-bottom: 4rpx; }
+.activity-meta { display: flex; gap: 12rpx; font-size: calc(22rpx * var(--font-scale, 1)); color: $facade-text-muted; flex-wrap: wrap; }
+.hours-tag { background: $brand-primary-soft; color: $brand-primary-deep; padding: 2rpx 8rpx; border-radius: 4rpx; font-size: calc(20rpx * var(--font-scale, 1)); }
+.activity-actions { display: flex; gap: 8rpx; }
+.btn-checkin { background: $brand-primary; color: $app-surface; padding: 10rpx 20rpx; border-radius: 8rpx; font-size: calc(24rpx * var(--font-scale, 1)); border: none; }
+.btn-checkin:disabled { opacity: 0.6; }
+.checked-tag { font-size: calc(24rpx * var(--font-scale, 1)); color: $status-success-deep; background: $status-success-tint; padding: 8rpx 16rpx; border-radius: 8rpx; }
+
+.exam-section { background: $app-surface; border-radius: 16rpx; padding: 24rpx; margin-bottom: 24rpx; box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.04); border-left: 4rpx solid $brand-primary; }
+.exam-card { background: $facade-fill; border-radius: 12rpx; padding: 20rpx; }
+.exam-status { font-size: calc(26rpx * var(--font-scale, 1)); font-weight: 500; margin-bottom: 12rpx; }
+.exam-status .exam-score { margin-left: 16rpx; color: $brand-primary; }
+.exam-actions { display: flex; gap: 12rpx; flex-wrap: wrap; align-items: center; }
+.btn-exam { background: $status-info; color: $app-surface; padding: 14rpx 28rpx; border-radius: 8rpx; font-size: calc(26rpx * var(--font-scale, 1)); font-weight: 500; border: none; }
+.btn-exam:disabled { opacity: 0.6; }
+.btn-result { background: $brand-primary; color: $app-surface; padding: 14rpx 28rpx; border-radius: 8rpx; font-size: calc(26rpx * var(--font-scale, 1)); font-weight: 500; border: none; }
+.exam-locked { font-size: calc(24rpx * var(--font-scale, 1)); color: $facade-text-muted; flex: 1; }
+
+.cert-card { background: linear-gradient(135deg, $brand-primary, $brand-secondary); border-radius: 16rpx; padding: 24rpx; color: $app-surface; }
+.cert-row { display: flex; justify-content: space-between; align-items: center; padding: 12rpx 0; border-bottom: 1rpx solid rgba(255,255,255,0.2); }
+.cert-row:last-child { border-bottom: none; }
+.cert-label { font-size: calc(24rpx * var(--font-scale, 1)); opacity: 0.8; }
+.cert-value { font-size: calc(28rpx * var(--font-scale, 1)); font-weight: 600; }
+
+.empty-state { text-align: center; padding: 40rpx; color: $facade-text-muted; font-size: calc(26rpx * var(--font-scale, 1)); }
+.empty-main { text-align: center; padding: 120rpx 24rpx; }
+.empty-icon { font-size: calc(80rpx * var(--font-scale, 1)); display: block; margin-bottom: 16rpx; }
+.empty-text { font-size: calc(28rpx * var(--font-scale, 1)); color: $facade-text-muted; }
+
+/* 考核弹窗 */
+.modal-mask { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 1000; padding: 24rpx; }
+.modal-exam { background: $app-surface; border-radius: 16rpx; width: 100%; max-width: 680rpx; max-height: 90vh; overflow: hidden; display: flex; flex-direction: column; }
+.modal-header { display: flex; justify-content: space-between; align-items: center; padding: 20rpx 24rpx; border-bottom: 1rpx solid $facade-border-soft; }
+.modal-title { font-size: calc(30rpx * var(--font-scale, 1)); font-weight: 600; }
+.modal-timer { font-size: calc(24rpx * var(--font-scale, 1)); color: $status-danger-deep; background: $status-danger-tint-2; padding: 4rpx 12rpx; border-radius: 12rpx; }
+.exam-questions { flex: 1; overflow-y: auto; padding: 20rpx 24rpx; }
+.question-item { margin-bottom: 24rpx; }
+.question-header { display: flex; align-items: center; gap: 8rpx; margin-bottom: 8rpx; font-size: calc(24rpx * var(--font-scale, 1)); color: $facade-text-muted; }
+.question-num { font-weight: 600; color: $facade-text-strong; }
+.question-text { font-size: calc(28rpx * var(--font-scale, 1)); color: $facade-text-strong; margin-bottom: 12rpx; line-height: 1.5; }
+.options { display: flex; flex-direction: column; gap: 8rpx; }
+.option { display: flex; align-items: center; gap: 12rpx; padding: 16rpx; background: $facade-fill; border-radius: 8rpx; border: 2rpx solid transparent; transition: background-color 0.2s, border-color 0.2s; }
+.option.selected { border-color: $brand-primary; background: $brand-primary-soft; }
+.option:active { transform: scale(0.99); }
+.opt-label { width: 32rpx; height: 32rpx; border-radius: 50%; border: 2rpx solid $facade-border-soft; display: flex; align-items: center; justify-content: center; font-size: calc(24rpx * var(--font-scale, 1)); font-weight: bold; color: $facade-text-muted; }
+.option.selected .opt-label { border-color: $brand-primary; background: $brand-primary; color: $app-surface; }
+.opt-text { font-size: calc(26rpx * var(--font-scale, 1)); color: $facade-text-strong; flex: 1; }
+.modal-footer { display: flex; gap: 12rpx; padding: 20rpx 24rpx; border-top: 1rpx solid $facade-border-soft; }
+.btn-submit { flex: 1; background: $brand-primary; color: $app-surface; padding: 16rpx; border-radius: 8rpx; font-size: calc(28rpx * var(--font-scale, 1)); font-weight: 500; border: none; }
+.btn-submit:disabled { opacity: 0.5; }
+.btn-cancel { background: $facade-fill-2; color: $facade-text-strong; padding: 16rpx 28rpx; border-radius: 8rpx; font-size: calc(28rpx * var(--font-scale, 1)); border: none; }
+
+/* 证书弹窗 */
+.modal-cert { background: $app-surface; border-radius: 16rpx; width: 100%; max-width: 560rpx; padding: 32rpx 24rpx; text-align: center; }
+.cert-modal-header { margin-bottom: 24rpx; }
+.cert-modal-title { font-size: calc(32rpx * var(--font-scale, 1)); font-weight: 600; color: $facade-text-strong; }
+.cert-modal-body { background: linear-gradient(135deg, $brand-primary, $brand-secondary); border-radius: 16rpx; padding: 32rpx; color: $app-surface; margin-bottom: 24rpx; }
+.cert-modal-no { font-size: calc(26rpx * var(--font-scale, 1)); opacity: 0.8; margin-bottom: 16rpx; letter-spacing: 2rpx; }
+.cert-modal-name { font-size: calc(40rpx * var(--font-scale, 1)); font-weight: bold; margin-bottom: 16rpx; }
+.cert-modal-date { font-size: calc(24rpx * var(--font-scale, 1)); opacity: 0.8; }
+.btn-close-modal { background: $brand-primary; color: $app-surface; padding: 16rpx 48rpx; border-radius: 8rpx; font-size: calc(28rpx * var(--font-scale, 1)); font-weight: 500; border: none; width: 100%; }
+</style>

+ 81 - 0
cfc-web/src/api/plannerCert.js

@@ -0,0 +1,81 @@
+import request from '@/utils/request';
+
+// ============ 规划师培训认证(W3 认证闸门)管理端 API ============
+
+// ---- 学习种类 / 认证类别 CRUD ----
+export function certCategoryList() {
+  return request({ url: '/api/admin/planner-cert-categories/list', method: 'post' });
+}
+export function certCategoryCreate(data) {
+  return request({ url: '/api/admin/planner-cert-categories/create', method: 'post', data });
+}
+export function certCategoryUpdate(data) {
+  return request({ url: '/api/admin/planner-cert-categories/update', method: 'post', data });
+}
+export function certCategoryDelete(id) {
+  return request({ url: '/api/admin/planner-cert-categories/delete', method: 'post', data: { id } });
+}
+
+// ---- 线上课程目录 CRUD ----
+export function certCourseList(categoryId) {
+  const params = {};
+  if (categoryId) params.categoryId = categoryId;
+  return request({ url: '/api/admin/planner-courses/list', method: 'post', params });
+}
+export function certCourseCreate(data) {
+  return request({ url: '/api/admin/planner-courses/create', method: 'post', data });
+}
+export function certCourseUpdate(data) {
+  return request({ url: '/api/admin/planner-courses/update', method: 'post', data });
+}
+export function certCourseDelete(id) {
+  return request({ url: '/api/admin/planner-courses/delete', method: 'post', params: { id } });
+}
+
+// ---- 线下课程映射 CRUD(活动↔类别,复用 activities) ----
+export function certOfflineList() {
+  return request({ url: '/api/admin/planner-offline-course-map/list', method: 'post' });
+}
+export function certOfflineCreate(data) {
+  return request({ url: '/api/admin/planner-offline-course-map/create', method: 'post', data });
+}
+export function certOfflineUpdate(data) {
+  return request({ url: '/api/admin/planner-offline-course-map/update', method: 'post', data });
+}
+export function certOfflineDelete(id) {
+  return request({ url: '/api/admin/planner-offline-course-map/delete', method: 'post', params: { id } });
+}
+
+// ---- 考核试卷/题库 ----
+export function certExamPapers(categoryId) {
+  return request({ url: '/api/admin/planner-exam/papers', method: 'post', params: { categoryId } });
+}
+export function certExamPaperCreate(data) {
+  return request({ url: '/api/admin/planner-exam/paper/create', method: 'post', data });
+}
+export function certExamPaperUpdate(data) {
+  return request({ url: '/api/admin/planner-exam/paper/update', method: 'post', data });
+}
+export function certExamPaperDelete(id) {
+  return request({ url: '/api/admin/planner-exam/paper/delete', method: 'post', params: { id } });
+}
+export function certExamQuestions(paperId) {
+  return request({ url: '/api/admin/planner-exam/questions', method: 'post', params: { paperId } });
+}
+export function certExamQuestionsSave(data) {
+  return request({ url: '/api/admin/planner-exam/questions/save', method: 'post', data });
+}
+
+// ---- 认证看板 ----
+export function certBoardList(params) {
+  return request({ url: '/api/admin/planner-certifications/list', method: 'post', params });
+}
+export function certSyncHours(data) {
+  return request({ url: '/api/admin/planner-certifications/sync-hours', method: 'post', data });
+}
+export function certInitByUser(userId) {
+  return request({ url: '/api/admin/planner-certifications/init-by-user', method: 'post', data: { userId } });
+}
+export function certBackfill() {
+  return request({ url: '/api/admin/planner-certifications/backfill', method: 'post' });
+}

+ 2 - 2
cfc-web/src/utils/permissions.js

@@ -10,9 +10,9 @@ const ROLE_PERMISSIONS = {
   // 超管:全部权限
   admin: ['*'],
   // 运营:审核全量、活动全量、任务全量、家庭/商城/知识/测评只读、组织管理
-  operator: ['dashboard', 'audit:*', 'activity:*', 'task:*', 'assessment:read', 'family:read', 'commerce:read', 'articles:read', 'knowledge:read', 'plan:*', 'org:*'],
+  operator: ['dashboard', 'audit:*', 'activity:*', 'task:*', 'assessment:read', 'family:read', 'commerce:read', 'articles:read', 'knowledge:read', 'plan:*', 'org:*', 'planner:cert'],
   // 财务:财务/退款/提现、订单只读、CF值、商城营销、业务管理、组织管理
-  finance: ['dashboard', 'finance:*', 'audit:withdraw', 'audit:refund', 'commerce:read', 'commerce:orders:read', 'biz:*', 'cf-value:*', 'org:*'],
+  finance: ['dashboard', 'finance:*', 'audit:withdraw', 'audit:refund', 'commerce:read', 'commerce:orders:read', 'biz:*', 'cf-value:*', 'org:*', 'planner:cert'],
   // 管家:家庭客服 + 规划师 + 营养师权限并集(getEffectivePermissions 中自动并入 teacher/nutritionist)
   butler: ['dashboard', 'family:*', 'task:*', 'reward:*', 'service:*', 'growth:*', 'biz:*',
            'assessment:dan', 'health:*', 'diet:*', 'report:audit', 'plan:*'],

+ 175 - 0
cfc-web/src/views/admin/planner-cert/Categories.vue

@@ -0,0 +1,175 @@
+<template>
+  <div class="planner-cert-page">
+    <el-card>
+      <PageHeader slot="header" title="学习种类管理">
+        <el-button v-if="isPlatform" type="primary" size="small" @click="openCreate">+ 新增学习种类</el-button>
+      </PageHeader>
+
+      <div class="adm-table-wrap">
+        <el-table :data="list" stripe v-loading="loading" height="100%">
+          <el-table-column prop="id" label="ID" width="80"></el-table-column>
+          <el-table-column prop="code" label="类别编码" min-width="140"></el-table-column>
+          <el-table-column prop="name" label="类别名称" min-width="160"></el-table-column>
+          <el-table-column prop="description" label="说明" min-width="200" show-overflow-tooltip></el-table-column>
+          <el-table-column label="要求总课时" width="120">
+            <template slot-scope="scope">{{ scope.row.requiredHours || 0 }} 课时</template>
+          </el-table-column>
+          <el-table-column label="基础类别" width="100">
+            <template slot-scope="scope">
+              <el-tag :type="scope.row.isBase === 1 ? 'success' : ''" size="mini">
+                {{ scope.row.isBase === 1 ? '是' : '否' }}
+              </el-tag>
+            </template>
+          </el-table-column>
+          <el-table-column label="状态" width="90">
+            <template slot-scope="scope">
+              <StatusTag :type="scope.row.status === 1 ? 'success' : 'danger'"
+                         :text="scope.row.status === 1 ? '启用' : '停用'" />
+            </template>
+          </el-table-column>
+          <el-table-column prop="orderIndex" label="排序" width="80"></el-table-column>
+          <el-table-column label="操作" width="150" fixed="right" v-if="isPlatform">
+            <template slot-scope="scope">
+              <el-button size="mini" @click="openEdit(scope.row)">编辑</el-button>
+              <el-button size="mini" type="danger" @click="deleteRow(scope.row)">删除</el-button>
+            </template>
+          </el-table-column>
+        </el-table>
+      </div>
+    </el-card>
+
+    <!-- 新增/编辑弹窗 -->
+    <el-dialog :title="dialogTitle" :visible.sync="dialogVisible" width="520px" append-to-body>
+      <el-form :model="form" :rules="rules" ref="form" label-width="110px">
+        <el-form-item label="类别编码" prop="code">
+          <el-input v-model="form.code" placeholder="如 planner / nutritionist" />
+        </el-form-item>
+        <el-form-item label="类别名称" prop="name">
+          <el-input v-model="form.name" />
+        </el-form-item>
+        <el-form-item label="说明" prop="description">
+          <el-input v-model="form.description" type="textarea" :rows="2" />
+        </el-form-item>
+        <el-form-item label="要求总课时" prop="requiredHours">
+          <el-input-number v-model="form.requiredHours" :min="0" :precision="1" :step="0.5" />
+        </el-form-item>
+        <el-form-item label="基础类别" prop="isBase">
+          <el-switch v-model="form.isBase" :active-value="1" :inactive-value="0" />
+          <div class="el-form-item-help">基础类别:获得即 Guide.status=active(可接单)</div>
+        </el-form-item>
+        <el-form-item label="启用状态" prop="status">
+          <el-switch v-model="form.status" :active-value="1" :inactive-value="0" />
+        </el-form-item>
+        <el-form-item label="排序" prop="orderIndex">
+          <el-input-number v-model="form.orderIndex" :min="0" />
+        </el-form-item>
+      </el-form>
+      <div slot="footer" class="dialog-footer">
+        <el-button @click="dialogVisible = false">取消</el-button>
+        <el-button type="primary" :loading="saving" @click="saveRow">确定</el-button>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { certCategoryList, certCategoryCreate, certCategoryUpdate, certCategoryDelete } from '@/api/plannerCert'
+import { Message } from 'element-ui'
+
+export default {
+  name: 'PlannerCertCategories',
+  computed: {
+    isPlatform() {
+      const roles = this.$store.state.user.roles
+      return roles.some(r => ['admin', 'operator', 'finance'].indexOf(r) >= 0)
+    },
+    dialogTitle() {
+      return this.form.id ? '编辑学习种类' : '新增学习种类'
+    }
+  },
+  data() {
+    return {
+      list: [],
+      loading: false,
+      saving: false,
+      dialogVisible: false,
+      form: {
+        id: null,
+        code: '',
+        name: '',
+        description: '',
+        requiredHours: 0,
+        isBase: 0,
+        status: 1,
+        orderIndex: 0
+      },
+      rules: {
+        code: [{ required: true, message: '请输入类别编码', trigger: 'blur' }],
+        name: [{ required: true, message: '请输入类别名称', trigger: 'blur' }]
+      }
+    }
+  },
+  created() {
+    this.loadData()
+  },
+  methods: {
+    async loadData() {
+      this.loading = true
+      try {
+        const res = await certCategoryList()
+        if (res.data) {
+          this.list = res.data || []
+        }
+      } catch (e) {
+        console.error('加载学习种类失败', e)
+      } finally {
+        this.loading = false
+      }
+    },
+    openCreate() {
+      this.form = { id: null, code: '', name: '', description: '', requiredHours: 0, isBase: 0, status: 1, orderIndex: 0 }
+      this.dialogVisible = true
+    },
+    openEdit(row) {
+      this.form = Object.assign({}, row)
+      this.dialogVisible = true
+    },
+    saveRow() {
+      this.$refs.form.validate(async valid => {
+        if (!valid) return
+        this.saving = true
+        try {
+          if (this.form.id) {
+            await certCategoryUpdate(this.form)
+            Message.success('更新成功')
+          } else {
+            await certCategoryCreate(this.form)
+            Message.success('创建成功')
+          }
+          this.dialogVisible = false
+          this.loadData()
+        } catch (e) {
+          console.error('保存学习种类失败', e)
+        } finally {
+          this.saving = false
+        }
+      })
+    },
+    async deleteRow(row) {
+      try {
+        await this.$confirm(`确定删除学习种类「${row.name}」?`, '提示', { type: 'warning' })
+        await certCategoryDelete(row.id)
+        Message.success('删除成功')
+        this.loadData()
+      } catch (e) {
+        if (e !== 'cancel') console.error('删除学习种类失败', e)
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.planner-cert-page { padding: 16px; }
+.el-form-item-help { font-size: 12px; color: #999; margin-top: 4px; }
+</style>

+ 271 - 0
cfc-web/src/views/admin/planner-cert/CertBoard.vue

@@ -0,0 +1,271 @@
+<template>
+  <div class="planner-cert-page">
+    <el-card>
+      <PageHeader slot="header" title="规划师认证看板">
+        <el-button v-if="isPlatform" size="small" type="warning" :loading="backfillLoading" @click="runBackfill">全量补登</el-button>
+      </PageHeader>
+
+      <el-form :inline="true" class="mb-15">
+        <el-form-item>
+          <el-select v-model="filterStatus" placeholder="认证状态" clearable style="width: 140px" @change="loadData">
+            <el-option v-for="(label, val) in statusLabels" :key="val" :label="label" :value="val" />
+          </el-select>
+          <el-select v-model="filterCategoryId" placeholder="类别" clearable style="width: 160px" @change="loadData">
+            <el-option v-for="cat in categories" :key="cat.id" :label="cat.name" :value="cat.id" />
+          </el-select>
+          <el-input v-model="searchKeyword" placeholder="搜索规划师姓名/手机号/编号" style="width: 220px" @keyup.enter.native="loadData" />
+          <el-button type="primary" @click="loadData" :loading="loading">查询</el-button>
+        </el-form-item>
+      </el-form>
+
+      <div class="adm-table-wrap">
+        <el-table :data="list" stripe v-loading="loading" height="calc(100vh - 320px)">
+          <el-table-column prop="id" label="ID" width="80"></el-table-column>
+          <el-table-column prop="userName" label="规划师" min-width="120">
+            <template slot-scope="scope">{{ scope.row.userName || ('ID:' + scope.row.userId) }}</template>
+          </el-table-column>
+          <el-table-column label="类别" width="140">
+            <template slot-scope="scope">{{ getCategoryName(scope.row.categoryId) }}</template>
+          </el-table-column>
+          <el-table-column label="课时进度" width="180">
+            <template slot-scope="scope">
+              <el-progress :percentage="hoursPercent(scope.row)" :stroke-width="8" />
+              <span class="progress-text">{{ scope.row.completedHours || 0 }} / {{ scope.row.requiredHours || 0 }}</span>
+            </template>
+          </el-table-column>
+          <el-table-column label="考核状态" width="110">
+            <template slot-scope="scope">
+              <el-tag :type="examStatusType(scope.row.examStatus)" size="mini">{{ examStatusLabel(scope.row.examStatus) }}</el-tag>
+            </template>
+          </el-table-column>
+          <el-table-column prop="examScore" label="考核分" width="80"></el-table-column>
+          <el-table-column prop="certNo" label="证书编号" min-width="150" show-overflow-tooltip></el-table-column>
+          <el-table-column label="状态" width="110">
+            <template slot-scope="scope">
+              <el-tag :type="statusType(scope.row.status)" size="mini">{{ statusLabels[scope.row.status] || scope.row.status }}</el-tag>
+            </template>
+          </el-table-column>
+          <el-table-column prop="certifiedAt" label="获得时间" width="170">
+            <template slot-scope="scope">{{ formatTime(scope.row.certifiedAt) }}</template>
+          </el-table-column>
+          <el-table-column label="操作" width="180" fixed="right">
+            <template slot-scope="scope">
+              <el-button size="mini" @click="openSync(scope.row)">同步课时</el-button>
+              <el-button size="mini" type="warning" @click="initCert(scope.row)">初始化</el-button>
+            </template>
+          </el-table-column>
+        </el-table>
+      </div>
+
+      <el-pagination
+        class="pagination-right-15"
+        background
+        layout="total, prev, pager, next"
+        :total="total"
+        :page-size="pageSize"
+        :current-page="page"
+        @current-change="handlePageChange">
+      </el-pagination>
+    </el-card>
+
+    <!-- 手动同步课时弹窗 -->
+    <el-dialog title="手动同步课时" :visible.sync="syncVisible" width="480px" append-to-body>
+      <el-form :model="syncForm" label-width="110px">
+        <el-form-item label="规划师">
+          <span>{{ currentRow ? (currentRow.userName || ('ID:' + currentRow.userId)) : '-' }}</span>
+        </el-form-item>
+        <el-form-item label="课程" required>
+          <el-select v-model="syncForm.courseId" placeholder="选择线上课程" style="width: 100%" filterable>
+            <el-option v-for="course in coursesForCategory" :key="course.id" :label="course.title" :value="course.id" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="课时(h)" required>
+          <el-input-number v-model="syncForm.hours" :min="0.1" :precision="1" :step="0.5" />
+        </el-form-item>
+        <el-form-item label="完成进度%">
+          <el-input-number v-model="syncForm.progressPercent" :min="0" :max="100" />
+        </el-form-item>
+      </el-form>
+      <div slot="footer" class="dialog-footer">
+        <el-button @click="syncVisible = false">取消</el-button>
+        <el-button type="primary" :loading="saving" @click="doSync">确定</el-button>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import {
+  certCategoryList,
+  certBoardList,
+  certCourseList,
+  certSyncHours,
+  certInitByUser,
+  certBackfill
+} from '@/api/plannerCert'
+import { Message } from 'element-ui'
+
+export default {
+  name: 'PlannerCertBoard',
+  computed: {
+    isPlatform() {
+      const roles = this.$store.state.user.roles
+      return roles.some(r => ['admin', 'operator', 'finance'].indexOf(r) >= 0)
+    },
+    coursesForCategory() {
+      return this.syncForm.categoryId ? (this.courseMap[this.syncForm.categoryId] || []) : []
+    }
+  },
+  data() {
+    return {
+      list: [],
+      categories: [],
+      courseMap: {},
+      loading: false,
+      saving: false,
+      backfillLoading: false,
+      searchKeyword: '',
+      filterStatus: '',
+      filterCategoryId: null,
+      page: 1,
+      pageSize: 10,
+      total: 0,
+      statusLabels: {
+        pending_cert: '待认证',
+        pending_learning: '学习中',
+        learning_completed: '待考核',
+        certified: '已认证',
+        suspended: '已暂停',
+        exam_failed: '考核未通过'
+      },
+      syncVisible: false,
+      currentRow: null,
+      syncForm: { courseId: null, hours: 1, progressPercent: 100, categoryId: null }
+    }
+  },
+  created() {
+    this.loadCategories()
+    this.loadData()
+  },
+  methods: {
+    async loadCategories() {
+      try {
+        const res = await certCategoryList()
+        this.categories = res.data || []
+      } catch (e) {
+        console.error('加载类别列表失败', e)
+      }
+    },
+    getCategoryName(cid) {
+      const cat = this.categories.find(c => c.id === cid)
+      return cat ? cat.name : (cid || '-')
+    },
+    async loadData() {
+      this.loading = true
+      try {
+        const params = {
+          status: this.filterStatus || undefined,
+          categoryId: this.filterCategoryId || undefined,
+          page: this.page,
+          size: this.pageSize
+        }
+        const res = await certBoardList(params)
+        if (res.data) {
+          this.list = res.data.records || []
+          this.total = res.data.total || 0
+        }
+      } catch (e) {
+        console.error('加载认证看板失败', e)
+      } finally {
+        this.loading = false
+      }
+    },
+    handlePageChange(p) {
+      this.page = p
+      this.loadData()
+    },
+    hoursPercent(row) {
+      const required = parseFloat(row.requiredHours) || 0
+      const completed = parseFloat(row.completedHours) || 0
+      return required > 0 ? Math.min(100, Math.round((completed / required) * 100)) : 0
+    },
+    examStatusType(s) {
+      return { none: '', exam_failed: 'danger', exam_passed: 'success' }[s] || ''
+    },
+    examStatusLabel(s) {
+      return { none: '未参加', exam_failed: '未通过', exam_passed: '已通过' }[s] || s
+    },
+    statusType(s) {
+      return { certified: 'success', learning_completed: 'warning', suspended: 'danger' }[s] || 'info'
+    },
+    formatTime(str) {
+      return str ? str.replace('T', ' ').substring(0, 19) : '-'
+    },
+    async openSync(row) {
+      this.currentRow = row
+      this.syncForm = { courseId: null, hours: 1, progressPercent: 100, categoryId: row.categoryId }
+      this.syncVisible = true
+      // 按类别预加载课程下拉
+      if (row.categoryId && !this.courseMap[row.categoryId]) {
+        try {
+          const res = await certCourseList(row.categoryId)
+          this.$set(this.courseMap, row.categoryId, res.data || [])
+        } catch (e) {
+          console.error('加载课程失败', e)
+        }
+      }
+    },
+    async doSync() {
+      if (!this.syncForm.courseId) {
+        Message.warning('请选择课程')
+        return
+      }
+      if (!this.currentRow) return
+      this.saving = true
+      try {
+        await certSyncHours({
+          userId: this.currentRow.userId,
+          courseId: this.syncForm.courseId,
+          hours: this.syncForm.hours,
+          progressPercent: this.syncForm.progressPercent
+        })
+        Message.success('课时同步成功')
+        this.syncVisible = false
+        this.loadData()
+      } catch (e) {
+        console.error('同步课时失败', e)
+      } finally {
+        this.saving = false
+      }
+    },
+    async initCert(row) {
+      try {
+        await this.$confirm(`确定为该规划师初始化(或补齐)全部类别认证记录?`, '提示', { type: 'warning' })
+        await certInitByUser(row.userId)
+        Message.success('初始化成功')
+        this.loadData()
+      } catch (e) {
+        if (e !== 'cancel') console.error('初始化认证失败', e)
+      }
+    },
+    async runBackfill() {
+      try {
+        await this.$confirm('全量补登存量规划师的认证记录(幂等,可重复执行)?', '提示', { type: 'warning' })
+        this.backfillLoading = true
+        const res = await certBackfill()
+        Message.success((res && res.data) || '已执行存量补登')
+        this.loadData()
+      } catch (e) {
+        if (e !== 'cancel') console.error('全量补登失败', e)
+      } finally {
+        this.backfillLoading = false
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.planner-cert-page { padding: 16px; }
+.progress-text { font-size: 12px; color: #999; }
+</style>

+ 202 - 0
cfc-web/src/views/admin/planner-cert/Courses.vue

@@ -0,0 +1,202 @@
+<template>
+  <div class="planner-cert-page">
+    <el-card>
+      <PageHeader slot="header" title="线上课程管理">
+        <el-button v-if="isPlatform" type="primary" size="small" @click="openCreate">+ 新增课程</el-button>
+      </PageHeader>
+
+      <el-form :inline="true" class="mb-15" v-if="isPlatform">
+        <el-form-item>
+          <el-select v-model="form.categoryId" placeholder="筛选类别" clearable style="width: 180px" @change="loadData">
+            <el-option v-for="cat in categories" :key="cat.id" :label="cat.name" :value="cat.id" />
+          </el-select>
+          <el-input v-model="form.keyword" placeholder="搜索课程标题" style="width: 200px" />
+          <el-select v-model="form.status" placeholder="状态" clearable style="width: 120px">
+            <el-option label="全部" value=""></el-option>
+            <el-option label="启用" value="1"></el-option>
+            <el-option label="停用" value="0"></el-option>
+          </el-select>
+          <el-button type="primary" @click="loadData" :loading="loading">查询</el-button>
+        </el-form-item>
+      </el-form>
+
+      <div class="adm-table-wrap">
+        <el-table :data="list" stripe v-loading="loading" height="100%">
+          <el-table-column prop="id" label="ID" width="70"></el-table-column>
+          <el-table-column prop="categoryId" label="所属类别" width="140">
+            <template slot-scope="scope">{{ getCategoryName(scope.row.categoryId) }}</template>
+          </el-table-column>
+          <el-table-column prop="title" label="课程标题" min-width="200" show-overflow-tooltip></el-table-column>
+          <el-table-column prop="xiaoeResourceId" label="小鹅通ID" width="120"></el-table-column>
+          <el-table-column label="要求课时" width="100">
+            <template slot-scope="scope">{{ scope.row.requiredHours || 0 }} 课时</template>
+          </el-table-column>
+          <el-table-column label="必修" width="80">
+            <template slot-scope="scope">
+              <el-tag :type="scope.row.isRequired === 1 ? 'success' : ''" size="mini">{{ scope.row.isRequired === 1 ? '是' : '否' }}</el-tag>
+            </template>
+          </el-table-column>
+          <el-table-column label="状态" width="90">
+            <template slot-scope="scope">
+              <StatusTag :type="scope.row.status === 1 ? 'success' : 'danger'"
+                         :text="scope.row.status === 1 ? '启用' : '停用'" />
+            </template>
+          </el-table-column>
+          <el-table-column prop="orderIndex" label="排序" width="70"></el-table-column>
+          <el-table-column label="操作" width="150" fixed="right" v-if="isPlatform">
+            <template slot-scope="scope">
+              <el-button size="mini" @click="openEdit(scope.row)">编辑</el-button>
+              <el-button size="mini" type="danger" @click="deleteRow(scope.row)">删除</el-button>
+            </template>
+          </el-table-column>
+        </el-table>
+      </div>
+    </el-card>
+
+    <!-- 新增/编辑弹窗 -->
+    <el-dialog :title="dialogTitle" :visible.sync="dialogVisible" width="560px" append-to-body>
+      <el-form :model="form" :rules="rules" ref="form" label-width="110px">
+        <el-form-item label="所属类别" prop="categoryId">
+          <el-select v-model="form.categoryId" placeholder="请选择" style="width: 100%">
+            <el-option v-for="cat in categories" :key="cat.id" :label="cat.name" :value="cat.id" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="课程标题" prop="title">
+          <el-input v-model="form.title" />
+        </el-form-item>
+        <el-form-item label="小鹅通资源ID" prop="xiaoeResourceId">
+          <el-input v-model="form.xiaoeResourceId" placeholder="小鹅通课程/商品ID" />
+        </el-form-item>
+        <el-form-item label="要求课时" prop="requiredHours">
+          <el-input-number v-model="form.requiredHours" :min="0" :precision="1" :step="0.5" />
+        </el-form-item>
+        <el-form-item label="是否必修" prop="isRequired">
+          <el-switch v-model="form.isRequired" :active-value="1" :inactive-value="0" />
+        </el-form-item>
+        <el-form-item label="启用状态" prop="status">
+          <el-switch v-model="form.status" :active-value="1" :inactive-value="0" />
+        </el-form-item>
+        <el-form-item label="排序" prop="orderIndex">
+          <el-input-number v-model="form.orderIndex" :min="0" />
+        </el-form-item>
+      </el-form>
+      <div slot="footer" class="dialog-footer">
+        <el-button @click="dialogVisible = false">取消</el-button>
+        <el-button type="primary" :loading="saving" @click="saveRow">确定</el-button>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { certCourseList, certCourseCreate, certCourseUpdate, certCourseDelete, certCategoryList } from '@/api/plannerCert'
+import { Message } from 'element-ui'
+
+export default {
+  name: 'PlannerCertCourses',
+  computed: {
+    isPlatform() {
+      const roles = this.$store.state.user.roles
+      return roles.some(r => ['admin', 'operator', 'finance'].indexOf(r) >= 0)
+    },
+    dialogTitle() {
+      return this.form.id ? '编辑课程' : '新增课程'
+    }
+  },
+  data() {
+    return {
+      list: [],
+      loading: false,
+      saving: false,
+      dialogVisible: false,
+      categories: [],
+      form: {
+        id: null,
+        categoryId: null,
+        title: '',
+        xiaoeResourceId: '',
+        requiredHours: 0,
+        isRequired: 0,
+        status: 1,
+        orderIndex: 0
+      },
+      rules: {
+        categoryId: [{ required: true, message: '请选择所属类别', trigger: 'change' }],
+        title: [{ required: true, message: '请输入课程标题', trigger: 'blur' }]
+      }
+    }
+  },
+  created() {
+    this.loadCategories()
+    this.loadData()
+  },
+  methods: {
+    async loadCategories() {
+      try {
+        const res = await certCategoryList()
+        this.categories = res.data || []
+      } catch (e) {
+        console.error('加载类别列表失败', e)
+      }
+    },
+    getCategoryName(cid) {
+      const cat = this.categories.find(c => c.id === cid)
+      return cat ? cat.name : (cid || '-')
+    },
+    async loadData() {
+      this.loading = true
+      try {
+        const res = await certCourseList(this.form.categoryId || undefined)
+        this.list = res.data || []
+      } catch (e) {
+        console.error('加载课程列表失败', e)
+      } finally {
+        this.loading = false
+      }
+    },
+    openCreate() {
+      this.form = { id: null, categoryId: null, title: '', xiaoeResourceId: '', requiredHours: 0, isRequired: 0, status: 1, orderIndex: 0 }
+      this.dialogVisible = true
+    },
+    openEdit(row) {
+      this.form = Object.assign({}, row)
+      this.dialogVisible = true
+    },
+    saveRow() {
+      this.$refs.form.validate(async valid => {
+        if (!valid) return
+        this.saving = true
+        try {
+          if (this.form.id) {
+            await certCourseUpdate(this.form)
+            Message.success('更新成功')
+          } else {
+            await certCourseCreate(this.form)
+            Message.success('创建成功')
+          }
+          this.dialogVisible = false
+          this.loadData()
+        } catch (e) {
+          console.error('保存课程失败', e)
+        } finally {
+          this.saving = false
+        }
+      })
+    },
+    async deleteRow(row) {
+      try {
+        await this.$confirm(`确定删除课程「${row.title}」?`, '提示', { type: 'warning' })
+        await certCourseDelete(row.id)
+        Message.success('删除成功')
+        this.loadData()
+      } catch (e) {
+        if (e !== 'cancel') console.error('删除课程失败', e)
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.planner-cert-page { padding: 16px; }
+</style>

+ 310 - 0
cfc-web/src/views/admin/planner-cert/Exams.vue

@@ -0,0 +1,310 @@
+<template>
+  <div class="planner-cert-page">
+    <el-card>
+      <PageHeader slot="header" title="考核题库管理">
+        <el-button type="primary" size="small" @click="openCreatePaper">+ 新增试卷</el-button>
+      </PageHeader>
+
+      <el-form :inline="true" class="mb-15">
+        <el-form-item>
+          <el-select v-model="filterCategoryId" placeholder="筛选类别" clearable style="width: 180px" @change="loadPapers">
+            <el-option v-for="cat in categories" :key="cat.id" :label="cat.name" :value="cat.id" />
+          </el-select>
+          <el-button type="primary" @click="loadPapers" :loading="loading">查询</el-button>
+        </el-form-item>
+      </el-form>
+
+      <div class="adm-table-wrap">
+        <el-table :data="papers" stripe v-loading="loading" height="100%">
+          <el-table-column prop="id" label="ID" width="70"></el-table-column>
+          <el-table-column label="所属类别" width="140">
+            <template slot-scope="scope">{{ getCategoryName(scope.row.categoryId) }}</template>
+          </el-table-column>
+          <el-table-column prop="title" label="试卷标题" min-width="200" show-overflow-tooltip></el-table-column>
+          <el-table-column prop="passScore" label="及格分" width="90"></el-table-column>
+          <el-table-column prop="durationMinutes" label="限时(分钟)" width="110"></el-table-column>
+          <el-table-column prop="questionCount" label="题数" width="80"></el-table-column>
+          <el-table-column label="状态" width="90">
+            <template slot-scope="scope">
+              <StatusTag :type="scope.row.status === 1 ? 'success' : 'danger'"
+                         :text="scope.row.status === 1 ? '启用' : '停用'" />
+            </template>
+          </el-table-column>
+          <el-table-column label="操作" width="240" fixed="right">
+            <template slot-scope="scope">
+              <el-button size="mini" type="success" @click="openQuestions(scope.row)">题目</el-button>
+              <el-button size="mini" @click="openEditPaper(scope.row)">编辑</el-button>
+              <el-button size="mini" type="danger" @click="deletePaper(scope.row)">删除</el-button>
+            </template>
+          </el-table-column>
+        </el-table>
+      </div>
+    </el-card>
+
+    <!-- 试卷新增/编辑弹窗 -->
+    <el-dialog :title="paperDialogTitle" :visible.sync="paperDialogVisible" width="500px" append-to-body>
+      <el-form :model="paperForm" :rules="paperRules" ref="paperForm" label-width="110px">
+        <el-form-item label="所属类别" prop="categoryId">
+          <el-select v-model="paperForm.categoryId" placeholder="请选择" style="width: 100%">
+            <el-option v-for="cat in categories" :key="cat.id" :label="cat.name" :value="cat.id" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="试卷标题" prop="title">
+          <el-input v-model="paperForm.title" />
+        </el-form-item>
+        <el-form-item label="及格分" prop="passScore">
+          <el-input-number v-model="paperForm.passScore" :min="0" :max="100" />
+        </el-form-item>
+        <el-form-item label="限时(分钟)" prop="durationMinutes">
+          <el-input-number v-model="paperForm.durationMinutes" :min="1" />
+        </el-form-item>
+        <el-form-item label="启用状态" prop="status">
+          <el-switch v-model="paperForm.status" :active-value="1" :inactive-value="0" />
+        </el-form-item>
+      </el-form>
+      <div slot="footer" class="dialog-footer">
+        <el-button @click="paperDialogVisible = false">取消</el-button>
+        <el-button type="primary" :loading="saving" @click="savePaper">确定</el-button>
+      </div>
+    </el-dialog>
+
+    <!-- 题目管理弹窗 -->
+    <el-dialog :title="'题目管理 - ' + (currentPaper ? currentPaper.title : '')" :visible.sync="questionDialogVisible"
+               width="760px" append-to-body top="6vh">
+      <el-alert type="info" :closable="false" class="mb-15"
+                title="选项用 JSON 数组保存(如 ["选项A","选项B","选项C"]),正确答案填选项索引字母(A/B/C)" />
+      <el-button type="primary" size="small" class="mb-15" @click="addQuestion">+ 添加题目</el-button>
+      <el-table :data="questionList" stripe v-loading="qLoading">
+        <el-table-column type="index" label="#" width="50"></el-table-column>
+        <el-table-column prop="question" label="题干" min-width="260" show-overflow-tooltip></el-table-column>
+        <el-table-column prop="options" label="选项(JSON)" min-width="220" show-overflow-tooltip></el-table-column>
+        <el-table-column prop="answer" label="正确答案" width="90"></el-table-column>
+        <el-table-column prop="score" label="分值" width="70"></el-table-column>
+        <el-table-column label="操作" width="130" fixed="right">
+          <template slot-scope="scope">
+            <el-button size="mini" @click="editQuestion(scope.$index)">编辑</el-button>
+            <el-button size="mini" type="danger" @click="questionList.splice(scope.$index, 1)">删除</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+      <div slot="footer" class="dialog-footer">
+        <el-button @click="questionDialogVisible = false">取消</el-button>
+        <el-button type="primary" :loading="saving" @click="saveQuestions">保存题目</el-button>
+      </div>
+    </el-dialog>
+
+    <!-- 题目编辑弹窗 -->
+    <el-dialog :title="questionEditTitle" :visible.sync="questionEditVisible" width="600px" append-to-body>
+      <el-form :model="qForm" label-width="90px">
+        <el-form-item label="题干" required>
+          <el-input v-model="qForm.question" type="textarea" :rows="2" />
+        </el-form-item>
+        <el-form-item label="选项(JSON)">
+          <el-input v-model="qForm.options" type="textarea" :rows="3" placeholder='["选项A","选项B","选项C"]' />
+        </el-form-item>
+        <el-form-item label="正确答案" required>
+          <el-input v-model="qForm.answer" placeholder="A / B / C" style="width: 160px" />
+        </el-form-item>
+        <el-form-item label="分值" required>
+          <el-input-number v-model="qForm.score" :min="1" :max="100" />
+        </el-form-item>
+        <el-form-item label="排序">
+          <el-input-number v-model="qForm.orderIndex" :min="0" />
+        </el-form-item>
+      </el-form>
+      <div slot="footer" class="dialog-footer">
+        <el-button @click="questionEditVisible = false">取消</el-button>
+        <el-button type="primary" @click="confirmQuestionEdit">确定</el-button>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import {
+  certCategoryList,
+  certExamPapers,
+  certExamPaperCreate,
+  certExamPaperUpdate,
+  certExamPaperDelete,
+  certExamQuestions,
+  certExamQuestionsSave
+} from '@/api/plannerCert'
+import { Message } from 'element-ui'
+
+export default {
+  name: 'PlannerCertExams',
+  computed: {
+    paperDialogTitle() {
+      return this.paperForm.id ? '编辑试卷' : '新增试卷'
+    },
+    questionEditTitle() {
+      return this.qFormIndex >= 0 ? '编辑题目' : '新增题目'
+    }
+  },
+  data() {
+    return {
+      categories: [],
+      papers: [],
+      loading: false,
+      saving: false,
+      qLoading: false,
+      filterCategoryId: null,
+      paperDialogVisible: false,
+      questionDialogVisible: false,
+      questionEditVisible: false,
+      currentPaper: null,
+      paperForm: { id: null, categoryId: null, title: '', passScore: 60, durationMinutes: 30, status: 1 },
+      paperRules: {
+        categoryId: [{ required: true, message: '请选择类别', trigger: 'change' }],
+        title: [{ required: true, message: '请输入试卷标题', trigger: 'blur' }]
+      },
+      questionList: [],
+      qForm: { question: '', options: '[""]', answer: 'A', score: 10, orderIndex: 0 },
+      qFormIndex: -1
+    }
+  },
+  created() {
+    this.loadCategories()
+    this.loadPapers()
+  },
+  methods: {
+    async loadCategories() {
+      try {
+        const res = await certCategoryList()
+        this.categories = res.data || []
+      } catch (e) {
+        console.error('加载类别列表失败', e)
+      }
+    },
+    getCategoryName(cid) {
+      const cat = this.categories.find(c => c.id === cid)
+      return cat ? cat.name : (cid || '-')
+    },
+    async loadPapers() {
+      if (!this.filterCategoryId) {
+        this.papers = []
+        this.total = 0
+        return
+      }
+      this.loading = true
+      try {
+        const res = await certExamPapers(this.filterCategoryId)
+        this.papers = res.data || []
+      } catch (e) {
+        console.error('加载试卷失败', e)
+      } finally {
+        this.loading = false
+      }
+    },
+    openCreatePaper() {
+      this.paperForm = { id: null, categoryId: this.filterCategoryId || null, title: '', passScore: 60, durationMinutes: 30, status: 1 }
+      this.paperDialogVisible = true
+    },
+    openEditPaper(row) {
+      this.paperForm = Object.assign({}, row)
+      this.paperDialogVisible = true
+    },
+    savePaper() {
+      if (!this.paperForm.categoryId) {
+        Message.warning('请选择类别')
+        return
+      }
+      if (!this.paperForm.title) {
+        Message.warning('请输入试卷标题')
+        return
+      }
+      this.saving = true
+      const action = this.paperForm.id ? certExamPaperUpdate : certExamPaperCreate
+      action(this.paperForm).then(() => {
+        Message.success('保存成功')
+        this.paperDialogVisible = false
+        this.loadPapers()
+      }).catch(e => {
+        console.error('保存试卷失败', e)
+      }).finally(() => {
+        this.saving = false
+      })
+    },
+    async deletePaper(row) {
+      try {
+        await this.$confirm(`确定删除试卷「${row.title}」?`, '提示', { type: 'warning' })
+        await certExamPaperDelete(row.id)
+        Message.success('删除成功')
+        this.loadPapers()
+      } catch (e) {
+        if (e !== 'cancel') console.error('删除试卷失败', e)
+      }
+    },
+    async openQuestions(paper) {
+      this.currentPaper = paper
+      this.qLoading = true
+      this.questionList = []
+      this.questionDialogVisible = true
+      try {
+        const res = await certExamQuestions(paper.id)
+        this.questionList = res.data || []
+      } catch (e) {
+        console.error('加载题目失败', e)
+      } finally {
+        this.qLoading = false
+      }
+    },
+    addQuestion() {
+      this.qForm = { question: '', options: '[""]', answer: 'A', score: 10, orderIndex: this.questionList.length }
+      this.qFormIndex = -1
+      this.questionEditVisible = true
+    },
+    editQuestion(idx) {
+      const q = this.questionList[idx]
+      this.qForm = Object.assign({ question: '', options: '[]', answer: 'A', score: 10, orderIndex: 0 }, q)
+      this.qFormIndex = idx
+      this.questionEditVisible = true
+    },
+    confirmQuestionEdit() {
+      if (!this.qForm.question) {
+        Message.warning('请输入题干')
+        return
+      }
+      let optionsValid = true
+      try {
+        JSON.parse(this.qForm.options)
+      } catch (e) {
+        optionsValid = false
+      }
+      if (!optionsValid) {
+        Message.warning('选项必须是合法 JSON 数组')
+        return
+      }
+      if (!this.qForm.answer) {
+        Message.warning('请填写正确答案')
+        return
+      }
+      if (this.qFormIndex >= 0) {
+        this.$set(this.questionList, this.qFormIndex, Object.assign({}, this.qForm))
+      } else {
+        this.questionList = this.questionList.concat([Object.assign({}, this.qForm)])
+      }
+      this.questionEditVisible = false
+    },
+    async saveQuestions() {
+      if (!this.currentPaper) return
+      this.saving = true
+      try {
+        await certExamQuestionsSave({ paperId: this.currentPaper.id, questions: this.questionList })
+        Message.success('题目已保存')
+        this.questionDialogVisible = false
+        this.loadPapers()
+      } catch (e) {
+        console.error('保存题目失败', e)
+      } finally {
+        this.saving = false
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.planner-cert-page { padding: 16px; }
+</style>

+ 220 - 0
cfc-web/src/views/admin/planner-cert/OfflineMap.vue

@@ -0,0 +1,220 @@
+<template>
+  <div class="planner-cert-page">
+    <el-card>
+      <PageHeader slot="header" title="线下课程映射">
+        <el-button v-if="isPlatform" type="primary" size="small" @click="openCreate">+ 新增映射</el-button>
+      </PageHeader>
+
+      <el-form :inline="true" class="mb-15" v-if="isPlatform">
+        <el-form-item>
+          <el-select v-model="form.categoryId" placeholder="筛选类别" clearable style="width: 180px" @change="loadData">
+            <el-option v-for="cat in categories" :key="cat.id" :label="cat.name" :value="cat.id" />
+          </el-select>
+          <el-input v-model="form.keyword" placeholder="搜索活动标题" style="width: 200px" />
+          <el-select v-model="form.status" placeholder="状态" clearable style="width: 120px">
+            <el-option label="全部" value=""></el-option>
+            <el-option label="启用" value="1"></el-option>
+            <el-option label="停用" value="0"></el-option>
+          </el-select>
+          <el-button type="primary" @click="loadData" :loading="loading">查询</el-button>
+        </el-form-item>
+      </el-form>
+
+      <div class="adm-table-wrap">
+        <el-table :data="list" stripe v-loading="loading" height="100%">
+          <el-table-column prop="id" label="ID" width="70"></el-table-column>
+          <el-table-column prop="activityId" label="活动ID" width="90"></el-table-column>
+          <el-table-column prop="title" label="活动标题" min-width="200" show-overflow-tooltip></el-table-column>
+          <el-table-column label="所属类别" width="140">
+            <template slot-scope="scope">{{ getCategoryName(scope.row.categoryId) }}</template>
+          </el-table-column>
+          <el-table-column label="计入课时" width="100">
+            <template slot-scope="scope">{{ scope.row.durationHours || 0 }} 课时</template>
+          </el-table-column>
+          <el-table-column label="必修" width="80">
+            <template slot-scope="scope">
+              <el-tag :type="scope.row.isRequired === 1 ? 'success' : ''" size="mini">{{ scope.row.isRequired === 1 ? '是' : '否' }}</el-tag>
+            </template>
+          </el-table-column>
+          <el-table-column label="状态" width="90">
+            <template slot-scope="scope">
+              <StatusTag :type="scope.row.status === 1 ? 'success' : 'danger'"
+                         :text="scope.row.status === 1 ? '启用' : '停用'" />
+            </template>
+          </el-table-column>
+          <el-table-column prop="activityStartTime" label="活动时间" width="160">
+            <template slot-scope="scope">{{ formatTime(scope.row.activityStartTime) }}</template>
+          </el-table-column>
+          <el-table-column label="操作" width="150" fixed="right" v-if="isPlatform">
+            <template slot-scope="scope">
+              <el-button size="mini" @click="openEdit(scope.row)">编辑</el-button>
+              <el-button size="mini" type="danger" @click="deleteRow(scope.row)">删除</el-button>
+            </template>
+          </el-table-column>
+        </el-table>
+      </div>
+    </el-card>
+
+    <!-- 新增/编辑弹窗 -->
+    <el-dialog :title="dialogTitle" :visible.sync="dialogVisible" width="560px" append-to-body>
+      <el-form :model="form" :rules="rules" ref="form" label-width="110px">
+        <el-form-item label="活动" prop="activityId">
+          <el-select v-model="form.activityId" placeholder="请选择活动" style="width: 100%" filterable remote :remote-method="remoteActivitySearch" :loading="activityLoading">
+            <el-option v-for="act in activityOptions" :key="act.id" :label="act.title" :value="act.id" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="所属类别" prop="categoryId">
+          <el-select v-model="form.categoryId" placeholder="请选择" style="width: 100%">
+            <el-option v-for="cat in categories" :key="cat.id" :label="cat.name" :value="cat.id" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="计入课时" prop="durationHours">
+          <el-input-number v-model="form.durationHours" :min="0" :precision="1" :step="0.5" />
+        </el-form-item>
+        <el-form-item label="是否必修" prop="isRequired">
+          <el-switch v-model="form.isRequired" :active-value="1" :inactive-value="0" />
+        </el-form-item>
+        <el-form-item label="启用状态" prop="status">
+          <el-switch v-model="form.status" :active-value="1" :inactive-value="0" />
+        </el-form-item>
+      </el-form>
+      <div slot="footer" class="dialog-footer">
+        <el-button @click="dialogVisible = false">取消</el-button>
+        <el-button type="primary" :loading="saving" @click="saveRow">确定</el-button>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { certOfflineList, certOfflineCreate, certOfflineUpdate, certOfflineDelete, certCategoryList } from '@/api/plannerCert'
+import { getActivityList } from '@/api/activity'
+import { Message } from 'element-ui'
+
+export default {
+  name: 'PlannerCertOfflineMap',
+  computed: {
+    isPlatform() {
+      const roles = this.$store.state.user.roles
+      return roles.some(r => ['admin', 'operator', 'finance'].indexOf(r) >= 0)
+    },
+    dialogTitle() {
+      return this.form.id ? '编辑映射' : '新增映射'
+    }
+  },
+  data() {
+    return {
+      list: [],
+      loading: false,
+      saving: false,
+      dialogVisible: false,
+      categories: [],
+      activityOptions: [],
+      activityLoading: false,
+      activitySearchTimer: null,
+      form: {
+        id: null,
+        activityId: null,
+        categoryId: null,
+        durationHours: 0,
+        isRequired: 0,
+        status: 1
+      },
+      rules: {
+        activityId: [{ required: true, message: '请选择活动', trigger: 'change' }],
+        categoryId: [{ required: true, message: '请选择所属类别', trigger: 'change' }]
+      }
+    }
+  },
+  created() {
+    this.loadCategories()
+    this.loadData()
+  },
+  methods: {
+    async loadCategories() {
+      try {
+        const res = await certCategoryList()
+        this.categories = res.data || []
+      } catch (e) {
+        console.error('加载类别列表失败', e)
+      }
+    },
+    getCategoryName(cid) {
+      const cat = this.categories.find(c => c.id === cid)
+      return cat ? cat.name : (cid || '-')
+    },
+    formatTime(str) {
+      return str ? str.replace('T', ' ').substring(0, 16) : '-'
+    },
+    async loadData() {
+      this.loading = true
+      try {
+        const res = await certOfflineList()
+        this.list = res.data || []
+      } catch (e) {
+        console.error('加载映射列表失败', e)
+      } finally {
+        this.loading = false
+      }
+    },
+    remoteActivitySearch(query) {
+      if (this.activitySearchTimer) clearTimeout(this.activitySearchTimer)
+      this.activitySearchTimer = setTimeout(async () => {
+        this.activityLoading = true
+        try {
+          const res = await getActivityList({ keyword: query, page: 1, size: 20 })
+          this.activityOptions = (res.data && res.data.records) || (res.data instanceof Array ? res.data : [])
+        } catch (e) {
+          console.error('搜索活动失败', e)
+          this.activityOptions = []
+        } finally {
+          this.activityLoading = false
+        }
+      }, 300)
+    },
+    openCreate() {
+      this.form = { id: null, activityId: null, categoryId: null, durationHours: 0, isRequired: 0, status: 1 }
+      this.dialogVisible = true
+    },
+    openEdit(row) {
+      this.form = Object.assign({}, row)
+      this.dialogVisible = true
+    },
+    saveRow() {
+      this.$refs.form.validate(async valid => {
+        if (!valid) return
+        this.saving = true
+        try {
+          if (this.form.id) {
+            await certOfflineUpdate(this.form)
+            Message.success('更新成功')
+          } else {
+            await certOfflineCreate(this.form)
+            Message.success('创建成功')
+          }
+          this.dialogVisible = false
+          this.loadData()
+        } catch (e) {
+          console.error('保存映射失败', e)
+        } finally {
+          this.saving = false
+        }
+      })
+    },
+    async deleteRow(row) {
+      try {
+        await this.$confirm(`确定删除该映射?`, '提示', { type: 'warning' })
+        await certOfflineDelete(row.id)
+        Message.success('删除成功')
+        this.loadData()
+      } catch (e) {
+        if (e !== 'cancel') console.error('删除映射失败', e)
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.planner-cert-page { padding: 16px; }
+</style>