@@ -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 : 12 px ;
}
. upload - card : deep ( . el - upload ) {
width : 100 % ;
}
. upload - row {
display : flex ;
gap : 12 px ;
align - items : flex - start ;
}
. upload - hint {
padding : 6 px 0 ;
}
. upload - icon {
font - size : 40 px ;
color : # c0c4cc ;
}
. detail - body {
display : flex ;
gap : 12 px ;
height : 100 % ;
}
. clause - panel {
width : 280 px ;
flex - shrink : 0 ;
overflow - y : auto ;
border : 1 px solid # e4e7ed ;
border - radius : 8 px ;
padding : 8 px ;
}
. panel - title {
font - weight : 600 ;
margin - bottom : 8 px ;
color : # 303133 ;
}
. clause - item {
padding : 8 px ;
border - radius : 6 px ;
cursor : pointer ;
margin - bottom : 6 px ;
border : 1 px solid # f0f0f0 ;
}
. clause - item : hover , . clause - item . active {
border - color : # 409 eff ;
background : # ecf5ff ;
}
. clause - title {
font - weight : 600 ;
color : # 409 eff ;
margin - left : 6 px ;
}
. clause - preview {
font - size : 12 px ;
color : # 909399 ;
margin - top : 4 px ;
overflow : hidden ;
text - overflow : ellipsis ;
white - space : nowrap ;
}
. mark - panel {
flex : 1 ;
overflow - y : auto ;
}
. clause - full {
background : # fafafa ;
border : 1 px solid # e4e7ed ;
border - radius : 8 px ;
padding : 10 px 12 px ;
font - size : 13 px ;
line - height : 1.8 ;
color : # 606266 ;
margin - bottom : 12 px ;
}
. clause - full - title {
font - weight : 600 ;
color : # 303133 ;
}
. mark - card {
border : 1 px solid # e4e7ed ;
border - radius : 8 px ;
padding : 10 px 12 px ;
margin - bottom : 10 px ;
background : # fff ;
}
. mark - card . weak {
background : # fafafa ;
}
. mark - head {
display : flex ;
align - items : center ;
gap : 8 px ;
margin - bottom : 6 px ;
}
. mark - law {
font - weight : 600 ;
color : # 303133 ;
}
. mark - content {
font - size : 13 px ;
color : # 606266 ;
line - height : 1.7 ;
margin - bottom : 6 px ;
}
. mark - reason {
font - size : 12 px ;
color : # 409 eff ;
background : # ecf5ff ;
border - radius : 4 px ;
padding : 4 px 8 px ;
}
< / style >