瀏覽代碼

Merge remote-tracking branch 'origin/cfclub' into cfclub

# Conflicts:
#	cfc-web/.last_build_commit
#	cfc-web/package.json
#	cfc-web/public/CHANGELOG-v1.0.md
#	cfc-web/public/CHANGELOG.md
Sisyphus 1 月之前
父節點
當前提交
0e9f0692bd

+ 5 - 1
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminArticleController.java

@@ -118,7 +118,11 @@ public class AdminArticleController {
         if (body.get("status") != null) {
         if (body.get("status") != null) {
             article.setStatus((String) body.get("status"));
             article.setStatus((String) body.get("status"));
         }
         }
-        articleService.create(article, adminId);
+        try {
+            articleService.create(article, adminId);
+        } catch (RuntimeException e) {
+            return Result.error(e.getMessage());
+        }
 
 
         Map<String, Object> result = new HashMap<>();
         Map<String, Object> result = new HashMap<>();
         result.put("id", article.getId());
         result.put("id", article.getId());

+ 41 - 19
cfc-backend/src/main/java/com/etotem/cfc/service/ArticleService.java

@@ -24,6 +24,7 @@ import lombok.extern.slf4j.Slf4j;
 import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
 import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
 import com.baomidou.mybatisplus.core.toolkit.Wrappers;
 import com.baomidou.mybatisplus.core.toolkit.Wrappers;
 import org.springframework.jdbc.core.JdbcTemplate;
 import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
 import org.springframework.stereotype.Service;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 import org.springframework.transaction.annotation.Transactional;
 import javax.annotation.Resource;
 import javax.annotation.Resource;
@@ -76,6 +77,9 @@ public class ArticleService {
     @Resource
     @Resource
     private JdbcTemplate jdbcTemplate;
     private JdbcTemplate jdbcTemplate;
 
 
+    @Resource(name = "taskExecutor")
+    private ThreadPoolTaskExecutor taskExecutor;
+
     private final ObjectMapper objectMapper = new ObjectMapper();
     private final ObjectMapper objectMapper = new ObjectMapper();
 
 
     public Page<Article> getPublicList(Long categoryId, String keyword, String dimensionCode, int page, int size,
     public Page<Article> getPublicList(Long categoryId, String keyword, String dimensionCode, int page, int size,
@@ -386,6 +390,16 @@ public class ArticleService {
 
 
     @Transactional
     @Transactional
     public void create(Article article, Long adminId) {
     public void create(Article article, Long adminId) {
+        // 幂等检查:同一标题不允许重复创建
+        if (article.getTitle() != null && !article.getTitle().trim().isEmpty()) {
+            LambdaQueryWrapper<Article> dupCheck = new LambdaQueryWrapper<Article>()
+                    .eq(Article::getTitle, article.getTitle().trim());
+            Long existingCount = articleMapper.selectCount(dupCheck);
+            if (existingCount != null && existingCount > 0) {
+                throw new RuntimeException("标题「" + article.getTitle().trim() + "」已存在,请勿重复创建");
+            }
+        }
+
         // Auto-calculate wordCount from content
         // Auto-calculate wordCount from content
         if (article.getContent() != null) {
         if (article.getContent() != null) {
             article.setWordCount(calculateWordCount(article.getContent()));
             article.setWordCount(calculateWordCount(article.getContent()));
@@ -403,16 +417,20 @@ public class ArticleService {
         }
         }
         articleMapper.insert(article);
         articleMapper.insert(article);
 
 
-        // AI auto-tagging
-        try {
-            List<String> tagNames = autoGenerateTags(article.getContent());
-            if (!tagNames.isEmpty()) {
-                saveAutoTags(article.getId(), tagNames);
+        // AI auto-tagging (异步,不阻塞 HTTP 响应)
+        final Long articleId = article.getId();
+        final String content = article.getContent();
+        taskExecutor.execute(() -> {
+            try {
+                List<String> tagNames = autoGenerateTags(content);
+                if (!tagNames.isEmpty()) {
+                    saveAutoTags(articleId, tagNames);
+                }
+            } catch (Exception e) {
+                log.warn("AI 自动标签生成失败(不影响文章创建): articleId={}, error={}",
+                    articleId, e.getMessage());
             }
             }
-        } catch (Exception e) {
-            log.warn("AI 自动标签生成失败(不影响文章创建): articleId={}, error={}",
-                article.getId(), e.getMessage());
-        }
+        });
     }
     }
 
 
     @Transactional
     @Transactional
@@ -428,18 +446,22 @@ public class ArticleService {
         article.setUpdatedAt(new Date());
         article.setUpdatedAt(new Date());
         articleMapper.updateById(article);
         articleMapper.updateById(article);
 
 
-        // AI auto-tagging only when content changed
+        // AI auto-tagging only when content changed (异步,不阻塞 HTTP 响应)
         if (contentChanged && article.getContent() != null) {
         if (contentChanged && article.getContent() != null) {
-            try {
-                jdbcTemplate.update("DELETE FROM article_tags WHERE article_id = ?", article.getId());
-                List<String> tagNames = autoGenerateTags(article.getContent());
-                if (!tagNames.isEmpty()) {
-                    saveAutoTags(article.getId(), tagNames);
+            final Long articleId = article.getId();
+            final String content = article.getContent();
+            taskExecutor.execute(() -> {
+                try {
+                    jdbcTemplate.update("DELETE FROM article_tags WHERE article_id = ?", articleId);
+                    List<String> tagNames = autoGenerateTags(content);
+                    if (!tagNames.isEmpty()) {
+                        saveAutoTags(articleId, tagNames);
+                    }
+                } catch (Exception e) {
+                    log.warn("AI 自动标签更新失败(不影响文章更新): articleId={}, error={}",
+                        articleId, e.getMessage());
                 }
                 }
-            } catch (Exception e) {
-                log.warn("AI 自动标签更新失败(不影响文章更新): articleId={}, error={}",
-                    article.getId(), e.getMessage());
-            }
+            });
         }
         }
     }
     }
 
 

+ 3 - 2
cfc-frontend/pages/article-center/article-detail.vue

@@ -32,7 +32,7 @@
           </view>
           </view>
         </view>
         </view>
         <view class="divider"></view>
         <view class="divider"></view>
-        <view class="detail-body"><rich-text :nodes="article.content"></rich-text></view>
+        <view class="detail-body"><mp-html :content="article.content" :domain="config.API_BASE_URL" /></view>
 
 
         <!-- 评论区(已屏蔽) -->
         <!-- 评论区(已屏蔽) -->
         <view style="height: 200rpx;"></view>
         <view style="height: 200rpx;"></view>
@@ -82,11 +82,12 @@
 import { getArticleDetail, reportReadingTime, getShareQrCode, completeArticleRead, generateQuiz, submitQuiz, getMyMembership } from '@/utils/api.js'
 import { getArticleDetail, reportReadingTime, getShareQrCode, completeArticleRead, generateQuiz, submitQuiz, getMyMembership } from '@/utils/api.js'
 import shareMixin from '../../components/share-mixin.js'
 import shareMixin from '../../components/share-mixin.js'
 import ContentSharePoster from '@/components/ContentSharePoster.vue'
 import ContentSharePoster from '@/components/ContentSharePoster.vue'
+import MpHtml from '@/components/mp-html/mp-html.vue'
 import config from '@/config.js'
 import config from '@/config.js'
 
 
 export default {
 export default {
   mixins: [shareMixin],
   mixins: [shareMixin],
-  components: { ContentSharePoster },
+  components: { ContentSharePoster, MpHtml },
   data() {
   data() {
     return {
     return {
       articleId: '', article: null, loading: true, error: false, errorMsg: '',
       articleId: '', article: null, loading: true, error: false, errorMsg: '',

+ 17 - 3
cfc-frontend/pages/mind-detail/articles.vue

@@ -68,7 +68,7 @@
               </view>
               </view>
             </view>
             </view>
             <text class="article-title">{{ item.title }}</text>
             <text class="article-title">{{ item.title }}</text>
-            <text class="article-summary">{{ item.summary || item.content }}</text>
+            <text class="article-summary" v-if="item.summary">{{ item.summary }}</text>
             <view class="article-footer">
             <view class="article-footer">
               <text class="article-author">{{ item.author || '浠艾福' }}</text>
               <text class="article-author">{{ item.author || '浠艾福' }}</text>
               <text class="article-read-count">阅读 {{ item.readCount || 0 }}</text>
               <text class="article-read-count">阅读 {{ item.readCount || 0 }}</text>
@@ -190,6 +190,7 @@ export default {
     async loadArticles() {
     async loadArticles() {
       if (this.loading) return
       if (this.loading) return
       this.loading = true
       this.loading = true
+      var self = this
       try {
       try {
         var params = { page: this.page, size: this.size }
         var params = { page: this.page, size: this.size }
         if (this.currentCategory) {
         if (this.currentCategory) {
@@ -206,8 +207,7 @@ export default {
             return {
             return {
               id: item.id,
               id: item.id,
               title: item.title || '',
               title: item.title || '',
-              summary: item.summary || '',
-              content: item.content || '',
+              summary: self.stripHtml(item.summary || item.content),
               coverImage: item.coverImage || '',
               coverImage: item.coverImage || '',
               author: item.author || '浠艾福',
               author: item.author || '浠艾福',
               categoryName: item.categoryName || item.category || '',
               categoryName: item.categoryName || item.category || '',
@@ -243,6 +243,20 @@ export default {
     goDetail(id) {
     goDetail(id) {
       uni.navigateTo({ url: '/pages/article-center/article-detail?id=' + id })
       uni.navigateTo({ url: '/pages/article-center/article-detail?id=' + id })
     },
     },
+    stripHtml: function(html) {
+      if (!html) return ''
+      return html
+        .replace(/<style[\s\S]*?<\/style>/gi, ' ')
+        .replace(/<script[\s\S]*?<\/script>/gi, ' ')
+        .replace(/<[^>]+>/g, ' ')
+        .replace(/&nbsp;/gi, ' ')
+        .replace(/&lt;/gi, '<')
+        .replace(/&gt;/gi, '>')
+        .replace(/&amp;/gi, '&')
+        .replace(/&quot;/gi, '"')
+        .replace(/\s+/g, ' ')
+        .trim()
+    },
     getImageUrl: function(path) {
     getImageUrl: function(path) {
       return config.API_BASE_URL + path
       return config.API_BASE_URL + path
     }
     }

+ 17 - 3
cfc-frontend/pages/mind-extra/articles.vue

@@ -96,7 +96,7 @@
                 </view>
                 </view>
               </view>
               </view>
               <text class="article-title">{{ item.title }}</text>
               <text class="article-title">{{ item.title }}</text>
-              <text class="article-summary">{{ item.summary || item.content }}</text>
+              <text class="article-summary" v-if="item.summary">{{ item.summary }}</text>
               <view class="article-footer">
               <view class="article-footer">
                 <text class="article-author">{{ item.author || '浠艾福' }}</text>
                 <text class="article-author">{{ item.author || '浠艾福' }}</text>
                 <text class="article-read-count">阅读 {{ item.readCount || 0 }}</text>
                 <text class="article-read-count">阅读 {{ item.readCount || 0 }}</text>
@@ -255,6 +255,7 @@ export default {
     async loadArticles() {
     async loadArticles() {
       if (this.loading) return
       if (this.loading) return
       this.loading = true
       this.loading = true
+      var self = this
       try {
       try {
         var params = { page: this.page, size: this.size }
         var params = { page: this.page, size: this.size }
         if (this.currentCategory) {
         if (this.currentCategory) {
@@ -274,8 +275,7 @@ export default {
             return {
             return {
               id: item.id,
               id: item.id,
               title: item.title || '',
               title: item.title || '',
-              summary: item.summary || '',
-              content: item.content || '',
+              summary: self.stripHtml(item.summary || item.content),
               coverImage: item.coverImage || '',
               coverImage: item.coverImage || '',
               author: item.author || '浠艾福',
               author: item.author || '浠艾福',
               categoryName: item.categoryName || item.category || '',
               categoryName: item.categoryName || item.category || '',
@@ -311,6 +311,20 @@ export default {
     goDetail(id) {
     goDetail(id) {
       uni.navigateTo({ url: '/pages/article-center/article-detail?id=' + id })
       uni.navigateTo({ url: '/pages/article-center/article-detail?id=' + id })
     },
     },
+    stripHtml: function(html) {
+      if (!html) return ''
+      return html
+        .replace(/<style[\s\S]*?<\/style>/gi, ' ')
+        .replace(/<script[\s\S]*?<\/script>/gi, ' ')
+        .replace(/<[^>]+>/g, ' ')
+        .replace(/&nbsp;/gi, ' ')
+        .replace(/&lt;/gi, '<')
+        .replace(/&gt;/gi, '>')
+        .replace(/&amp;/gi, '&')
+        .replace(/&quot;/gi, '"')
+        .replace(/\s+/g, ' ')
+        .trim()
+    },
     goCreate() {
     goCreate() {
       if (this.isLoggedIn) {
       if (this.isLoggedIn) {
         uni.navigateTo({ url: '/pages/article-center/article-edit' })
         uni.navigateTo({ url: '/pages/article-center/article-edit' })

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-658d71cc13bdf5bac3938e95f20a533cba24e5da
+2ecb6d66b9ae6d688cde84698af4dad222ffae6f

+ 2 - 2
cfc-web/package-lock.json

@@ -1,12 +1,12 @@
 {
 {
   "name": "cfc-web",
   "name": "cfc-web",
-  "version": "1.0.1106",
+  "version": "1.0.1108",
   "lockfileVersion": 3,
   "lockfileVersion": 3,
   "requires": true,
   "requires": true,
   "packages": {
   "packages": {
     "": {
     "": {
       "name": "cfc-web",
       "name": "cfc-web",
-      "version": "1.0.1106",
+      "version": "1.0.1108",
       "dependencies": {
       "dependencies": {
         "@wangeditor/editor": "^5.1.23",
         "@wangeditor/editor": "^5.1.23",
         "@wangeditor/editor-for-vue": "^1.0.2",
         "@wangeditor/editor-for-vue": "^1.0.2",

+ 1 - 1
cfc-web/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "cfc-web",
   "name": "cfc-web",
-  "version": "1.0.1108",
+  "version": "1.0.1109",
   "private": true,
   "private": true,
   "scripts": {
   "scripts": {
     "dev": "vue-cli-service serve",
     "dev": "vue-cli-service serve",

+ 18 - 0
cfc-web/public/CHANGELOG-v$(node.undefined.md

@@ -3,6 +3,24 @@
 [« 返回最新版本](CHANGELOG.md)
 [« 返回最新版本](CHANGELOG.md)
 
 
 ---
 ---
+## v$(node (2026-08-17)
+
+### 新功能
+- 首页能量沙盘门禁引导卡 - 未测评时显示GateLock (sandbox-gate)
+- 能量沙盘门禁改家庭级判据 - 任一成员做过五维自检即解锁 (sandbox-gate)
+
+### 其他
+- - gate-lock SELF_CHECK 按钮文案对齐五维基础能量检测
+- Ultraworked with [Sisyphus](https://github.com/OhMyOpenCode)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- - EnergyController.sandbox 个人视角改用家庭级判据 + selfCheckGateOrFallback 内置兜底 gate(无种子时前端 GateLock 也能渲染引导按钮)
+- - getUserUnlockStatus 个人沙盘锁定标志同步家庭级,与 /api/energy/sandbox 一致
+- Ultraworked with [Sisyphus](https://github.com/OhMyOpenCode)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+
+
 ## v$(node (2026-08-03)
 ## v$(node (2026-08-03)
 
 
 ### 新功能
 ### 新功能

+ 25 - 0
cfc-web/public/CHANGELOG-v1.0.md

@@ -4,6 +4,31 @@
 
 
 ---
 ---
 
 
+## v1.0.1109 (2026-08-17)
+
+### Bug 修复
+- 请求超时提升至60s + 复制新建按钮loading态
+
+### 其他
+- - ArticleEdit 复制并新建按钮加 loading 防重复点击
+- Ultraworked with [Sisyphus](https://github.com/OhMyOpenCode)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- - mind-detail/mind-extra 列表摘要 stripHtml 清理,避免富文本标签直接展示
+- Ultraworked with [Sisyphus](https://github.com/OhMyOpenCode)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- - AI 自动标签生成改为 taskExecutor 异步执行,不阻塞 HTTP 响应
+- - AdminArticleController.create 捕获业务异常返回 Result.error
+- Ultraworked with [Sisyphus](https://github.com/OhMyOpenCode)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+
+### 新功能
+- 文章详情mp-html富文本渲染 + 列表摘要去HTML标签
+- 文章创建幂等检查 + AI标签生成异步化
+
+
 ## v1.0.1108 (2026-08-17)
 ## v1.0.1108 (2026-08-17)
 
 
 ### 新功能
 ### 新功能

+ 26 - 1
cfc-web/public/CHANGELOG.md

@@ -1,6 +1,6 @@
 # 更新日志
 # 更新日志
 
 
-> 当前版本: v1.0.1108
+> 当前版本: v1.0.1109
 
 
 ## 历史版本
 ## 历史版本
 
 
@@ -8,6 +8,31 @@
 
 
 ---
 ---
 
 
+## v1.0.1109 (2026-08-17)
+
+### Bug 修复
+- 请求超时提升至60s + 复制新建按钮loading态
+
+### 其他
+- - ArticleEdit 复制并新建按钮加 loading 防重复点击
+- Ultraworked with [Sisyphus](https://github.com/OhMyOpenCode)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- - mind-detail/mind-extra 列表摘要 stripHtml 清理,避免富文本标签直接展示
+- Ultraworked with [Sisyphus](https://github.com/OhMyOpenCode)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- - AI 自动标签生成改为 taskExecutor 异步执行,不阻塞 HTTP 响应
+- - AdminArticleController.create 捕获业务异常返回 Result.error
+- Ultraworked with [Sisyphus](https://github.com/OhMyOpenCode)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+
+### 新功能
+- 文章详情mp-html富文本渲染 + 列表摘要去HTML标签
+- 文章创建幂等检查 + AI标签生成异步化
+
+
 ## v1.0.1108 (2026-08-17)
 ## v1.0.1108 (2026-08-17)
 
 
 ### 新功能
 ### 新功能

+ 1 - 1
cfc-web/src/utils/request.js

@@ -4,7 +4,7 @@ import router from '@/router'
 
 
 const service = axios.create({
 const service = axios.create({
   baseURL: process.env.VUE_APP_BASE_API || '',
   baseURL: process.env.VUE_APP_BASE_API || '',
-  timeout: 10000
+  timeout: 60000
 })
 })
 
 
 // 请求拦截器
 // 请求拦截器

+ 1 - 1
cfc-web/src/views/admin/ArticleEdit.vue

@@ -14,7 +14,7 @@
             <el-button type="primary" @click="handleReDraft" :loading="reDraftLoading">保存到草稿箱重新编辑</el-button>
             <el-button type="primary" @click="handleReDraft" :loading="reDraftLoading">保存到草稿箱重新编辑</el-button>
           </template>
           </template>
           <template v-if="readonly && articleStatus !== 'rejected'">
           <template v-if="readonly && articleStatus !== 'rejected'">
-            <el-button type="default" @click="handleCopyAsNew">复制并新建</el-button>
+            <el-button type="default" @click="handleCopyAsNew" :loading="saving">复制并新建</el-button>
           </template>
           </template>
         </div>
         </div>
       </div>
       </div>