diff --git a/src/api/common.ts b/src/api/common.ts index 738e17c..97f357b 100644 --- a/src/api/common.ts +++ b/src/api/common.ts @@ -89,3 +89,32 @@ export interface RegionItem { export function fetchRegionTree() { return request.get>('/public/regions/tree') } + +// ==================== OSS 文件上传相关 ==================== + +/** 文件上传路径枚举 */ +export type OssPathEnum = 'UserImage' | 'ResumeImage' | 'SystemImage' | 'ResumeFile' | 'OtherFile' + +/** 文件上传返回结果 */ +export interface OssUploadResult { + /** 上传地址 */ + uploadUrl?: string + /** 下载地址 */ + downloadUrl?: string +} + +/** + * 上传文件到OSS + * POST /oss/simpleUpload + * @param file 要上传的文件 + * @param pathEnum 路径枚举,默认 ResumeFile + * @returns 上传结果(含下载地址) + */ +export function uploadFileToOss(file: File, pathEnum: OssPathEnum = 'ResumeFile') { + const formData = new FormData() + formData.append('file', file) + return request.post>('/oss/simpleUpload', formData, { + params: { pathEnum }, + headers: { 'Content-Type': 'multipart/form-data' }, + }) +} diff --git a/src/assets/styles/pages/home.scss b/src/assets/styles/pages/home.scss index 1ef53f9..9ac0e59 100644 --- a/src/assets/styles/pages/home.scss +++ b/src/assets/styles/pages/home.scss @@ -1441,6 +1441,12 @@ margin: 0 0 0.32rem; } + a{ + text-decoration: none; + color: inherit; + cursor: pointer; + } + // 链接列表 — ul > li 自然纵排,无需 flex-direction: column ul { list-style: none; diff --git a/src/assets/styles/pages/jobs.scss b/src/assets/styles/pages/jobs.scss index ee0e348..254003e 100644 --- a/src/assets/styles/pages/jobs.scss +++ b/src/assets/styles/pages/jobs.scss @@ -630,6 +630,7 @@ padding: 0.16rem 0; font-size: 0.12rem; color: $text-light; + z-index: 10; } // 高匹配度特殊样式 diff --git a/src/components/SettingsDialog.vue b/src/components/SettingsDialog.vue index c455d95..cc33fdd 100644 --- a/src/components/SettingsDialog.vue +++ b/src/components/SettingsDialog.vue @@ -275,7 +275,7 @@ const store = useStore() const tabs = [ { key: 'account', label: '账号与安全', icon: '👤' }, { key: 'member', label: '会员', icon: '🏅' }, - { key: 'reminder', label: '目标岗位设置', icon: '🔔' }, + // { key: 'reminder', label: '目标岗位设置', icon: '🔔' }, ] /** 当前选中的 Tab */ diff --git a/src/main.ts b/src/main.ts index 2219ddd..9fc5c1b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,9 +2,11 @@ import { createApp } from 'vue' import App from './App.vue' import router from './router' import store from './stores' +import remAdaptPlugin from './plugins/remAdapt' import '@/assets/styles/index.scss' createApp(App) .use(router) .use(store) + .use(remAdaptPlugin) .mount('#app') \ No newline at end of file diff --git a/src/plugins/remAdapt.ts b/src/plugins/remAdapt.ts new file mode 100644 index 0000000..3e7c371 --- /dev/null +++ b/src/plugins/remAdapt.ts @@ -0,0 +1,71 @@ +/** + * REM 适配插件 + * 以 1920px 设计稿为基准,1rem = 100px + * 移动端使用 transform scale 缩放,避免浏览器最小字体限制 + */ +import type { Plugin } from 'vue' + +const remAdaptPlugin: Plugin = { + install() { + const docEl = document.documentElement + const designWidth = 1920 + + // 检测是否为移动端 + const isMobile = (): boolean => { + return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) + || window.innerWidth < 1050 + } + + // 重新计算缩放 + const recalc = () => { + const clientWidth = docEl.clientWidth + if (!clientWidth) return + + // 始终使用 100px 作为基准 + docEl.style.fontSize = '100px' + + const body = document.body + if (!body) return + + if (isMobile()) { + // 移动端:使用 transform scale 缩放整个页面 + const scale = clientWidth / designWidth + body.style.width = designWidth + 'px' + body.style.transform = `scale(${scale})` + body.style.transformOrigin = 'top left' + // 设置 html 高度,让滚动正常工作 + docEl.style.height = (body.scrollHeight * scale) + 'px' + docEl.style.overflow = 'auto' + } else { + // PC 端:移除缩放 + body.style.width = '' + body.style.transform = '' + body.style.transformOrigin = '' + docEl.style.height = '' + docEl.style.overflow = '' + } + } + + // 监听窗口变化 + window.addEventListener('resize', recalc, false) + window.addEventListener('orientationchange', recalc, false) + + // 初始执行,延迟确保 DOM 加载完成 + if (document.readyState === 'complete') { + recalc() + } else { + window.addEventListener('load', recalc) + } + + // 监听 DOM 变化,更新高度 + const observer = new MutationObserver(() => { + if (isMobile()) { + const scale = docEl.clientWidth / designWidth + docEl.style.height = (document.body.scrollHeight * scale) + 'px' + } + }) + observer.observe(document.body, { childList: true, subtree: true }) + } +} + +export default remAdaptPlugin diff --git a/src/utils/resumeExport.ts b/src/utils/resumeExport.ts index 534a1e9..cec8fe2 100644 --- a/src/utils/resumeExport.ts +++ b/src/utils/resumeExport.ts @@ -183,3 +183,103 @@ export async function loadResumeTemplateData(resumeId: string): Promise { + const options = { + margin: [10, 10, 10, 10] as [number, number, number, number], + filename: `${fileName}.pdf`, + image: { type: 'jpeg' as const, quality: 0.98 }, + html2canvas: { scale: 2, useCORS: true, logging: false }, + jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' as const }, + pagebreak: { mode: ['css', 'legacy'] }, + } + // 使用 outputPdf('blob') 获取 Blob,不触发下载 + const blob: Blob = await html2pdf().set(options).from(element).outputPdf('blob') + return new File([blob], `${fileName}.pdf`, { type: 'application/pdf' }) +} + +/** + * 生成简历Word的File对象(不触发浏览器下载) + * @param element 简历DOM元素 + * @param fileName 文件名(不含扩展名) + * @returns Word格式的File对象 + */ +export function generateResumeWordFile(element: HTMLElement, fileName: string): File { + // 复用与 exportResumeWord 相同的 Word HTML 组装逻辑 + const wordCss = ` + @page WordSection1 { + size: 595.3pt 841.9pt; + mso-page-orientation: portrait; + mso-header-margin: 0pt; + mso-footer-margin: 0pt; + mso-paper-source: 0; + margin-top: 72pt; + margin-right: 54pt; + margin-bottom: 72pt; + margin-left: 54pt; + } + div.WordSection1 { page: WordSection1; } + body { font-family: 'SimSun', 'Songti SC', serif; color: #000; line-height: 1.6; margin: 0; padding: 0; } + .job-resume-template { width: 100%; background: #fff; box-sizing: border-box; } + .resume-html { padding: 0; font-family: 'SimSun', 'Songti SC', serif; color: #000; line-height: 1.6; } + .resume-html__name { font-size: 24pt; font-weight: 700; margin: 0 0 4.8pt 0; line-height: 1.3; } + .resume-html__contact { font-size: 12pt; color: #000; margin-bottom: 3.6pt; line-height: 1.5; } + .resume-html__contact-row { display: flex; align-items: center; gap: 0; } + .resume-html__separator { margin: 0 4.8pt; color: #777; } + .resume-html__section-title { font-size: 17.3pt; font-weight: 700; color: #000; margin-top: 20pt; margin-bottom: 10pt; line-height: 1.3; } + .resume-html__divider { height: 1.5px; background: #000; margin-bottom: 9.6pt; } + .resume-html__summary { font-size: 12pt; line-height: 1.7; margin-bottom: 4.8pt; } + .resume-html__item { margin-bottom: 9.6pt; } + .resume-html__item-header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 2pt; } + .resume-html__item-main { font-size: 13.2pt; font-weight: 600; color: #000; line-height: 1.4; } + .resume-html__item-right { display: flex; align-items: center; gap: 4.8pt; flex-shrink: 0; text-align: right; } + .resume-html__item-location { font-size: 12pt; color: #000; } + .resume-html__item-date { font-size: 12pt; color: #000; white-space: nowrap; } + .resume-html__item-desc { font-size: 12pt; color: #000; line-height: 1.7; margin-top: 2pt; } + .resume-html__desc-list { margin: 0; padding-left: 0; list-style: none; mso-list: none; } + .resume-html__desc-list li { font-size: 12pt; line-height: 1.7; color: #000; list-style: none; mso-list: none; margin-left: 0; padding-left: 24pt; text-indent: 0; } + .resume-html__skills { font-size: 12pt; line-height: 1.7; } + .resume-html__skill-row { margin-bottom: 2.4pt; } + .resume-html__skill-label { font-weight: 600; } + .resume-html__diff-highlight { background-color: #D4EDDA; color: #155724; border-radius: 2px; padding: 0 1px; } + ` + + const parts: string[] = [] + parts.push('') + parts.push('') + parts.push('') + parts.push('') + parts.push('') + parts.push('') + parts.push('') + parts.push('') + parts.push('') + parts.push('') + parts.push('
' + element.outerHTML + '
') + parts.push('') + parts.push('') + const fullHtml = parts.join('\n') + + // 生成File对象,不触发下载 + const blob = new Blob([fullHtml], { type: 'application/msword' }) + return new File([blob], `${fileName}.doc`, { type: 'application/msword' }) +} diff --git a/src/views/Agent.vue b/src/views/Agent.vue index 68f7522..2a3a59d 100644 --- a/src/views/Agent.vue +++ b/src/views/Agent.vue @@ -157,6 +157,9 @@ + + + @@ -177,6 +180,7 @@ import type { JobListItem } from '@/api/jobs' import { fetchResumeList } from '@/api/resume' import { getIntentionCategoryNames, getIntentionRegionNames, getIntentionIndustryNames } from '@/utils/intention' import AiThinkingIndicator from '@/components/tools/AiThinkingIndicator.vue' +import JobGoalDialog from '@/components/JobGoalDialog.vue' /** 页面初始加载状态 */ const pageLoading = ref(true) @@ -228,6 +232,9 @@ const isSending = ref(false) /** 是否正在投递流程中(防止重复点击开始按钮) */ const isApplying = ref(false) +/** 是否显示求职目标设置弹窗(由 AI 对话 editPreference 工具触发) */ +const showJobGoalDialog = ref(false) + /** 是否暂停投递 */ const isPaused = ref(false) @@ -1170,6 +1177,25 @@ async function handleSendMessage() { } catch { console.error('[Agent] 推荐岗位请求失败') } + } else if (tool === 'editPreference') { + /* AI 要求打开偏好设置弹窗 — 先插入 AI 回复消息,再弹出求职目标设置弹窗 */ + const aiNow = Math.floor(Date.now() / 1000) + chatMessages.value.push({ + id: Date.now() + 1, + type: 'assistant', + content: aiContent, + extra: '', + createTime: { seconds: aiNow, nanos: 0 }, + }) + try { + await addAgentChatMessage({ type: 'assistant', content: aiContent }) + } catch { /* 静默 */ } + + await nextTick() + scrollChatToBottom() + + /* 打开求职目标设置弹窗 */ + showJobGoalDialog.value = true } else { /* 普通 AI 回复 — 直接插入 assistant 消息 */ const aiNow = Math.floor(Date.now() / 1000) diff --git a/src/views/Home.vue b/src/views/Home.vue index e3a14b6..5ebae28 100644 --- a/src/views/Home.vue +++ b/src/views/Home.vue @@ -421,10 +421,11 @@ - +