Bladeren bron

feat(web): 文章管理/编辑展示知识库校验状态与强制发布警告

Xiaogang Liao 2 dagen geleden
bovenliggende
commit
aa6ac8914d

+ 146 - 0
cfc-web/src/__tests__/components/ArticleEdit.kbCheck.test.js

@@ -0,0 +1,146 @@
+import { mount, createLocalVue } from "@vue/test-utils"
+import ElementUI from "element-ui"
+import ArticleEdit from "@/views/admin/ArticleEdit.vue"
+import {
+  adminArticleCreate,
+  adminArticleUpdate,
+  adminArticlePublish,
+  adminArticleCategoriesList
+} from "@/api/article.js"
+
+jest.mock("@/router", () => ({
+  __esModule: true,
+  default: { push: jest.fn(), replace: jest.fn(), back: jest.fn() }
+}))
+jest.mock("@/api/article.js", () => ({
+  adminArticleCreate: jest.fn(),
+  adminArticleUpdate: jest.fn(),
+  adminArticlePublish: jest.fn(),
+  adminArticleCategoriesList: jest.fn().mockResolvedValue({ data: [] }),
+  adminArticleDetail: jest.fn(),
+  adminArticleSubmitReview: jest.fn(),
+  adminArticleReDraft: jest.fn()
+}))
+jest.mock("@/api/dimension-weight.js", () => ({
+  saveDimensionWeights: jest.fn().mockResolvedValue({}),
+  getDimensionWeights: jest.fn().mockResolvedValue({ data: null })
+}))
+jest.mock("@/utils/editorAssetUpload", () => ({
+  getUploadHeaders: jest.fn(() => ({})),
+  getEditorCustomAlert: jest.fn(() => ({})),
+  buildEditorAssetMenuConf: jest.fn(() => ({})),
+  ASSET_MENU_KEY: "assetLibrary",
+  registerAssetLibraryMenu: jest.fn(),
+  setAssetMenuHandler: jest.fn(),
+  buildAssetToolbarConfig: jest.fn(() => ({}))
+}))
+jest.mock("@wangeditor/editor-for-vue", () => ({
+  Editor: { render: h => h("div") },
+  Toolbar: { render: h => h("div") }
+}))
+jest.mock("@wangeditor/editor", () => ({
+  DomEditor: {},
+  SlateTransforms: {},
+  SlatePath: {}
+}))
+jest.mock("@wangeditor/editor/dist/css/style.css", () => ({}), { virtual: true })
+
+const localVue = createLocalVue()
+localVue.use(ElementUI)
+
+function mountArticleEdit() {
+  const $message = { success: jest.fn(), error: jest.fn(), warning: jest.fn() }
+  const $alert = jest.fn(() => Promise.resolve())
+  const $confirm = jest.fn(() => Promise.resolve("confirm"))
+  const $router = { push: jest.fn() }
+  const wrapper = mount(ArticleEdit, {
+    localVue,
+    mocks: { $route: { query: {} }, $router, $alert, $message, $confirm },
+    stubs: {
+      PageHeader: true,
+      Editor: true,
+      Toolbar: true,
+      AssetPicker: true,
+      DimensionWeightPicker: true,
+      ContentDimensionThresholdEditor: true
+    }
+  })
+  return { wrapper, $message, $alert, $confirm, $router }
+}
+
+beforeEach(() => {
+  jest.clearAllMocks()
+})
+
+describe("ArticleEdit 知识库校验状态处理", () => {
+  it("保存返回 REJECTED → 弹警告(含失败句 + 已自动转为草稿)", async () => {
+    const { wrapper, $alert, $message } = mountArticleEdit()
+    adminArticleCreate.mockResolvedValue({
+      code: 200,
+      data: {
+        id: 1,
+        kbCheckStatus: "REJECTED",
+        kbCheckResult: JSON.stringify({ failed: [{ text: "此句未在知识库中找到出处" }] })
+      }
+    })
+    wrapper.vm.form.title = "益生菌选购指南"
+    wrapper.vm.form.content = "双歧杆菌有助于维持肠道菌群平衡。此句未在知识库中找到出处。"
+    await wrapper.vm.$nextTick()
+
+    await wrapper.vm.handleSave(false)
+
+    expect(adminArticleCreate).toHaveBeenCalled()
+    expect($alert).toHaveBeenCalled()
+    const alertArgs = $alert.mock.calls[0][0]
+    expect(alertArgs).toContain("已自动转为草稿")
+    expect(alertArgs).toContain("此句未在知识库中找到出处")
+    expect($message.success).not.toHaveBeenCalled()
+  })
+
+  it("发布 REJECTED → 触发 $confirm 二次确认后才调发布接口", async () => {
+    const { wrapper, $confirm } = mountArticleEdit()
+    // 模拟已加载的 REJECTED 文章
+    wrapper.vm.isEdit = true
+    wrapper.vm.form.id = 1
+    wrapper.vm.kbCheckStatus = "REJECTED"
+    adminArticleUpdate.mockResolvedValue({ code: 200, data: { kbCheckStatus: "REJECTED" } })
+    adminArticlePublish.mockResolvedValue({ code: 200, data: { kbCheckRejected: true } })
+
+    await wrapper.vm.handleSave(true)
+
+    expect($confirm).toHaveBeenCalled()
+    expect($confirm.mock.calls[0][0]).toContain("强制发布")
+    expect(adminArticlePublish).toHaveBeenCalled()
+  })
+
+  it("发布 REJECTED 但用户取消 $confirm → 不调发布接口", async () => {
+    const { wrapper, $confirm } = mountArticleEdit()
+    wrapper.vm.isEdit = true
+    wrapper.vm.form.id = 1
+    wrapper.vm.kbCheckStatus = "REJECTED"
+    $confirm.mockRejectedValue(new Error("cancel"))
+    adminArticleUpdate.mockResolvedValue({ code: 200, data: { kbCheckStatus: "REJECTED" } })
+
+    await wrapper.vm.handleSave(true)
+
+    expect($confirm).toHaveBeenCalled()
+    expect(adminArticleUpdate).not.toHaveBeenCalled()
+    expect(adminArticlePublish).not.toHaveBeenCalled()
+  })
+
+  it("保存返回 APPROVED → 绿色成功提示,无警告", async () => {
+    const { wrapper, $alert, $message } = mountArticleEdit()
+    adminArticleCreate.mockResolvedValue({
+      code: 200,
+      data: { id: 1, kbCheckStatus: "APPROVED", kbCheckResult: JSON.stringify({ all_passed: true }) }
+    })
+    wrapper.vm.form.title = "益生菌选购指南"
+    wrapper.vm.form.content = "双歧杆菌有助于维持肠道菌群平衡,菌株 LGG 有研究支持。"
+    await wrapper.vm.$nextTick()
+
+    await wrapper.vm.handleSave(false)
+
+    expect($alert).not.toHaveBeenCalled()
+    expect($message.success).toHaveBeenCalled()
+  })
+})

+ 46 - 2
cfc-web/src/views/admin/ArticleEdit.vue

@@ -198,6 +198,8 @@ export default {
       reDraftLoading: false,
       readonly: false,
       articleWasPublished: false,
+      kbCheckStatus: '',
+      kbCheckResult: '',
       articleStatus: '',
       rejectReason: '',
       categories: [],
@@ -606,6 +608,8 @@ export default {
           const d = res.data
           this.articleStatus = d.status || ''
           this.rejectReason = d.auditReason || ''
+          this.kbCheckStatus = d.kbCheckStatus || ''
+          this.kbCheckResult = d.kbCheckResult || ''
           this.articleWasPublished = !!(d.publishedAt || d.status === 'published')
           if (d.auditStatus === 'pending') {
             this.readonly = true
@@ -742,10 +746,29 @@ export default {
         this.saving = true
       }
       try {
+        // 发布二次确认:知识库自检 REJECTED 的文章,发布前提示强制发布(不硬拦,用户可取消)
+        if (publishNow && this.kbCheckStatus === 'REJECTED') {
+          try {
+            await this.$confirm('知识库校验未通过,仍要强制发布?', '强制发布确认', {
+              type: 'warning',
+              confirmButtonText: '仍要发布',
+              cancelButtonText: '取消'
+            })
+          } catch (e) {
+            return // 用户取消发布
+          }
+        }
         const data = this.buildSubmitData()
         let articleId = this.form.id
+        // 保存/更新结果中的校验状态回填(后端 create/update 返回 data.kbCheckStatus/kbCheckResult)
+        let kbCheckStatus = this.kbCheckStatus
+        let kbCheckResult = this.kbCheckResult
         if (this.isEdit) {
-          await adminArticleUpdate(data)
+          const updRes = await adminArticleUpdate(data)
+          if (updRes && updRes.data && updRes.data.kbCheckStatus) {
+            kbCheckStatus = updRes.data.kbCheckStatus
+            kbCheckResult = updRes.data.kbCheckResult || kbCheckResult
+          }
           if (publishNow) {
             await adminArticlePublish({ id: this.form.id, status: 'published' })
           }
@@ -758,8 +781,14 @@ export default {
             articleId = res.data.id
             this.form.id = articleId
             this.isEdit = true
+            if (res.data.kbCheckStatus) {
+              kbCheckStatus = res.data.kbCheckStatus
+              kbCheckResult = res.data.kbCheckResult || kbCheckResult
+            }
           }
         }
+        this.kbCheckStatus = kbCheckStatus
+        this.kbCheckResult = kbCheckResult
         if (articleId && this.form.dimensionWeights && this.form.dimensionWeights.length) {
           try {
             await saveDimensionWeights('article', articleId, this.form.dimensionWeights)
@@ -767,7 +796,12 @@ export default {
             console.error('保存维度权重失败', e)
           }
         }
-        this.$message.success(publishNow ? '文章已发布' : (this.articleStatus === 'published' ? '修改已保存' : '草稿已保存'))
+        if (kbCheckStatus === 'REJECTED' && !publishNow) {
+          // REJECTED:警告弹窗展示失败句子 + 已自动转为草稿
+          await this.$alert('文章已自动转为草稿。知识库校验未通过的句子:\n' + this.buildKbFailedText(kbCheckResult), '知识库校验未通过', { type: 'warning' })
+        } else {
+          this.$message.success(publishNow ? '文章已发布' : (this.articleStatus === 'published' ? '修改已保存' : '草稿已保存'))
+        }
         this.destroyEditor()
         this.$router.push('/article-manage')
       } catch (e) {
@@ -777,6 +811,16 @@ export default {
         this.publishing = false
       }
     },
+    buildKbFailedText(kbCheckResult) {
+      try {
+        const parsed = JSON.parse(kbCheckResult || '{}')
+        const failed = (parsed && parsed.failed) || []
+        const texts = failed.filter(f => f && f.text).map(f => '· ' + f.text)
+        return texts.length ? texts.join('\n') : '(详见校验结果)'
+      } catch (e) {
+        return '(详见校验结果)'
+      }
+    },
     async handleSubmitReview() {
       this.reviewLoading = true
       try {

+ 8 - 0
cfc-web/src/views/admin/ArticleManage.vue

@@ -141,6 +141,14 @@
             <span v-else>-</span>
           </template>
         </el-table-column>
+        <el-table-column label="知识库校验" width="110">
+          <template slot-scope="{ row }">
+            <StatusTag v-if="row.kbCheckStatus === APPROVED" size="small" type="success" text="已校验" />
+            <StatusTag v-else-if="row.kbCheckStatus === REJECTED" size="small" type="danger" title="知识库出处自检未通过,详见编辑页" text="未通过" />
+            <StatusTag v-else-if="row.kbCheckStatus === SKIP" size="small" type="warning" title="校验服务不可用或跳过" text="跳过" />
+            <span v-else>-</span>
+          </template>
+        </el-table-column>
         <el-table-column prop="viewCount" label="阅读" width="70">
           <template slot="header">
             <span class="sort-header" @click.stop="onSortClick('viewCount', $event)" @dblclick.stop="onSortToggle('viewCount')">阅读<span v-if="sortIndex('viewCount') !== null" class="sort-index" :class="sortClass('viewCount') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('viewCount') }}</span>{{ sortIcon('viewCount') }}</span>