407 lines
14 KiB
Vue
407 lines
14 KiB
Vue
<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>
|