Files
offerpai_web/src/components/ResumeIssueFixAllDrawer.vue
T

408 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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
/** 经历记录 IDsummary 时为 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 ''
// summaryoptimizedContent 是字符串
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>