feat(report): 更新评估报告前端组件和 API
- 优化 DimensionAnalysisPanel 维度分析面板 - 更新 LlmResultPanel LLM 结果展示组件 - 完善 PromptEditor 提示词编辑器功能 - 改进 ReportForm 报告表单交互 - 优化 ReportEditDrawer 报告编辑抽屉 - 调整 prisoner 页面显示 - 更新 evaluation-report 和 report API Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
934f2935ac
commit
79cfcf9c6d
@ -1,4 +1,5 @@
|
||||
import request from '@/config/axios'
|
||||
import { getAccessToken } from '@/utils/auth'
|
||||
|
||||
export interface TemplateVO {
|
||||
id?: number
|
||||
@ -72,10 +73,15 @@ export interface ReportVO {
|
||||
templateName?: string
|
||||
title?: string
|
||||
evaluationDate?: string
|
||||
evaluationType?: number
|
||||
evaluationCycle?: number
|
||||
riskLevel?: number
|
||||
conclusion?: string
|
||||
suggestions?: string // 与后端一致
|
||||
areaName?: string // 与后端一致
|
||||
areaId?: number
|
||||
evaluatorId?: number
|
||||
evaluatorName?: string
|
||||
status: number
|
||||
aiStatus?: number
|
||||
auditorId?: number
|
||||
@ -86,6 +92,17 @@ export interface ReportVO {
|
||||
createTime?: string
|
||||
}
|
||||
|
||||
// 报告更新请求VO - 只包含需要更新的字段
|
||||
export interface ReportUpdateReqVO {
|
||||
id: number
|
||||
dimensions?: string // 维度内容,JSON格式
|
||||
conclusion?: string
|
||||
suggestions?: string
|
||||
riskLevel?: number
|
||||
status?: number
|
||||
remark?: string
|
||||
}
|
||||
|
||||
export interface ReportPageReqVO {
|
||||
pageNo: number
|
||||
pageSize: number
|
||||
@ -109,6 +126,7 @@ export interface DimensionDataVO {
|
||||
modifiedTime?: string
|
||||
dataSource?: string
|
||||
rawData?: string
|
||||
createTime?: string
|
||||
}
|
||||
|
||||
export interface CommentVO {
|
||||
@ -318,7 +336,8 @@ export interface DimensionDataSourcesVO {
|
||||
// ========== 流式生成 API ==========
|
||||
export const StreamApi = {
|
||||
// 流式生成维度内容
|
||||
streamGenerateDimension: async (dimensionId: number, prisonerId: number, customPrompt?: string): Promise<EventSource> => {
|
||||
// 注意:EventSource API 不支持自定义 HTTP Header,所以需要通过 URL 参数传递 token
|
||||
streamGenerateDimension: async (dimensionId: number, prisonerId: number, customPrompt?: string, systemPrompt?: string): Promise<EventSource> => {
|
||||
const baseUrl = import.meta.env.VITE_BASE_URL || ''
|
||||
const url = new URL(`${baseUrl}/admin-api/prison/evaluation-report/dimension/stream-generate`, window.location.origin)
|
||||
url.searchParams.set('dimensionId', dimensionId.toString())
|
||||
@ -326,6 +345,14 @@ export const StreamApi = {
|
||||
if (customPrompt) {
|
||||
url.searchParams.set('customPrompt', customPrompt)
|
||||
}
|
||||
if (systemPrompt) {
|
||||
url.searchParams.set('systemPrompt', systemPrompt)
|
||||
}
|
||||
// 添加 token 参数用于认证(EventSource 不支持 Header)
|
||||
const token = getAccessToken()
|
||||
if (token) {
|
||||
url.searchParams.set('token', token)
|
||||
}
|
||||
return new EventSource(url.toString())
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
import request from '@/config/axios'
|
||||
|
||||
// 导入评估报告更新请求类型
|
||||
import type { ReportUpdateReqVO } from '../evaluation-report'
|
||||
|
||||
// ============ 评估报告模板相关类型 ============
|
||||
|
||||
/** 评估报告模板分页参数 */
|
||||
@ -63,6 +66,8 @@ export interface ReportDimensionContent {
|
||||
aiGenerateTime?: string // AI生成时间
|
||||
lastModifyTime?: string // 最后修改时间
|
||||
lastModifyBy?: string // 最后修改人
|
||||
enableAi?: boolean
|
||||
sort?: number
|
||||
}
|
||||
|
||||
/** 评估报告 */
|
||||
@ -72,6 +77,8 @@ export interface Report {
|
||||
prisonerId: number // 罪犯ID
|
||||
prisonerNo: string // 罪犯编号
|
||||
prisonerName?: string // 罪犯姓名
|
||||
areaId?: number // 监区ID
|
||||
areaName?: string // 监区名称
|
||||
templateId: number // 模板ID
|
||||
templateName?: string // 模板名称
|
||||
title: string // 报告标题
|
||||
@ -97,6 +104,23 @@ export interface Report {
|
||||
createTime?: string
|
||||
}
|
||||
|
||||
/** 报告创建请求 */
|
||||
export interface ReportCreateReqVO {
|
||||
prisonerId: number
|
||||
templateId: number
|
||||
title: string
|
||||
reportDate?: string
|
||||
evaluationDate?: string
|
||||
evaluationCycle?: number
|
||||
evaluationType?: number
|
||||
prisonerNo?: string
|
||||
prisonerName?: string
|
||||
areaId?: number
|
||||
areaName?: string
|
||||
status?: number
|
||||
dimensions?: ReportDimensionContent[]
|
||||
}
|
||||
|
||||
// ============ 快捷评语相关类型 ============
|
||||
|
||||
/** 快捷评语分类 */
|
||||
@ -201,12 +225,12 @@ export const ReportApi = {
|
||||
},
|
||||
|
||||
// 新增报告
|
||||
createReport: async (data: Report) => {
|
||||
createReport: async (data: ReportCreateReqVO) => {
|
||||
return await request.post({ url: '/prison/evaluation-report/report/create', data })
|
||||
},
|
||||
|
||||
// 修改报告
|
||||
updateReport: async (data: Report) => {
|
||||
// 修改报告 - 只传递需要更新的字段
|
||||
updateReport: async (data: ReportUpdateReqVO) => {
|
||||
return await request.put({ url: '/prison/evaluation-report/report/update', data })
|
||||
},
|
||||
|
||||
|
||||
@ -1,60 +1,81 @@
|
||||
<template>
|
||||
<div class="dimension-analysis">
|
||||
<!-- Tab 切换维度 -->
|
||||
<div class="dimension-tabs">
|
||||
<el-tabs v-model="activeDimensionId" @tab-change="handleDimensionChange">
|
||||
<el-tab-pane
|
||||
v-for="dimension in dimensions"
|
||||
:key="dimension.id"
|
||||
:name="dimension.id"
|
||||
:label="dimension.name"
|
||||
>
|
||||
<template #label>
|
||||
<span class="dimension-tab">
|
||||
<el-icon v-if="dimension.aiEnabled === 1"><Monitor /></el-icon>
|
||||
{{ dimension.name }}
|
||||
</span>
|
||||
</template>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
<div class="dimension-toolbar">
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="batchGenerating"
|
||||
:disabled="batchGenerating || hasAnyGenerating"
|
||||
@click="handleGenerateAll"
|
||||
>
|
||||
一键AI生成
|
||||
<span v-if="batchGenerating"> ({{ batchProgress.current }}/{{ batchProgress.total }})</span>
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="dimension-list">
|
||||
<div v-for="item in dimensionViews" :key="item.dimension.id" class="dimension-item">
|
||||
<div class="dimension-item-header">
|
||||
<div class="dimension-title">
|
||||
<el-icon v-if="item.dimension.aiEnabled === 1"><Monitor /></el-icon>
|
||||
<span>{{ item.dimension.name }}</span>
|
||||
<el-tag size="small" :type="getStatusTag(item.state).type">{{ getStatusTag(item.state).label }}</el-tag>
|
||||
<el-tag v-if="item.state.dirty" size="small" type="warning">未保存</el-tag>
|
||||
<span v-else-if="item.state.lastSavedAt" class="saved-at">已保存 {{ item.state.lastSavedAt }}</span>
|
||||
</div>
|
||||
<div class="dimension-actions">
|
||||
<el-button size="small" @click="openDataSourceDialog(item.dimension.id!)">查看数据源</el-button>
|
||||
<el-button size="small" @click="handleCopy(item.dimension.id!)" :disabled="!item.state.generatedResult">复制</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
type="primary"
|
||||
@click="handleSave(item.dimension.id!)"
|
||||
:disabled="!item.state.finalContent"
|
||||
>
|
||||
保存
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 左右拆分布局 -->
|
||||
<div class="split-container">
|
||||
<!-- 左边:数据源面板 -->
|
||||
<div class="left-panel" v-loading="dataSourcesLoading">
|
||||
<DataSourcePanel
|
||||
:data-sources="dataSources"
|
||||
:data-sources-config="currentDimension?.dataSources"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 右边:分析面板 -->
|
||||
<div class="right-panel">
|
||||
<PromptEditor
|
||||
ref="promptEditorRef"
|
||||
:dimension="currentDimension"
|
||||
:dimension="item.dimension"
|
||||
:prisoner="prisoner"
|
||||
@generate="handleGenerate"
|
||||
@regenerate="handleRegenerate"
|
||||
:generating="item.state.generating"
|
||||
:generated-content="item.state.generatedResult"
|
||||
@generate="(customPrompt?: string, systemPrompt?: string) => handleGenerate(item.dimension.id!, customPrompt, systemPrompt)"
|
||||
@regenerate="(customPrompt?: string, systemPrompt?: string) => handleRegenerate(item.dimension.id!, customPrompt, systemPrompt)"
|
||||
/>
|
||||
|
||||
<LlmResultPanel
|
||||
ref="llmResultRef"
|
||||
:generating="generating"
|
||||
:result="generatedResult"
|
||||
:thinking="thinkingContent"
|
||||
@save="handleSave"
|
||||
@copy="handleCopy"
|
||||
:generating="item.state.generating"
|
||||
:result="item.state.generatedResult"
|
||||
:thinking="item.state.thinkingContent"
|
||||
@save="(content?: string) => handleSave(item.dimension.id!, content)"
|
||||
@copy="() => handleCopy(item.dimension.id!)"
|
||||
@regenerate="() => handleRegenerate(item.dimension.id!)"
|
||||
@update-result="(content: string) => handleUpdateResult(item.dimension.id!, content)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 数据源弹窗 -->
|
||||
<el-dialog
|
||||
v-model="dataSourceDialogVisible"
|
||||
:title="dataSourceDialogTitle"
|
||||
width="70%"
|
||||
top="8vh"
|
||||
>
|
||||
<div class="data-source-dialog-body" v-loading="dataSourceDialogLoading">
|
||||
<DataSourcePanel
|
||||
:data-sources="dataSourceDialogData"
|
||||
:data-sources-config="dataSourceDialogDimension?.dataSources"
|
||||
/>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Monitor } from '@element-plus/icons-vue'
|
||||
import { DimensionDataApi, DimensionDataSourcesVO, StreamApi, DimensionVO, ReportVO } from '@/api/prison/evaluation-report'
|
||||
import { DimensionDataApi, DimensionDataSourcesVO, DimensionDataVO, StreamApi, DimensionVO, ReportVO } from '@/api/prison/evaluation-report'
|
||||
import DataSourcePanel from './DataSourcePanel.vue'
|
||||
import PromptEditor from './PromptEditor.vue'
|
||||
import LlmResultPanel from './LlmResultPanel.vue'
|
||||
@ -73,144 +94,463 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
// 状态
|
||||
const activeDimensionId = ref<number>()
|
||||
const dataSources = ref<DimensionDataSourcesVO | null>(null)
|
||||
const dataSourcesLoading = ref(false)
|
||||
const generating = ref(false)
|
||||
const generatedResult = ref('')
|
||||
const thinkingContent = ref('')
|
||||
const dataSourceDialogVisible = ref(false)
|
||||
const dataSourceDialogDimensionId = ref<number | null>(null)
|
||||
const dataSourcesMap = reactive<Record<string, DimensionDataSourcesVO | null>>({})
|
||||
const dataSourcesLoadingMap = reactive<Record<string, boolean>>({})
|
||||
const dimensionDataLoading = ref(false)
|
||||
|
||||
// Refs
|
||||
const promptEditorRef = ref()
|
||||
const llmResultRef = ref()
|
||||
// 结构化数据
|
||||
interface SectionData {
|
||||
type: 'analysis' | 'final'
|
||||
key: string
|
||||
title: string
|
||||
data: Record<string, any>
|
||||
}
|
||||
interface DimensionState {
|
||||
generating: boolean
|
||||
generatedResult: string
|
||||
thinkingContent: string
|
||||
analysisSections: SectionData[]
|
||||
finalSections: SectionData[]
|
||||
finalContent: string
|
||||
eventSource: EventSource | null
|
||||
lastCustomPrompt?: string
|
||||
lastSystemPrompt?: string
|
||||
dirty: boolean
|
||||
lastSavedAt?: string
|
||||
}
|
||||
|
||||
// 当前维度
|
||||
const currentDimension = computed(() => {
|
||||
return props.dimensions.find(d => d.id === activeDimensionId.value)
|
||||
})
|
||||
const dimensionStates = reactive<Record<string, DimensionState>>({})
|
||||
const batchGenerating = ref(false)
|
||||
const batchProgress = ref({ total: 0, current: 0 })
|
||||
|
||||
// EventSource 实例
|
||||
let eventSource: EventSource | null = null
|
||||
|
||||
/** 初始化 */
|
||||
onMounted(() => {
|
||||
if (props.dimensions.length > 0) {
|
||||
activeDimensionId.value = props.dimensions[0].id
|
||||
loadDataSources()
|
||||
const getDimensionState = (dimensionId: number): DimensionState => {
|
||||
const key = String(dimensionId)
|
||||
if (!dimensionStates[key]) {
|
||||
dimensionStates[key] = {
|
||||
generating: false,
|
||||
generatedResult: '',
|
||||
thinkingContent: '',
|
||||
analysisSections: [],
|
||||
finalSections: [],
|
||||
finalContent: '',
|
||||
eventSource: null,
|
||||
lastCustomPrompt: undefined,
|
||||
lastSystemPrompt: undefined,
|
||||
dirty: false,
|
||||
lastSavedAt: undefined
|
||||
}
|
||||
}
|
||||
return dimensionStates[key]
|
||||
}
|
||||
|
||||
const dimensionViews = computed(() => {
|
||||
return props.dimensions
|
||||
.filter(d => d.id !== undefined && d.id !== null)
|
||||
.map(dimension => ({
|
||||
dimension,
|
||||
state: getDimensionState(dimension.id as number)
|
||||
}))
|
||||
})
|
||||
|
||||
/** 切换维度 */
|
||||
const handleDimensionChange = () => {
|
||||
loadDataSources()
|
||||
// 重置生成结果
|
||||
generatedResult.value = ''
|
||||
thinkingContent.value = ''
|
||||
type TagType = 'primary' | 'success' | 'warning' | 'info' | 'danger'
|
||||
|
||||
const getStatusTag = (state: DimensionState): { label: string; type: TagType } => {
|
||||
if (state.generating) {
|
||||
return { label: '生成中', type: 'warning' }
|
||||
}
|
||||
if (state.generatedResult) {
|
||||
return { label: '已生成', type: 'success' }
|
||||
}
|
||||
return { label: '未生成', type: 'info' }
|
||||
}
|
||||
|
||||
const hasAnyGenerating = computed(() => dimensionViews.value.some(item => item.state.generating))
|
||||
|
||||
const buildSystemPrompt = (dimension?: DimensionVO) => {
|
||||
if (!dimension) return undefined
|
||||
const requirement = dimension.aiPrompt || `根据服刑人员的基本信息和相关数据,撰写关于「${dimension.name}」的客观评估。`
|
||||
return `你是一位专业的监狱评估专家,负责撰写服刑人员的评估报告。
|
||||
当前评估维度:${dimension.name}
|
||||
|
||||
评估要求:
|
||||
${requirement}
|
||||
|
||||
请直接返回评估内容,格式为段落文本。`
|
||||
}
|
||||
const dataSourceDialogDimension = computed(() => {
|
||||
return props.dimensions.find(d => d.id === dataSourceDialogDimensionId.value)
|
||||
})
|
||||
|
||||
const dataSourceDialogData = computed(() => {
|
||||
if (!dataSourceDialogDimensionId.value) return null
|
||||
return dataSourcesMap[String(dataSourceDialogDimensionId.value)] || null
|
||||
})
|
||||
|
||||
const dataSourceDialogLoading = computed(() => {
|
||||
if (!dataSourceDialogDimensionId.value) return false
|
||||
return dataSourcesLoadingMap[String(dataSourceDialogDimensionId.value)] || false
|
||||
})
|
||||
|
||||
const dataSourceDialogTitle = computed(() => {
|
||||
return dataSourceDialogDimension.value ? `数据源 - ${dataSourceDialogDimension.value.name}` : '数据源'
|
||||
})
|
||||
|
||||
|
||||
/** 打开数据源弹窗 */
|
||||
const openDataSourceDialog = async (dimensionId: number) => {
|
||||
dataSourceDialogDimensionId.value = dimensionId
|
||||
dataSourceDialogVisible.value = true
|
||||
await loadDataSources(dimensionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 将结构化数据格式化为可读的 Markdown 文本
|
||||
* 前端完全控制显示格式
|
||||
*/
|
||||
const formatStructuredContent = (sections: SectionData[]): string => {
|
||||
const lines: string[] = []
|
||||
|
||||
for (const section of sections) {
|
||||
lines.push(`## ${section.title}`)
|
||||
lines.push('')
|
||||
|
||||
switch (section.key) {
|
||||
case 'custom':
|
||||
lines.push(section.data.content || '')
|
||||
break
|
||||
|
||||
case 'prisoner':
|
||||
if (section.data.name) lines.push(`- **姓名**: ${section.data.name}`)
|
||||
if (section.data.prisonerNo) lines.push(`- **编号**: ${section.data.prisonerNo}`)
|
||||
if (section.data.crime) lines.push(`- **罪名**: ${section.data.crime}`)
|
||||
break
|
||||
|
||||
case 'consumption':
|
||||
if (section.data.totalAmount !== undefined) {
|
||||
lines.push(`- **总消费金额**: ${section.data.totalAmount} 元`)
|
||||
}
|
||||
if (section.data.recordCount !== undefined) {
|
||||
lines.push(`- **消费记录数**: ${section.data.recordCount} 条`)
|
||||
}
|
||||
break
|
||||
|
||||
case 'score':
|
||||
if (section.data.totalScore !== undefined) {
|
||||
lines.push(`- **总得分**: ${section.data.totalScore} 分`)
|
||||
}
|
||||
if (section.data.recordCount !== undefined) {
|
||||
lines.push(`- **考核记录数**: ${section.data.recordCount} 条`)
|
||||
}
|
||||
if (section.data.level !== undefined) {
|
||||
lines.push(`- **平均等级**: ${section.data.level}`)
|
||||
}
|
||||
break
|
||||
|
||||
case 'risk':
|
||||
if (section.data.riskLevel) lines.push(`- **风险等级**: ${section.data.riskLevel}`)
|
||||
if (section.data.totalScore !== undefined) lines.push(`- **总分**: ${section.data.totalScore}`)
|
||||
if (section.data.riskFactors) lines.push(`- **风险因素**: ${section.data.riskFactors}`)
|
||||
if (section.data.suggestions) lines.push(`- **建议措施**: ${section.data.suggestions}`)
|
||||
break
|
||||
|
||||
case 'questionnaire':
|
||||
const records = section.data.records as Array<Record<string, any>>
|
||||
if (records && records.length > 0) {
|
||||
for (const record of records) {
|
||||
let item = `- ${record.questionnaireName || '未知问卷'}`
|
||||
if (record.totalScore !== undefined) {
|
||||
item += ` (得分: ${record.totalScore})`
|
||||
}
|
||||
lines.push(item)
|
||||
}
|
||||
} else {
|
||||
lines.push('暂无问卷记录')
|
||||
}
|
||||
break
|
||||
|
||||
case 'summary':
|
||||
lines.push(section.data.content || '暂无分析建议')
|
||||
break
|
||||
|
||||
default:
|
||||
// 通用处理:遍历 data 对象
|
||||
for (const [key, value] of Object.entries(section.data)) {
|
||||
if (value !== null && value !== undefined) {
|
||||
lines.push(`- **${key}**: ${value}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
/** 加载数据源 */
|
||||
const loadDataSources = async () => {
|
||||
if (!activeDimensionId.value) return
|
||||
|
||||
dataSourcesLoading.value = true
|
||||
const loadDataSources = async (dimensionId: number) => {
|
||||
const key = String(dimensionId)
|
||||
dataSourcesLoadingMap[key] = true
|
||||
try {
|
||||
dataSources.value = await DimensionDataApi.getDimensionDataSources(
|
||||
activeDimensionId.value,
|
||||
dataSourcesMap[key] = await DimensionDataApi.getDimensionDataSources(
|
||||
dimensionId,
|
||||
props.prisonerId
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('加载数据源失败:', error)
|
||||
} finally {
|
||||
dataSourcesLoading.value = false
|
||||
dataSourcesLoadingMap[key] = false
|
||||
}
|
||||
}
|
||||
|
||||
const resetDimensionStates = () => {
|
||||
props.dimensions.forEach(dimension => {
|
||||
if (!dimension.id) return
|
||||
const state = getDimensionState(dimension.id)
|
||||
state.generating = false
|
||||
state.generatedResult = ''
|
||||
state.thinkingContent = ''
|
||||
state.analysisSections = []
|
||||
state.finalSections = []
|
||||
state.finalContent = ''
|
||||
state.dirty = false
|
||||
state.lastSavedAt = undefined
|
||||
})
|
||||
}
|
||||
|
||||
const applyDimensionData = (list: DimensionDataVO[]) => {
|
||||
list.forEach(item => {
|
||||
if (!item.dimensionId) return
|
||||
const state = getDimensionState(item.dimensionId)
|
||||
if (item.aiAnalysis) {
|
||||
state.finalContent = item.aiAnalysis
|
||||
state.generatedResult = item.aiAnalysis
|
||||
state.dirty = false
|
||||
state.lastSavedAt = item.modifiedTime || item.createTime
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const loadDimensionData = async () => {
|
||||
if (!props.reportId) return
|
||||
dimensionDataLoading.value = true
|
||||
try {
|
||||
resetDimensionStates()
|
||||
const list = await DimensionDataApi.getDimensionDataListByReportId(props.reportId)
|
||||
applyDimensionData(list || [])
|
||||
} catch (error) {
|
||||
console.error('加载维度内容失败:', error)
|
||||
} finally {
|
||||
dimensionDataLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 开始生成 */
|
||||
const handleGenerate = async (customPrompt?: string) => {
|
||||
if (!activeDimensionId.value) return
|
||||
const handleGenerate = async (dimensionId: number, customPrompt?: string, systemPrompt?: string): Promise<void> => {
|
||||
const state = getDimensionState(dimensionId)
|
||||
const promptToUse = customPrompt !== undefined ? customPrompt : state.lastCustomPrompt
|
||||
let systemPromptToUse = systemPrompt !== undefined ? systemPrompt : state.lastSystemPrompt
|
||||
|
||||
if (customPrompt !== undefined) {
|
||||
state.lastCustomPrompt = customPrompt
|
||||
}
|
||||
if (systemPrompt !== undefined) {
|
||||
state.lastSystemPrompt = systemPrompt
|
||||
}
|
||||
if (!systemPromptToUse) {
|
||||
const dimension = props.dimensions.find(d => d.id === dimensionId)
|
||||
systemPromptToUse = buildSystemPrompt(dimension)
|
||||
if (systemPromptToUse) {
|
||||
state.lastSystemPrompt = systemPromptToUse
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭之前的连接
|
||||
closeEventSource()
|
||||
closeEventSource(dimensionId)
|
||||
|
||||
generating.value = true
|
||||
generatedResult.value = ''
|
||||
thinkingContent.value = ''
|
||||
state.generating = true
|
||||
state.generatedResult = ''
|
||||
state.thinkingContent = ''
|
||||
state.analysisSections = []
|
||||
state.finalSections = []
|
||||
state.finalContent = ''
|
||||
state.dirty = false
|
||||
|
||||
try {
|
||||
eventSource = await StreamApi.streamGenerateDimension(
|
||||
activeDimensionId.value,
|
||||
state.eventSource = await StreamApi.streamGenerateDimension(
|
||||
dimensionId,
|
||||
props.prisonerId,
|
||||
customPrompt
|
||||
promptToUse,
|
||||
systemPromptToUse
|
||||
)
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
const content = event.data
|
||||
if (content.startsWith('**思考过程**:')) {
|
||||
// 思考过程内容,逐字显示
|
||||
const thinkingText = content.replace('**思考过程**:', '')
|
||||
thinkingContent.value += thinkingText
|
||||
} else {
|
||||
// 正式内容
|
||||
generatedResult.value += content
|
||||
// 监听开始事件 - 包含维度基本信息
|
||||
state.eventSource.addEventListener('start', (event: MessageEvent) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data)
|
||||
console.log('SSE 开始:', data)
|
||||
// 可以在这里处理维度名称、描述等信息
|
||||
} catch (e) {
|
||||
console.error('解析 start 事件失败:', e)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
eventSource.onerror = () => {
|
||||
generating.value = false
|
||||
closeEventSource()
|
||||
}
|
||||
// 监听数据段落事件 - 结构化数据
|
||||
// section payload: { type: 'analysis' | 'final', key, title, data }
|
||||
state.eventSource.addEventListener('section', (event: MessageEvent) => {
|
||||
try {
|
||||
const section = JSON.parse(event.data) as SectionData
|
||||
if (section.type === 'analysis') {
|
||||
state.analysisSections.push(section)
|
||||
state.thinkingContent = formatStructuredContent(state.analysisSections)
|
||||
} else if (section.type === 'final') {
|
||||
state.finalSections.push(section)
|
||||
state.finalContent = formatStructuredContent(state.finalSections)
|
||||
state.generatedResult = state.finalContent
|
||||
state.dirty = true
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('解析 section 事件失败:', e)
|
||||
}
|
||||
})
|
||||
|
||||
eventSource.onopen = () => {
|
||||
console.log('SSE 连接已建立')
|
||||
}
|
||||
return await new Promise<void>((resolve) => {
|
||||
const finalize = () => {
|
||||
closeEventSource(dimensionId)
|
||||
resolve()
|
||||
}
|
||||
|
||||
// 监听完成事件
|
||||
state.eventSource!.addEventListener('complete', (event: MessageEvent) => {
|
||||
console.log('SSE 完成:', event.data)
|
||||
finalize()
|
||||
})
|
||||
|
||||
// 监听错误事件(服务端发送的)
|
||||
state.eventSource!.addEventListener('error', (event: MessageEvent) => {
|
||||
console.error('SSE 服务端错误:', event.data)
|
||||
finalize()
|
||||
})
|
||||
|
||||
// 连接错误(网络等)
|
||||
state.eventSource!.onerror = (error) => {
|
||||
console.error('SSE 连接错误:', error)
|
||||
finalize()
|
||||
}
|
||||
|
||||
state.eventSource!.onopen = () => {
|
||||
console.log('SSE 连接已建立')
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('启动流式生成失败:', error)
|
||||
generating.value = false
|
||||
state.generating = false
|
||||
closeEventSource(dimensionId)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
/** 重新生成 */
|
||||
const handleRegenerate = () => {
|
||||
const customPrompt = promptEditorRef.value?.getCustomPrompt()
|
||||
handleGenerate(customPrompt)
|
||||
const handleRegenerate = (dimensionId: number, customPrompt?: string, systemPrompt?: string) => {
|
||||
if (customPrompt !== undefined || systemPrompt !== undefined) {
|
||||
handleGenerate(dimensionId, customPrompt, systemPrompt)
|
||||
return
|
||||
}
|
||||
const state = getDimensionState(dimensionId)
|
||||
handleGenerate(dimensionId, state.lastCustomPrompt, state.lastSystemPrompt)
|
||||
}
|
||||
|
||||
/** 关闭 EventSource 连接 */
|
||||
const closeEventSource = () => {
|
||||
if (eventSource) {
|
||||
eventSource.close()
|
||||
eventSource = null
|
||||
const closeEventSource = (dimensionId: number) => {
|
||||
const state = getDimensionState(dimensionId)
|
||||
if (state.eventSource) {
|
||||
state.eventSource.close()
|
||||
state.eventSource = null
|
||||
}
|
||||
generating.value = false
|
||||
state.generating = false
|
||||
}
|
||||
|
||||
/** 保存结果 */
|
||||
const handleSave = () => {
|
||||
if (!activeDimensionId.value || !generatedResult.value) return
|
||||
const handleSave = (dimensionId: number, content?: string) => {
|
||||
const state = getDimensionState(dimensionId)
|
||||
if (content !== undefined) {
|
||||
state.finalContent = content
|
||||
state.generatedResult = content
|
||||
state.dirty = true
|
||||
}
|
||||
if (!state.finalContent) return
|
||||
state.dirty = false
|
||||
state.lastSavedAt = new Date().toLocaleString()
|
||||
emit('save', {
|
||||
dimensionId: activeDimensionId.value,
|
||||
content: generatedResult.value
|
||||
dimensionId,
|
||||
content: state.finalContent
|
||||
})
|
||||
}
|
||||
|
||||
const handleUpdateResult = (dimensionId: number, content: string) => {
|
||||
const state = getDimensionState(dimensionId)
|
||||
state.finalContent = content
|
||||
state.generatedResult = content
|
||||
state.dirty = true
|
||||
}
|
||||
|
||||
const handleGenerateAll = async () => {
|
||||
if (batchGenerating.value) return
|
||||
const targets = dimensionViews.value
|
||||
.filter(item => item.dimension.aiEnabled === 1)
|
||||
.map(item => item.dimension.id as number)
|
||||
|
||||
if (targets.length === 0) return
|
||||
|
||||
batchGenerating.value = true
|
||||
batchProgress.value = { total: targets.length, current: 0 }
|
||||
|
||||
try {
|
||||
for (let i = 0; i < targets.length; i += 1) {
|
||||
batchProgress.value = { total: targets.length, current: i + 1 }
|
||||
await handleGenerate(targets[i])
|
||||
}
|
||||
} finally {
|
||||
batchGenerating.value = false
|
||||
batchProgress.value = { total: 0, current: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
/** 复制内容 */
|
||||
const handleCopy = () => {
|
||||
if (!generatedResult.value) return
|
||||
navigator.clipboard.writeText(generatedResult.value)
|
||||
const handleCopy = (dimensionId: number) => {
|
||||
const state = getDimensionState(dimensionId)
|
||||
if (!state.generatedResult) return
|
||||
navigator.clipboard.writeText(state.generatedResult)
|
||||
}
|
||||
|
||||
/** 组件卸载时关闭连接 */
|
||||
onUnmounted(() => {
|
||||
closeEventSource()
|
||||
props.dimensions.forEach(dimension => {
|
||||
if (dimension.id) {
|
||||
closeEventSource(dimension.id)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// 监听维度变化,自动加载数据
|
||||
// 监听维度变化,初始化状态
|
||||
watch(() => props.dimensions, (newDimensions) => {
|
||||
if (newDimensions.length > 0 && !activeDimensionId.value) {
|
||||
activeDimensionId.value = newDimensions[0].id
|
||||
loadDataSources()
|
||||
}
|
||||
newDimensions.forEach(dimension => {
|
||||
if (dimension.id) {
|
||||
getDimensionState(dimension.id)
|
||||
}
|
||||
})
|
||||
}, { immediate: true })
|
||||
|
||||
watch(
|
||||
[() => props.reportId, () => props.dimensions.map(d => d.id).filter(Boolean).join(',')],
|
||||
([reportId]) => {
|
||||
if (!reportId) return
|
||||
loadDimensionData()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@ -220,40 +560,53 @@ watch(() => props.dimensions, (newDimensions) => {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.dimension-tabs {
|
||||
padding: 0 20px;
|
||||
background: var(--el-bg-color-page);
|
||||
border-bottom: 1px solid var(--el-border-color);
|
||||
|
||||
:deep(.el-tabs__header) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.dimension-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.split-container {
|
||||
flex: 1;
|
||||
.dimension-toolbar {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 12px 20px 0;
|
||||
}
|
||||
|
||||
.left-panel {
|
||||
width: 40%;
|
||||
border-right: 1px solid var(--el-border-color);
|
||||
.dimension-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
background: var(--el-bg-color);
|
||||
}
|
||||
|
||||
.right-panel {
|
||||
width: 60%;
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.dimension-item {
|
||||
background: var(--el-bg-color);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dimension-item-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
background: var(--el-bg-color-page);
|
||||
|
||||
.dimension-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.saved-at {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.data-source-dialog-body {
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -26,6 +26,24 @@
|
||||
<span>生成结果</span>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-button
|
||||
v-if="editable"
|
||||
link
|
||||
type="primary"
|
||||
@click="toggleEdit"
|
||||
>
|
||||
<el-icon><Edit /></el-icon>
|
||||
{{ isEditing ? '取消编辑' : '手动编辑' }}
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="editable && isEditing"
|
||||
link
|
||||
type="success"
|
||||
@click="applyEdit"
|
||||
>
|
||||
<el-icon><Check /></el-icon>
|
||||
应用
|
||||
</el-button>
|
||||
<el-button link type="primary" @click="$emit('copy')">
|
||||
<el-icon><CopyDocument /></el-icon>
|
||||
复制
|
||||
@ -33,16 +51,42 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="result-content" v-if="result">
|
||||
<MarkdownView :content="result" />
|
||||
<div class="result-content" v-if="result || thinking || isEditing">
|
||||
<div v-if="thinking" class="analysis-section">
|
||||
<div class="analysis-header">
|
||||
<el-icon><Warning /></el-icon>
|
||||
<span>分析过程</span>
|
||||
</div>
|
||||
<div class="analysis-content">
|
||||
<MarkdownView :content="thinking" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="result || isEditing"
|
||||
class="final-section"
|
||||
@dblclick="enterEdit"
|
||||
>
|
||||
<div v-if="isEditing" class="final-edit">
|
||||
<el-input
|
||||
v-model="editValue"
|
||||
type="textarea"
|
||||
:rows="12"
|
||||
placeholder="可直接编辑维度结果内容"
|
||||
/>
|
||||
</div>
|
||||
<MarkdownView v-else :content="result" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-empty v-else description="暂无生成结果,点击上方按钮开始生成" />
|
||||
<el-empty v-else description="暂无生成结果,可手动编辑">
|
||||
<el-button type="primary" @click="toggleEdit">手动编辑</el-button>
|
||||
</el-empty>
|
||||
</div>
|
||||
|
||||
<!-- 底部操作栏 -->
|
||||
<div class="footer-actions" v-if="result && !generating">
|
||||
<el-button type="primary" @click="$emit('save')">
|
||||
<div class="footer-actions" v-if="!generating && (result || isEditing)">
|
||||
<el-button type="primary" @click="handleSaveClick">
|
||||
<el-icon><Check /></el-icon>
|
||||
保存到评估
|
||||
</el-button>
|
||||
@ -55,7 +99,8 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Loading, Warning, DocumentChecked, CopyDocument, Check, Refresh } from '@element-plus/icons-vue'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { Loading, Warning, DocumentChecked, CopyDocument, Check, Refresh, Edit } from '@element-plus/icons-vue'
|
||||
|
||||
defineOptions({ name: 'LlmResultPanel' })
|
||||
|
||||
@ -63,13 +108,64 @@ const props = defineProps<{
|
||||
generating: boolean
|
||||
result: string
|
||||
thinking: string
|
||||
editable?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
(e: 'save'): void
|
||||
const emit = defineEmits<{
|
||||
(e: 'save', content?: string): void
|
||||
(e: 'copy'): void
|
||||
(e: 'regenerate'): void
|
||||
(e: 'update-result', content: string): void
|
||||
}>()
|
||||
|
||||
const isEditing = ref(false)
|
||||
const editValue = ref('')
|
||||
|
||||
const editable = computed(() => props.editable !== false)
|
||||
|
||||
watch(
|
||||
() => props.result,
|
||||
(value) => {
|
||||
if (!isEditing.value) {
|
||||
editValue.value = value || ''
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const toggleEdit = () => {
|
||||
if (!editable.value) return
|
||||
if (isEditing.value) {
|
||||
isEditing.value = false
|
||||
editValue.value = props.result || ''
|
||||
} else {
|
||||
isEditing.value = true
|
||||
editValue.value = props.result || ''
|
||||
}
|
||||
}
|
||||
|
||||
const applyEdit = () => {
|
||||
if (!editable.value) return
|
||||
emit('update-result', editValue.value)
|
||||
isEditing.value = false
|
||||
}
|
||||
|
||||
const enterEdit = () => {
|
||||
if (!editable.value) return
|
||||
if (!isEditing.value) {
|
||||
toggleEdit()
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveClick = () => {
|
||||
if (editable.value && isEditing.value) {
|
||||
emit('update-result', editValue.value)
|
||||
emit('save', editValue.value)
|
||||
isEditing.value = false
|
||||
return
|
||||
}
|
||||
emit('save')
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
@ -99,7 +195,6 @@ const MarkdownView = defineComponent({
|
||||
nodes.push(h('h1', { key: index, class: 'markdown-h1' }, line.slice(2)))
|
||||
} else {
|
||||
// 处理粗体文本
|
||||
let text = line
|
||||
const boldRegex = /\*\*(.*?)\*\*/g
|
||||
const parts: any[] = []
|
||||
let lastIndex = 0
|
||||
@ -216,6 +311,22 @@ export { MarkdownView }
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.analysis-section {
|
||||
margin-bottom: 16px;
|
||||
padding: 12px;
|
||||
background: var(--el-fill-color-light);
|
||||
border-radius: 6px;
|
||||
|
||||
.analysis-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 500;
|
||||
color: var(--el-color-warning);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.footer-actions {
|
||||
|
||||
@ -1,11 +1,24 @@
|
||||
<template>
|
||||
<div class="prompt-editor">
|
||||
<div class="prompt-toggle">
|
||||
<div class="toggle-title">
|
||||
<el-icon><Document /></el-icon>
|
||||
<span>提示词与变量</span>
|
||||
</div>
|
||||
<el-button link type="primary" @click="showPromptDetails = !showPromptDetails">
|
||||
{{ showPromptDetails ? '收起' : '展开' }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div v-show="showPromptDetails">
|
||||
<!-- 系统提示词 -->
|
||||
<div class="prompt-section">
|
||||
<div class="section-header">
|
||||
<el-icon><Document /></el-icon>
|
||||
<span>系统提示词</span>
|
||||
<el-tag size="small" type="info">只读</el-tag>
|
||||
<div class="header-left">
|
||||
<el-icon><Document /></el-icon>
|
||||
<span>系统提示词</span>
|
||||
<el-tag size="small" type="info">只读</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
<el-input
|
||||
type="textarea"
|
||||
@ -19,12 +32,16 @@
|
||||
<!-- 用户提示词 -->
|
||||
<div class="prompt-section">
|
||||
<div class="section-header">
|
||||
<el-icon><Edit /></el-icon>
|
||||
<span>自定义提示词</span>
|
||||
<el-button link type="primary" @click="resetToDefault">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
恢复默认
|
||||
</el-button>
|
||||
<div class="header-left">
|
||||
<el-icon><Edit /></el-icon>
|
||||
<span>自定义提示词</span>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-button link type="primary" @click="resetToDefault">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
恢复默认
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-input
|
||||
type="textarea"
|
||||
@ -37,12 +54,16 @@
|
||||
<!-- 变量预览 -->
|
||||
<div class="prompt-section">
|
||||
<div class="section-header">
|
||||
<el-icon><InfoFilled /></el-icon>
|
||||
<span>变量预览</span>
|
||||
<el-button link type="primary" @click="showVariableHelp = true">
|
||||
<el-icon><QuestionFilled /></el-icon>
|
||||
帮助
|
||||
</el-button>
|
||||
<div class="header-left">
|
||||
<el-icon><InfoFilled /></el-icon>
|
||||
<span>变量预览</span>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-button link type="primary" @click="showVariableHelp = true">
|
||||
<el-icon><QuestionFilled /></el-icon>
|
||||
帮助
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="variable-preview">
|
||||
<el-tag
|
||||
@ -55,6 +76,7 @@
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="action-buttons">
|
||||
@ -103,18 +125,21 @@ defineOptions({ name: 'PromptEditor' })
|
||||
const props = defineProps<{
|
||||
dimension: DimensionVO | undefined
|
||||
prisoner?: { name?: string; prisonerNo?: string }
|
||||
generating?: boolean
|
||||
generatedContent?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'generate', customPrompt?: string): void
|
||||
(e: 'regenerate', customPrompt?: string): void
|
||||
(e: 'generate', customPrompt?: string, systemPrompt?: string): void
|
||||
(e: 'regenerate', customPrompt?: string, systemPrompt?: string): void
|
||||
}>()
|
||||
|
||||
// 生成状态(从父组件传入)
|
||||
const generating = inject<Ref<boolean>>('generating', ref(false))
|
||||
// 生成状态(优先使用 props,其次从注入获取)
|
||||
const injectedGenerating = inject<Ref<boolean>>('generating', ref(false))
|
||||
const injectedGeneratedContent = inject<Ref<string>>('generatedContent', ref(''))
|
||||
|
||||
// 已生成的内容(从父组件传入)
|
||||
const generatedContent = inject<Ref<string>>('generatedContent', ref(''))
|
||||
const generating = computed(() => props.generating ?? injectedGenerating.value)
|
||||
const generatedContent = computed(() => props.generatedContent ?? injectedGeneratedContent.value)
|
||||
|
||||
// 系统提示词
|
||||
const systemPrompt = computed(() => {
|
||||
@ -140,6 +165,8 @@ const variables = [
|
||||
{ key: 'data.summary', desc: '数据汇总' }
|
||||
]
|
||||
|
||||
const showPromptDetails = ref(false)
|
||||
|
||||
// 显示变量帮助
|
||||
const showVariableHelp = ref(false)
|
||||
|
||||
@ -169,12 +196,12 @@ const insertVariable = (key: string) => {
|
||||
|
||||
// 生成
|
||||
const handleGenerate = () => {
|
||||
emit('generate', customPrompt.value || undefined)
|
||||
emit('generate', customPrompt.value || undefined, systemPrompt.value || undefined)
|
||||
}
|
||||
|
||||
// 重新生成
|
||||
const handleRegenerate = () => {
|
||||
emit('regenerate', customPrompt.value || undefined)
|
||||
emit('regenerate', customPrompt.value || undefined, systemPrompt.value || undefined)
|
||||
}
|
||||
|
||||
// 获取自定义提示词(供父组件调用)
|
||||
@ -192,6 +219,23 @@ defineExpose({ getCustomPrompt })
|
||||
border-bottom: 1px solid var(--el-border-color);
|
||||
}
|
||||
|
||||
.prompt-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-bottom: 8px;
|
||||
margin-bottom: 12px;
|
||||
border-bottom: 1px dashed var(--el-border-color);
|
||||
|
||||
.toggle-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.prompt-section {
|
||||
margin-bottom: 16px;
|
||||
|
||||
@ -202,11 +246,24 @@ defineExpose({ getCustomPrompt })
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.variable-preview {
|
||||
|
||||
@ -218,15 +218,15 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
|
||||
import { DICT_TYPE } from '@/utils/dict'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ReportApi, Report, ReportDimensionContent } from '@/api/prison/report'
|
||||
import { ReportApi, Report, ReportVersionApi } from '@/api/prison/report'
|
||||
import { PrisonerSelectApi, PrisonerBrief } from '@/api/prison/report'
|
||||
import QuickCommentDialog from '@/views/prison/evaluation-mgmt/report/components/QuickCommentDialog.vue'
|
||||
import VersionHistoryDialog from '@/views/prison/evaluation-mgmt/report/components/VersionHistoryDialog.vue'
|
||||
import ReportPreviewDialog from '@/views/prison/evaluation-mgmt/report/components/ReportPreviewDialog.vue'
|
||||
import DimensionAnalysisPanel from '@/views/prison/evaluation-mgmt/report/DimensionAnalysisPanel.vue'
|
||||
import { DimensionApi, DimensionVO } from '@/api/prison/evaluation-report'
|
||||
import { DimensionApi, DimensionVO, ReportUpdateReqVO } from '@/api/prison/evaluation-report'
|
||||
|
||||
/** 评估报告编辑页面 */
|
||||
defineOptions({ name: 'EvaluationReportForm' })
|
||||
@ -386,15 +386,17 @@ const updateDataSourceStatus = () => {
|
||||
dataSourceStatus.value = { total: 6, loaded: 6, complete: true }
|
||||
}
|
||||
|
||||
type TagType = 'primary' | 'success' | 'warning' | 'info' | 'danger'
|
||||
|
||||
/** 获取风险等级类型 */
|
||||
const getRiskLevelType = (level?: number): string => {
|
||||
const getRiskLevelType = (level?: number): TagType => {
|
||||
const map: Record<number, string> = {
|
||||
1: 'success',
|
||||
2: 'warning',
|
||||
3: 'danger',
|
||||
4: 'danger'
|
||||
}
|
||||
return level ? map[level] || 'info' : 'info'
|
||||
return (level ? map[level] || 'info' : 'info') as TagType
|
||||
}
|
||||
|
||||
/** 获取风险等级标签 */
|
||||
@ -408,24 +410,34 @@ const getRiskLevelLabel = (level?: number): string => {
|
||||
return level ? map[level] || '-' : '未评估'
|
||||
}
|
||||
|
||||
/** 标记维度已修改 */
|
||||
const markDimensionModified = (dimension: ReportDimensionContent) => {
|
||||
dimension.isAiGenerated = false
|
||||
dimension.lastModifyTime = new Date().toISOString().slice(0, 19).replace('T', ' ')
|
||||
}
|
||||
|
||||
/** 保存维度内容 */
|
||||
const handleSaveDimension = async (data: { dimensionId: number; content: string }) => {
|
||||
if (!currentReport.value) return
|
||||
|
||||
try {
|
||||
// 更新维度内容
|
||||
const dimension = currentReport.value.dimensions.find(d => d.dimensionId === data.dimensionId)
|
||||
if (dimension) {
|
||||
dimension.content = data.content
|
||||
dimension.isAiGenerated = true
|
||||
dimension.aiGenerateTime = new Date().toLocaleString()
|
||||
if (!currentReport.value.dimensions) {
|
||||
currentReport.value.dimensions = []
|
||||
}
|
||||
|
||||
const dimensionConfig = dimensions.value.find(d => d.id === data.dimensionId)
|
||||
let dimension = currentReport.value.dimensions.find(d => d.dimensionId === data.dimensionId)
|
||||
|
||||
if (!dimension) {
|
||||
dimension = {
|
||||
dimensionId: data.dimensionId,
|
||||
dimensionName: dimensionConfig?.name || `维度${data.dimensionId}`,
|
||||
content: '',
|
||||
isAiGenerated: false,
|
||||
dataSources: dimensionConfig?.dataSources ? JSON.stringify(dimensionConfig.dataSources) : undefined,
|
||||
enableAi: dimensionConfig?.aiEnabled === 1,
|
||||
sort: dimensionConfig?.sort
|
||||
}
|
||||
currentReport.value.dimensions.push(dimension)
|
||||
}
|
||||
|
||||
dimension.content = data.content
|
||||
dimension.isAiGenerated = true
|
||||
dimension.aiGenerateTime = new Date().toLocaleString()
|
||||
message.success('维度内容已保存')
|
||||
} catch (error) {
|
||||
console.error('保存维度内容失败:', error)
|
||||
@ -433,28 +445,6 @@ const handleSaveDimension = async (data: { dimensionId: number; content: string
|
||||
}
|
||||
}
|
||||
|
||||
/** AI生成单个维度 */
|
||||
const handleAiGenerate = async (dimension: ReportDimensionContent) => {
|
||||
if (!currentReport.value) return
|
||||
|
||||
generatingDimensionId.value = dimension.dimensionId
|
||||
aiGenerating.value = true
|
||||
|
||||
try {
|
||||
// 调用AI生成接口
|
||||
await ReportApi.generateReportByAi(currentReport.value.id, [dimension.dimensionId])
|
||||
|
||||
// 重新获取报告数据
|
||||
const data = await ReportApi.getReport(currentReport.value.id)
|
||||
currentReport.value = data
|
||||
message.success(t('prison.report.aiGenerateComplete'))
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || t('prison.report.aiGenerateFailed'))
|
||||
} finally {
|
||||
aiGenerating.value = false
|
||||
generatingDimensionId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/** AI生成全部 */
|
||||
const handleAiGenerateAll = async () => {
|
||||
@ -484,8 +474,17 @@ const handleSaveDraft = async () => {
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
// 调用保存接口
|
||||
await ReportApi.updateReport(currentReport.value)
|
||||
// 只传递需要更新的字段,避免传递大量冗余数据
|
||||
const updateData: ReportUpdateReqVO = {
|
||||
id: currentReport.value.id,
|
||||
dimensions: JSON.stringify(currentReport.value.dimensions),
|
||||
conclusion: currentReport.value.conclusion,
|
||||
suggestions: currentReport.value.suggestions,
|
||||
riskLevel: currentReport.value.riskLevel,
|
||||
status: currentReport.value.status,
|
||||
remark: currentReport.value.remark
|
||||
}
|
||||
await ReportApi.updateReport(updateData)
|
||||
|
||||
lastSaveTime.value = new Date().toLocaleTimeString()
|
||||
message.success(t('common.saveSuccess'))
|
||||
@ -509,18 +508,6 @@ const handleSubmitReview = async () => {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/** 恢复AI原始内容 */
|
||||
const handleRestoreAiContent = async (dimension: ReportDimensionContent) => {
|
||||
try {
|
||||
await message.confirm(t('prison.report.restoreAiConfirm'))
|
||||
// 调用接口获取AI原始内容
|
||||
await ReportApi.generateReportByAi(currentReport.value!.id, [dimension.dimensionId])
|
||||
// 重新获取报告数据
|
||||
const data = await ReportApi.getReport(currentReport.value!.id)
|
||||
currentReport.value = data
|
||||
message.success(t('prison.report.restoreAiSuccess'))
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/** 预览报告 */
|
||||
const handlePreview = () => {
|
||||
@ -530,12 +517,15 @@ const handlePreview = () => {
|
||||
}
|
||||
|
||||
/** 导出报告 */
|
||||
const handleExport = async (format: 'pdf' | 'word') => {
|
||||
const handleExport = async (_format: 'pdf' | 'word') => {
|
||||
if (!currentReport.value) return
|
||||
|
||||
try {
|
||||
const data = await ReportApi.exportReport(currentReport.value.id, format)
|
||||
const fileName = `${currentReport.value.reportNo}_${currentReport.value.prisonerName}.${format}`
|
||||
await ReportApi.exportReportExcel({
|
||||
pageNo: 1,
|
||||
pageSize: 1,
|
||||
prisonerNo: currentReport.value.prisonerNo
|
||||
})
|
||||
message.success('导出成功')
|
||||
} catch {}
|
||||
}
|
||||
@ -547,6 +537,7 @@ const handleShowQuickComment = () => {
|
||||
|
||||
/** 插入快捷评语 */
|
||||
const insertComment = (comment: string) => {
|
||||
if (!comment) return
|
||||
message.success(t('prison.report.commentInserted'))
|
||||
}
|
||||
|
||||
|
||||
@ -52,26 +52,6 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 综合结论与建议 -->
|
||||
<div class="conclusion-section">
|
||||
<div class="section-header">
|
||||
<span class="section-title">综合结论与建议</span>
|
||||
</div>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<div class="conclusion-box">
|
||||
<div class="box-header">综合结论</div>
|
||||
<el-input v-model="selectedReport.conclusion" type="textarea" :rows="6" placeholder="请输入综合结论" />
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<div class="conclusion-box">
|
||||
<div class="box-header">改造建议</div>
|
||||
<el-input v-model="selectedReport.suggestions" type="textarea" :rows="6" placeholder="请输入改造建议" />
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="!loading">
|
||||
@ -86,6 +66,7 @@ import { DICT_TYPE, getIntDictOptions, getDictLabel } from '@/utils/dict'
|
||||
import { formatDateTime } from '@/utils/formatTime'
|
||||
import download from '@/utils/download'
|
||||
import { ReportApi, ReportVO, DimensionDataApi, DimensionDataVO, DimensionApi, DimensionVO } from '@/api/prison/evaluation-report'
|
||||
import { PrisonerApi } from '@/api/prison/prisoner'
|
||||
import DimensionAnalysisPanel from '@/views/prison/evaluation-mgmt/report/DimensionAnalysisPanel.vue'
|
||||
|
||||
defineOptions({ name: 'ReportEditDrawer' })
|
||||
@ -123,6 +104,15 @@ const loadReportDetail = async (id: number) => {
|
||||
dimensionDataList.value = await DimensionDataApi.getDimensionDataListByReportId(id)
|
||||
drawerTitle.value = selectedReport.value?.title || `${selectedReport.value?.prisonerName} - 评估报告`
|
||||
|
||||
// 监区信息兜底(报告未返回监区时,从罪犯档案补齐)
|
||||
if (selectedReport.value?.prisonerId && !selectedReport.value.areaName) {
|
||||
const prisoner = await PrisonerApi.get(selectedReport.value.prisonerId)
|
||||
if (prisoner?.prisonAreaName) {
|
||||
selectedReport.value.areaName = prisoner.prisonAreaName
|
||||
selectedReport.value.areaId = prisoner.prisonAreaId
|
||||
}
|
||||
}
|
||||
|
||||
// 加载维度配置
|
||||
if (selectedReport.value?.templateId) {
|
||||
try {
|
||||
@ -221,10 +211,35 @@ const handleSave = async () => {
|
||||
if (!selectedReport.value) return
|
||||
saving.value = true
|
||||
try {
|
||||
await ReportApi.updateReport(selectedReport.value)
|
||||
const updateData = {
|
||||
id: selectedReport.value.id,
|
||||
prisonerId: selectedReport.value.prisonerId,
|
||||
templateId: selectedReport.value.templateId,
|
||||
evaluationType: selectedReport.value.evaluationType,
|
||||
evaluationCycle: selectedReport.value.evaluationCycle,
|
||||
evaluationDate: selectedReport.value.evaluationDate,
|
||||
status: selectedReport.value.status,
|
||||
riskLevel: selectedReport.value.riskLevel,
|
||||
conclusion: selectedReport.value.conclusion,
|
||||
suggestions: selectedReport.value.suggestions,
|
||||
remark: selectedReport.value.remark,
|
||||
areaId: selectedReport.value.areaId,
|
||||
areaName: selectedReport.value.areaName,
|
||||
evaluatorId: selectedReport.value.evaluatorId,
|
||||
evaluatorName: selectedReport.value.evaluatorName
|
||||
}
|
||||
|
||||
if (!updateData.prisonerId || !updateData.templateId || !updateData.evaluationType || !updateData.evaluationCycle || !updateData.evaluationDate) {
|
||||
message.error('报告缺少必填信息,请刷新后重试')
|
||||
return
|
||||
}
|
||||
|
||||
await ReportApi.updateReport(updateData)
|
||||
for (const dimension of dimensionDataList.value) {
|
||||
if (dimension.id) {
|
||||
await DimensionDataApi.updateDimensionData(dimension)
|
||||
} else {
|
||||
await DimensionDataApi.createDimensionData(dimension)
|
||||
}
|
||||
}
|
||||
message.success('保存成功')
|
||||
|
||||
@ -245,7 +245,7 @@ const handleDeleteDimension = async (id: number) => {
|
||||
await message.delConfirm()
|
||||
await DimensionApi.deleteDimension(id)
|
||||
message.success(t('common.delSuccess'))
|
||||
getDimensionsByTemplateId(selectedTemplate.value?.id!)
|
||||
getDimensionsByTemplateId(selectedTemplate.value?.id)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
|
||||
@ -308,15 +308,13 @@ import PrisonerWorkbench from './PrisonerWorkbench.vue'
|
||||
import TransferForm from './TransferForm.vue'
|
||||
import download from '@/utils/download'
|
||||
import { PrisonerApi } from '@/api/prison/prisoner'
|
||||
import type { PrisonerVO, PrisonerCreateVO } from '@/api/prison/prisoner'
|
||||
import type { PrisonerVO } from '@/api/prison/prisoner'
|
||||
import { AreaApi, type AreaNode } from '@/api/prison/area'
|
||||
import { CellApi, type CellVO } from '@/api/prison/cell'
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
defineOptions({ name: 'PrisonPrisoner' })
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n() // 国际化
|
||||
const message = useMessage() // 消息弹窗
|
||||
|
||||
@ -328,18 +326,18 @@ const cellList = ref<CellVO[]>([]) // 监室列表
|
||||
const queryParams = reactive({
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
prisonerNo: undefined,
|
||||
name: undefined,
|
||||
gender: undefined,
|
||||
crime: undefined,
|
||||
supervisionLevel: undefined,
|
||||
riskLevel: undefined,
|
||||
prisonAreaId: undefined,
|
||||
prisonCellId: undefined,
|
||||
status: undefined,
|
||||
prisonerNo: undefined as string | undefined,
|
||||
name: undefined as string | undefined,
|
||||
gender: undefined as number | undefined,
|
||||
crime: undefined as string | undefined,
|
||||
supervisionLevel: undefined as number | undefined,
|
||||
riskLevel: undefined as number | undefined,
|
||||
prisonAreaId: undefined as number | undefined,
|
||||
prisonCellId: undefined as number | undefined,
|
||||
status: undefined as number | undefined,
|
||||
imprisonmentDate: [] as string[], // 日期范围数组
|
||||
imprisonmentDateStart: undefined,
|
||||
imprisonmentDateEnd: undefined
|
||||
imprisonmentDateStart: undefined as string | undefined,
|
||||
imprisonmentDateEnd: undefined as string | undefined
|
||||
})
|
||||
const queryFormRef = ref() // 搜索的表单
|
||||
const exportLoading = ref(false) // 导出的加载中
|
||||
@ -455,7 +453,7 @@ const handleAreaChange = async (areaId: number) => {
|
||||
queryParams.prisonCellId = undefined
|
||||
cellList.value = []
|
||||
if (areaId) {
|
||||
cellList.value = await CellApi.getCellPage({ areaId, pageNo: 1, pageSize: 200 }).then((res: PageResult<CellVO>) => res.list || [])
|
||||
cellList.value = await CellApi.getCellPage({ areaId, pageNo: 1, pageSize: 200 }).then((res: { list: CellVO[] }) => res.list || [])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user