This commit is contained in:
2026-08-06 10:05:40 +08:00
parent 139f6ccfd6
commit 74ecb6d277
22 changed files with 1444 additions and 24 deletions
+17
View File
@@ -0,0 +1,17 @@
import request from './request.js'
export function uploadContract(formData) {
return request.post('/contract/upload', formData, { headers: { 'Content-Type': 'multipart/form-data' } })
}
export function listContracts(params) {
return request.get('/contract/list', { params })
}
export function getContractDetail(id) {
return request.get('/contract/detail', { params: { id } })
}
export function deleteContract(id) {
return request.post('/contract/delete', { id })
}
+6
View File
@@ -36,6 +36,12 @@ const routes = [
meta: { title: '知识图谱' },
component: () => import('../views/KgGraph.vue')
},
{
path: 'contract',
name: 'Contract',
meta: { title: '合同标注' },
component: () => import('../views/Contract.vue')
},
{
path: 'settings',
name: 'Settings',
+1 -1
View File
@@ -33,7 +33,7 @@
<div class="citation-head">
<span class="citation-idx">[{{ c.index }}]</span>
<el-tag v-for="s in c.sources" :key="s" size="small" type="info">{{ s }}</el-tag>
<span class="citation-score">得分 {{ c.score.toFixed(4) }}</span>
<span class="citation-score">得分 {{ c.score.toFixed(1) }}</span>
</div>
<div class="citation-content">{{ c.content }}</div>
</div>
+327
View File
@@ -0,0 +1,327 @@
<template>
<div class="contract-page">
<!-- 上传区 -->
<el-card class="upload-card" shadow="never">
<div class="upload-row">
<el-select v-model="dsIds" multiple placeholder="选择法律语料数据集(可多选)" style="width: 320px">
<el-option v-for="d in datasets" :key="d.id" :label="d.name" :value="d.id" />
</el-select>
<el-upload drag :show-file-list="false" :http-request="doUpload" :disabled="uploading" accept=".txt,.md,.pdf,.docx,.doc,.html,.htm">
<div class="upload-hint">
<el-icon class="upload-icon"><UploadFilled /></el-icon>
<div>上传合同文件逐条款标注对应法律条文</div>
</div>
</el-upload>
</div>
</el-card>
<!-- 任务列表 -->
<el-card class="task-card" shadow="never">
<template #header><span>标注任务</span></template>
<el-table :data="tasks" v-loading="loading" size="default">
<el-table-column prop="filename" label="合同文件" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
<el-link type="primary" @click="openDetail(row)">{{ row.filename }}</el-link>
</template>
</el-table-column>
<el-table-column label="法律语料" min-width="140">
<template #default="{ row }">
<el-tag v-for="id in parseDsIds(row.dataset_ids)" :key="id" size="small" style="margin-right: 4px">
{{ dsName(id) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="进度" width="160">
<template #default="{ row }">
<el-progress v-if="row.total_clauses > 0" :percentage="Math.round(row.done_clauses * 100 / row.total_clauses)" :stroke-width="12" />
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="状态" width="110">
<template #default="{ row }">
<el-tag :type="statusType(row.status)">{{ statusText(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="160" />
<el-table-column label="操作" width="80">
<template #default="{ row }">
<el-button link type="danger" @click="removeTask(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 详情抽屉条款 + 标注 -->
<el-drawer v-model="detailVisible" :title="detailTitle" size="80%" destroy-on-close>
<div v-if="detail" class="detail-body">
<div class="clause-panel">
<div class="panel-title">合同条款{{ detail.clauses.length }}</div>
<div v-for="cl in detail.clauses" :key="cl.id" class="clause-item"
:class="{ active: cl.id === activeClauseId }" @click="activeClauseId = cl.id">
<el-tag :type="clauseStatusType(cl.status)" size="small">{{ clauseStatusText(cl.status) }}</el-tag>
<span class="clause-title">{{ cl.title }}</span>
<div class="clause-preview">{{ cl.content.slice(0, 60) }}</div>
</div>
</div>
<div class="mark-panel">
<template v-if="activeClause">
<div class="panel-title">标注结果{{ (marksFor(activeClause.id) || []).length }}</div>
<div class="clause-full">
<span class="clause-full-title">{{ activeClause.title }}</span>
{{ activeClause.content }}
</div>
<div v-for="m in sortedMarks(activeClause.id)" :key="m.id" class="mark-card" :class="{ weak: m.score < 5 }">
<div class="mark-head">
<el-tag :type="scoreType(m.score)" size="small">{{ m.score }} </el-tag>
<span class="mark-law">{{ m.law_title }}{{ m.law_item }}</span>
</div>
<div class="mark-content">{{ m.content }}</div>
<div class="mark-reason">{{ m.reason }}</div>
</div>
<el-empty v-if="!(marksFor(activeClause.id) || []).length" description="暂无标注" :image-size="60" />
</template>
<el-empty v-else description="点击左侧条款查看标注" :image-size="80" />
</div>
</div>
</el-drawer>
</div>
</template>
<script setup>
import { onMounted, onUnmounted, computed, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { UploadFilled } from '@element-plus/icons-vue'
import { listDatasets } from '../api/dataset.js'
import { uploadContract, listContracts, getContractDetail, deleteContract } from '../api/contract.js'
const datasets = ref([])
const dsIds = ref([])
const uploading = ref(false)
const tasks = ref([])
const loading = ref(false)
const detailVisible = ref(false)
const detail = ref(null)
const activeClauseId = ref(null)
let pollTimer = null
const dsNameMap = computed(() => {
const m = {}
for (const d of datasets.value) m[d.id] = d.name
return m
})
onMounted(async () => {
try {
const d = await listDatasets()
if (d && d.list) datasets.value = d.list
} catch { /* 忽略 */ }
await loadTasks()
})
onUnmounted(stopPolling)
function dsName(id) { return dsNameMap.value[id] || ('#' + id) }
function parseDsIds(s) { return (s || '').split(',').filter(Boolean).map(Number) }
function statusText(s) { return ['待处理', '标注中', '完成', '失败'][s] || '-' }
function statusType(s) { return ['info', 'warning', 'success', 'danger'][s] || 'info' }
function clauseStatusText(s) { return ['待处理', '标注中', '完成', '失败'][s] || '-' }
function clauseStatusType(s) { return ['info', 'warning', 'success', 'danger'][s] || 'info' }
function scoreType(score) { return score >= 8 ? 'success' : score >= 5 ? 'warning' : 'info' }
async function loadTasks() {
loading.value = true
try {
const r = await listContracts({ page: 1, page_size: 100 })
tasks.value = (r && r.list) || []
const hasRunning = tasks.value.some(t => t.status === 0 || t.status === 1)
if (hasRunning) startPolling()
} finally {
loading.value = false
}
}
function startPolling() {
stopPolling()
pollTimer = setInterval(async () => {
const r = await listContracts({ page: 1, page_size: 100 })
tasks.value = (r && r.list) || []
if (!tasks.value.some(t => t.status === 0 || t.status === 1)) stopPolling()
}, 3000)
}
function stopPolling() {
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
}
async function doUpload({ file }) {
if (!dsIds.value.length) {
ElMessage.warning('请先选择法律语料数据集')
return
}
uploading.value = true
try {
const formData = new FormData()
formData.append('dataset_ids', JSON.stringify(dsIds.value))
formData.append('file', file)
await uploadContract(formData)
ElMessage.success('已提交标注任务')
await loadTasks()
} catch (e) {
ElMessage.error(e.message || '上传失败')
} finally {
uploading.value = false
}
}
async function openDetail(row) {
const r = await getContractDetail(row.id)
detail.value = r
activeClauseId.value = (r.clauses && r.clauses.length) ? r.clauses[0].id : null
detailVisible.value = true
if (row.status === 0 || row.status === 1) {
startPolling()
}
}
async function removeTask(row) {
try {
await ElMessageBox.confirm('删除任务将同时删除条款与标注结果,确认?', '删除任务', { type: 'warning' })
} catch {
return
}
await deleteContract(row.id)
ElMessage.success('已删除')
await loadTasks()
}
const activeClause = computed(() => {
if (!detail.value) return null
return detail.value.clauses.find(c => c.id === activeClauseId.value) || null
})
function marksFor(clauseId) {
return (detail.value && detail.value.marks && detail.value.marks[clauseId]) || []
}
function sortedMarks(clauseId) {
return [...marksFor(clauseId)].sort((a, b) => b.score - a.score)
}
const detailTitle = computed(() => detail.value ? detail.value.task.filename : '')
</script>
<style scoped>
.contract-page {
display: flex;
flex-direction: column;
gap: 12px;
}
.upload-card :deep(.el-upload) {
width: 100%;
}
.upload-row {
display: flex;
gap: 12px;
align-items: flex-start;
}
.upload-hint {
padding: 6px 0;
}
.upload-icon {
font-size: 40px;
color: #c0c4cc;
}
.detail-body {
display: flex;
gap: 12px;
height: 100%;
}
.clause-panel {
width: 280px;
flex-shrink: 0;
overflow-y: auto;
border: 1px solid #e4e7ed;
border-radius: 8px;
padding: 8px;
}
.panel-title {
font-weight: 600;
margin-bottom: 8px;
color: #303133;
}
.clause-item {
padding: 8px;
border-radius: 6px;
cursor: pointer;
margin-bottom: 6px;
border: 1px solid #f0f0f0;
}
.clause-item:hover, .clause-item.active {
border-color: #409eff;
background: #ecf5ff;
}
.clause-title {
font-weight: 600;
color: #409eff;
margin-left: 6px;
}
.clause-preview {
font-size: 12px;
color: #909399;
margin-top: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mark-panel {
flex: 1;
overflow-y: auto;
}
.clause-full {
background: #fafafa;
border: 1px solid #e4e7ed;
border-radius: 8px;
padding: 10px 12px;
font-size: 13px;
line-height: 1.8;
color: #606266;
margin-bottom: 12px;
}
.clause-full-title {
font-weight: 600;
color: #303133;
}
.mark-card {
border: 1px solid #e4e7ed;
border-radius: 8px;
padding: 10px 12px;
margin-bottom: 10px;
background: #fff;
}
.mark-card.weak {
background: #fafafa;
}
.mark-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.mark-law {
font-weight: 600;
color: #303133;
}
.mark-content {
font-size: 13px;
color: #606266;
line-height: 1.7;
margin-bottom: 6px;
}
.mark-reason {
font-size: 12px;
color: #409eff;
background: #ecf5ff;
border-radius: 4px;
padding: 4px 8px;
}
</style>
+5 -1
View File
@@ -15,6 +15,10 @@
<el-icon><Share /></el-icon>
<span>知识图谱</span>
</el-menu-item>
<el-menu-item index="/contract">
<el-icon><Document /></el-icon>
<span>合同标注</span>
</el-menu-item>
<el-menu-item index="/settings">
<el-icon><Setting /></el-icon>
<span>设置</span>
@@ -36,7 +40,7 @@
<script setup>
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Folder, ChatDotRound, Setting, Share } from '@element-plus/icons-vue'
import { Folder, ChatDotRound, Setting, Share, Document } from '@element-plus/icons-vue'
import { useAuthStore } from '../stores/auth.js'
const route = useRoute()