Browse Source

feat(admin/小程序): 首页引导配置化

管理端新增引导配置页面,可视化编辑家长/孩子端引导步骤
(icon/title/desc/isIntro)、展示位置(首页/孩子端)、版本号(version)。
小程序 index-home/child-index 异步拉取配置并按版本比对决定是否展示,
无配置或接口失败时回退到硬编码默认步骤。
后端复用 sys_config 表(key=onboarding_guide_config),新增 AdminGuideConfigController
(管理端 get/save)和 GuideConfigController(小程序 current)。
DatabaseInitializer 迁移318预置默认配置。
Sisyphus 10 hours ago
parent
commit
4734bcf450

+ 28 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -11296,6 +11296,34 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
         } catch (Exception e) {
             // 索引已存在,忽略错误
         }
+
+        // 迁移318: sys_config 预置首页引导浮层默认配置(onboarding_guide_config key)
+        try {
+            String defaultConfig = "{"
+                + "\"enabled\":true,"
+                + "\"version\":\"1.0\","
+                + "\"positions\":[\"home\",\"child\"],"
+                + "\"parent\":["
+                    + "{\"icon\":\"🔍\",\"title\":\"你真的了解孩子的状态吗?\",\"desc\":\"情绪低落没察觉、习惯变化没注意、能力短板没发现——很多问题等爆发了才看见。浠艾福帮你在问题发生前,看清孩子真实的身心状态。\",\"isIntro\":true},"
+                    + "{\"icon\":\"🌏\",\"title\":\"五维沙盘,看见全家\",\"desc\":\"身·心·智·行·富,一张全景图看清每个维度。孩子身体好不好、情绪怎么样、能力到哪了——不用猜,看得见.\"},"
+                    + "{\"icon\":\"📋\",\"title\":\"小任务,大改变\",\"desc\":\"布置每日小任务,完成就能攒星星。每天一点点积累,好习惯自然养成.\"},"
+                    + "{\"icon\":\"🎁\",\"title\":\"心愿墙,看得见努力\",\"desc\":\"孩子攒够星星就能兑换心愿。努力有回报,目标感和坚持力一起培养.\"}"
+                + "],"
+                + "\"child\":["
+                    + "{\"icon\":\"🔍\",\"title\":\"你的每一点成长,都被看见\",\"desc\":\"今天完成了几个任务?比昨天进步了多少?浠艾福帮你记录每一点努力,让成长看得见。\",\"isIntro\":true},"
+                    + "{\"icon\":\"🌟\",\"title\":\"赚星星\",\"desc\":\"完成爸爸妈妈布置的任务,就能获得星星积分,越努力越多!\"},"
+                    + "{\"icon\":\"🎮\",\"title\":\"玩中学\",\"desc\":\"完成任务后可以玩小游戏、兑换心愿,学习和快乐两不误!\"},"
+                    + "{\"icon\":\"🏆\",\"title\":\"升级挑战\",\"desc\":\"从星星宝宝到宇宙小英雄,一步步升级,看看你能走多远!\"}"
+                + "]"
+                + "}";
+            jdbcTemplate.update(
+                "INSERT IGNORE INTO sys_config (config_key, config_value, description) VALUES ('onboarding_guide_config', ?, '首页引导浮层配置')",
+                defaultConfig
+            );
+            log.info("迁移318: 已预置onboarding_guide_config默认配置");
+        } catch (Exception e) {
+            log.warn("预置onboarding_guide_config可能已存在: {}", e.getMessage());
+        }
     }
 
     /**

+ 58 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminGuideConfigController.java

@@ -0,0 +1,58 @@
+package com.etotem.cfc.controller.admin;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.service.SysConfigService;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import java.util.Map;
+
+/**
+ * 首页引导浮层配置管理(管理端)
+ * 存储于 sys_config 表,key = onboarding_guide_config,value 为 JSON 字符串
+ */
+@RestController
+@RequestMapping("/api/admin/guide-config")
+public class AdminGuideConfigController {
+
+    private static final String CONFIG_KEY = "onboarding_guide_config";
+
+    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+
+    @Resource
+    private SysConfigService sysConfigService;
+
+    /**
+     * 获取引导配置(key 不存在时返回 null,前端使用默认值)
+     */
+    @PostMapping("/get")
+    public Result<Map<String, Object>> get() {
+        String value = sysConfigService.getValue(CONFIG_KEY);
+        if (value == null || value.isEmpty()) {
+            return Result.success(null);
+        }
+        try {
+            Map<String, Object> config = OBJECT_MAPPER.readValue(value, new TypeReference<Map<String, Object>>() {});
+            return Result.success(config);
+        } catch (Exception e) {
+            return Result.error("首页引导配置解析失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 保存引导配置
+     */
+    @PostMapping("/save")
+    public Result<String> save(@RequestBody Map<String, String> body) {
+        String configValue = body == null ? null : body.get("configValue");
+        if (configValue == null || configValue.isEmpty()) {
+            return Result.error("configValue 不能为空");
+        }
+        return sysConfigService.update(CONFIG_KEY, configValue, "首页引导浮层配置");
+    }
+}

+ 46 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/config/GuideConfigController.java

@@ -0,0 +1,46 @@
+package com.etotem.cfc.controller.config;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.service.SysConfigService;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestAttribute;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import java.util.Map;
+
+/**
+ * 首页引导浮层配置读取(小程序端)
+ * 存储于 sys_config 表,key = onboarding_guide_config,value 为 JSON 字符串
+ */
+@RestController
+@RequestMapping("/api/guide-config")
+public class GuideConfigController {
+
+    private static final String CONFIG_KEY = "onboarding_guide_config";
+
+    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+
+    @Resource
+    private SysConfigService sysConfigService;
+
+    /**
+     * 获取引导配置(需登录;key 不存在时 data 为 null,前端使用默认值)
+     */
+    @PostMapping("/current")
+    public Result<Map<String, Object>> current(@RequestAttribute("userId") Long userId) {
+        String value = sysConfigService.getValue(CONFIG_KEY);
+        if (value == null || value.isEmpty()) {
+            return Result.success(null);
+        }
+        try {
+            Map<String, Object> config = OBJECT_MAPPER.readValue(value, new TypeReference<Map<String, Object>>() {});
+            return Result.success(config);
+        } catch (Exception e) {
+            return Result.error("首页引导配置解析失败: " + e.getMessage());
+        }
+    }
+}

+ 23 - 3
cfc-frontend/components/OnboardingGuide.vue

@@ -35,6 +35,8 @@
 </template>
 
 <script>
+import { getGuideConfig } from '../utils/api.js'
+
 var PARENT_STEPS = [
   {
     icon: '🔍',
@@ -87,15 +89,33 @@ export default {
   name: 'OnboardingGuide',
   props: {
     role: { type: String, default: 'parent' },
-    show: { type: Boolean, default: false }
+    show: { type: Boolean, default: false },
+    position: { type: String, default: 'home' }
   },
   data() {
     return {
-      currentStep: 0
+      currentStep: 0,
+      _configSteps: null,
+      _configVersion: '1.0'
     }
   },
+  created() {
+    var self = this
+    getGuideConfig().then(function(res) {
+      var cfg = (res && res.data) ? res.data : null
+      if (cfg && (cfg.parent || cfg.child)) {
+        self._configSteps = cfg
+        self._configVersion = cfg.version || '1.0'
+      }
+    }).catch(function() {
+      // 拉取配置失败,保持 null 走默认步骤
+    })
+  },
   computed: {
     steps() {
+      if (this._configSteps) {
+        return this.role === 'child' ? (this._configSteps.child || CHILD_STEPS) : (this._configSteps.parent || PARENT_STEPS)
+      }
       if (this.role === 'child') return CHILD_STEPS
       return PARENT_STEPS
     }
@@ -112,7 +132,7 @@ export default {
       if (this.currentStep < this.steps.length - 1) {
         this.currentStep++
       } else {
-        uni.setStorageSync('_onboardingDone', true)
+        uni.setStorageSync('_onboardingDone', JSON.stringify({ version: this._configVersion || '1.0', doneAt: Date.now() }))
         this.$emit('close')
       }
     }

+ 44 - 6
cfc-frontend/pages/home-pages/child-index.vue

@@ -9,6 +9,7 @@
     <!-- 首次登录引导浮层 -->
     <OnboardingGuide
       role="child"
+      position="child"
       :show="showOnboarding"
       @close="showOnboarding = false" />
 
@@ -309,7 +310,7 @@ B) 积分卡片 — Claymorphism 暖橙黏土风格
 </template>
 
 <script>
-import { getChildren, getTodayTasks, getEnergyOverview, getEnergyLogs, getActivityList, getProductsByDomain, getFamilyEnergySandbox, getEnergySandbox, getUnlockStatus, getFamilyMemberList } from '../../utils/api.js'
+import { getChildren, getTodayTasks, getEnergyOverview, getEnergyLogs, getActivityList, getProductsByDomain, getFamilyEnergySandbox, getEnergySandbox, getUnlockStatus, getFamilyMemberList, getGuideConfig } from '../../utils/api.js'
 import FamilyEnergyBar from '../../components/FamilyEnergyBar.vue'
 import WuxingSandbox from '../../components/wuxing-sandbox.vue'
 import RecommendedFeed from '../../components/RecommendedFeed.vue'
@@ -506,11 +507,8 @@ export default {
     }
   },
   mounted() {
-    // 首次登录引导
-    var onboardingDone = uni.getStorageSync('_onboardingDone')
-    if (!onboardingDone) {
-      this.showOnboarding = true
-    }
+    // 首次登录引导:异步拉取配置并按版本号判断是否展示
+    this._checkOnboardingShow()
     this.loadData()
   },
   onShow() {
@@ -756,6 +754,46 @@ export default {
         }
       })
     },
+    _checkOnboardingShow() {
+      var self = this
+      getGuideConfig().then(function(res) {
+        var cfg = (res && res.data) ? res.data : null
+        var cfgVersion = cfg ? (cfg.version || '1.0') : '1.0'
+        // 检查启用
+        if (cfg && cfg.enabled === false) return
+        // 检查位置
+        if (cfg && cfg.positions && cfg.positions.indexOf('child') === -1) return
+        // 比较版本
+        var stored = uni.getStorageSync('_onboardingDone')
+        var seenVersion = '0.0'
+        if (stored) {
+          try {
+            var obj = typeof stored === 'string' ? JSON.parse(stored) : stored
+            if (obj && obj.version) seenVersion = obj.version
+          } catch (e) {}
+        }
+        if (self._compareVersion(seenVersion, cfgVersion) >= 0) return
+        self.showOnboarding = true
+      }).catch(function() {
+        // 接口失败按旧逻辑:无配置则 version='1.0',已看过则不显示
+        var stored = uni.getStorageSync('_onboardingDone')
+        if (stored) {
+          try {
+            var obj = typeof stored === 'string' ? JSON.parse(stored) : stored
+            if (obj && obj.version && self._compareVersion(obj.version, '1.0') >= 0) return
+          } catch (e) {}
+        }
+        self.showOnboarding = true
+      })
+    },
+    _compareVersion(v1, v2) {
+      var parts1 = String(v1).split('.').map(function(s) { return parseInt(s || '0', 10) })
+      var parts2 = String(v2).split('.').map(function(s) { return parseInt(s || '0', 10) })
+      for (var i = 0; i < Math.max(parts1.length, parts2.length); i++) {
+        if ((parts1[i] || 0) !== (parts2[i] || 0)) return (parts1[i] || 0) - (parts2[i] || 0)
+      }
+      return 0
+    },
     goToDomain(domain) {
       uni.showToast({ title: '即将上线', icon: 'none' })
     },

+ 57 - 8
cfc-frontend/pages/index-home/index.vue

@@ -244,6 +244,7 @@
     <OnboardingGuide
       :role="currentRole"
       :show="showOnboardingGuide"
+      position="home"
       @close="closeOnboardingGuide" />
   </view>
 
@@ -430,7 +431,7 @@ import DailyTaskCard from '../../components/daily-task-card.vue'
 import ReportUploadCard from '../../components/report-upload-card.vue'
 import OnboardingProgressBar from '../../components/OnboardingProgressBar.vue'
 import OnboardingGuide from '../../components/OnboardingGuide.vue'
-import { acceptParentInvite, getActivityList, getFeaturedArticles, getEnergySandbox, getEnergyOverview, getFamilyMemberList, getChallengeList, switchToFamilyMember, getMyMembership, updateChallengeProgress, respondChallenge, getTodayTasks, completeTask as completeTaskApi, getTodayParentTasks, completeParentTask as completeParentTaskApi, getTaskHistory, getNotices, getNotificationList, getSurveyStatus, getSelfCheckStatus, getReferralCode, extractReferralCode } from '../../utils/api.js'
+import { acceptParentInvite, getActivityList, getFeaturedArticles, getEnergySandbox, getEnergyOverview, getFamilyMemberList, getChallengeList, getGuideConfig, switchToFamilyMember, getMyMembership, updateChallengeProgress, respondChallenge, getTodayTasks, completeTask as completeTaskApi, getTodayParentTasks, completeParentTask as completeParentTaskApi, getTaskHistory, getNotices, getNotificationList, getSurveyStatus, getSelfCheckStatus, getReferralCode, extractReferralCode } from '../../utils/api.js'
 import nav from '../../utils/nav.js'
 import config from '@/config.js'
 import { parseDate } from '../../utils/format.js'
@@ -470,6 +471,7 @@ export default {
       selfCheckScore: null,
       selfCheckLoading: false,
       showOnboardingGuide: false,
+      _onboardingGuideAttempted: false,
 
       dimensionActivities: [],
       indexArticles: [],
@@ -710,6 +712,11 @@ membershipDays: 0,
 
     // 首次加载:onLoad 已触发 _doInit,此处跳过避免重复调用
     // 后续 onShow(切 Tab 返回):刷新易变数据(会员状态、今日任务、挑战列表)
+    // 关键修复:在 onShow 中也检查 onboarding,兼容 switchTab 后未触发 onLoad 的情况
+    if (!this.showOnboardingGuide) {
+      this._maybeShowOnboardingGuide()
+    }
+
     if (this._onLoadFired) {
       this._onLoadFired = false // 清标记,后续 onShow 走刷新路径
     } else {
@@ -1218,16 +1225,47 @@ membershipDays: 0,
         if (newVal) that._tryHideSplash()
       })
     },
-    // 首次登录引导浮层:仅当从未看过(无 _onboardingDone 标记)时展示一次
+    // 首次登录引导浮层:异步拉取配置并按版本号判断是否展示
     _maybeShowOnboardingGuide() {
       if (this.showOnboardingGuide) return
-      if (uni.getStorageSync('_onboardingDone')) return
-      // 延迟到 splash 蒙板关闭后再弹,避免层级冲突
+      if (this._onboardingGuideAttempted) return
+      this._onboardingGuideAttempted = true
       var self = this
-      setTimeout(function() {
-        if (!self.isLoggedIn()) return
-        self.showOnboardingGuide = true
-      }, 600)
+      getGuideConfig().then(function(res) {
+        var cfg = (res && res.data) ? res.data : null
+        var cfgVersion = cfg ? (cfg.version || '1.0') : '1.0'
+        // 检查启用
+        if (cfg && cfg.enabled === false) return
+        // 检查位置(index-home 对应 'home' 位置)
+        if (cfg && cfg.positions && cfg.positions.indexOf('home') === -1) return
+        // 比较版本
+        var stored = uni.getStorageSync('_onboardingDone')
+        var seenVersion = '0.0'
+        if (stored) {
+          try {
+            var obj = typeof stored === 'string' ? JSON.parse(stored) : stored
+            if (obj && obj.version) seenVersion = obj.version
+          } catch (e) {}
+        }
+        if (self._compareVersion(seenVersion, cfgVersion) >= 0) return
+        setTimeout(function() {
+          if (!self.isLoggedIn()) return
+          self.showOnboardingGuide = true
+        }, 600)
+      }).catch(function() {
+        // 接口失败走旧逻辑
+        var stored = uni.getStorageSync('_onboardingDone')
+        if (stored) {
+          try {
+            var obj = typeof stored === 'string' ? JSON.parse(stored) : stored
+            if (obj && obj.version && self._compareVersion(obj.version, '1.0') >= 0) return
+          } catch (e) {}
+        }
+        setTimeout(function() {
+          if (!self.isLoggedIn()) return
+          self.showOnboardingGuide = true
+        }, 600)
+      })
     },
     closeOnboardingGuide() {
       this.showOnboardingGuide = false
@@ -1235,6 +1273,17 @@ membershipDays: 0,
     isLoggedIn() {
       return !!uni.getStorageSync('token')
     },
+    // 版本比较:返回 >0 表示 v1 > v2,0 相等,<0 表示 v1 < v2
+    _compareVersion: function(v1, v2) {
+      var parts1 = String(v1).split('.')
+      var parts2 = String(v2).split('.')
+      for (var i = 0; i < Math.max(parts1.length, parts2.length); i++) {
+        var n1 = parseInt(parts1[i] || '0', 10)
+        var n2 = parseInt(parts2[i] || '0', 10)
+        if (n1 !== n2) return n1 - n2
+      }
+      return 0
+    },
     // ===== 会员状态(沙盘下方会员入口) =====
     checkMemberStatus: function() {
       var that = this

+ 5 - 0
cfc-frontend/utils/api.js

@@ -409,6 +409,11 @@ export const getFamilyMemberList = (params) => {
 	return request('/api/family/member/list', 'POST', params || {})
 }
 
+// 首页引导浮层配置(管理端可配置家长/孩子引导步骤,无配置时返回 null data)
+export const getGuideConfig = () => {
+	return request('/api/guide-config/current', 'POST', {})
+}
+
 // 获取以指定成员为中心的所有成员关系(关系类型+信任/亲密度/沟通评分)
 export const getPairwiseRelationships = (centerMemberId) => {
 	return request('/api/family/relationship/pairwise', 'POST', { centerMemberId })

+ 17 - 0
cfc-web/src/api/admin.js

@@ -2150,3 +2150,20 @@ export function getProfileTrend(memberId, days) {
     data: { memberId, days: days || 30 }
   })
 }
+
+// ========== 引导配置 API ==========
+
+export function getGuideConfig() {
+  return request({
+    url: '/api/admin/guide-config/get',
+    method: 'post'
+  })
+}
+
+export function saveGuideConfig(configValue) {
+  return request({
+    url: '/api/admin/guide-config/save',
+    method: 'post',
+    data: { configValue }
+  })
+}

+ 6 - 0
cfc-web/src/router/index.js

@@ -325,6 +325,12 @@ const routes = [
       component: () => import('@/views/admin/SysConfig.vue'),
       meta: { title: '系统配置', perm: 'system:base' }
     },
+    {
+      path: 'guide-config',
+      name: 'GuideConfig',
+      component: () => import('@/views/admin/GuideConfig.vue'),
+      meta: { title: '引导配置', perm: 'system:base' }
+    },
     {
       path: 'menu-manage',
       name: 'MenuManage',

+ 1 - 0
cfc-web/src/views/Layout.vue

@@ -294,6 +294,7 @@ export default {
             { title: '基础管理', icon: 'el-icon-s-tools', perm: 'system:base',
               children: [
                 { path: '/sys-config', label: '系统配置', icon: 'el-icon-s-tools', perm: 'system:base' },
+                { path: '/guide-config', label: '引导配置', icon: 'el-icon-setting', perm: 'system:base' },
                 { path: '/users', label: '用户管理', icon: 'el-icon-user', perm: 'system:users' },
                 { path: '/operation-logs', label: '操作日志', icon: 'el-icon-s-order', perm: 'system:logs' },
                 { path: '/membership-center', label: '会员中心', icon: 'el-icon-s-custom', perm: 'config:member' },

+ 264 - 0
cfc-web/src/views/admin/GuideConfig.vue

@@ -0,0 +1,264 @@
+<template>
+  <div class="guide-config admin-page">
+    <div class="header admin-page-header">
+      <h2 class="admin-page-title">引导配置</h2>
+      <div class="admin-page-desc">配置微信小程序引导遮罩的启用状态、展示位置与步骤内容</div>
+    </div>
+
+    <div class="config-body" v-loading="loading">
+      <!-- 基本配置 -->
+      <el-card shadow="never" class="config-block">
+        <div slot="header" class="block-header">
+          <span class="block-title">基本配置</span>
+        </div>
+        <el-form :model="form" label-width="110px">
+          <el-form-item label="启用引导">
+            <el-switch v-model="form.enabled" active-text="启用" inactive-text="停用" />
+          </el-form-item>
+          <el-form-item label="版本号">
+            <el-input v-model="form.version" placeholder="如: 1.0" class="version-input" />
+            <div class="form-tip">升级版本号后,小程序用户会重新看到引导遮罩</div>
+          </el-form-item>
+          <el-form-item label="展示位置">
+            <el-checkbox-group v-model="form.positions">
+              <el-checkbox label="home">首页</el-checkbox>
+              <el-checkbox label="child">孩子端首页</el-checkbox>
+            </el-checkbox-group>
+          </el-form-item>
+        </el-form>
+      </el-card>
+
+      <!-- 步骤配置 -->
+      <el-card shadow="never" class="config-block">
+        <div slot="header" class="block-header">
+          <span class="block-title">步骤配置</span>
+        </div>
+        <el-tabs v-model="activeTab">
+          <el-tab-pane label="家长端" name="parent">
+            <div v-for="(step, index) in stepsFor('parent')" :key="index">
+              <el-card shadow="never" class="step-card">
+                <div class="step-card-head">
+                  <span class="step-title">步骤 {{ index + 1 }}</span>
+                  <div class="step-actions">
+                    <el-button size="mini" type="danger" @click="removeStep('parent', index)">删除</el-button>
+                    <el-button size="mini" :disabled="index === 0" @click="moveUp('parent', index)">↑ 上移</el-button>
+                    <el-button size="mini" :disabled="index === stepsFor('parent').length - 1" @click="moveDown('parent', index)">↓ 下移</el-button>
+                  </div>
+                </div>
+                <el-row :gutter="16">
+                  <el-col :span="8">
+                    <div class="field-label">图标</div>
+                    <el-input v-model="step.icon" placeholder="如: el-icon-star" />
+                  </el-col>
+                  <el-col :span="8">
+                    <div class="field-label">标题</div>
+                    <el-input v-model="step.title" placeholder="步骤标题" />
+                  </el-col>
+                  <el-col :span="8">
+                    <div class="field-label">开场页</div>
+                    <el-switch v-model="step.isIntro" active-text="是" inactive-text="否" />
+                  </el-col>
+                </el-row>
+                <div class="field-label desc-label">描述</div>
+                <el-input v-model="step.desc" type="textarea" :rows="2" placeholder="步骤描述" />
+              </el-card>
+            </div>
+            <el-button size="small" icon="el-icon-plus" @click="addStep('parent')">新增步骤</el-button>
+          </el-tab-pane>
+
+          <el-tab-pane label="孩子端" name="child">
+            <div v-for="(step, index) in stepsFor('child')" :key="index">
+              <el-card shadow="never" class="step-card">
+                <div class="step-card-head">
+                  <span class="step-title">步骤 {{ index + 1 }}</span>
+                  <div class="step-actions">
+                    <el-button size="mini" type="danger" @click="removeStep('child', index)">删除</el-button>
+                    <el-button size="mini" :disabled="index === 0" @click="moveUp('child', index)">↑ 上移</el-button>
+                    <el-button size="mini" :disabled="index === stepsFor('child').length - 1" @click="moveDown('child', index)">↓ 下移</el-button>
+                  </div>
+                </div>
+                <el-row :gutter="16">
+                  <el-col :span="8">
+                    <div class="field-label">图标</div>
+                    <el-input v-model="step.icon" placeholder="如: el-icon-star" />
+                  </el-col>
+                  <el-col :span="8">
+                    <div class="field-label">标题</div>
+                    <el-input v-model="step.title" placeholder="步骤标题" />
+                  </el-col>
+                  <el-col :span="8">
+                    <div class="field-label">开场页</div>
+                    <el-switch v-model="step.isIntro" active-text="是" inactive-text="否" />
+                  </el-col>
+                </el-row>
+                <div class="field-label desc-label">描述</div>
+                <el-input v-model="step.desc" type="textarea" :rows="2" placeholder="步骤描述" />
+              </el-card>
+            </div>
+            <el-button size="small" icon="el-icon-plus" @click="addStep('child')">新增步骤</el-button>
+          </el-tab-pane>
+        </el-tabs>
+      </el-card>
+
+      <!-- 保存 -->
+      <div class="save-wrap">
+        <el-button type="primary" size="medium" :loading="submitting" @click="handleSave">保存</el-button>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import { getGuideConfig, saveGuideConfig } from '@/api/admin'
+
+export default {
+  name: 'GuideConfig',
+  data() {
+    return {
+      loading: false,
+      submitting: false,
+      activeTab: 'parent',
+      form: {
+        enabled: true,
+        version: '',
+        positions: [],
+        parent: [],
+        child: []
+      }
+    }
+  },
+  mounted() {
+    this.loadConfig()
+  },
+  methods: {
+    stepsFor(key) {
+      return key === 'child' ? this.form.child : this.form.parent
+    },
+    async loadConfig() {
+      this.loading = true
+      try {
+        const res = await getGuideConfig()
+        if (res.data) {
+          this.form.enabled = typeof res.data.enabled === 'boolean' ? res.data.enabled : true
+          this.form.version = res.data.version || ''
+          this.form.positions = res.data.positions && res.data.positions.length ? res.data.positions : []
+          this.form.parent = res.data.parent && res.data.parent.length ? res.data.parent : []
+          this.form.child = res.data.child && res.data.child.length ? res.data.child : []
+        }
+      } catch (e) {
+        this.$message.error('加载失败')
+      } finally {
+        this.loading = false
+      }
+    },
+    addStep(key) {
+      const list = this.stepsFor(key)
+      const isFirst = !list || list.length === 0
+      list.push({ icon: '', title: '', desc: '', isIntro: isFirst })
+    },
+    removeStep(key, index) {
+      this.stepsFor(key).splice(index, 1)
+    },
+    moveUp(key, index) {
+      const list = this.stepsFor(key)
+      if (index <= 0) return
+      const item = list[index]
+      list.splice(index, 1)
+      list.splice(index - 1, 0, item)
+    },
+    moveDown(key, index) {
+      const list = this.stepsFor(key)
+      if (index >= list.length - 1) return
+      const item = list[index]
+      list.splice(index, 1)
+      list.splice(index + 1, 0, item)
+    },
+    async handleSave() {
+      const version = (this.form.version || '').trim()
+      if (!version) {
+        this.$message.warning('请填写版本号')
+        return
+      }
+      const totalSteps = (this.form.parent ? this.form.parent.length : 0) + (this.form.child ? this.form.child.length : 0)
+      if (totalSteps === 0) {
+        this.$message.warning('请至少添加一个引导步骤')
+        return
+      }
+      const payload = {
+        enabled: this.form.enabled,
+        version: version,
+        positions: this.form.positions,
+        parent: this.form.parent,
+        child: this.form.child
+      }
+      this.submitting = true
+      try {
+        await saveGuideConfig(JSON.stringify(payload))
+        this.$message.success('保存成功')
+      } catch (e) {
+        this.$message.error('保存失败')
+      } finally {
+        this.submitting = false
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.guide-config {
+  padding: 20px;
+}
+.admin-page-desc {
+  margin-top: 4px;
+  font-size: 13px;
+  color: #909399;
+}
+.config-body {
+  max-width: 1200px;
+  margin: 0 auto;
+}
+.config-block {
+  margin-bottom: 20px;
+}
+.block-header {
+  display: flex;
+  align-items: center;
+}
+.block-title {
+  font-weight: 600;
+}
+.version-input {
+  max-width: 320px;
+}
+.form-tip {
+  margin-top: 6px;
+  font-size: 12px;
+  color: #909399;
+  line-height: 1.5;
+}
+.step-card {
+  margin-bottom: 16px;
+}
+.step-card-head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 12px;
+}
+.step-title {
+  font-weight: 600;
+}
+.field-label {
+  font-size: 12px;
+  color: #909399;
+  margin-bottom: 6px;
+}
+.desc-label {
+  margin-top: 12px;
+}
+.save-wrap {
+  margin-top: 24px;
+  text-align: center;
+}
+</style>