Просмотр исходного кода

feat(admin): 报告解析管理端页面 - 类型管理/指纹自学习/解析器导入/未知聚类

Xiaogang Liao 1 месяц назад
Родитель
Сommit
f8e668022b

+ 136 - 0
cfc-web/src/api/reportParser.js

@@ -0,0 +1,136 @@
+import request from '@/utils/request'
+
+// ========== 报告类型管理 ==========
+export function getReportTypes(params) {
+  return request({
+    url: '/api/admin/report-parser/types',
+    method: 'post',
+    data: params
+  })
+}
+
+export function saveReportType(data) {
+  return request({
+    url: '/api/admin/report-parser/types/save',
+    method: 'post',
+    data
+  })
+}
+
+export function toggleReportType(data) {
+  return request({
+    url: '/api/admin/report-parser/types/toggle',
+    method: 'post',
+    data
+  })
+}
+
+export function deleteReportType(data) {
+  return request({
+    url: '/api/admin/report-parser/types/delete',
+    method: 'post',
+    data
+  })
+}
+
+// ========== 指纹规则管理 ==========
+export function getFingerprints(params) {
+  return request({
+    url: '/api/admin/report-parser/fingerprints/list',
+    method: 'post',
+    data: params
+  })
+}
+
+export function saveFingerprint(data) {
+  return request({
+    url: '/api/admin/report-parser/fingerprints/save',
+    method: 'post',
+    data
+  })
+}
+
+export function deleteFingerprint(data) {
+  return request({
+    url: '/api/admin/report-parser/fingerprints/delete',
+    method: 'post',
+    data
+  })
+}
+
+// ========== 导入管理 ==========
+export function getParserImports(params) {
+  return request({
+    url: '/api/admin/report-parser/imports',
+    method: 'post',
+    data: params
+  })
+}
+
+export function approveParserImport(data) {
+  return request({
+    url: '/api/admin/report-parser/imports/approve',
+    method: 'post',
+    data
+  })
+}
+
+export function rejectParserImport(data) {
+  return request({
+    url: '/api/admin/report-parser/imports/reject',
+    method: 'post',
+    data
+  })
+}
+
+export function importParserPackage(data) {
+  return request({
+    url: '/api/admin/report-parser/imports/import',
+    method: 'post',
+    data
+  })
+}
+
+// ========== 未知报告聚类 ==========
+export function getUnknownClusters(params) {
+  return request({
+    url: '/api/admin/report-parser/unknown-clusters',
+    method: 'post',
+    data: params
+  })
+}
+
+export function getClusterUploads(params) {
+  return request({
+    url: '/api/admin/report-parser/unknown-uploads',
+    method: 'post',
+    data: params
+  })
+}
+
+// ========== 自学习生成 ==========
+export function generateTypeFromCluster(data) {
+  return request({
+    url: '/api/admin/report-parser/generate-type',
+    method: 'post',
+    data
+  })
+}
+
+// ========== 上传/检测 ==========
+export function uploadAndParseReport(formData) {
+  return request({
+    url: '/api/report-parser/upload',
+    method: 'post',
+    data: formData,
+    headers: { 'Content-Type': 'multipart/form-data' }
+  })
+}
+
+export function detectReportType(data) {
+  return request({
+    url: '/api/report-parser/detect',
+    method: 'post',
+    data
+  })
+}

+ 114 - 0
cfc-web/src/views/admin/ReportAutoLearn.vue

@@ -0,0 +1,114 @@
+<template>
+  <div class="report-autolearn admin-page">
+    <el-card>
+      <div slot="header">
+        <span>报告自学习</span>
+        <div style="float: right;">
+          <el-button size="small" @click="fetchClusters" icon="el-icon-refresh">刷新</el-button>
+        </div>
+      </div>
+
+      <el-alert title="自学习机制:当同一类型的未知报告积累到3份后,系统自动标记为"可生成"状态,管理员可在此生成报告类型和指纹规则。" type="info" :closable="false" show-icon style="margin-bottom: 16px;" />
+
+      <el-table :data="clusters" v-loading="loading" border stripe>
+        <el-table-column prop="id" label="ID" width="60" />
+        <el-table-column prop="clusterKey" label="聚类键" width="140" />
+        <el-table-column prop="reportCount" label="报告数" width="80" />
+        <el-table-column label="状态" width="100">
+          <template slot-scope="{ row }">
+            <el-tag :type="row.status === 'ready' ? 'warning' : 'success'" size="small">
+              {{ row.status === 'ready' ? '可生成' : '已生成' }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="generatedTypeId" label="类型ID" width="140" />
+        <el-table-column prop="updatedAt" label="更新时间" width="180" />
+        <el-table-column label="操作" width="180" fixed="right">
+          <template slot-scope="{ row }">
+            <el-button size="mini" type="primary" @click="handleDetail(row)">详情</el-button>
+            <el-button v-if="row.status === 'ready'" size="mini" type="success" @click="handleGenerate(row)">生成类型</el-button>
+            <el-button v-if="row.status === 'generated'" size="mini" type="primary" @click="handleViewType(row)" plain>查看类型</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+    </el-card>
+
+    <el-dialog :visible.sync="detailVisible" title="聚类详情" width="750px">
+      <template v-if="currentCluster">
+        <el-descriptions :column="2" border size="small">
+          <el-descriptions-item label="聚类键">{{ currentCluster.clusterKey }}</el-descriptions-item>
+          <el-descriptions-item label="报告数">{{ currentCluster.reportCount }}</el-descriptions-item>
+          <el-descriptions-item label="状态">{{ currentCluster.status === 'ready' ? '可生成' : '已生成' }}</el-descriptions-item>
+          <el-descriptions-item label="类型ID">{{ currentCluster.generatedTypeId || '—' }}</el-descriptions-item>
+        </el-descriptions>
+        <h4>公共文本特征</h4>
+        <pre class="detail-pre">{{ currentCluster.commonSignatures }}</pre>
+        <h4>LLM 解析模板</h4>
+        <pre class="detail-pre">{{ formatJson(currentCluster.llmTemplate) }}</pre>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { getUnknownClusters, generateTypeFromCluster } from '@/api/reportParser'
+
+export default {
+  name: 'ReportAutoLearn',
+  data() {
+    return {
+      loading: false,
+      clusters: [],
+      detailVisible: false,
+      currentCluster: null,
+    }
+  },
+  mounted() { this.fetchClusters() },
+  methods: {
+    async fetchClusters() {
+      this.loading = true
+      try {
+        const res = await getUnknownClusters({ page: 1, size: 50 })
+        this.clusters = (res.data?.records || res.data || []).filter(
+          c => c.status === 'ready' || c.status === 'generated')
+      } catch (e) { this.$message.error('加载失败') }
+      this.loading = false
+    },
+    formatJson(str) {
+      if (!str) return ''
+      try { return JSON.stringify(JSON.parse(str), null, 2) } catch (e) { return str }
+    },
+    handleDetail(row) {
+      this.currentCluster = row
+      this.detailVisible = true
+    },
+    handleGenerate(row) {
+      this.$confirm('确认为此聚类生成报告类型和指纹规则?生成后需在"报告类型管理"中审核启用。', '提示', { type: 'warning' })
+        .then(async () => {
+          try {
+            const res = await generateTypeFromCluster({ clusterId: row.id })
+            this.$message.success('已生成类型: ' + (res.data?.typeId || ''))
+            this.fetchClusters()
+          } catch (e) { this.$message.error('生成失败') }
+        }).catch(() => {})
+    },
+    handleViewType(row) {
+      this.$router.push('/report-types')
+    },
+  }
+}
+</script>
+
+<style scoped>
+.detail-pre {
+  background: #f5f7fa;
+  border: 1px solid #e4e7ed;
+  border-radius: 4px;
+  padding: 10px;
+  max-height: 300px;
+  overflow: auto;
+  font-size: 12px;
+  white-space: pre-wrap;
+  word-break: break-all;
+}
+</style>

+ 175 - 0
cfc-web/src/views/admin/ReportParserImport.vue

@@ -0,0 +1,175 @@
+<template>
+  <div class="report-import admin-page">
+    <el-card>
+      <div slot="header">
+        <span>解析器导入管理</span>
+        <div style="float: right; display: flex; gap: 10px;">
+          <el-button type="primary" size="small" icon="el-icon-upload2" @click="uploadDialogVisible = true">上传导入包</el-button>
+        </div>
+      </div>
+
+      <el-table :data="imports" v-loading="loading" border stripe>
+        <el-table-column prop="id" label="ID" width="60" />
+        <el-table-column prop="packageName" label="导入包名称" min-width="200" />
+        <el-table-column prop="packageFileUrl" label="文件" width="150" />
+        <el-table-column label="状态" width="100">
+          <template slot-scope="{ row }">
+            <el-tag :type="statusType(row.status)" size="small">{{ statusText(row.status) }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="createdAt" label="导入时间" width="180" />
+        <el-table-column label="操作" width="160" fixed="right">
+          <template slot-scope="{ row }">
+            <template v-if="row.status === 'pending'">
+              <el-button size="mini" type="success" @click="handleApprove(row)">批准</el-button>
+              <el-button size="mini" type="danger" @click="handleReject(row)">驳回</el-button>
+            </template>
+            <el-button size="mini" type="primary" @click="handlePreview(row)">预览</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+    </el-card>
+
+    <!-- 上传导入包对话框 -->
+    <el-dialog :visible.sync="uploadDialogVisible" title="上传导入包" width="600px" :close-on-click-modal="false">
+      <el-upload
+        drag
+        :auto-upload="false"
+        :limit="1"
+        accept=".json"
+        :on-change="handleFileChange">
+        <i class="el-icon-upload"></i>
+        <div class="el-upload__text">将 json 文件拖到此处,或<em>点击上传</em></div>
+        <div class="el-upload__tip" slot="tip">导入包 JSON 格式:{ packageName, typeId, displayName, family, description, parserScript, fingerprints: [{pattern, weight, isExclusion}] }</div>
+      </el-upload>
+      <div slot="footer">
+        <el-button @click="uploadDialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="handleImport" :loading="submitting">确认导入</el-button>
+      </div>
+    </el-dialog>
+
+    <!-- 预览导入包对话框 -->
+    <el-dialog :visible.sync="previewDialogVisible" title="导入包预览" width="700px">
+      <div v-if="previewData">
+        <el-descriptions :column="2" border size="small">
+          <el-descriptions-item label="名称">{{ previewData.packageName }}</el-descriptions-item>
+          <el-descriptions-item label="类型ID">{{ previewData.typeId }}</el-descriptions-item>
+          <el-descriptions-item label="显示名称">{{ previewData.displayName }}</el-descriptions-item>
+          <el-descriptions-item label="家族">{{ previewData.family }}</el-descriptions-item>
+          <el-descriptions-item label="描述" :span="2">{{ previewData.description }}</el-descriptions-item>
+        </el-descriptions>
+        <h4>指纹规则</h4>
+        <el-table :data="previewData.fingerprints || []" border size="small">
+          <el-table-column prop="pattern" label="文本特征" min-width="200" />
+          <el-table-column prop="weight" label="权重" width="70" />
+          <el-table-column label="类型" width="80">
+            <template slot-scope="{ row }">
+              <el-tag :type="row.isExclusion ? 'danger' : 'success'" size="mini">{{ row.isExclusion ? '排除' : '正向' }}</el-tag>
+            </template>
+          </el-table-column>
+        </el-table>
+        <h4>解析脚本</h4>
+        <pre class="script-preview">{{ previewData.parserScript }}</pre>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { getParserImports, approveParserImport, rejectParserImport, importParserPackage } from '@/api/reportParser'
+
+export default {
+  name: 'ReportParserImport',
+  data() {
+    return {
+      loading: false, submitting: false,
+      imports: [],
+      uploadDialogVisible: false,
+      previewDialogVisible: false,
+      selectedFile: null,
+      previewData: null,
+    }
+  },
+  mounted() { this.fetchImports() },
+  methods: {
+    async fetchImports() {
+      this.loading = true
+      try {
+        const res = await getParserImports({ page: 1, size: 50 })
+        this.imports = res.data?.records || res.data || []
+      } catch (e) { this.$message.error('加载失败') }
+      this.loading = false
+    },
+    statusType(status) {
+      return { pending: 'warning', approved: 'success', rejected: 'danger' }[status] || 'info'
+    },
+    statusText(status) {
+      return { pending: '待审核', approved: '已批准', rejected: '已驳回' }[status] || status
+    },
+    handleFileChange(file) {
+      this.selectedFile = file.raw
+    },
+    async handleImport() {
+      if (!this.selectedFile) {
+        this.$message.warning('请先选择文件')
+        return
+      }
+      this.submitting = true
+      const reader = new FileReader()
+      reader.onload = async (e) => {
+        try {
+          const jsonContent = e.target.result
+          const res = await importParserPackage({ jsonContent, fileName: this.selectedFile.name })
+          this.$message.success('导入成功,待审核')
+          this.uploadDialogVisible = false
+          this.selectedFile = null
+          this.fetchImports()
+        } catch (err) { this.$message.error('导入失败') }
+        this.submitting = false
+      }
+      reader.readAsText(this.selectedFile)
+    },
+    handlePreview(row) {
+      try {
+        const jsonContent = row.fingerprintJson
+        this.previewData = JSON.parse(jsonContent)
+        this.previewDialogVisible = true
+      } catch (e) { this.$message.error('预览失败:JSON 解析错误') }
+    },
+    handleApprove(row) {
+      this.$confirm('确认批准此导入?将注册报告类型和指纹规则。', '提示', { type: 'warning' })
+        .then(async () => {
+          try {
+            await approveParserImport({ id: row.id })
+            this.$message.success('已批准')
+            this.fetchImports()
+          } catch (e) { this.$message.error('操作失败') }
+        }).catch(() => {})
+    },
+    handleReject(row) {
+      this.$prompt('请输入驳回原因', '驳回导入', { inputPlaceholder: '驳回原因', inputValidator: (v) => !!v || '请输入原因' })
+        .then(async ({ value }) => {
+          try {
+            await rejectParserImport({ id: row.id, note: value })
+            this.$message.success('已驳回')
+            this.fetchImports()
+          } catch (e) { this.$message.error('操作失败') }
+        }).catch(() => {})
+    },
+  }
+}
+</script>
+
+<style scoped>
+.script-preview {
+  background: #f5f7fa;
+  border: 1px solid #e4e7ed;
+  border-radius: 4px;
+  padding: 10px;
+  max-height: 300px;
+  overflow: auto;
+  font-size: 12px;
+  white-space: pre-wrap;
+  word-break: break-all;
+}
+</style>

+ 240 - 0
cfc-web/src/views/admin/ReportTypeManagement.vue

@@ -0,0 +1,240 @@
+<template>
+  <div class="report-types admin-page">
+    <el-card>
+      <div slot="header">
+        <span>报告类型管理</span>
+        <div style="float: right; display: flex; gap: 10px;">
+          <el-input v-model="keyword" placeholder="搜索类型..." prefix-icon="el-icon-search" size="small" clearable style="width: 200px;" />
+          <el-button type="primary" size="small" @click="handleCreate">+ 新增类型</el-button>
+        </div>
+      </div>
+
+      <el-table :data="filteredTypes" v-loading="loading" border stripe>
+        <el-table-column prop="id" label="ID" width="60" />
+        <el-table-column prop="typeId" label="类型标识" width="140" />
+        <el-table-column prop="displayName" label="显示名称" min-width="160" />
+        <el-table-column prop="family" label="家族" width="100" />
+        <el-table-column prop="parserPath" label="解析器路径" width="200" />
+        <el-table-column prop="minConfidence" label="阈值" width="70" />
+        <el-table-column label="状态" width="80">
+          <template slot-scope="{ row }">
+            <el-tag :type="row.isActive ? 'success' : 'info'" size="small">
+              {{ row.isActive ? '启用' : '停用' }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="source" label="来源" width="100" />
+        <el-table-column label="操作" width="220" fixed="right">
+          <template slot-scope="{ row }">
+            <el-button size="mini" type="primary" @click="handleEdit(row)">编辑</el-button>
+            <el-button size="mini" :type="row.isActive ? 'warning' : 'success'" @click="handleToggle(row)">
+              {{ row.isActive ? '停用' : '启用' }}
+            </el-button>
+            <el-dropdown trigger="hover" @command="(cmd) => handleActionCmd(row, cmd)">
+              <el-button size="mini">
+                更多<i class="el-icon-arrow-down el-icon--right"></i>
+              </el-button>
+              <el-dropdown-menu slot="dropdown">
+                <el-dropdown-item command="fingerprints" icon="el-icon-s-operation">指纹规则</el-dropdown-item>
+                <el-dropdown-item command="delete" icon="el-icon-delete" divided>删除</el-dropdown-item>
+              </el-dropdown-menu>
+            </el-dropdown>
+          </template>
+        </el-table-column>
+      </el-table>
+    </el-card>
+
+    <!-- 编辑/新增对话框 -->
+    <el-dialog :visible.sync="dialogVisible" :title="isEdit ? '编辑报告类型' : '新增报告类型'" width="550px">
+      <el-form :model="form" label-width="100px" size="small">
+        <el-form-item label="类型标识" required>
+          <el-input v-model="form.typeId" placeholder="如 dan_a1 / gut_beijing" :disabled="isEdit" />
+          <div class="el-form-item-msg">唯一标识,创建后不可修改</div>
+        </el-form-item>
+        <el-form-item label="显示名称" required>
+          <el-input v-model="form.displayName" placeholder="如 A1 儿童核心认知发展" />
+        </el-form-item>
+        <el-form-item label="所属家族">
+          <el-input v-model="form.family" placeholder="如 dan / gut_flora / custom" />
+        </el-form-item>
+        <el-form-item label="描述">
+          <el-input v-model="form.description" type="textarea" :rows="2" />
+        </el-form-item>
+        <el-form-item label="解析器路径">
+          <el-input v-model="form.parserPath" placeholder="如 dan_reports/extract_all_types.py" />
+        </el-form-item>
+        <el-form-item label="识别阈值">
+          <el-input-number v-model="form.minConfidence" :min="1" :max="100" />
+        </el-form-item>
+      </el-form>
+      <div slot="footer">
+        <el-button @click="dialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="handleSubmit" :loading="submitting">保存</el-button>
+      </div>
+    </el-dialog>
+
+    <!-- 指纹规则管理对话框 -->
+    <el-dialog :visible.sync="fpDialogVisible" title="指纹规则管理" width="700px" :close-on-click-modal="false">
+      <div slot="header">
+        <span>指纹规则 - {{ currentType?.typeId }}</span>
+        <el-button type="primary" size="small" style="float:right" @click="handleAddFp">+ 新增指纹</el-button>
+      </div>
+      <el-table :data="fingerprints" v-loading="fpLoading" border stripe size="small">
+        <el-table-column prop="pattern" label="文本特征" min-width="250" />
+        <el-table-column prop="weight" label="权重" width="80" />
+        <el-table-column label="类型" width="80">
+          <template slot-scope="{ row }">
+            <el-tag :type="row.isExclusion ? 'danger' : 'success'" size="mini">
+              {{ row.isExclusion ? '排除' : '正向' }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="sortOrder" label="排序" width="60" />
+        <el-table-column label="操作" width="120" fixed="right">
+          <template slot-scope="{ row }">
+            <el-button size="mini" type="danger" @click="handleDeleteFp(row)">删除</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+
+      <el-dialog :visible.sync="fpFormVisible" title="新增指纹规则" width="450px" :append-to-body="true">
+        <el-form :model="fpForm" label-width="80px" size="small">
+          <el-form-item label="文本特征" required>
+            <el-input v-model="fpForm.pattern" placeholder="如 GROWTH MINDSET" />
+          </el-form-item>
+          <el-form-item label="权重">
+            <el-input-number v-model="fpForm.weight" :min="-100" :max="100" />
+            <div class="el-form-item-msg">正=正向匹配加分,负=排除扣分</div>
+          </el-form-item>
+          <el-form-item label="排除规则">
+            <el-switch v-model="fpForm.isExclusion" />
+          </el-form-item>
+          <el-form-item label="排序">
+            <el-input-number v-model="fpForm.sortOrder" :min="0" :max="999" />
+          </el-form-item>
+        </el-form>
+        <div slot="footer">
+          <el-button @click="fpFormVisible = false">取消</el-button>
+          <el-button type="primary" @click="handleSubmitFp" :loading="fpSubmitting">保存</el-button>
+        </div>
+      </el-dialog>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { getReportTypes, saveReportType, toggleReportType, deleteReportType,
+         getFingerprints, saveFingerprint, deleteFingerprint } from '@/api/reportParser'
+
+export default {
+  name: 'ReportTypeManagement',
+  data() {
+    return {
+      loading: false, submitting: false, fpLoading: false, fpSubmitting: false,
+      types: [], keyword: '',
+      dialogVisible: false, isEdit: false,
+      form: { typeId: '', displayName: '', family: '', description: '', parserPath: '', minConfidence: 10 },
+      fpDialogVisible: false, currentType: null,
+      fingerprints: [], fpFormVisible: false,
+      fpForm: { pattern: '', weight: 10, isExclusion: false, sortOrder: 0 },
+    }
+  },
+  computed: {
+    filteredTypes() {
+      if (!this.keyword) return this.types
+      const kw = this.keyword.toLowerCase()
+      return this.types.filter(t =>
+        (t.typeId || '').toLowerCase().includes(kw) ||
+        (t.displayName || '').toLowerCase().includes(kw) ||
+        (t.family || '').toLowerCase().includes(kw))
+    }
+  },
+  mounted() { this.fetchTypes() },
+  methods: {
+    async fetchTypes() {
+      this.loading = true
+      try {
+        const res = await getReportTypes({ page: 1, size: 999 })
+        this.types = (res.data?.records || res.data || [])
+      } catch (e) { this.$message.error('加载失败') }
+      this.loading = false
+    },
+    handleCreate() {
+      this.isEdit = false
+      this.form = { typeId: '', displayName: '', family: '', description: '', parserPath: '', minConfidence: 10 }
+      this.dialogVisible = true
+    },
+    handleEdit(row) {
+      this.isEdit = true
+      this.form = { ...row }
+      this.dialogVisible = true
+    },
+    async handleSubmit() {
+      this.submitting = true
+      try {
+        await saveReportType(this.form)
+        this.$message.success('保存成功')
+        this.dialogVisible = false
+        this.fetchTypes()
+      } catch (e) { this.$message.error('保存失败') }
+      this.submitting = false
+    },
+    async handleToggle(row) {
+      try {
+        await toggleReportType({ typeId: row.typeId, isActive: !row.isActive })
+        this.$message.success('已更新')
+        this.fetchTypes()
+      } catch (e) { this.$message.error('操作失败') }
+    },
+    handleActionCmd(row, cmd) {
+      if (cmd === 'fingerprints') this.handleFingerprints(row)
+      if (cmd === 'delete') this.handleDelete(row)
+    },
+    async handleDelete(row) {
+      await this.$confirm('确定删除此类型及其指纹规则?', '提示', { type: 'warning' })
+      try {
+        await deleteReportType({ typeId: row.typeId })
+        this.$message.success('已删除')
+        this.fetchTypes()
+      } catch (e) { this.$message.error('删除失败') }
+    },
+    async handleFingerprints(row) {
+      this.currentType = row
+      this.fpDialogVisible = true
+      this.fingerprints = []
+      this.fpLoading = true
+      try {
+        const res = await getFingerprints({ typeId: row.typeId })
+        this.fingerprints = res.data || []
+      } catch (e) { this.$message.error('加载指纹失败') }
+      this.fpLoading = false
+    },
+    handleAddFp() {
+      this.fpForm = { pattern: '', weight: 10, isExclusion: false, sortOrder: 0 }
+      this.fpFormVisible = true
+    },
+    async handleSubmitFp() {
+      this.fpSubmitting = true
+      try {
+        await saveFingerprint({ ...this.fpForm, typeId: this.currentType.typeId })
+        this.$message.success('保存成功')
+        this.fpFormVisible = false
+        this.handleFingerprints(this.currentType)
+      } catch (e) { this.$message.error('保存失败') }
+      this.fpSubmitting = false
+    },
+    async handleDeleteFp(row) {
+      await this.$confirm('确定删除此指纹规则?', '提示', { type: 'warning' })
+      try {
+        await deleteFingerprint({ id: row.id })
+        this.$message.success('已删除')
+        this.handleFingerprints(this.currentType)
+      } catch (e) { this.$message.error('删除失败') }
+    },
+  }
+}
+</script>
+
+<style scoped>
+.el-form-item-msg { font-size: 12px; color: #999; margin-top: 2px; }
+</style>

+ 133 - 0
cfc-web/src/views/admin/ReportUnknownCluster.vue

@@ -0,0 +1,133 @@
+<template>
+  <div class="report-unknown admin-page">
+    <el-card>
+      <div slot="header">
+        <span>未知报告审核</span>
+        <div style="float: right; display: flex; gap: 10px;">
+          <el-select v-model="statusFilter" size="small" style="width: 140px;" @change="fetchClusters">
+            <el-option label="全部" value="" />
+            <el-option label="收集" value="collecting" />
+            <el-option label="可生成" value="ready" />
+            <el-option label="已生成" value="generated" />
+          </el-select>
+        </div>
+      </div>
+
+      <el-table :data="clusters" v-loading="loading" border stripe>
+        <el-table-column prop="id" label="ID" width="60" />
+        <el-table-column prop="clusterKey" label="聚类键" width="140" />
+        <el-table-column prop="reportCount" label="报告数" width="80" />
+        <el-table-column label="状态" width="100">
+          <template slot-scope="{ row }">
+            <el-tag :type="statusType(row.status)" size="small">{{ statusText(row.status) }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="generatedTypeId" label="生成类型" width="140" />
+        <el-table-column prop="updatedAt" label="更新时间" width="180" />
+        <el-table-column label="操作" width="160" fixed="right">
+          <template slot-scope="{ row }">
+            <el-button size="mini" type="primary" @click="handleDetail(row)">详情</el-button>
+            <el-button v-if="row.status === 'ready'" size="mini" type="success" @click="handleGenerate(row)">生成类型</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+    </el-card>
+
+    <!-- 详情对话框 -->
+    <el-dialog :visible.sync="detailVisible" title="聚类详情" width="750px" :close-on-click-modal="false">
+      <template v-if="currentCluster">
+        <el-descriptions :column="2" border size="small">
+          <el-descriptions-item label="聚类键">{{ currentCluster.clusterKey }}</el-descriptions-item>
+          <el-descriptions-item label="报告数">{{ currentCluster.reportCount }}</el-descriptions-item>
+          <el-descriptions-item label="状态">{{ statusText(currentCluster.status) }}</el-descriptions-item>
+          <el-descriptions-item label="生成类型">{{ currentCluster.generatedTypeId || '—' }}</el-descriptions-item>
+        </el-descriptions>
+
+        <h4>公共文本特征</h4>
+        <pre class="detail-pre">{{ currentCluster.commonSignatures }}</pre>
+
+        <h4>LLM 解析模板</h4>
+        <pre class="detail-pre">{{ formatJson(currentCluster.llmTemplate) }}</pre>
+
+        <h4>聚类内报告</h4>
+        <el-table :data="uploads" v-loading="uploadLoading" border size="small">
+          <el-table-column prop="id" label="ID" width="60" />
+          <el-table-column prop="fileUrl" label="文件" min-width="200" />
+          <el-table-column prop="uploadedAt" label="上传时间" width="180" />
+        </el-table>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { getUnknownClusters, getClusterUploads, generateTypeFromCluster } from '@/api/reportParser'
+
+export default {
+  name: 'ReportUnknownCluster',
+  data() {
+    return {
+      loading: false, uploadLoading: false,
+      clusters: [], statusFilter: '',
+      detailVisible: false, currentCluster: null,
+      uploads: [],
+    }
+  },
+  mounted() { this.fetchClusters() },
+  methods: {
+    async fetchClusters() {
+      this.loading = true
+      try {
+        const res = await getUnknownClusters({ page: 1, size: 50, status: this.statusFilter })
+        this.clusters = res.data?.records || res.data || []
+      } catch (e) { this.$message.error('加载失败') }
+      this.loading = false
+    },
+    statusType(status) {
+      return { collecting: 'info', ready: 'warning', generated: 'success', imported: 'success' }[status] || 'info'
+    },
+    statusText(status) {
+      return { collecting: '收集', ready: '可生成', generated: '已生成', imported: '已导入' }[status] || status
+    },
+    formatJson(str) {
+      if (!str) return ''
+      try { return JSON.stringify(JSON.parse(str), null, 2) } catch (e) { return str }
+    },
+    async handleDetail(row) {
+      this.currentCluster = row
+      this.detailVisible = true
+      this.uploads = []
+      this.uploadLoading = true
+      try {
+        const res = await getClusterUploads({ clusterId: row.id })
+        this.uploads = res.data || []
+      } catch (e) { this.$message.error('加载报告失败') }
+      this.uploadLoading = false
+    },
+    handleGenerate(row) {
+      this.$confirm('确认为此聚类生成报告类型?将创建待审核的报告类型和指纹规则。', '提示', { type: 'warning' })
+        .then(async () => {
+          try {
+            const res = await generateTypeFromCluster({ clusterId: row.id })
+            this.$message.success('已生成类型: ' + (res.data?.typeId || ''))
+            this.fetchClusters()
+          } catch (e) { this.$message.error('生成失败') }
+        }).catch(() => {})
+    },
+  }
+}
+</script>
+
+<style scoped>
+.detail-pre {
+  background: #f5f7fa;
+  border: 1px solid #e4e7ed;
+  border-radius: 4px;
+  padding: 10px;
+  max-height: 300px;
+  overflow: auto;
+  font-size: 12px;
+  white-space: pre-wrap;
+  word-break: break-all;
+}
+</style>