Files
offerpai_web/src/components/ResumeIssueFixDrawer.vue
T

407 lines
14 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>
<!-- 简历评估问题修复抽屉 从右侧滑入占满屏幕高度宽度 8rem -->
<Teleport to="body">
<!-- 遮罩层 -->
<Transition name="fix-drawer-overlay">
<div
v-if="modelValue"
class="fix-drawer-overlay"
@click="handleClose"
/>
</Transition>
<!-- 抽屉主体 -->
<Transition name="fix-drawer-slide">
<div v-if="modelValue" class="fix-drawer" @click.stop>
<!-- 顶部栏折叠按钮 + 模块标题 -->
<div class="fix-drawer__header">
<button class="fix-drawer__close-btn" @click="handleClose" aria-label="收起">
<svg viewBox="0 0 16 16" fill="none" class="fix-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-drawer__title">{{ moduleTitle }}</h3>
</div>
<!-- 可滚动内容区 -->
<div class="fix-drawer__body">
<!-- 原文部分 -->
<div class="fix-drawer__section">
<h4 class="fix-drawer__section-title">原文</h4>
<div class="fix-drawer__card">
<p class="fix-drawer__original-text">{{ originalText }}</p>
</div>
</div>
<!-- 问题检查部分 -->
<div class="fix-drawer__section">
<h4 class="fix-drawer__section-title">问题检查</h4>
<div class="fix-drawer__card">
<!-- 发现问题 -->
<p class="fix-drawer__label">发现问题</p>
<p class="fix-drawer__text">{{ matchedIssue?.finding || '暂无' }}</p>
<!-- 重要性说明 -->
<p class="fix-drawer__label">重要性说明</p>
<p class="fix-drawer__text">{{ matchedIssue?.importance || '暂无' }}</p>
<!-- 改进建议 -->
<p class="fix-drawer__label">改进建议</p>
<p class="fix-drawer__text">{{ matchedIssue?.suggestion || '暂无' }}</p>
</div>
</div>
<!-- AI 改进后的版本 -->
<div class="fix-drawer__section">
<h4 class="fix-drawer__section-title">AI改写后的版本</h4>
<div class="fix-drawer__card">
<p class="fix-drawer__diff-text">
<span
v-for="(seg, idx) in diffSegments"
:key="idx"
:class="{ 'fix-drawer__highlight': seg.highlight }"
>{{ seg.text }}</span>
</p>
</div>
</div>
<!-- 自己改写区域 每个 optimizedContent 子元素对应一个输入框 -->
<div class="fix-drawer__section">
<h4 class="fix-drawer__section-title">自己改写</h4>
<div class="fix-drawer__card">
<textarea
v-for="(_, idx) in userRewriteList"
:key="'rewrite-' + idx"
v-model="userRewriteList[idx]"
class="fix-drawer__textarea"
:placeholder="'第 ' + (idx + 1) + ' 段改写内容…'"
:ref="(el: any) => setTextareaRef(el, idx)"
@input="autoResize(idx)"
/>
<!-- AI 优化按钮 -->
<div class="fix-drawer__textarea-footer">
<button class="fix-drawer__ai-btn" @click="handleAiPolish">AI优化</button>
</div>
</div>
</div>
<!-- AI 润色结果区域 调用接口返回后显示 -->
<div v-if="polishResult.length" class="fix-drawer__section">
<h4 class="fix-drawer__section-title">AI润色结果</h4>
<div class="fix-drawer__card">
<p
v-for="(text, idx) in polishResult"
:key="'polish-' + idx"
class="fix-drawer__polish-text"
>{{ text }}</p>
<!-- 替换按钮 -->
<div class="fix-drawer__textarea-footer">
<button class="fix-drawer__replace-btn" @click="handleReplace">替换</button>
</div>
</div>
</div>
<!-- 用户评价区域 始终显示 -->
<div class="fix-drawer__section">
<div class="fix-drawer__card fix-drawer__feedback-card">
<span class="fix-drawer__feedback-label">你认为这个修改建议有用吗</span>
<div class="fix-drawer__feedback-actions">
<!-- 有用按钮 -->
<button
class="fix-drawer__feedback-btn"
:class="{ 'fix-drawer__feedback-btn--active': feedbackValue === 1 }"
@click="handleFeedback(1)"
>
<svg viewBox="0 0 16 16" fill="none" class="fix-drawer__feedback-icon">
<path d="M4.5 7v6.5h-2a1 1 0 01-1-1V8a1 1 0 011-1h2zm1 6.5h5.3a1.5 1.5 0 001.47-1.2l1.08-5.4A1 1 0 0012.37 5.5H9V3a1.5 1.5 0 00-1.5-1.5L5.5 7z" stroke="currentColor" stroke-width="1.1" stroke-linejoin="round"/>
</svg>
有用
</button>
<!-- 无用按钮 -->
<button
class="fix-drawer__feedback-btn"
:class="{ 'fix-drawer__feedback-btn--active': feedbackValue === 2 }"
@click="handleFeedback(2)"
>
<svg viewBox="0 0 16 16" fill="none" class="fix-drawer__feedback-icon fix-drawer__feedback-icon--down">
<path d="M4.5 7v6.5h-2a1 1 0 01-1-1V8a1 1 0 011-1h2zm1 6.5h5.3a1.5 1.5 0 001.47-1.2l1.08-5.4A1 1 0 0012.37 5.5H9V3a1.5 1.5 0 00-1.5-1.5L5.5 7z" stroke="currentColor" stroke-width="1.1" stroke-linejoin="round"/>
</svg>
无用
</button>
</div>
</div>
</div>
</div>
<!-- 底部提交按钮 -->
<div class="fix-drawer__footer">
<button class="fix-drawer__submit-btn" @click="handleSubmit">提交新版本</button>
</div>
</div>
</Transition>
</Teleport>
<!-- 全屏加载遮罩 -->
<FullscreenLoading :visible="fullLoading" :text="fullLoadingText" title="Nova 正在帮您努力处理~" />
</template>
<script setup lang="ts">
import { computed, ref, watch, nextTick } from 'vue'
import type { DiagnosisIssue, DescriptionParagraph } from '@/api/resume'
import { polishDiagnosisIssue, feedbackDiagnosisIssue, resolveDiagnosisIssue } from '@/api/resume'
import { computeDiff, type DiffSegment } from '@/utils/textDiff'
import FullscreenLoading from '@/components/FullscreenLoading.vue'
// ==================== Props ====================
const props = defineProps<{
/** 控制抽屉显示/隐藏 */
modelValue: boolean
/** 模块类型:summary / education / work / internship / project / competition */
moduleType: string
/** 当前记录 ID(个人概述时为 resumeId,经历时为该段经历的 id) */
recordId: string
/** 原文内容 — 个人概述时为 summary 字符串,经历时为 description 数组 */
originalContent: string | DescriptionParagraph[]
/** 该记录对应的诊断问题 */
issue: DiagnosisIssue | undefined
}>()
const emit = defineEmits<{
(e: 'update:modelValue', val: boolean): void
/** 提交新版本:携带自己改写的最终内容数组 */
(e: 'submit', content: string[]): void
}>()
// ==================== 模块标题映射 ====================
/** 模块类型到中文标题的映射 */
const moduleTitleMap: Record<string, string> = {
summary: '个人概述',
education: '教育背景',
work: '工作经历',
internship: '实习经历',
project: '项目经历',
competition: '竞赛经历',
}
/** 抽屉标题 */
const moduleTitle = computed(() => moduleTitleMap[props.moduleType] || '问题修复')
// ==================== 全屏加载状态 ====================
/** 全屏加载是否显示 */
const fullLoading = ref(false)
/** 全屏加载文案 */
const fullLoadingText = ref('')
// ==================== 原文处理 ====================
/** 将原文内容统一转为字符串(description 数组的 text 逐行拼接) */
const originalText = computed(() => {
if (typeof props.originalContent === 'string') {
return props.originalContent
}
if (Array.isArray(props.originalContent)) {
return props.originalContent.map(d => d.text || '').join('\n')
}
return ''
})
// ==================== 匹配的诊断问题 ====================
/** 当前记录匹配的 issue */
const matchedIssue = computed(() => props.issue)
// ==================== AI 改进内容(optimizedContent 数组) ====================
/** optimizedContent 子元素数组 — 兼容字符串(个人概述)和数组对象(经历模块) */
const optimizedItems = computed<any[]>(() => {
const oc = matchedIssue.value?.optimizedContent
if (!oc) return []
// 个人概述:optimizedContent 是字符串
if (typeof oc === 'string') return [oc]
if (Array.isArray(oc)) return oc
return []
})
/** AI 改进后的文本(统一拼接为字符串,用于差异对比) */
const optimizedText = computed(() => {
if (!optimizedItems.value.length) return ''
const oc = matchedIssue.value?.optimizedContent
// 个人概述:直接就是字符串
if (typeof oc === 'string') return oc
// 经历模块:数组对象,取每个子元素的 optimizedContent 字段拼接
return optimizedItems.value
.map((item: any) => item.optimizedContent || item.text || '')
.join('\n')
})
/** 差异对比片段 */
const diffSegments = computed<DiffSegment[]>(() => {
if (!optimizedText.value) return []
return computeDiff(originalText.value, optimizedText.value)
})
// ==================== 自己改写(多输入框) ====================
/** 用户自己改写的内容列表 — 每个 optimizedContent 子元素对应一个输入框 */
const userRewriteList = ref<string[]>([])
/** 抽屉打开时用 AI 改写内容初始化每个输入框 */
watch(() => props.modelValue, (val) => {
if (val) {
// 用 optimizedContent 初始化每个输入框(兼容字符串和数组对象)
const oc = matchedIssue.value?.optimizedContent
if (typeof oc === 'string') {
// 个人概述:单个字符串 → 单个输入框
userRewriteList.value = [oc]
} else {
userRewriteList.value = optimizedItems.value.map(
(item: any) => item.optimizedContent || item.text || ''
)
}
// 如果没有 optimizedContent,至少给一个空输入框
if (!userRewriteList.value.length) {
userRewriteList.value = ['']
}
// 清空上次的润色结果
polishResult.value = []
// 重置评价状态
feedbackValue.value = 0
// 初始化 textarea 高度
initAllTextareaHeight()
}
})
// ==================== AI 润色(调用接口) ====================
/** AI 润色返回的结果数组 */
const polishResult = ref<string[]>([])
/** 调用 AI 润色接口 */
async function handleAiPolish() {
const issueId = matchedIssue.value?.id
if (!issueId) {
ElMessage.warning('当前没有可润色的问题')
return
}
// 收集每个输入框的内容
const content = userRewriteList.value.map(s => s.trim()).filter(Boolean)
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) {
polishResult.value = res.data.content
ElMessage.success('润色完成')
} else {
ElMessage.error(res.msg || '润色失败,请稍后重试')
}
} catch {
ElMessage.error('润色请求失败,请稍后重试')
} finally {
fullLoading.value = false
}
}
/** 点击替换按钮 — 以 AI 润色结果段数为准,替换自己改写的输入框 */
function handleReplace() {
userRewriteList.value = [...polishResult.value]
// 替换完成后清空润色结果区域
polishResult.value = []
ElMessage.success('已替换')
// 重新计算 textarea 高度
initAllTextareaHeight()
}
// ==================== 用户评价 ====================
/** 当前评价状态:0=未评价 1=有用 2=无用 */
const feedbackValue = ref(0)
/** 提交用户评价 */
async function handleFeedback(value: number) {
const issueId = matchedIssue.value?.id
if (!issueId) return
try {
const res = await feedbackDiagnosisIssue(issueId, value)
if (res.code === 0) {
feedbackValue.value = value
ElMessage.success('已收到,感谢您的评价')
} else {
ElMessage.error(res.msg || '评价提交失败')
}
} catch {
ElMessage.error('评价请求失败,请稍后重试')
}
}
// ==================== textarea 自适应高度 ====================
/** textarea DOM 引用数组 */
const textareaRefs: HTMLTextAreaElement[] = []
/** 收集 textarea ref */
function setTextareaRef(el: HTMLTextAreaElement | null, idx: number) {
if (el) textareaRefs[idx] = el
}
/** 根据内容自动调整 textarea 高度 */
function autoResize(idx: number) {
const el = textareaRefs[idx]
if (!el) return
el.style.height = 'auto'
el.style.height = el.scrollHeight + 'px'
}
/** 初始化所有 textarea 高度(抽屉打开后 DOM 渲染完成时调用) */
function initAllTextareaHeight() {
nextTick(() => {
userRewriteList.value.forEach((_, idx) => autoResize(idx))
})
}
// ==================== 事件处理 ====================
/** 提交新版本 — 调用 resolve 接口标记已处理,然后通知父组件更新数据 */
async function handleSubmit() {
const issueId = matchedIssue.value?.id
if (!issueId) {
ElMessage.warning('当前没有可提交的问题')
return
}
// 全屏加载提示
fullLoadingText.value = '正在提交新版本…'
fullLoading.value = true
try {
// 1. 调用 resolve 接口标记问题已处理
const res = await resolveDiagnosisIssue(issueId)
if (res.code !== 0) {
ElMessage.error(res.msg || '标记已处理失败')
return
}
// 2. 关闭抽屉并通知父组件携带最终改写内容
emit('update:modelValue', false)
emit('submit', [...userRewriteList.value])
} catch {
ElMessage.error('提交失败,请稍后重试')
} finally {
fullLoading.value = false
}
}
/** 关闭抽屉 */
function handleClose() {
emit('update:modelValue', false)
}
</script>