Browse Source

Merge branch 'cfclub' of https://git.iwintrue.com/liaoxg/cfc into cfclub

Xiaogang Liao 2 months ago
parent
commit
768c811fc7

+ 7 - 0
AGENTS.md

@@ -123,6 +123,13 @@ npm test                               # Jest 测试
 cd cfc-frontend && npm install
 # 通过微信开发者工具导入项目根目录
 
+# 管理端部署 (cfc-web)
+# 编译 → rsync 到 192.168.16.251
+cd cfc-web && npm run build && rsync -av --delete dist/ 192.168.16.251:/var/www/cfc-admin/
+
+# 服务器上有自动化部署脚本 /home/iwt/cfc-auto-deploy.sh
+# 可在 251 上执行: bash /home/iwt/cfc-auto-deploy.sh
+
 # 数据迁移 (从 sfms 导入历史数据)
 curl -X POST http://localhost:9082/api/migration/run
 ```

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

@@ -2823,6 +2823,26 @@ log.info("已添加template_id列到tasks表");
         } catch (Exception e) {
             log.warn("health_norm_reference 常模种子数据初始化失败: {}", e.getMessage());
         }
+
+        // 迁移: articles表添加审核和作者类型字段
+        try {
+            Integer exists = jdbcTemplate.queryForObject(
+                "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'articles' AND COLUMN_NAME = 'audit_status'",
+                Integer.class);
+            if (exists == null || exists == 0) {
+                jdbcTemplate.execute("ALTER TABLE articles " +
+                    "ADD COLUMN author_type VARCHAR(20) DEFAULT 'admin' COMMENT '作者类型:admin/parent/child' AFTER created_by, " +
+                    "ADD COLUMN audit_status VARCHAR(20) DEFAULT 'approved' COMMENT '审核状态:approved/pending/rejected' AFTER status, " +
+                    "ADD COLUMN audit_reason TEXT COMMENT '驳回原因' AFTER audit_status, " +
+                    "ADD COLUMN auditor_id BIGINT DEFAULT 0 COMMENT '审核人 ID' AFTER audit_reason, " +
+                    "ADD COLUMN audited_at DATETIME COMMENT '审核时间' AFTER auditor_id");
+                log.info("已添加审核和作者类型字段到articles表");
+            } else {
+                log.info("articles表审核字段已存在,跳过");
+            }
+        } catch (Exception e) {
+            log.warn("检查/添加articles表审核字段失败: {}", e.getMessage());
+        }
     }
 
     private void initializeDefaultData() {

+ 1 - 0
cfc-frontend/.superpowers/brainstorm/1770834-1783004252/state/server.pid

@@ -0,0 +1 @@
+1770846

+ 1 - 0
cfc-frontend/.superpowers/brainstorm/1775256-1783004500/state/server.pid

@@ -0,0 +1 @@
+1775256

+ 104 - 0
cfc-frontend/.superpowers/brainstorm/1782495-1783004956/content/full-design-v1.html

@@ -0,0 +1,104 @@
+<h2>活动主页 — 完整设计方案</h2>
+<p class="subtitle">推荐路由: /pages/activity/index · 支持维度筛选 · 我的活动优先</p>
+
+<div class="mockup">
+  <div class="mockup-header">预览:活动主页(完整方案)</div>
+  <div class="mockup-body" style="padding:0;">
+
+    <!-- 顶部状态栏 -->
+    <div style="height:24px;background:#1A0F00;"></div>
+
+    <!-- 导航栏 -->
+    <div style="background:#1A0F00;padding:8px 16px;display:flex;align-items:center;justify-content:space-between;">
+      <div style="color:#fff;font-size:16px;font-weight:bold;">�activities 活动中心</div>
+      <div style="color:#fff;font-size:12px;opacity:0.8;">🔍 搜索</div>
+    </div>
+
+    <!-- Tab Bar -->
+    <div style="display:flex;background:#fff;border-bottom:1px solid #eee;">
+      <div style="flex:1;text-align:center;padding:10px 0;color:#F97316;font-size:13px;font-weight:bold;border-bottom:2px solid #F97316;">我的活动</div>
+      <div style="flex:1;text-align:center;padding:10px 0;color:#666;font-size:13px;">发现活动</div>
+      <div style="flex:1;text-align:center;padding:10px 0;color:#666;font-size:13px;">历史记录</div>
+    </div>
+
+    <!-- 我的活动 内容 -->
+    <div style="padding:12px 14px;background:#F5F5F5;">
+
+      <!-- 状态筛选 Chips -->
+      <div style="display:flex;gap:6px;margin-bottom:10px;overflow-x:auto;">
+        <div style="background:#FFF7ED;color:#F97316;padding:5px 12px;border-radius:16px;font-size:11px;white-space:nowrap;">全部</div>
+        <div style="background:#fff;color:#666;padding:5px 12px;border-radius:16px;font-size:11px;white-space:nowrap;">🟢 待开始</div>
+        <div style="background:#fff;color:#666;padding:5px 12px;border-radius:16px;font-size:11px;white-space:nowrap;">🟡 待审核</div>
+        <div style="background:#fff;color:#666;padding:5px 12px;border-radius:16px;font-size:11px;white-space:nowrap;">✅ 已签到</div>
+      </div>
+
+      <!-- 活动卡片1 -->
+      <div style="background:#fff;border-radius:12px;padding:12px;margin-bottom:10px;box-shadow:0 1px 4px rgba(0,0,0,0.06);">
+        <div style="display:flex;">
+          <div style="width:72px;height:72px;background:linear-gradient(135deg,#FDE68A,#F97316);border-radius:8px;display:flex;align-items:center;justify-content:center;font-size:28px;flex-shrink:0;">🏃</div>
+          <div style="flex:1;margin-left:10px;">
+            <div style="font-size:13px;font-weight:bold;color:#333;margin-bottom:3px;">周末亲子自然探索活动</div>
+            <div style="font-size:10px;color:#999;margin-bottom:4px;">🕐 07-05 09:00 ~ 17:00</div>
+            <div style="font-size:10px;color:#999;margin-bottom:6px;">📍 朝阳公园东门</div>
+            <div style="display:flex;gap:4px;flex-wrap:wrap;">
+              <span style="background:#F0FDF4;color:#22C55E;font-size:9px;padding:2px 6px;border-radius:3px;">报名成功</span>
+              <span style="background:#FFF7ED;color:#F97316;font-size:9px;padding:2px 6px;border-radius:3px;">3天后</span>
+            </div>
+          </div>
+        </div>
+        <div style="margin-top:8px;padding-top:8px;border-top:1px solid #f5f5f5;display:flex;justify-content:flex-end;gap:6px;">
+          <button style="background:#fff;border:1px solid #ddd;color:#666;padding:5px 12px;border-radius:14px;font-size:10px;">取消报名</button>
+          <button style="background:linear-gradient(135deg,#F97316,#FB923C);border:none;color:#fff;padding:5px 12px;border-radius:14px;font-size:10px;">签到</button>
+        </div>
+      </div>
+
+      <!-- 活动卡片2 -->
+      <div style="background:#fff;border-radius:12px;padding:12px;margin-bottom:10px;box-shadow:0 1px 4px rgba(0,0,0,0.06);">
+        <div style="display:flex;">
+          <div style="width:72px;height:72px;background:linear-gradient(135deg,#E0E7FF,#6366F1);border-radius:8px;display:flex;align-items:center;justify-content:center;font-size:28px;flex-shrink:0;">🎨</div>
+          <div style="flex:1;margin-left:10px;">
+            <div style="font-size:13px;font-weight:bold;color:#333;margin-bottom:3px;">青少年创客编程体验课</div>
+            <div style="font-size:10px;color:#999;margin-bottom:4px;">🕐 07-12 14:00 ~ 16:00</div>
+            <div style="font-size:10px;color:#999;margin-bottom:6px;">📍 中关村创业大厦3层</div>
+            <div style="display:flex;gap:4px;flex-wrap:wrap;">
+              <span style="background:#FEF3C7;color:#D97706;font-size:9px;padding:2px 6px;border-radius:3px;">待审核</span>
+            </div>
+          </div>
+        </div>
+      </div>
+
+    </div>
+  </div>
+</div>
+
+<h3 style="margin-top:16px;">推荐技术方案</h3>
+
+<div class="pros-cons">
+  <div class="pros">
+    <h4>✅ 优势</h4>
+    <ul>
+      <li>路由 `/pages/activity/index` — 独立清晰,便于分享和跳转</li>
+      <li>三 Tab 结构:我的活动 / 发现活动 / 历史记录</li>
+      <li>支持按维度(身/心/智/行/富)筛选,发现页可复用 dimensionCode</li>
+      <li>签到、取消报名操作直接内嵌在卡片内</li>
+      <li>空状态引导去发现页,减少流失</li>
+    </ul>
+  </div>
+  <div class="cons">
+    <h4>⚠️ 需确认</h4>
+    <ul>
+      <li>「历史记录」Tab — 是否需要?(已结束/已签到的活动)</li>
+      <li>是否需要展示「我发布的活动」(服务商视角)?</li>
+    </ul>
+  </div>
+</div>
+
+<div class="options" style="margin-top:12px;">
+  <div class="option" data-choice="ok" onclick="toggleSelect(this)">
+    <div class="letter">✓</div>
+    <div class="content">
+      <h3>方案可行,开始写设计文档</h3>
+      <p>请在终端回复:确认或有调整需求(如去掉历史记录Tab等)</p>
+    </div>
+  </div>
+</div>

+ 79 - 0
cfc-frontend/.superpowers/brainstorm/1782495-1783004956/content/layout-myactivities-v1.html

@@ -0,0 +1,79 @@
+<h2>活动主页 — 「我的活动」优先布局</h2>
+<p class="subtitle">顶部 Tab:「我的活动」|「发现活动」|「历史记录」</p>
+
+<div class="mockup">
+  <div class="mockup-header">活动主页 — 我的活动优先</div>
+  <div class="mockup-body">
+    <!-- Tab Bar -->
+    <div style="display:flex;background:#fff;border-bottom:1px solid #eee;padding:0 16px;">
+      <div style="flex:1;text-align:center;padding:12px 0;color:#F97316;font-weight:bold;border-bottom:2px solid #F97316;">我的活动</div>
+      <div style="flex:1;text-align:center;padding:12px 0;color:#999;">发现活动</div>
+      <div style="flex:1;text-align:center;padding:12px 0;color:#999;">历史记录</div>
+    </div>
+
+    <!-- 我的活动 Tab Content -->
+    <div style="padding:16px;">
+
+      <!-- 报名状态筛选 -->
+      <div style="display:flex;gap:8px;margin-bottom:12px;">
+        <div style="background:#FFF7ED;color:#F97316;padding:6px 14px;border-radius:20px;font-size:12px;">全部</div>
+        <div style="background:#f5f5f5;color:#666;padding:6px 14px;border-radius:20px;font-size:12px;">待开始</div>
+        <div style="background:#f5f5f5;color:#666;padding:6px 14px;border-radius:20px;font-size:12px;">报名待审核</div>
+        <div style="background:#f5f5f5;color:#666;padding:6px 14px;border-radius:20px;font-size:12px;">已签到</div>
+      </div>
+
+      <!-- 待参加活动 Card -->
+      <div style="background:#fff;border-radius:12px;padding:14px;margin-bottom:12px;box-shadow:0 2px 8px rgba(0,0,0,0.06);">
+        <div style="display:flex;">
+          <div style="width:80px;height:80px;background:linear-gradient(135deg,#FDE68A,#F97316);border-radius:8px;display:flex;align-items:center;justify-content:center;font-size:32px;">🏃</div>
+          <div style="flex:1;margin-left:12px;">
+            <div style="font-size:14px;font-weight:bold;color:#333;margin-bottom:4px;">周末亲子自然探索活动</div>
+            <div style="font-size:11px;color:#999;margin-bottom:6px;">🕐 2026-07-05 09:00 ~ 17:00</div>
+            <div style="font-size:11px;color:#999;margin-bottom:6px;">📍 朝阳公园东门集合</div>
+            <div style="display:flex;align-items:center;gap:6px;">
+              <span style="background:#F0FDF4;color:#22C55E;font-size:10px;padding:2px 8px;border-radius:4px;">报名成功</span>
+              <span style="background:#FFF7ED;color:#F97316;font-size:10px;padding:2px 8px;border-radius:4px;">3天后开始</span>
+            </div>
+          </div>
+        </div>
+        <div style="margin-top:10px;padding-top:10px;border-top:1px solid #f0f0f0;display:flex;justify-content:flex-end;gap:8px;">
+          <button style="background:#fff;border:1px solid #ddd;color:#666;padding:6px 16px;border-radius:16px;font-size:11px;">取消报名</button>
+          <button style="background:linear-gradient(135deg,#F97316,#FB923C);border:none;color:#fff;padding:6px 16px;border-radius:16px;font-size:11px;">签到</button>
+        </div>
+      </div>
+
+      <!-- 待审核 Card -->
+      <div style="background:#fff;border-radius:12px;padding:14px;margin-bottom:12px;box-shadow:0 2px 8px rgba(0,0,0,0.06);">
+        <div style="display:flex;">
+          <div style="width:80px;height:80px;background:linear-gradient(135deg,#E0E7FF,#6366F1);border-radius:8px;display:flex;align-items:center;justify-content:center;font-size:32px;">🎨</div>
+          <div style="flex:1;margin-left:12px;">
+            <div style="font-size:14px;font-weight:bold;color:#333;margin-bottom:4px;">青少年创客编程体验课</div>
+            <div style="font-size:11px;color:#999;margin-bottom:6px;">🕐 2026-07-12 14:00 ~ 16:00</div>
+            <div style="font-size:11px;color:#999;margin-bottom:6px;">📍 中关村创业大厦3层</div>
+            <div style="display:flex;align-items:center;gap:6px;">
+              <span style="background:#FEF3C7;color:#D97706;font-size:10px;padding:2px 8px;border-radius:4px;">报名待审核</span>
+            </div>
+          </div>
+        </div>
+      </div>
+
+      <!-- 空状态引导 -->
+      <div style="text-align:center;padding:40px 20px;background:#fff;border-radius:12px;">
+        <div style="font-size:48px;margin-bottom:12px;">📋</div>
+        <div style="font-size:14px;color:#666;margin-bottom:4px;">暂无报名的活动</div>
+        <div style="font-size:12px;color:#999;margin-bottom:16px;">去发现页探索精彩活动吧</div>
+        <button style="background:linear-gradient(135deg,#F97316,#FB923C);border:none;color:#fff;padding:10px 24px;border-radius:20px;font-size:13px;">去发现活动</button>
+      </div>
+    </div>
+  </div>
+</div>
+
+<div class="options" style="margin-top:16px;">
+  <div class="option" data-choice="b1" onclick="toggleSelect(this)">
+    <div class="letter">✓</div>
+    <div class="content">
+      <h3>这个布局方向可以</h3>
+      <p>「我的活动」Tab 优先 + 状态筛选 + 空状态引导,发现页作为次级 Tab</p>
+    </div>
+  </div>
+</div>

+ 28 - 0
cfc-frontend/.superpowers/brainstorm/1782495-1783004956/content/layout-v1.html

@@ -0,0 +1,28 @@
+<h2>活动主页布局选择</h2>
+<p class="subtitle">通过「更多」按钮进入的专属活动聚合页</p>
+
+<div class="options">
+  <div class="option" data-choice="a" onclick="toggleSelect(this)">
+    <div class="letter">A</div>
+    <div class="content">
+      <h3>卡片瀑布流</h3>
+      <p>上方筛选标签 + 下方卡片列表,支持下拉刷新/上拉加载更多。适合活动数量较多的场景。</p>
+    </div>
+  </div>
+
+  <div class="option" data-choice="b" onclick="toggleSelect(this)">
+    <div class="letter">B</div>
+    <div class="content">
+      <h3>Banner + 分类Tab</h3>
+      <p>顶部Banner轮播 + 横向分类Tab + 单列活动卡片。适合突出重点活动。</p>
+    </div>
+  </div>
+
+  <div class="option" data-choice="c" onclick="toggleSelect(this)">
+    <div class="letter">C</div>
+    <div class="content">
+      <h3>网格+列表混合</h3>
+      <p>置顶推荐活动用大卡片网格,列表用双列卡片。兼顾曝光与浏览效率。</p>
+    </div>
+  </div>
+</div>

+ 1 - 0
cfc-frontend/.superpowers/brainstorm/1782495-1783004956/state/server-info

@@ -0,0 +1 @@
+{"type":"server-started","port":54465,"host":"127.0.0.1","url_host":"localhost","url":"http://localhost:54465","screen_dir":"/sc-data/cfc/cfc-frontend/.superpowers/brainstorm/1782495-1783004956/content","state_dir":"/sc-data/cfc/cfc-frontend/.superpowers/brainstorm/1782495-1783004956/state"}

+ 1 - 0
cfc-frontend/.superpowers/brainstorm/1782495-1783004956/state/server.pid

@@ -0,0 +1 @@
+1782495

+ 91 - 274
cfc-frontend/components/HealthDimensionsSection.vue

@@ -1,330 +1,147 @@
 <template>
-  <view class="hds-section">
-    <!-- 健康指数 -->
-    <view class="hds-health-index" v-if="healthData && healthData.healthIndex">
-      <view class="index-circle">
-        <text class="index-value">{{ healthData.healthIndex }}</text>
-        <text class="index-label">健康指数</text>
-      </view>
+  <view class="health-dimensions-section">
+    <view class="section-header">
+      <text class="section-title">七维健康全景</text>
+      <text class="section-more" @click="goHealthDimensions">查看详情 ›</text>
     </view>
 
-    <!-- 七维健康雷达 -->
-    <view class="hds-radar" v-if="healthData && healthData.dimensions">
-      <view class="radar-title">七维健康雷达</view>
-      <view class="radar-wrap">
-        <view class="radar-center">
-          <view class="radar-grid">
-            <view class="grid-ring ring-3"></view>
-            <view class="grid-ring ring-2"></view>
-            <view class="grid-ring ring-1"></view>
-          </view>
-          <view class="radar-spokes">
-            <view
-              class="spoke-item"
-              v-for="(dim, idx) in healthData.dimensions"
-              :key="dim.dimension"
-              :style="spokeStyles[idx]"
-            >
-              <view class="spoke-dot" :class="'level-' + dim.level"></view>
-              <view class="spoke-label">{{ dim.label }}</view>
-              <view class="spoke-score">{{ dim.score || '--' }}</view>
-            </view>
-          </view>
-        </view>
-      </view>
+    <!-- 加载态 -->
+    <view v-if="!healthData" class="loading-state">
+      <text class="loading-text">加载中...</text>
+    </view>
+
+    <!-- 空数据 -->
+    <view v-else-if="!healthData.dimensions || healthData.dimensions.length === 0" class="empty-state">
+      <text class="empty-tip">暂无健康数据</text>
     </view>
 
     <!-- 维度列表 -->
-    <view class="hds-dim-list" v-if="healthData && healthData.dimensions">
+    <view v-else class="dimension-grid">
       <view
-        class="dim-card"
+        class="dimension-item"
         v-for="dim in healthData.dimensions"
-        :key="dim.dimension"
-        @click="goDimDetail(dim)"
-      >
-        <view class="dim-header">
-          <text class="dim-label">{{ dim.label }}</text>
-          <text class="dim-score" :class="'score-' + dim.level">{{ dim.score || '--' }}分</text>
-        </view>
-        <view class="dim-bar-wrap">
-          <view class="dim-bar-bg">
-            <view
-              class="dim-bar-fill"
-              :class="'fill-' + dim.level"
-              :style="{ width: (dim.score || 0) + '%' }"
-            ></view>
-          </view>
-          <text class="dim-norms" v-if="dim.normMin && dim.normMax">
-            正常范围 {{ dim.normMin }}-{{ dim.normMax }}
-          </text>
-        </view>
-        <view class="dim-footer">
-          <text class="dim-level-tag" :class="'tag-' + dim.level">{{ getLevelText(dim.level) }}</text>
-          <text class="dim-source" v-if="dim.trend">{{ dim.trend === 'up' ? '↑ 上升' : dim.trend === 'down' ? '↓ 下降' : '→ 稳定' }}</text>
+        :key="dim.dimension || dim.key"
+        @click="goDimensionDetail(dim)">
+        <text class="dimension-icon">{{ getDimIcon(dim.dimension || dim.key) }}</text>
+        <text class="dimension-name">{{ dim.label }}</text>
+        <view class="dimension-score-bar">
+          <view
+            class="dimension-score-fill"
+            :style="'width:' + Math.min(100, dim.score || 0) + '%'"></view>
         </view>
+        <text class="dimension-score">{{ dim.score || 0 }}分</text>
       </view>
     </view>
-
-    <!-- 空数据 -->
-    <view class="hds-empty" v-if="!healthData">
-      <text class="empty-tip">暂无维度数据</text>
-      <text class="empty-sub">上传健康报告或填写问卷后可查看</text>
-    </view>
   </view>
 </template>
 
 <script>
+var DIM_ICONS = {
+  body: '\u{1F3C3}',
+  mind: '\u{1F9E0}',
+  wisdom: '\u{1F4D6}',
+  action: '\u{1F331}',
+  wealth: '\u{1F4B0}',
+  social: '\u{1F91D}',
+  family: '\u{1F46A}'
+}
+
 export default {
   props: {
-    healthData: {
-      type: Object,
-      default: null
-    },
-    activeChildId: {
-      type: [Number, String],
-      default: null
-    }
-  },
-  computed: {
-    spokeStyles: function() {
-      if (!this.healthData || !this.healthData.dimensions) return []
-      var cache = []
-      var dims = this.healthData.dimensions
-      for (var i = 0; i < dims.length; i++) {
-        var angle = i * 51.4 - 90
-        var radius = 85
-        var normalizedScore = (dims[i].score || 0) / 100
-        var translateY = -radius * normalizedScore
-        cache.push({
-          transform: 'rotate(' + angle + 'deg) translateY(' + translateY + 'px)',
-          '-webkit-transform': 'rotate(' + angle + 'deg) translateY(' + translateY + 'px)'
-        })
-      }
-      return cache
-    }
+    healthData: { type: Object, default: null },
+    activeChildId: { type: [Number, String], default: null }
   },
   methods: {
-    getLevelText: function(level) {
-      var map = {
-        'excellent': '优秀',
-        'good': '良好',
-        'fair': '一般',
-        'poor': '较差',
-        'none': '暂无数据'
+    getDimIcon: function(key) {
+      return DIM_ICONS[key] || '\u{2B50}'
+    },
+    goHealthDimensions: function() {
+      if (!this.activeChildId) {
+        uni.showToast({ title: '请先选择孩子', icon: 'none' })
+        return
       }
-      return map[level] || ''
+      uni.navigateTo({ url: '/pages/body-detail/health-dimensions?childId=' + this.activeChildId })
     },
-    goDimDetail: function(dim) {
-      if (!dim || !dim.dimension) return
-      var childId = this.activeChildId || ''
-      uni.navigateTo({
-        url: '/pages/body-detail/dimension-detail?dimension=' + dim.dimension + '&childId=' + childId
-      })
+    goDimensionDetail: function(dim) {
+      var key = dim.dimension || dim.key
+      if (!key) return
+      uni.navigateTo({ url: '/pages/body-detail/dimension-detail?dimension=' + key + '&childId=' + this.activeChildId })
     }
   }
 }
 </script>
 
 <style scoped>
-.hds-section {
-  margin: 0 30rpx 20rpx;
-}
-.hds-health-index {
+.health-dimensions-section {
+  margin: 20rpx 20rpx;
+  background: #FFFFFF;
+  border-radius: 24rpx;
+  padding: 24rpx 24rpx 32rpx;
+  box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
+}
+.section-header {
   display: flex;
-  justify-content: center;
-  padding: 30rpx;
-  background: #fff;
-  border-radius: 20rpx;
-  margin-bottom: 20rpx;
-}
-.index-circle {
-  width: 160rpx;
-  height: 160rpx;
-  border-radius: 50%;
-  background: linear-gradient(135deg, #FF8C42, #FF6B35);
-  display: flex;
-  flex-direction: column;
+  justify-content: space-between;
   align-items: center;
-  justify-content: center;
-  color: #fff;
-}
-.index-value {
-  font-size: 48rpx;
-  font-weight: bold;
-  line-height: 1;
-}
-.index-label {
-  font-size: 22rpx;
-  opacity: 0.9;
-  margin-top: 8rpx;
-}
-.hds-radar {
-  background: #fff;
-  border-radius: 20rpx;
-  padding: 30rpx;
-  margin-bottom: 20rpx;
+  margin-bottom: 24rpx;
 }
-.radar-title {
-  font-size: 32rpx;
+.section-title {
+  font-size: 30rpx;
   font-weight: bold;
   color: #333;
-  margin-bottom: 30rpx;
-  text-align: center;
 }
-.radar-wrap {
-  display: flex;
-  justify-content: center;
+.section-more {
+  font-size: 24rpx;
+  color: #FF8C42;
 }
-.radar-center {
-  width: 440rpx;
-  height: 440rpx;
-  position: relative;
+.loading-state, .empty-state {
   display: flex;
-  align-items: center;
   justify-content: center;
+  padding: 40rpx 0;
 }
-.radar-grid {
-  position: absolute;
-  width: 100%;
-  height: 100%;
-}
-.grid-ring {
-  position: absolute;
-  border-radius: 50%;
-  border: 1px solid #ddd;
-  left: 50%;
-  top: 50%;
-  transform: translate(-50%, -50%);
+.loading-text, .empty-tip {
+  font-size: 24rpx;
+  color: #999;
 }
-.ring-1 { width: 33%; height: 33%; }
-.ring-2 { width: 66%; height: 66%; }
-.ring-3 { width: 100%; height: 100%; }
-.radar-spokes {
-  position: absolute;
-  width: 100%;
-  height: 100%;
+.dimension-grid {
   display: flex;
-  align-items: center;
-  justify-content: center;
+  flex-wrap: wrap;
+  gap: 20rpx;
 }
-.spoke-item {
-  position: absolute;
+.dimension-item {
+  width: calc(33.33% - 14rpx);
   display: flex;
   flex-direction: column;
   align-items: center;
-  transform-origin: center center;
-  width: 80rpx;
-  margin-left: -40rpx;
-  top: 10rpx;
-}
-.spoke-dot {
-  width: 20rpx;
-  height: 20rpx;
-  border-radius: 50%;
-  background: #FF8C42;
-  margin-bottom: 6rpx;
-}
-.spoke-dot.level-excellent { background: #10B981; }
-.spoke-dot.level-good { background: #3B82F6; }
-.spoke-dot.level-fair { background: #F59E0B; }
-.spoke-dot.level-poor { background: #EF4444; }
-.spoke-dot.level-none { background: #ddd; }
-.spoke-label {
-  font-size: 22rpx;
-  color: #333;
-  white-space: nowrap;
-}
-.spoke-score {
-  font-size: 20rpx;
-  color: #666;
-  margin-top: 2rpx;
+  padding: 16rpx 0;
 }
-.hds-dim-list {
-  padding: 0;
+.dimension-icon {
+  font-size: 40rpx;
+  margin-bottom: 8rpx;
 }
-.dim-card {
-  background: #fff;
-  border-radius: 16rpx;
-  padding: 24rpx;
-  margin-bottom: 20rpx;
-}
-.dim-header {
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  margin-bottom: 16rpx;
-}
-.dim-label {
-  font-size: 30rpx;
-  font-weight: bold;
+.dimension-name {
+  font-size: 24rpx;
   color: #333;
+  font-weight: 500;
+  margin-bottom: 10rpx;
 }
-.dim-score {
-  font-size: 36rpx;
-  font-weight: bold;
-  color: #FF8C42;
-}
-.dim-score.score-excellent { color: #10B981; }
-.dim-score.score-good { color: #3B82F6; }
-.dim-score.score-fair { color: #F59E0B; }
-.dim-score.score-poor { color: #EF4444; }
-.dim-bar-wrap {
-  margin-bottom: 12rpx;
-}
-.dim-bar-bg {
-  height: 16rpx;
-  background: #f0f0f0;
-  border-radius: 8rpx;
+.dimension-score-bar {
+  width: 100%;
+  height: 8rpx;
+  background: #F5F5F5;
+  border-radius: 4rpx;
   overflow: hidden;
   margin-bottom: 6rpx;
 }
-.dim-bar-fill {
+.dimension-score-fill {
   height: 100%;
-  border-radius: 8rpx;
-  background: #FF8C42;
+  background: linear-gradient(90deg, #FF8C42, #FFB088);
+  border-radius: 4rpx;
   transition: width 0.3s;
 }
-.dim-bar-fill.fill-excellent { background: #10B981; }
-.dim-bar-fill.fill-good { background: #3B82F6; }
-.dim-bar-fill.fill-fair { background: #F59E0B; }
-.dim-bar-fill.fill-poor { background: #EF4444; }
-.dim-norms {
-  font-size: 22rpx;
-  color: #999;
-}
-.dim-footer {
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-}
-.dim-level-tag {
+.dimension-score {
   font-size: 22rpx;
-  padding: 4rpx 16rpx;
-  border-radius: 20rpx;
-  background: #f0f0f0;
-  color: #666;
-}
-.dim-level-tag.tag-excellent { background: #D1FAE5; color: #10B981; }
-.dim-level-tag.tag-good { background: #DBEAFE; color: #3B82F6; }
-.dim-level-tag.tag-fair { background: #FEF3C7; color: #F59E0B; }
-.dim-level-tag.tag-poor { background: #FEE2E2; color: #EF4444; }
-.dim-source {
-  font-size: 22rpx;
-  color: #999;
-}
-.hds-empty {
-  padding: 80rpx 30rpx;
-  text-align: center;
-}
-.hds-empty .empty-tip {
-  font-size: 30rpx;
-  color: #999;
-  display: block;
-  margin-bottom: 16rpx;
-}
-.hds-empty .empty-sub {
-  font-size: 24rpx;
-  color: #ccc;
-  display: block;
+  color: #FF8C42;
+  font-weight: 500;
 }
 </style>

+ 25 - 0
cfc-frontend/pages/activity/activity-detail/activity-detail.vue

@@ -484,6 +484,31 @@ export default {
   line-height: 1.6;
 }
 
+/* 付费不退费说明 */
+.refund-notice {
+  margin-top: 20rpx;
+  margin-left: 30rpx;
+  margin-right: 30rpx;
+  margin-bottom: 10rpx;
+  background: #FEF2F2;
+  border: 2rpx solid #FECACA;
+  border-radius: 12rpx;
+  padding: 16rpx 20rpx;
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+}
+.refund-notice-icon {
+  font-size: 24rpx;
+  margin-right: 10rpx;
+  flex-shrink: 0;
+}
+.refund-notice-text {
+  font-size: 24rpx;
+  color: #DC2626;
+  line-height: 1.4;
+}
+
 /* 底部操作栏 */
 .bottom-bar {
   position: fixed;

+ 9 - 0
cfc-web/src/api/activity.js

@@ -36,6 +36,15 @@ export function updateActivity(data) {
   })
 }
 
+// 获取活动详情
+export function getActivityDetail(data) {
+  return request({
+    url: '/api/admin/activity/detail',
+    method: 'post',
+    data: data
+  })
+}
+
 // 发布活动(透传后端已有接口)
 export function publishActivity(id) {
   return request({

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

@@ -376,6 +376,12 @@ const routes = [
         component: () => import('@/views/admin/Activities.vue'),
         meta: { title: '活动列表', perm: 'activity:list' }
       },
+      {
+        path: 'activity-edit',
+        name: 'ActivityEdit',
+        component: () => import('@/views/admin/ActivityEdit.vue'),
+        meta: { title: '活动编辑', perm: 'activity:list' }
+      },
       {
         path: 'activity-review',
         name: 'ActivityReview',

+ 4 - 327
cfc-web/src/views/admin/Activities.vue

@@ -17,7 +17,7 @@
             <el-option label="已发布" value="published" />
             <el-option label="已结束" value="ended" />
           </el-select>
-          <el-button type="primary" size="mini" @click="openCreate">创建活动</el-button>
+          <el-button type="primary" size="mini" @click="$router.push('/activity-edit')">创建活动</el-button>
         </div>
       </div>
       <el-table :data="activities" v-loading="loading" border stripe>
@@ -57,8 +57,8 @@
         <el-table-column prop="endTime" label="结束时间" width="170" />
         <el-table-column label="操作" width="280">
           <template slot-scope="{ row }">
-            <el-button size="mini" @click="viewActivity(row)" v-if="row.status !== 'draft'">查看</el-button>
-            <el-button size="mini" @click="editActivity(row)" :disabled="row.status !== 'draft'" v-else>编辑</el-button>
+            <el-button size="mini" @click="$router.push('/activity-edit?id=' + row.id + '&readonly=1')" v-if="row.status !== 'draft'">查看</el-button>
+            <el-button size="mini" @click="$router.push('/activity-edit?id=' + row.id)" v-else>编辑</el-button>
             <el-button
               size="mini"
               type="success"
@@ -87,117 +87,6 @@
       />
     </el-card>
 
-    <el-dialog
-      :title="viewMode ? '查看活动' : (editId ? '编辑活动' : '创建活动')"
-      :visible.sync="dialogVisible"
-      width="700px"
-      @closed="resetForm"
-    >
-      <el-form label-width="120px" ref="activityForm">
-        <el-form-item label="活动标题">
-          <el-input v-model="form.title" placeholder="请输入活动标题" />
-        </el-form-item>
-
-        <el-form-item label="封面图片">
-          <el-upload
-            :action="uploadActionUrl"
-            :headers="uploadHeaders"
-            :on-success="handleUploadSuccess"
-            :on-error="handleUploadError"
-            :before-upload="beforeUpload"
-            :show-file-list="false"
-            accept="image/jpeg,image/png,image/gif,image/webp"
-          >
-            <img v-if="form.coverImage" :src="form.coverImage" class="cover-preview" />
-            <el-button v-else type="primary" plain>+ 上传封面</el-button>
-          </el-upload>
-          <el-button v-if="form.coverImage" size="mini" type="text" style="margin-left:10px;" @click="form.coverImage = ''">清除</el-button>
-        </el-form-item>
-
-        <el-form-item label="活动类型">
-          <el-select v-model="form.activityType" placeholder="请选择活动类型" style="width:100%">
-            <el-option label="线下活动" value="offline" />
-            <el-option label="线上活动" value="online" />
-            <el-option label="主题活动" value="campaign" />
-          </el-select>
-        </el-form-item>
-
-        <el-form-item label="活动描述">
-          <el-input v-model="form.description" type="textarea" :rows="3" placeholder="请输入活动描述" />
-        </el-form-item>
-
-        <el-form-item label="五维权重">
-          <dimension-weight-picker v-model="form.dimensionWeights" :show-presets="true" />
-        </el-form-item>
-
-        <el-form-item label="活动地点">
-          <el-input v-model="form.location" placeholder="请输入活动地点" />
-        </el-form-item>
-
-        <el-row :gutter="16">
-          <el-col :span="12">
-            <el-form-item label="最大人数">
-              <el-input-number v-model="form.maxParticipants" :min="0" :max="99999" placeholder="0表示不限" style="width:100%" />
-            </el-form-item>
-          </el-col>
-          <el-col :span="12">
-            <el-form-item label="价格(元)">
-              <el-input-number v-model="form.priceYuan" :min="0" :precision="2" :step="0.01" placeholder="0表示免费" style="width:100%" />
-            </el-form-item>
-          </el-col>
-        </el-row>
-
-        <el-row :gutter="16">
-          <el-col :span="12">
-            <el-form-item label="开始时间">
-              <el-date-picker v-model="form.startTime" type="datetime" placeholder="选择开始时间" style="width:100%" />
-            </el-form-item>
-          </el-col>
-          <el-col :span="12">
-            <el-form-item label="结束时间">
-              <el-date-picker v-model="form.endTime" type="datetime" placeholder="选择结束时间" style="width:100%" />
-            </el-form-item>
-          </el-col>
-        </el-row>
-
-        <el-row :gutter="16">
-          <el-col :span="12">
-            <el-form-item label="可见范围">
-              <el-select v-model="form.visibility" placeholder="请选择可见范围" style="width:100%">
-                <el-option label="公开" value="public" />
-                <el-option label="受限" value="restricted" />
-                <el-option label="全体" value="all" />
-                <el-option label="会员" value="member" />
-              </el-select>
-            </el-form-item>
-          </el-col>
-          <el-col :span="12">
-            <el-form-item label="可见对象">
-              <el-select v-model="form.visibleScope" placeholder="请选择可见对象类型" style="width:100%">
-                <el-option label="不限" value="" />
-                <el-option label="家庭" value="family" />
-                <el-option label="角色" value="role" />
-                <el-option label="用户" value="user" />
-              </el-select>
-            </el-form-item>
-          </el-col>
-        </el-row>
-
-        <el-form-item label="可见对象ID" v-if="form.visibleScope">
-          <el-input v-model="form.visibleTo" placeholder="多个用逗号分隔,如: family_1,family_2" />
-        </el-form-item>
-
-        <el-form-item label="签到积分">
-          <el-input-number v-model="form.checkinPoints" :min="0" :max="9999" placeholder="0使用系统默认" style="width:200px" />
-          <span style="margin-left:8px;color:#999;font-size:12px;">0表示使用系统默认积分</span>
-        </el-form-item>
-      </el-form>
-      <div slot="footer">
-        <el-button @click="dialogVisible = false">{{ viewMode ? '关闭' : '取消' }}</el-button>
-        <el-button v-if="!viewMode" type="primary" @click="saveActivity" :loading="saving">保存</el-button>
-      </div>
-    </el-dialog>
-
     <!-- 报名列表对话框 -->
     <el-dialog
       :title="'报名列表 - ' + (currentActivity ? currentActivity.title : '')"
@@ -235,9 +124,7 @@
 </template>
 
 <script>
-import DimensionWeightPicker from '@/components/DimensionWeightPicker.vue'
-import { getActivityList, createActivity, updateActivity, publishActivity, endActivity, getRegistrationList, approveRegistration, rejectRegistration } from '@/api/activity'
-import { saveDimensionWeights, getDimensionWeights } from '@/api/dimension-weight.js'
+import { getActivityList, publishActivity, endActivity, getRegistrationList, approveRegistration, rejectRegistration } from '@/api/activity'
 
 const STATUS_MAP = {
   draft: { label: '草稿', type: 'info' },
@@ -259,38 +146,16 @@ const DIMENSION_MAP = {
 
 export default {
   name: 'Activities',
-  components: { DimensionWeightPicker },
   data() {
     return {
       loading: false,
-      saving: false,
       activities: [],
       total: 0,
-      editId: null,
-      viewMode: false,
-      dialogVisible: false,
       filter: {
         status: '',
         page: 1,
         size: 10
       },
-      form: {
-        title: '',
-        coverImage: '',
-        activityType: '',
-        description: '',
-        dimensionCode: '',
-        dimensionWeights: [],
-        location: '',
-        maxParticipants: null,
-        priceYuan: null,
-        startTime: null,
-        endTime: null,
-        visibility: 'public',
-        visibleScope: '',
-        visibleTo: '',
-        checkinPoints: 0
-      },
       // 报名列表对话框
       regDialogVisible: false,
       regLoading: false,
@@ -298,16 +163,6 @@ export default {
       registrations: []
     }
   },
-  computed: {
-    uploadActionUrl() {
-      const baseURL = process.env.VUE_APP_BASE_API || ''
-      return baseURL + '/api/admin/articles/upload/image'
-    },
-    uploadHeaders() {
-      const token = localStorage.getItem('token')
-      return token ? { Authorization: 'Bearer ' + token } : {}
-    }
-  },
   created() {
     this.loadActivities()
   },
@@ -324,31 +179,6 @@ export default {
     dimensionLabel(code) {
       return DIMENSION_MAP[code] || code || '—'
     },
-    beforeUpload(file) {
-      const isImage = file.type === 'image/jpeg' || file.type === 'image/png' ||
-        file.type === 'image/gif' || file.type === 'image/webp'
-      if (!isImage) {
-        this.$message.error('只能上传 JPG/PNG/GIF/WebP 格式图片')
-        return false
-      }
-      const isLt5M = file.size / 1024 / 1024 < 5
-      if (!isLt5M) {
-        this.$message.error('图片大小不能超过 5MB')
-        return false
-      }
-      return true
-    },
-    handleUploadSuccess(resp) {
-      if (resp.code === 0 || resp.code === 200) {
-        this.form.coverImage = resp.data || resp
-        this.$message.success('封面上传成功')
-      } else {
-        this.$message.error((resp && resp.message) || '封面上传失败')
-      }
-    },
-    handleUploadError() {
-      this.$message.error('封面上传失败,请重试')
-    },
     async loadActivities() {
       this.loading = true
       try {
@@ -377,118 +207,6 @@ export default {
       this.filter.page = page
       this.loadActivities()
     },
-    openCreate() {
-      this.editId = null
-      this.viewMode = false
-      this.resetForm()
-      this.dialogVisible = true
-    },
-    resetForm() {
-      this.viewMode = false
-      this.form = {
-        title: '',
-        coverImage: '',
-        activityType: '',
-        description: '',
-        dimensionCode: '',
-        dimensionWeights: [],
-        location: '',
-        maxParticipants: null,
-        priceYuan: null,
-        startTime: null,
-        endTime: null,
-        visibility: 'public',
-        visibleScope: '',
-        visibleTo: '',
-        checkinPoints: 0
-      }
-    },
-    editActivity(row) {
-      this.editId = row.id
-      this.viewMode = false
-      this.form = {
-        title: row.title || '',
-        coverImage: row.coverImage || '',
-        activityType: row.activityType || '',
-        description: row.description || '',
-        dimensionCode: row.dimensionCode || '',
-        dimensionWeights: [],
-        location: row.location || '',
-        maxParticipants: row.maxParticipants || null,
-        priceYuan: row.price ? row.price / 100 : null,
-        startTime: row.startTime || null,
-        endTime: row.endTime || null,
-        visibility: row.visibility || 'public',
-        visibleScope: row.visibleScope || '',
-        visibleTo: row.visibleTo || '',
-        checkinPoints: row.checkinPoints || 0
-      }
-      this.dialogVisible = true
-      this.loadDimensionWeights(row.id)
-    },
-    async loadDimensionWeights(activityId) {
-      try {
-        const res = await getDimensionWeights('activity', activityId)
-        if (res.data) {
-          this.form.dimensionWeights = res.data
-        }
-      } catch (e) {
-      }
-    },
-    async saveActivity() {
-      if (!this.form.title) {
-        this.$message.warning('请填写活动标题')
-        return
-      }
-      if (!this.form.startTime || !this.form.endTime) {
-        this.$message.warning('请填写开始时间和结束时间')
-        return
-      }
-      this.saving = true
-      try {
-        const payload = {
-          title: this.form.title,
-          coverImage: this.form.coverImage,
-          activityType: this.form.activityType,
-          description: this.form.description,
-          dimensionCode: this.form.dimensionCode,
-          location: this.form.location,
-          maxParticipants: this.form.maxParticipants,
-          price: this.form.priceYuan != null ? Math.round(this.form.priceYuan * 100) : 0,
-          startTime: this.form.startTime,
-          endTime: this.form.endTime,
-          visibility: this.form.visibility,
-          visibleScope: this.form.visibleScope,
-          visibleTo: this.form.visibleTo,
-          checkinPoints: this.form.checkinPoints || 0
-        }
-        let activityId = this.editId
-        if (this.editId) {
-          payload.id = this.editId
-          await updateActivity(payload)
-          this.$message.success('更新成功')
-        } else {
-          const res = await createActivity(payload)
-          if (res.data && res.data.id) {
-            activityId = res.data.id
-          }
-          this.$message.success('创建成功')
-        }
-        if (activityId && this.form.dimensionWeights && this.form.dimensionWeights.length) {
-          try {
-            await saveDimensionWeights('activity', activityId, this.form.dimensionWeights)
-          } catch (e) {
-            console.error('保存维度权重失败', e)
-          }
-        }
-        this.dialogVisible = false
-        this.loadActivities()
-      } catch (e) {
-        this.$message.error('保存失败')
-      } finally {
-        this.saving = false
-      }
-    },
     async handlePublish(row) {
       try {
         await publishActivity(row.id)
@@ -507,40 +225,6 @@ export default {
         this.$message.error('操作失败')
       }
     },
-    // ===== 查看活动(只读,使用同一对话框) =====
-    viewActivity(row) {
-      this.viewMode = true
-      this.editId = row.id
-      this.form = {
-        title: row.title || '',
-        coverImage: row.coverImage || '',
-        activityType: row.activityType || '',
-        description: row.description || '',
-        dimensionCode: row.dimensionCode || '',
-        dimensionWeights: [],
-        location: row.location || '',
-        maxParticipants: row.maxParticipants || null,
-        priceYuan: row.price ? row.price / 100 : null,
-        startTime: row.startTime || null,
-        endTime: row.endTime || null,
-        visibility: row.visibility || 'public',
-        visibleScope: row.visibleScope || '',
-        visibleTo: row.visibleTo || '',
-        checkinPoints: row.checkinPoints || 0
-      }
-      this.dialogVisible = true
-      this.loadDimensionWeights(row.id)
-      this.$nextTick(() => {
-        if (this.$refs.activityForm) {
-          // 禁用所有 el-form-item 内的输入控件
-          this.$refs.activityForm.$el.querySelectorAll('input, textarea, .el-select, .el-input-number, .el-switch, .el-date-editor, .el-upload').forEach(el => {
-            el.classList.add('is-disabled')
-            const inp = el.querySelector('input') || el.querySelector('textarea')
-            if (inp) inp.disabled = true
-          })
-        }
-      })
-    },
     // ===== 报名列表 =====
     showRegistrations(row) {
       this.currentActivity = row
@@ -596,11 +280,4 @@ export default {
 
 <style scoped>
 .activities { padding: 20px; }
-.cover-preview {
-  width: 200px;
-  height: 120px;
-  object-fit: cover;
-  border-radius: 4px;
-  border: 1px solid #ddd;
-}
 </style>

+ 325 - 0
cfc-web/src/views/admin/ActivityEdit.vue

@@ -0,0 +1,325 @@
+<template>
+  <div class="activity-edit">
+    <el-card>
+      <div slot="header">
+        <span>{{ readonly ? '活动详情' : (isEdit ? '编辑活动' : '创建活动') }}</span>
+        <div style="float: right;">
+          <el-button @click="$router.push('/activities')">返回列表</el-button>
+          <template v-if="!readonly">
+            <el-button type="primary" @click="saveActivity(false)" :loading="saving">保存</el-button>
+            <el-button type="success" @click="saveActivity(true)" :loading="publishing" v-if="isEdit && activityStatus === 'draft'">发布</el-button>
+          </template>
+          <template v-if="readonly && activityStatus === 'draft'">
+            <el-button type="primary" size="mini" @click="editFromReadonly">编辑</el-button>
+          </template>
+        </div>
+      </div>
+
+      <el-form label-width="120px" v-loading="loading">
+        <el-form-item label="活动标题">
+          <el-input v-model="form.title" placeholder="请输入活动标题" :disabled="readonly" />
+        </el-form-item>
+
+        <el-form-item label="封面图片">
+          <template v-if="!readonly">
+            <el-upload
+              :action="uploadActionUrl"
+              :headers="uploadHeaders"
+              :on-success="handleUploadSuccess"
+              :on-error="handleUploadError"
+              :before-upload="beforeUpload"
+              :show-file-list="false"
+              accept="image/jpeg,image/png,image/gif,image/webp"
+            >
+              <img v-if="form.coverImage" :src="form.coverImage" class="cover-preview" />
+              <el-button v-else type="primary" plain>+ 上传封面</el-button>
+            </el-upload>
+            <el-button v-if="form.coverImage" size="mini" type="text" style="margin-left:10px;" @click="form.coverImage = ''">清除</el-button>
+          </template>
+          <img v-else-if="form.coverImage" :src="form.coverImage" class="cover-preview" />
+          <span v-else style="color:#999">无封面</span>
+        </el-form-item>
+
+        <el-form-item label="活动类型">
+          <el-select v-model="form.activityType" placeholder="请选择活动类型" style="width:100%" :disabled="readonly">
+            <el-option label="线下活动" value="offline" />
+            <el-option label="线上活动" value="online" />
+            <el-option label="主题活动" value="campaign" />
+          </el-select>
+        </el-form-item>
+
+        <el-form-item label="活动描述">
+          <el-input v-model="form.description" type="textarea" :rows="3" placeholder="请输入活动描述" :disabled="readonly" />
+        </el-form-item>
+
+        <el-form-item label="五维权重">
+          <dimension-weight-picker v-model="form.dimensionWeights" :show-presets="true" :readonly="readonly" />
+        </el-form-item>
+
+        <el-form-item label="活动地点">
+          <el-input v-model="form.location" placeholder="请输入活动地点" :disabled="readonly" />
+        </el-form-item>
+
+        <el-row :gutter="16">
+          <el-col :span="12">
+            <el-form-item label="最大人数">
+              <el-input-number v-model="form.maxParticipants" :min="0" :max="99999" placeholder="0表示不限" style="width:100%" :disabled="readonly" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="价格(元)">
+              <el-input-number v-model="form.priceYuan" :min="0" :precision="2" :step="0.01" placeholder="0表示免费" style="width:100%" :disabled="readonly" />
+            </el-form-item>
+          </el-col>
+        </el-row>
+
+        <el-row :gutter="16">
+          <el-col :span="12">
+            <el-form-item label="开始时间">
+              <el-date-picker v-model="form.startTime" type="datetime" placeholder="选择开始时间" style="width:100%" :disabled="readonly" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="结束时间">
+              <el-date-picker v-model="form.endTime" type="datetime" placeholder="选择结束时间" style="width:100%" :disabled="readonly" />
+            </el-form-item>
+          </el-col>
+        </el-row>
+
+        <el-row :gutter="16">
+          <el-col :span="12">
+            <el-form-item label="可见范围">
+              <el-select v-model="form.visibility" placeholder="请选择可见范围" style="width:100%" :disabled="readonly">
+                <el-option label="公开" value="public" />
+                <el-option label="受限" value="restricted" />
+                <el-option label="全体" value="all" />
+                <el-option label="会员" value="member" />
+              </el-select>
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="可见对象">
+              <el-select v-model="form.visibleScope" placeholder="请选择可见对象类型" style="width:100%" :disabled="readonly">
+                <el-option label="不限" value="" />
+                <el-option label="家庭" value="family" />
+                <el-option label="角色" value="role" />
+                <el-option label="用户" value="user" />
+              </el-select>
+            </el-form-item>
+          </el-col>
+        </el-row>
+
+        <el-form-item label="可见对象ID" v-if="form.visibleScope">
+          <el-input v-model="form.visibleTo" placeholder="多个用逗号分隔,如: family_1,family_2" :disabled="readonly" />
+        </el-form-item>
+
+        <el-form-item label="签到积分">
+          <el-input-number v-model="form.checkinPoints" :min="0" :max="9999" placeholder="0使用系统默认" style="width:200px" :disabled="readonly" />
+          <span style="margin-left:8px;color:#999;font-size:12px;">0表示使用系统默认积分</span>
+        </el-form-item>
+      </el-form>
+    </el-card>
+  </div>
+</template>
+
+<script>
+import DimensionWeightPicker from '@/components/DimensionWeightPicker.vue'
+import { getActivityDetail, createActivity, updateActivity, publishActivity } from '@/api/activity'
+import { saveDimensionWeights, getDimensionWeights } from '@/api/dimension-weight.js'
+
+export default {
+  name: 'ActivityEdit',
+  components: { DimensionWeightPicker },
+  data() {
+    return {
+      isEdit: false,
+      loading: false,
+      saving: false,
+      publishing: false,
+      readonly: false,
+      activityStatus: '',
+      form: {
+        title: '',
+        coverImage: '',
+        activityType: '',
+        description: '',
+        dimensionCode: '',
+        dimensionWeights: [],
+        location: '',
+        maxParticipants: null,
+        priceYuan: null,
+        startTime: null,
+        endTime: null,
+        visibility: 'public',
+        visibleScope: '',
+        visibleTo: '',
+        checkinPoints: 0
+      }
+    }
+  },
+  computed: {
+    uploadActionUrl() {
+      const baseURL = process.env.VUE_APP_BASE_API || ''
+      return baseURL + '/api/admin/articles/upload/image'
+    },
+    uploadHeaders() {
+      const token = localStorage.getItem('token')
+      return token ? { Authorization: 'Bearer ' + token } : {}
+    }
+  },
+  created() {
+    const id = this.$route.query.id
+    this.readonly = this.$route.query.readonly === '1'
+    if (id) {
+      this.isEdit = true
+      this.loadDetail(id)
+    }
+  },
+  methods: {
+    async loadDetail(id) {
+      this.loading = true
+      try {
+        const res = await getActivityDetail({ id })
+        if (res.data) {
+          const d = res.data
+          this.activityStatus = d.status || ''
+          if (this.readonly || d.status !== 'draft') {
+            this.readonly = true
+          }
+          this.form = {
+            title: d.title || '',
+            coverImage: d.coverImage || '',
+            activityType: d.activityType || '',
+            description: d.description || '',
+            dimensionCode: d.dimensionCode || '',
+            dimensionWeights: [],
+            location: d.location || '',
+            maxParticipants: d.maxParticipants || null,
+            priceYuan: d.price ? d.price / 100 : null,
+            startTime: d.startTime || null,
+            endTime: d.endTime || null,
+            visibility: d.visibility || 'public',
+            visibleScope: d.visibleScope || '',
+            visibleTo: d.visibleTo || '',
+            checkinPoints: d.checkinPoints || 0
+          }
+          try {
+            const dwRes = await getDimensionWeights('activity', d.id)
+            if (dwRes.data) {
+              this.form.dimensionWeights = dwRes.data
+            }
+          } catch (e) {
+          }
+        }
+      } catch (e) {
+        this.$message.error('加载活动详情失败')
+      } finally {
+        this.loading = false
+      }
+    },
+    beforeUpload(file) {
+      const isImage = file.type === 'image/jpeg' || file.type === 'image/png' ||
+        file.type === 'image/gif' || file.type === 'image/webp'
+      if (!isImage) {
+        this.$message.error('只能上传 JPG/PNG/GIF/WebP 格式图片')
+        return false
+      }
+      const isLt5M = file.size / 1024 / 1024 < 5
+      if (!isLt5M) {
+        this.$message.error('图片大小不能超过 5MB')
+        return false
+      }
+      return true
+    },
+    handleUploadSuccess(resp) {
+      if (resp.code === 0 || resp.code === 200) {
+        this.form.coverImage = resp.data || resp
+        this.$message.success('封面上传成功')
+      } else {
+        this.$message.error((resp && resp.message) || '封面上传失败')
+      }
+    },
+    handleUploadError() {
+      this.$message.error('封面上传失败,请重试')
+    },
+    async saveActivity(publishNow) {
+      if (!this.form.title) {
+        this.$message.warning('请填写活动标题')
+        return
+      }
+      if (!this.form.startTime || !this.form.endTime) {
+        this.$message.warning('请填写开始时间和结束时间')
+        return
+      }
+      if (publishNow) {
+        this.publishing = true
+      } else {
+        this.saving = true
+      }
+      try {
+        const payload = {
+          title: this.form.title,
+          coverImage: this.form.coverImage,
+          activityType: this.form.activityType,
+          description: this.form.description,
+          dimensionCode: this.form.dimensionCode,
+          location: this.form.location,
+          maxParticipants: this.form.maxParticipants,
+          price: this.form.priceYuan != null ? Math.round(this.form.priceYuan * 100) : 0,
+          startTime: this.form.startTime,
+          endTime: this.form.endTime,
+          visibility: this.form.visibility,
+          visibleScope: this.form.visibleScope,
+          visibleTo: this.form.visibleTo,
+          checkinPoints: this.form.checkinPoints || 0
+        }
+        let activityId = null
+        if (this.isEdit) {
+          payload.id = this.form._activityId || this.$route.query.id
+          await updateActivity(payload)
+          activityId = payload.id
+        } else {
+          const res = await createActivity(payload)
+          if (res.data && res.data.id) {
+            activityId = res.data.id
+          }
+          this.isEdit = true
+        }
+        if (activityId && this.form.dimensionWeights && this.form.dimensionWeights.length) {
+          try {
+            await saveDimensionWeights('activity', activityId, this.form.dimensionWeights)
+          } catch (e) {
+            console.error('保存维度权重失败', e)
+          }
+        }
+        if (publishNow && activityId) {
+          await publishActivity(activityId)
+          this.$message.success('发布成功')
+        } else {
+          this.$message.success(this.isEdit ? '更新成功' : '创建成功')
+        }
+        this.$router.push('/activities')
+      } catch (e) {
+        this.$message.error('保存失败')
+      } finally {
+        this.saving = false
+        this.publishing = false
+      }
+    },
+    editFromReadonly() {
+      this.readonly = false
+    }
+  }
+}
+</script>
+
+<style scoped>
+.activity-edit { padding: 20px; }
+.cover-preview {
+  width: 200px;
+  height: 120px;
+  object-fit: cover;
+  border-radius: 4px;
+  border: 1px solid #ddd;
+}
+</style>

+ 3 - 1
cfc-web/src/views/admin/ArticleManage.vue

@@ -1,5 +1,6 @@
 <template>
-  <div class="article-manage">
+  <div>
+    <div class="article-manage">
     <el-card>
       <div slot="header">
         <span>文章管理</span>
@@ -162,6 +163,7 @@
       <el-button type="primary" @click="confirmReject">确认驳回</el-button>
     </span>
   </el-dialog>
+  </div>
 </template>
 
 <script>

+ 20 - 2
tests/v9_result.txt

@@ -1,4 +1,22 @@
 === CFC API v9 Results ===
-Time: 2026-07-03T08:38:33.127Z
+Time: 2026-07-04T00:05:44.905Z
 
-[FAIL] Admin login: status=undefined raw=undefined
+[PASS] Admin login userId=7
+[PASS] NEW-01 Energy rule create
+[PASS] NEW-02 SKU list
+[PASS] NEW-03 Activity publish
+[SKIP] ISSUE-001 Vendor login failed
+[FAIL] ISSUE-002 Article publish: code=500 msg=
+[PASS] ISSUE-003 Create user
+[FAIL] Articles list (public): code=500 msg=
+[PASS] Activity list (public)
+[PASS] User list
+[PASS] Energy dimensions
+[PASS] Vendor list
+[FAIL] Admin article list: code=500 msg=
+[PASS] Points balance
+[PASS] Energy overview
+[PASS] Activity end
+[INFO] Guide activity list: code=500 msg=
+
+=== Summary: 12 PASS / 3 FAIL / 1 SKIP / 1 INFO / 17 Total ===