408 lines
13 KiB
Vue
408 lines
13 KiB
Vue
<template>
|
||
<!-- 一键修复抽屉 — 从右侧滑入,展示所有模块的诊断修复 -->
|
||
<Teleport to="body">
|
||
<!-- 遮罩层 -->
|
||
<Transition name="fix-all-drawer-overlay">
|
||
<div
|
||
v-if="modelValue"
|
||
class="fix-all-drawer-overlay"
|
||
@click="handleClose"
|
||
/>
|
||
</Transition>
|
||
|
||
<!-- 抽屉主体 -->
|
||
<Transition name="fix-all-drawer-slide">
|
||
<div v-if="modelValue" class="fix-all-drawer" @click.stop>
|
||
<!-- 顶部栏 -->
|
||
<div class="fix-all-drawer__header">
|
||
<button class="fix-all-drawer__close-btn" @click="handleClose" aria-label="收起">
|
||
<svg viewBox="0 0 16 16" fill="none" class="fix-all-drawer__close-icon">
|
||
<path d="M10 3l-5 5 5 5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||
</svg>
|
||
</button>
|
||
<h3 class="fix-all-drawer__title">一键修复</h3>
|
||
</div>
|
||
|
||
<!-- 可滚动内容区 -->
|
||
<div class="fix-all-drawer__body">
|
||
<!-- 遍历每个有诊断数据的模块 -->
|
||
<template v-for="module in moduleDataList" :key="module.moduleType">
|
||
<!-- 模块标题 -->
|
||
<h4 class="fix-all-drawer__module-title">{{ moduleTitleMap[module.moduleType] }}</h4>
|
||
|
||
<!-- 遍历该模块下每条经历/记录 -->
|
||
<div
|
||
v-for="record in module.records"
|
||
:key="record.issueId"
|
||
class="fix-all-drawer__record"
|
||
>
|
||
<!-- 经历标识(经历类模块显示标题,summary不显示) -->
|
||
<p v-if="record.recordLabel" class="fix-all-drawer__record-label">{{ record.recordLabel }}</p>
|
||
|
||
<!-- 原文编辑区 -->
|
||
<div class="fix-all-drawer__card">
|
||
<textarea
|
||
v-model="record.editText"
|
||
class="fix-all-drawer__textarea"
|
||
placeholder="原文内容…"
|
||
@input="autoResizeTextarea($event)"
|
||
/>
|
||
</div>
|
||
|
||
<!-- AI 润色结果区 -->
|
||
<div class="fix-all-drawer__ai-section">
|
||
<p class="fix-all-drawer__ai-label">✦ AI润色结果</p>
|
||
<div class="fix-all-drawer__card fix-all-drawer__card--ai">
|
||
<p class="fix-all-drawer__ai-text">{{ record.polishText }}</p>
|
||
</div>
|
||
<!-- 操作按钮 -->
|
||
<div class="fix-all-drawer__ai-actions">
|
||
<button class="fix-all-drawer__action-btn fix-all-drawer__action-btn--replace" @click="handleReplaceToOriginal(record)">+替换为此内容</button>
|
||
<button class="fix-all-drawer__action-btn fix-all-drawer__action-btn--regenerate" @click="handleRegenerate(record)">↻ 重新生成</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
|
||
<!-- 底部按钮区 -->
|
||
<div class="fix-all-drawer__footer">
|
||
<button class="fix-all-drawer__footer-btn fix-all-drawer__footer-btn--use-ai" @click="handleUseAllAi">一键使用AI润色结果</button>
|
||
<button class="fix-all-drawer__footer-btn fix-all-drawer__footer-btn--save" @click="handleSaveCurrent">保存当前修改</button>
|
||
</div>
|
||
</div>
|
||
</Transition>
|
||
</Teleport>
|
||
<!-- 全屏加载遮罩 -->
|
||
<FullscreenLoading :visible="fullLoading" :text="fullLoadingText" title="Nova 正在帮您努力处理~" />
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, watch, nextTick } from 'vue'
|
||
import type { DiagnosisIssue, DescriptionParagraph } from '@/api/resume'
|
||
import { polishDiagnosisIssue, resolveDiagnosisIssue } from '@/api/resume'
|
||
import FullscreenLoading from '@/components/FullscreenLoading.vue'
|
||
|
||
// ==================== Props ====================
|
||
|
||
/** 单条经历记录的原始数据(父组件传入) */
|
||
export interface OriginalRecordData {
|
||
/** 模块类型 */
|
||
moduleType: string
|
||
/** 经历记录 ID(summary 时为 resumeId) */
|
||
recordId: string
|
||
/** 经历显示标题(如"北京大学-经历描述") */
|
||
recordLabel: string
|
||
/** 原文内容:summary 为字符串,经历为 DescriptionParagraph[] */
|
||
originalContent: string | DescriptionParagraph[]
|
||
}
|
||
|
||
const props = defineProps<{
|
||
/** 控制抽屉显示/隐藏 */
|
||
modelValue: boolean
|
||
/** 所有诊断问题列表 */
|
||
issues: DiagnosisIssue[]
|
||
/** 所有模块的原始经历数据 */
|
||
originalRecords: OriginalRecordData[]
|
||
}>()
|
||
|
||
|
||
/** 提交结果的单条数据结构 */
|
||
export interface FixAllSubmitItem {
|
||
/** 模块类型 */
|
||
moduleType: string
|
||
/** 经历记录 ID */
|
||
recordId: string
|
||
/** 新的内容:summary 为字符串,经历为 DescriptionParagraph[] */
|
||
newContent: string | DescriptionParagraph[]
|
||
/** 关联的 issue ID */
|
||
issueId: string
|
||
}
|
||
|
||
const emit = defineEmits<{
|
||
(e: 'update:modelValue', val: boolean): void
|
||
/** 保存当前修改:携带有变化的模块数据 */
|
||
(e: 'save', items: FixAllSubmitItem[]): void
|
||
/** 一键使用AI润色结果:携带所有模块数据(强制更新) */
|
||
(e: 'useAllAi', items: FixAllSubmitItem[]): void
|
||
}>()
|
||
|
||
// ==================== 内部数据结构 ====================
|
||
|
||
/** 单条记录的内部状态 */
|
||
interface RecordState {
|
||
/** 关联的 issue ID */
|
||
issueId: string
|
||
/** 模块类型 */
|
||
moduleType: string
|
||
/** 经历记录 ID */
|
||
recordId: string
|
||
/** 经历显示标题 */
|
||
recordLabel: string
|
||
/** 原文编辑框内容(用户可编辑) */
|
||
editText: string
|
||
/** AI 润色结果文本 */
|
||
polishText: string
|
||
/** 原始内容(用于对比是否有变化) */
|
||
originalText: string
|
||
}
|
||
|
||
/** 模块分组数据 */
|
||
interface ModuleGroup {
|
||
/** 模块类型 */
|
||
moduleType: string
|
||
/** 该模块下所有记录 */
|
||
records: RecordState[]
|
||
}
|
||
|
||
// ==================== 模块标题映射 ====================
|
||
|
||
/** 模块类型到中文标题的映射 */
|
||
const moduleTitleMap: Record<string, string> = {
|
||
summary: '个人概述',
|
||
education: '教育背景',
|
||
work: '工作经历',
|
||
internship: '实习经历',
|
||
project: '项目经历',
|
||
competition: '竞赛经历',
|
||
}
|
||
|
||
/** 模块顺序 */
|
||
const moduleOrder = ['summary', 'education', 'work', 'internship', 'project', 'competition']
|
||
|
||
// ==================== 响应式数据 ====================
|
||
|
||
/** 全屏加载是否显示 */
|
||
const fullLoading = ref(false)
|
||
/** 全屏加载文案 */
|
||
const fullLoadingText = ref('')
|
||
|
||
/** 所有模块分组后的数据列表 */
|
||
const moduleDataList = ref<ModuleGroup[]>([])
|
||
|
||
// ==================== 工具方法 ====================
|
||
|
||
/** 生成 8 位随机字母数字 ID */
|
||
function generateId(): string {
|
||
return Math.random().toString(36).substring(2, 10)
|
||
}
|
||
|
||
/** 将 DescriptionParagraph[] 拼接为字符串(每段文字后加换行) */
|
||
function descriptionToText(desc: DescriptionParagraph[]): string {
|
||
return desc.map(d => d.text || '').join('\n')
|
||
}
|
||
|
||
/** 将文本按换行拆分为 DescriptionParagraph[](每行生成新 id) */
|
||
function textToDescription(text: string): DescriptionParagraph[] {
|
||
const lines = text.split('\n').filter(line => line.trim() !== '')
|
||
return lines.map(line => ({ id: generateId(), text: line }))
|
||
}
|
||
|
||
/** 获取 issue 的 optimizedContent 转为文本 */
|
||
function getOptimizedText(issue: DiagnosisIssue): string {
|
||
const oc = issue.optimizedContent
|
||
if (!oc) return ''
|
||
// summary:optimizedContent 是字符串
|
||
if (typeof oc === 'string') return oc
|
||
// 经历模块:数组对象,取每个子元素的 optimizedContent 或 text 拼接
|
||
if (Array.isArray(oc)) {
|
||
return oc.map((item: any) => item.optimizedContent || item.text || '').join('\n')
|
||
}
|
||
return ''
|
||
}
|
||
|
||
// ==================== 初始化逻辑 ====================
|
||
|
||
/** 抽屉打开时初始化数据 */
|
||
watch(() => props.modelValue, (val) => {
|
||
if (val) {
|
||
initData()
|
||
}
|
||
})
|
||
|
||
/** 根据 props 中的 issues 和 originalRecords 初始化内部数据 */
|
||
function initData() {
|
||
const groupMap: Record<string, RecordState[]> = {}
|
||
|
||
// 遍历每个 issue,找到对应的原始记录数据
|
||
for (const issue of props.issues) {
|
||
if (!issue.id || !issue.moduleType || issue.status !== 0) continue
|
||
|
||
const moduleType = issue.moduleType
|
||
const recordId = issue.moduleRecordId || ''
|
||
|
||
// 找到对应的原始记录
|
||
const originalRecord = props.originalRecords.find(
|
||
r => r.moduleType === moduleType && r.recordId === recordId
|
||
)
|
||
|
||
// 原文转文本
|
||
let originalText = ''
|
||
if (originalRecord) {
|
||
if (typeof originalRecord.originalContent === 'string') {
|
||
originalText = originalRecord.originalContent
|
||
} else if (Array.isArray(originalRecord.originalContent)) {
|
||
originalText = descriptionToText(originalRecord.originalContent)
|
||
}
|
||
}
|
||
|
||
// AI 润色结果:初始用 optimizedContent
|
||
const polishText = getOptimizedText(issue)
|
||
|
||
const state: RecordState = {
|
||
issueId: issue.id,
|
||
moduleType,
|
||
recordId,
|
||
recordLabel: originalRecord?.recordLabel || '',
|
||
editText: originalText,
|
||
polishText,
|
||
originalText,
|
||
}
|
||
|
||
if (!groupMap[moduleType]) {
|
||
groupMap[moduleType] = []
|
||
}
|
||
groupMap[moduleType].push(state)
|
||
}
|
||
|
||
// 按模块顺序排列
|
||
moduleDataList.value = moduleOrder
|
||
.filter(mt => groupMap[mt]?.length)
|
||
.map(mt => ({ moduleType: mt, records: groupMap[mt] }))
|
||
|
||
// 等 DOM 渲染完成后自动调整 textarea 高度
|
||
nextTick(() => {
|
||
const textareas = document.querySelectorAll('.fix-all-drawer__textarea')
|
||
textareas.forEach(el => {
|
||
const ta = el as HTMLTextAreaElement
|
||
ta.style.height = 'auto'
|
||
ta.style.height = ta.scrollHeight + 'px'
|
||
})
|
||
})
|
||
}
|
||
|
||
// ==================== 事件处理 ====================
|
||
|
||
/** textarea 自适应高度 */
|
||
function autoResizeTextarea(event: Event) {
|
||
const el = event.target as HTMLTextAreaElement
|
||
el.style.height = 'auto'
|
||
el.style.height = el.scrollHeight + 'px'
|
||
}
|
||
|
||
/** 替换为此内容 — 将 AI 润色结果替换到原文编辑框 */
|
||
function handleReplaceToOriginal(record: RecordState) {
|
||
record.editText = record.polishText
|
||
// 触发 textarea 重新计算高度
|
||
nextTick(() => {
|
||
const textareas = document.querySelectorAll('.fix-all-drawer__textarea')
|
||
textareas.forEach(el => {
|
||
const ta = el as HTMLTextAreaElement
|
||
ta.style.height = 'auto'
|
||
ta.style.height = ta.scrollHeight + 'px'
|
||
})
|
||
})
|
||
}
|
||
|
||
/** 重新生成 — 调用 AI 润色接口获取新结果 */
|
||
async function handleRegenerate(record: RecordState) {
|
||
if (!record.issueId) return
|
||
|
||
// 收集当前原文编辑框内容作为输入
|
||
let content: string[]
|
||
if (record.moduleType === 'summary') {
|
||
content = [record.editText.trim()]
|
||
} else {
|
||
content = record.editText.split('\n').filter(line => line.trim() !== '')
|
||
}
|
||
|
||
if (!content.length) {
|
||
ElMessage.warning('原文内容为空,无法生成')
|
||
return
|
||
}
|
||
|
||
fullLoadingText.value = 'AI 重新生成中…'
|
||
fullLoading.value = true
|
||
|
||
try {
|
||
const res = await polishDiagnosisIssue(content)
|
||
if (res.code === 0 && res.data?.content) {
|
||
// 将返回的数组内容用换行拼接
|
||
record.polishText = res.data.content.join('\n')
|
||
ElMessage.success('重新生成完成')
|
||
} else {
|
||
ElMessage.error(res.msg || '生成失败,请稍后重试')
|
||
}
|
||
} catch {
|
||
ElMessage.error('请求失败,请稍后重试')
|
||
} finally {
|
||
fullLoading.value = false
|
||
}
|
||
}
|
||
|
||
/** 一键使用AI润色结果 — 所有模块强制使用 AI 润色结果更新 */
|
||
function handleUseAllAi() {
|
||
const items: FixAllSubmitItem[] = []
|
||
|
||
for (const module of moduleDataList.value) {
|
||
for (const record of module.records) {
|
||
let newContent: string | DescriptionParagraph[]
|
||
if (record.moduleType === 'summary') {
|
||
// summary 直接用润色文本
|
||
newContent = record.polishText
|
||
} else {
|
||
// 经历模块:按换行拆分为 DescriptionParagraph[]
|
||
newContent = textToDescription(record.polishText)
|
||
}
|
||
items.push({
|
||
moduleType: record.moduleType,
|
||
recordId: record.recordId,
|
||
newContent,
|
||
issueId: record.issueId,
|
||
})
|
||
}
|
||
}
|
||
|
||
emit('useAllAi', items)
|
||
emit('update:modelValue', false)
|
||
}
|
||
|
||
/** 保存当前修改 — 只提交内容有变化的模块 */
|
||
function handleSaveCurrent() {
|
||
const items: FixAllSubmitItem[] = []
|
||
|
||
for (const module of moduleDataList.value) {
|
||
for (const record of module.records) {
|
||
// 对比当前编辑内容和原始内容是否有变化
|
||
if (record.editText.trim() === record.originalText.trim()) continue
|
||
|
||
let newContent: string | DescriptionParagraph[]
|
||
if (record.moduleType === 'summary') {
|
||
newContent = record.editText
|
||
} else {
|
||
newContent = textToDescription(record.editText)
|
||
}
|
||
items.push({
|
||
moduleType: record.moduleType,
|
||
recordId: record.recordId,
|
||
newContent,
|
||
issueId: record.issueId,
|
||
})
|
||
}
|
||
}
|
||
|
||
if (!items.length) {
|
||
ElMessage.info('没有检测到内容变化')
|
||
return
|
||
}
|
||
|
||
emit('save', items)
|
||
emit('update:modelValue', false)
|
||
}
|
||
|
||
/** 关闭抽屉 */
|
||
function handleClose() {
|
||
emit('update:modelValue', false)
|
||
}
|
||
</script>
|