소스 검색

Merge branch 'dev' of https://git.citupro.com/zhengnaiwen_citu/menduner-admin into dev

lifanagju_citu 2 주 전
부모
커밋
19405e2bcb

+ 12 - 0
src/api/menduner/system/talentMap/webParsing.ts

@@ -0,0 +1,12 @@
+import request from '@/config/axios'
+
+export const talentWebParsingApi = {
+	// 基于markdown格式解析人才信息
+	saveMarkdownContent: async (data: any) => {
+		return await request.post({ 
+			url: `/api/parse/webpage-parse`,
+			data,
+			baseURL: import.meta.env.VITE_BASE_URL
+		})
+	}
+}

+ 6 - 0
src/views/menduner/system/talentMap/components/FormPage.vue

@@ -254,6 +254,12 @@ onMounted(() => {
   }
 })
 
+watch(() => props.itemData, val => {
+  if (val) {
+    setFormData(val)
+  }
+}, { deep: true })
+
 const mobileChange = (val, index) => {
   const { cleanedNumber, valid } = validatePhoneNumber(val, true)
   formQuery.value.mobile[index] = cleanedNumber

+ 150 - 101
src/views/menduner/system/talentMap/maintenance/gather/components/webAnalysis.vue

@@ -5,144 +5,193 @@
 			:model="queryParams"
 			ref="queryFormRef"
 			:inline="true"
-			label-width="90px"
+			label-width="110px"
 		>
-			<el-form-item label="url抓取数据" prop="urls">
+			<el-form-item label="微信公众号链接" prop="urls" class="!w-100%">
 				<el-input
 					v-model="queryParams.urls"
-					class="!w-420px"
+					class="!w-75%"
 					type="textarea"
-					:rows="1"
-					placeholder="请输入需要爬取的页面,多个页面请用 ',' 隔开"
-				/>
-			</el-form-item>
-			<el-form-item>
-				<el-button type="primary" plain :loading="loading" @click="handleExecute">执行</el-button>
+					:rows="2"
+					placeholder="请输入需要解析的微信公众号链接"
+					/>
+					<el-button type="primary" class="ml-10px" plain :loading="loading" @click="handleAnalysis">解析</el-button>
 			</el-form-item>
 		</el-form>
 	</ContentWrap>
 
-	<ContentWrap v-if="contents.length">
-		<el-row gutter="20">
-			<el-col v-for="(content, index) in contents" :key="index" :span="12">
-				<el-card class="!h-500px" v-loading="!content.data">
-					<template #header>
-						<div class="flex items-center justify-between">
-							<el-text class="flex-1" truncated>{{ content.url }}</el-text>
-							<div class="!w-85px">
-								<Icon icon="ep:view" size="25" class="ml-10px cursor-pointer" color="#409eff" @click="showPage(content)" />
-								<Icon icon="ep:refresh" size="25" class=" ml-18px cursor-pointer" color="#409eff" @click="handleReload(content)" />
-							</div>
-						</div>
-					</template>
-					<div v-if="content.data">
-						<template v-if="typeof content.data === 'string'">{{ content.data }}</template>
-            <el-tabs v-else v-model="content.tab">
-              <el-tab-pane v-for="(v, k) in content.data.data[0]" :key="k" :label="k" :name="k" class="overflow-y-auto !h-360px">
-								<template v-if="k === 'html'">
-									<div class="position-sticky float-right">
-										<el-button
-											type="primary"
-											class="cursor-pointer"
-											@click="content.showHtml = !content.showHtml"
-											:icon="SetUp"
-											circle
-										/>
-									</div>
-                  <pre v-if="!content.showHtml">{{ v }}</pre>
-                  <div v-else v-html="v"></div>
-                </template>
-                <pre v-else>{{ v || '暂无数据' }}</pre>
-							</el-tab-pane>
-            </el-tabs>
-          </div>
-				</el-card>
-			</el-col>
-		</el-row>
-	</ContentWrap>
-
-	<el-drawer
-		v-model="drawer"
-		class="!w-50vw"
-		:with-header="false"
-		:modal="true"
-	>
-		<iframe class="!w-100% !h-[calc(100vh-90px)]" :src="drawerUrl" frameborder="0"></iframe>
-		<el-divider class="!ma-0" />
-		<div class="position-sticky left-20px !h-50px lh-50px">
-			<el-button type="primary" class="!w-100px" @click="drawer = false; drawerUrl = ''">关 闭</el-button>
-		</div>
-	</el-drawer>
+	<el-row v-if="contents.length" :gutter="20">
+		<el-col v-for="(content, index) in contents" :key="index" :span="24">
+			<el-card class="!h-600px" v-loading="!content.markdown_text">
+				<template #header>
+					<div class="flex items-center justify-between">
+						<el-text class="flex-1" truncated>{{ content.url }}</el-text>
+						<el-button type="primary" plain class="mt-10px" :loading="content.itemLoading" @click="handleSubmit(content)">信息提取</el-button>
+					</div>
+				</template>
+				<iframe :id="content.id" class="!w-100% !h-[calc(100vh-90px)]" src="" frameborder="0"></iframe>
+			</el-card>
+		</el-col>
+	</el-row>
 </template>
 
 <script setup>
 /** 人才采集 网页解析 */
-import FirecrawlApp from '@mendable/firecrawl-js'
-import { SetUp } from '@element-plus/icons-vue'
+defineOptions({ name: 'WebPageParsing' })
+import axios from 'axios'
+import TurndownService from 'turndown'
+import { talentWebParsingApi } from '@/api/menduner/system/talentMap/webParsing.ts'
+import { generateUUID } from '@/utils'
 
+const emit = defineEmits(['analysis'])
 const message = useMessage() // 消息弹窗
 const { t } = useI18n() // 国际化
 
 const loading = ref(false)
 const queryParams = reactive({
-	urls: 'https://mp.weixin.qq.com/s/WeCRR3zN3fPvlGR4t8YFDA'
+	urls: ''
 })
 const queryFormRef = ref()
 const contents = ref([])
 const drawer = ref(false)
-const drawerUrl = ref('')
 
-const showPage = (content) => {
-	drawer.value = true
-	drawerUrl.value = content.url
+// 创建转换服务
+const turndownService = new TurndownService({
+  headingStyle: 'atx',
+  codeBlockStyle: 'fenced',
+  bulletListMarker: '-'
+})
+
+// 添加自定义规则处理微信公众号特有内容
+turndownService.addRule('wechatImages', {
+  filter: 'img',
+  replacement: (content, node) => {
+    const alt = node.getAttribute('alt') || ''
+    const src = node.getAttribute('src') || node.getAttribute('data-src') || ''
+    return `![${alt}](${src})`
+  }
+})
+
+// 提取主要内容并转换为Markdown
+const wechatHtmlToMarkdown = (html) => {
+  // 创建一个临时DOM解析器
+  const parser = new DOMParser()
+  const doc = parser.parseFromString(html, 'text/html')
+  
+  // 提取正文内容 - 微信公众号文章通常在id="js_content"的div中
+  const content = doc.querySelector('#js_content') || doc.body
+  
+  // 移除不需要的元素
+  const elementsToRemove = [
+    'script', 'style', 'iframe', 'button', 
+    '.qr_code', '.rich_media_extra', '.copyright'
+  ]
+  
+  elementsToRemove.forEach(selector => {
+    content.querySelectorAll(selector).forEach(el => el.remove())
+  })
+  
+  // 转换为Markdown
+  return turndownService.turndown(content.innerHTML)
 }
 
-const handleReload = async (content) => {
-	content.data = null
-	const res = await handleData(queryParams.urls)
-	content.tab = 0
-	content.data = res
+// 转换为markdown格式
+const handleConvert = (res) => {
+	if (!res.data) return
+	const result = wechatHtmlToMarkdown(res.data)
+	if (!result) return message.warning('转换失败')
+	return result
 }
 
-const handleData = async (url) => {
-	try {
-    const app = new FirecrawlApp({ apiKey: 'fc-85c1550c6db64ce4ae8f2d2cd2606e6f' })
-    const crawlResponse = await app.crawlUrl(url, {
-      limit: 100,
-      scrapeOptions: {
-        formats: ['markdown', 'html']
-      }
+// 查看原网页
+const showPage = (res) => {
+  if (res.data) {
+    let html = res.data
+    html = html.replace(/data-src/g, 'src') // 将 data-src 转化为 src
+      .replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/g, '') // 移除HTML内容中所有的<script>标签,这样可以避免在iframe中执行潜在的不受信任的脚本。
+      .replace(/https/g, 'http') // 将HTML内容中所有的https替换为http,可能是为了避免在HTTPS环境下加载非HTTPS资源导致浏览器警告
+    
+    nextTick(() => {
+      const iframe = document.getElementById(res.id)
+      if (!iframe) return
+      const doc = iframe.contentDocument || iframe.document
+      // 设置 iframe 中请求不发送 referrer,以绕过图片防盗链
+      const htmlArr = html.split('</head>')
+      const html_src_add = htmlArr[0] + '<meta name="referrer" content="never"></head>' + htmlArr[1]
+      doc.open()
+      doc.write(html_src_add)
+      doc.close()
+      // 通过延时获取文档高度赋值Iframe去除滚动条,根据实际情况增加延时时间
+      setTimeout(() => {
+        const jsContent = doc.getElementById('js_content')
+        if (jsContent) {
+          jsContent.style.visibility = 'visible'
+          jsContent.style.opacity = 1
+        }
+      }, 100)
     })
-    if (!crawlResponse.success) {
-      throw new Error(`Failed to crawl: ${crawlResponse.error}`)
-    }
-    return crawlResponse
-  } catch (error) {
-    return error.message
   }
 }
 
-// 执行
-const handleExecute = async () => {
+// 提取
+const handleAnalysis = async () => {
 	if (!queryParams.urls) return
+
+	if (contents.value && contents.value.length) {
+		const isAnalysis = contents.value.every(e => e.itemLoading)
+		return message.warning('正在解析中,请稍后再试')
+	}
+
 	contents.value = []
-	const urls = queryParams.urls.split(',').map(url => url.trim()).filter(url => url)
-	if (urls.length === 0) return
+	loading.value = true
 
-	urls.forEach(url => {
-	  contents.value.push({ url, tab: 'markdown', showHtml: false, data: null })
-	})
+	const urlArr = queryParams.urls.split(',').map(url => url.trim()).filter(Boolean)
+
+	const isWeChatUrl = urlArr.every(url => url.includes('https://mp.weixin.qq.com'))
+	if (!isWeChatUrl) {
+		message.warning('请输入微信公众文章链接')
+		return
+	}
 
-	const crawlPromises = urls.map(async (url, index) => {
-		const res = await handleData(url)
-		contents.value[index] = { ...contents.value[index], data: res }
+	const base_url = import.meta.env.VITE_NODE_BASE_URL
+	axios.post(`${base_url}/process-urls`, { urlArr }, { timeout: 60000 }).then(res => {
+		if (!res?.data || !res?.data?.contents || !res?.data?.contents.length) return
+		const list = res?.data?.contents
+		list.forEach(e => {
+			contents.value.push({
+				...e,
+				itemLoading: false,
+				id: generateUUID(),
+				markdown_text: handleConvert(e)
+			})
+		})
+		contents.value.forEach(e => {
+			showPage(e)
+		})
+
+	}).catch(err => {
+		console.log(err, 'error');
+		message.error(err.message)
+	}).finally(_ => {
+		loading.value = false
 	})
+}
 
+// 解析
+const handleSubmit = async (content) => {
+	if (loading.value) return message.warning('正在提取中,请稍后再试')
+	if (!content.markdown_text) return
+
+	content.itemLoading = true
 	try {
-		await Promise.all(crawlPromises)
-		console.log('All crawls completed:', contents.value); // 可在此处添加成功回调
-	} catch (error) {
-		console.error('爬取过程中发生错误:', error);
+		const data = await talentWebParsingApi.saveMarkdownContent({ markdown_text: content.markdown_text })
+		emit('analysis', data ?? {})
+		message.success('信息提取成功')
+	} finally {
+		content.itemLoading = false
 	}
 }
-</script>
+</script>
+
+<style scoped>
+</style>

+ 616 - 17
src/views/menduner/system/talentMap/maintenance/gather/index.vue

@@ -1,27 +1,626 @@
 <template>
-  <div>
-    <el-tabs v-model="activeName" type="border-card">
-      <el-tab-pane label="简历解析" name="resume">
-        <resumePage/>
-      </el-tab-pane>
-      <el-tab-pane label="名片解析" name="card">
-        <cardPage/>
-      </el-tab-pane>
-      <el-tab-pane label="网页解析" name="webpage">
-        <webPageParsing/>
-      </el-tab-pane>
-    </el-tabs>
-  </div>
+  <ContentWrap>
+    <!-- 搜索工作栏 -->
+    <el-form
+      class="-mb-15px"
+      :model="queryParams"
+      ref="queryFormRef"
+      :inline="true"
+      label-width="68px"
+    >
+      <el-form-item label="名称" prop="name">
+        <el-input v-model="queryParams.name" placeholder="请输入名称" clearable @keyup.enter="handleQuery" class="!w-180px" />
+      </el-form-item>
+      <el-form-item>
+        <el-button @click="handleQuery('search')"><Icon icon="ep:search" /> 搜索</el-button>
+        <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
+        <el-button type="primary" plain @click="handleAdd">
+          <Icon icon="ep:plus" class="mr-5px" /> 新增人才
+        </el-button>
+      </el-form-item>
+    </el-form>
+  </ContentWrap>
+
+  <!-- 列表 -->
+  <ContentWrap>
+    <el-table v-loading="loading" :data="list" :stripe="true">
+      <el-table-column label="中文名称" align="center" prop="name_zh" fixed="left" />
+      <el-table-column label="英文名称" align="center" prop="name_en" />
+      <el-table-column label="职位" align="center" prop="title_zh" />
+      <el-table-column label="酒店/公司" align="center" prop="hotel_zh" />
+      <el-table-column label="手机号码" align="center" prop="mobile" />
+      <el-table-column label="固定电话" align="center" prop="phone" />
+      <el-table-column label="状态" align="center" prop="status" width="80">
+        <template #default="scope">
+          <el-tag type="success" v-if="scope.row.status === 'active'">已启用</el-tag>
+          <el-tag type="danger" v-else>已禁用</el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="创建日期" align="center" prop="created_at" :formatter="dateFormatter" />
+      <el-table-column label="操作" align="center" fixed="right" min-width="110">
+        <template #default="scope">
+          <el-button link type="primary" @click="handleEdit(scope.row)">编辑</el-button>
+          <el-button link type="danger" @click="handleDelete(scope.row.id)">删除</el-button>
+          <el-button link :type="scope.row.status === 'active' ? 'warning': 'success'" @click="handleDisable(scope.row)">
+            {{ scope.row.status === 'active' ? '禁用' : '启用'}}
+          </el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <!-- 选择来源 -->
+    <Dialog title="新增" v-model="openSelect" width="500" @close="openSelect = false">
+			<el-radio-group v-model="radioValue" size="large" class="radioBox">
+				<el-radio
+					v-for="item in radioList"
+					:key="item.value"
+					:value="item.value"
+				>
+					{{ item.label }}
+				</el-radio>
+			</el-radio-group>
+      <template #footer>
+        <el-button type="primary" @click="handleSelect">确 认</el-button>
+        <el-button @click="openSelect = false">取 消</el-button>
+      </template>
+    </Dialog>
+
+    <!-- 人员搜索 -->
+    <Dialog :title="radioObject.menduner" v-model="openSearch" width="1200" @close="openSearch = false">
+      <Search @detail="handleDetail" :detailButTxt="detailButTxt" />
+    </Dialog>
+
+    <!-- 解析文件上传 -->
+    <Dialog :title="radioObject[radioValue]" v-model="dialog_upload" :width="DialogWidth" @close="handleCancel">
+      <div>
+        <!-- 门墩儿人才库 -->
+        <!-- <template v-if="radioValue === 'menduner'">
+        </template> -->
+        <!-- 简历解析 -->
+        <template v-if="radioValue === 'file'">
+          <el-upload
+            ref="uploadRef"
+            v-model:file-list="fileList"
+            :action="uploadUrl"
+            :auto-upload="false"
+            :data="data"
+            :limit="1"
+            :on-change="handleChange"
+            :on-error="submitFormError"
+            :on-exceed="handleExceed"
+            :on-success="submitFormSuccess"
+            :http-request="httpRequest"
+            accept=".pdf, doc, .docx"
+            drag
+            class="flex-1"
+          >
+            <i class="el-icon-upload"></i>
+            <div class="el-upload__text">上传附件, 将文件拖到此处,或 <em>点击上传</em></div>
+            <template #tip>
+              <div class="el-upload__tip color-red">
+                提示:仅允许导入 pdf、doc、docx 格式文件!
+              </div>
+            </template>
+          </el-upload>
+        </template>
+        <!-- 名片解析 -->
+        <template v-if="radioValue === 'card'">
+          <UploadImg
+            v-model="cardImgUrl"
+            :limit="1"
+            :uploadSuccessTip="false"
+            @handle-change="cardUploadChange"
+            height="150px" width="150px" style="margin: 20px auto; width: 150px;"
+          >
+            <template #tip>{{ cardImgUrl ? '' : '请上传名片' }}</template>
+          </UploadImg>
+        </template>
+        <!-- 网页解析 -->
+        <!-- <template v-if="radioValue === 'web'"></template> -->
+      </div>
+      <template #footer>
+        <el-button @click="handleAnalysis" type="success" :disabled="analysisLoading" :loading="analysisLoading">解 析</el-button>
+        <el-button @click="handleCancel">取 消</el-button>
+      </template>
+    </Dialog>
+
+    <!-- 解析回显 -->
+    <Dialog :title="radioObject[radioValue]" v-model="dialog_analysisInfo" width="90%">
+      <div class="analysisInfoBox">
+        <div class="analysisFile">
+          <!-- 门墩儿人才库 -->
+          <template v-if="radioValue === 'menduner'">
+            <el-tabs v-model="activeName" type="border-card">
+              <el-tab-pane label="基本信息" name="info">
+                <el-card shadow="never" class="m-b-20px">
+                  <template #header>
+                    <CardTitle title="人才详情" />
+                  </template>
+                  <Info :id="id" :user-id="userId" />
+                </el-card>
+                
+                <el-card shadow="never" class="m-b-20px">
+                  <template #header>
+                    <CardTitle title="工作经历" />
+                  </template>
+                  <Exp :user-id="userId" />
+                </el-card>
+              </el-tab-pane>
+
+              <el-tab-pane label="附件简历" name="Attachment">
+                <Attachment showPreview :user-id="userId" />
+              </el-tab-pane>
+            </el-tabs>
+          </template>
+          <!-- 简历解析 -->
+          <template v-if="radioValue === 'file'">
+            <div v-if="fileUrl" style="position: relative;">
+              <div class="text-right m-b-10px">
+                <el-button v-if="!isEdit" @click="handleText">查看文本信息</el-button>
+                <el-button type="primary" @click="handleResetUpload">重新上传简历</el-button>
+              </div>
+              <IFrame :src="fileUrl" />
+              <el-drawer
+                v-model="drawer"
+                modal-class="drawer"
+                size="75%"
+                direction="ltr"
+                title="简历解析(可复制文本使用)"
+              >
+                <p v-for="(text, index) in resumeTxt" :key="text + index">{{ text }}</p>
+              </el-drawer>
+            </div>
+            <el-upload
+              v-else
+              ref="uploadRef"
+              v-model:file-list="fileList"
+              :action="uploadUrl"
+              :auto-upload="false"
+              :data="data"
+              :limit="1"
+              :on-change="handleChange"
+              :on-error="submitFormError"
+              :on-exceed="handleExceed"
+              :on-success="submitFormSuccess"
+              :http-request="httpRequest"
+              accept=".pdf, doc, .docx"
+              drag
+              class="flex-1"
+            >
+              <i class="el-icon-upload"></i>
+              <div class="el-upload__text">上传附件, 将文件拖到此处,或 <em>点击上传</em></div>
+              <template #tip>
+                <div class="el-upload__tip color-red">
+                  提示:仅允许导入 pdf、doc、docx 格式文件!
+                </div>
+              </template>
+            </el-upload>
+          </template>
+          <!-- 名片解析 -->
+          <template v-if="radioValue === 'card'">
+            <div class="image">
+              <el-image v-if="cardImgUrl" class="!w-100%" :src="cardImgUrl" />
+              <div v-else>
+                <UploadImg
+                  v-model="cardImgUrl"
+                  :limit="1"
+                  :uploadSuccessTip="false"
+                  drag
+                  buttonUpload
+                  @handle-change="cardUploadChange"
+                  height="32px" width="104px"
+                  style="margin: 0 auto; width: 104px;margin-top: 40%;"
+                >
+                  <template #tip>{{ cardImgUrl ? '' : '请上传名片' }}</template>
+                </UploadImg>
+              </div>
+            </div>
+          </template>
+          <!-- 网页解析 -->
+          <template v-if="radioValue === 'web'">
+            <webAnalysis v-if="showWebAnalysis" @analysis="val => formData = val" />
+          </template>
+        </div>
+        <FormPage ref="FormPageRef" :analysisType="analysisType" :itemData="formData" />
+      </div>
+      <template #footer>
+        <el-button @click="handleSave" type="success" :disabled="analysisLoading">保 存</el-button>
+        <el-button @click="dialog_analysisInfo = false">取 消</el-button>
+      </template>
+    </Dialog>
+  </ContentWrap>
+
+  <MergeForm ref="mergeFormRef" @refresh="getList" />
 </template>
 
 <script setup>
 defineOptions({ name: 'TalentMapStoreIndex' })
-import resumePage from './resume/index.vue'
-import cardPage from './businessCard/index.vue'
-import webPageParsing from './webPageParsing/index.vue'
+import { dateFormatter } from '@/utils/formatTime'
+import { talentLabelingApi } from '@/api/menduner/system/talentMap/labeling'
+import { TalentMap } from '@/api/menduner/system/talentMap'
+import { Delete, Plus } from '@element-plus/icons-vue'
+import MergeForm from '@/views/menduner/system/talentMap/components/merge.vue'
+import FormPage from '@/views/menduner/system/talentMap/components/FormPage.vue'
+// import uploadDialog from './components/uploadDialog.vue'
+import { timesTampChange, timestampToAge } from '@/utils/transform/date'
+import Search from './components/search.vue'
+import webAnalysis from './components/webAnalysis.vue'
+import { useUpload } from '@/components/UploadFile/src/useUpload'
+import { commonApi } from '@/api/menduner/common'
+import { Base64 } from 'js-base64'
+import Info from '@/views/menduner/system/person/details/components/info.vue'
+import Exp from '@/views/menduner/system/person/details/components/exp.vue'
+import Attachment from '@/views/menduner/system/person/details/components/attachment.vue'
+
+const baseUrl = import.meta.env.VITE_PREVIEW_URL
+const { uploadUrl, httpRequest } = useUpload()
+const message = useMessage() // 消息弹窗
+const { t } = useI18n() // 国际化
+
+const loading = ref(false) // 列表的加载中
+const list = ref([]) // 列表的数据
+const total = ref(0) // 列表的总页数
+const queryParams = reactive({
+  name: undefined
+})
+const queryFormRef = ref() // 搜索的表单
+const dialog_upload = ref(false)
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    list.value = []
+    const data = await talentLabelingApi.getCardList()
+    list.value = data ? data.reverse() : []
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 搜索按钮操作 */
+const handleQuery = (type) => {
+  if (type !== 'reset') {
+    message.warning('搜索正在建设中...')
+    return
+  }
+  getList()
+}
+
+/** 重置按钮操作 */
+const resetQuery = () => {
+  queryFormRef.value.resetFields()
+  handleQuery('reset')
+}
+
+/** 删除按钮操作 */
+const handleDelete = async (id) => {
+  try {
+    // 删除的二次确认
+    await message.delConfirm()
+    // 发起删除
+    await talentLabelingApi.deleteBusinessCard(id)
+    message.success(t('common.delSuccess'))
+    // 刷新列表
+    setTimeout(async () => {
+      await getList()
+    }, 0)
+  } catch {}
+}
+
+/** 编辑 */
+const { push } = useRouter()
+const handleEdit = async (item) => {
+  analysisType.value = 'edit'
+  formData.value = item
+  radioValue.value = item.type || 'card' // menduner
+  try {
+    if (radioValue.value === 'card') {
+      if (!item?.image_path) {
+        cardUploadRow.value = null
+        cardImgUrl.value = null
+        dialog_analysisInfo.value = true
+        return
+      }
+      const data = await talentLabelingApi.getBusinessCardImage(item.image_path)
+      cardUploadRow.value = data?.type ? new File([data], item.image_path, { type: data.type }) : null
+      cardImgUrl.value = data?.type ? URL.createObjectURL(data) : null
+    }
+  } catch (error) {
+    console.log('打印->getBusinessCardImage', error)
+  } finally {
+    dialog_analysisInfo.value = true
+  }
+}
+
+/** 禁用按钮操作 */
+const handleDisable = async (item) => {
+  if (!item?.id) return message.warning('操作失败,请稍后再试')
+  try {
+    // 禁用的二次确认
+    const status = item.status === 'active' ? 'inactive' : 'active'
+    const text = status === 'inactive' ? '禁用' : '启用'
+    
+    await message.delConfirm(`是否${text}该名片?`)
+    // 发起禁用
+    await talentLabelingApi.updateBusinessCardStatus({
+      status,
+    }, item.id)
+    message.success(`${text}成功`)
+    // 刷新列表
+    await getList()
+  } catch {}
+}
+
+// 更新
+const activeName = ref('info')
+const dialog_analysisInfo = ref(false)
+const formLoading = ref(false)
+const analysisType = ref('')
+const FormPageRef = ref(null)
+const mergeFormRef = ref() // 合并表单 Ref
+const handleSave = async () => {
+  const params = { ...FormPageRef.value.formQuery, type: radioValue.value }
+  if (!params.name_zh) return message.warning('请填写姓名!')
+  
+  // 数组转为字符串保存
+  if (Array.isArray(params?.mobile)) {
+    params.mobile = params.mobile.filter(i => Boolean(i)).map(j => String(j).replace(/,|,/g, '')).join(',');
+  }
+
+  console.log(params, 'handleSubmit')
+  try {
+    formLoading.value = true
+    let result = {}
+
+    if (analysisType.value === 'create') {
+      if (cardFileQuery.value) {
+        cardFileQuery.value.append('card_data', JSON.stringify(params)) // 名片
+        result = await talentLabelingApi.createBusinessCard(cardFileQuery.value)
+      } else {
+        // 结构化数据源 不传递文件
+        result = await talentLabelingApi.createBusinessCardPost(params)
+      }
+      message.success('新增成功')
+      dialog_analysisInfo.value = false
+      // 刷新列表
+      getList()
+
+      if (result.code === 202 || result.message.includes('疑似重复')) {
+        if (!result.data?.main_card?.id) return
+        
+        await message.confirm('发现与当前名片的疑似重复数据,去处理')
+        mergeFormRef.value.open(result.data?.main_card?.id)
+      }
+    } else {
+      await talentLabelingApi.updateBusinessCard(params, formData.value.id)
+      message.success('更新成功')
+      dialog_analysisInfo.value = false
+      // 刷新列表
+      getList()
+    }
+  } catch (error) {
+    console.log('更新失败', error)
+  } finally {
+    cardFileQuery.value = null
+    formLoading.value = false
+    openSearch.value = false
+  }
+}
+
+// 解析中
+const analysisLoading = ref(false)
+const formData = ref({})
+const handleAnalysis = async () => {
+  // 开始解析
+  analysisLoading.value = true
+  cardFileQuery.value = null
+  formData.value = null
+	const type = radioValue.value
+  try {
+    // if (type === 'menduner') { // 门墩儿人才库
+    // } else 
+    if (type === 'file') { // 简历解析
+      if (!fileUrl.value) return message.warning('获取文件失败,请重新上传!')
+      const data = await commonApi.resumeParser({ fileUrl: fileUrl.value })
+      resumeAnalysisToForm(data) // 简历解析
+    } else if (type === 'card') { // 名片解析
+      if (!cardImgUrl.value) {
+        message.warning('请先上传名片!')
+        return
+      }
+      cardFileQuery.value = new FormData()
+      cardFileQuery.value.append('image', cardUploadRow.value)
+      message.warning('正在解析...')
+  
+      const index = createAnalysisNum.value
+      const res = await talentLabelingApi.businessCardParse(cardFileQuery.value)
+      if (index !== createAnalysisNum.value || !dialog_upload.value) return // 不是最新的名片解析数据(用户在解析完成前已重新上传)或用户已取消解析
+      formData.value = res?.data || res
+      message.success('名片解析成功')
+    }
+    // else if (type === 'web') {}
+
+    dialog_upload.value = false
+    dialog_analysisInfo.value = true
+  } catch (error) {
+    console.log('解析失败', error)
+    cardFileQuery.value = null
+  } finally {
+    analysisLoading.value = false
+  }
+}
+
+// 简历解析
+const fileUrl = ref('') // https://minio.menduner.com/dev/person/229988673960153088/attachment/ee3eb21f45e13ede3557a03d18585ed80c5b4212ac5634e3436e309afaa8fe6a.pdf
+const uploadRef = ref()
+const fileList = ref([])
+const data = ref({ path: '' })
+// 文件上传
+const handleChange = async (file) => {
+  data.value.path = file.name
+  unref(uploadRef)?.submit()
+  if (!fileList.value.length) return
+
+  const url = fileList.value[0].response.data
+  fileUrl.value = !url.includes('.pdf') ?  `${baseUrl}/onlinePreview?url=${encodeURIComponent(Base64.encode(url))}` : url
+  if (dialog_analysisInfo.value) {
+    if (FormPageRef.value?.changeLoading) FormPageRef.value.changeLoading(true)
+    message.warning('正在解析...')
+    const data = await commonApi.resumeParser({ fileUrl: fileUrl.value })
+    resumeAnalysisToForm(data) // 简历解析
+    if (FormPageRef.value?.changeLoading) FormPageRef.value.changeLoading(false)
+  }
+}
+const submitFormError = () => {
+  message.error('上传失败,请您重新上传!')
+}
+const handleExceed = () => {
+  message.error('最多只能上传一个文件!')
+}
+const submitFormSuccess = (e) => {
+  // 清理
+  // unref(uploadRef)?.clearFiles()
+}
+
+const drawer = ref(false)
+const resumeTxt = ref([])
+// 查看文本信息
+const handleText = () => {
+  drawer.value = true
+}
+// 重新上传简历
+const handleResetUpload = async () => {
+  await message.confirm('是否确定重新上传简历?确定后将清空当前信息')
+  fileUrl.value = ''
+  data.value.path = ''
+  fileList.value = []
+  resumeAnalysisToForm('reset') // 简历解析
+}
+
+// 简历解析数据解构赋值
+const resumeAnalysisToForm = (data) => {
+  if (data === 'reset') {
+    // 重置表单
+    resumeTxt.value = ''
+    if (FormPageRef.value?.resetFormData) FormPageRef.value.resetFormData()
+  }
+  formData.value = {
+    name_zh: data?.person?.name || '',
+    email: data?.person?.email || '',
+    mobile: data?.person?.phone || '',
+    birthday: data?.person?.birthday ? timesTampChange(data.person.birthday, 'Y-M-D') : '',
+    age: data?.person?.birthday ? timestampToAge(data.person.birthday) : null,
+    career_path: data?.workList ? data.workList.map(e => {
+      return {
+        hotel_zh: e?.enterpriseName || null,
+        title_zh: e?.positionName || null,
+        date: e?.startTime ? timesTampChange(e.startTime, 'Y-M-D') : null
+      }
+    }) : null,
+    created_at: data?.person?.createTime ? timesTampChange(data.person.createTime, 'Y-M-D') : null,
+    updated_at: data?.person?.updateTime ? timesTampChange(data.person.updateTime, 'Y-M-D') : null,
+  }
+  resumeTxt.value = data?.resume?.rawText?.split('\n') || ''
+  if (FormPageRef.value?.setFormData) FormPageRef.value.setFormData(formData.value)
+}
+
+// 名片解析 
+const createAnalysisNum = ref(0)
+const cardFileQuery = ref(null)
+const cardUploadRow = ref(null)
+const cardImgUrl = ref(null)
+const cardUploadChange = (raw) => {
+  cardUploadRow.value = raw
+}
+
+// 人员搜索
+const detailButTxt = '加入人才地图'
+const openSearch = ref(false)
+const id = ref(null)
+const userId = ref(null)
+const handleDetail = async ({id: use_id, userId: use_userId}) => {
+  if (!use_userId || !use_userId)  return message.warning('请先选择人才!')
+  id.value = use_id; userId.value = use_userId
+  try {
+    const data = await TalentMap.getTalentMapDetail(use_userId)
+    // 去除id
+    resumeAnalysisToForm(data) // 简历解析
+    dialog_analysisInfo.value = true
+    // message.success(`操作成功`)
+  } catch {}
+}
+
+const DialogWidth = ref('500')
+const showWebAnalysis = ref(false)
+// 选择解析方式
+const handleSelect = () => {
+  openSelect.value = false
+  formData.value = null
+  showWebAnalysis.value = false
+	const type = radioValue.value
+  if (type === 'card') {
+    createAnalysisNum.value++
+  }
+  if (type === 'menduner') {
+    openSearch.value = true
+    return
+  }
+  if (type === 'web') {
+    showWebAnalysis.value = true
+    dialog_analysisInfo.value = true
+    return
+  }
+  dialog_upload.value = true
+}
+
+// 关闭上传弹窗
+const handleCancel = () => {
+  dialog_upload.value = false
+  analysisLoading.value = false
+}
+
+const openSelect = ref(false)
+const radioObject = { card: '名片解析', file: '简历解析', web: '网页解析', menduner: '门墩儿招聘' }
+const radioList = ref(Object.keys(radioObject).map(key => ({ value: key, label: radioObject[key]}) ))
+const defaultValue = radioList.value[0].value // 默认选中
+const radioValue = ref(defaultValue)
+// 新增解析
+const handleAdd = () => {
+  cardUploadRow.value = null
+  cardImgUrl.value = null
+  analysisLoading.value = false
+  analysisType.value = 'create'
+  radioValue.value = defaultValue // 重置解析类型
+	// 
+  openSelect.value = true
+}
+
+/** 初始化 **/
+onMounted(() => {
+  getList()
+})
 
-const activeName = ref('resume')
 </script>
 
 <style lang="scss" scoped>
+.analysisInfoBox {
+  display: flex;
+  .analysisFile {
+    width: 50%;
+    max-height: 70vh;
+    padding-right: 12px;
+    overflow: auto;
+  }
+}
+.radioBox {
+	margin: 40px 0;
+}
+
+:deep(.drawer) {
+  position: absolute;
+  .el-drawer {
+    background-color: aliceblue;
+  }
+}
 </style>

+ 27 - 0
src/views/menduner/system/talentMap/maintenance/gather/index1.vue

@@ -0,0 +1,27 @@
+<template>
+  <div>
+    <el-tabs v-model="activeName" type="border-card">
+      <el-tab-pane label="简历解析" name="resume">
+        <resumePage/>
+      </el-tab-pane>
+      <el-tab-pane label="名片解析" name="card">
+        <cardPage/>
+      </el-tab-pane>
+      <el-tab-pane label="网页解析" name="webpage">
+        <webPageParsing/>
+      </el-tab-pane>
+    </el-tabs>
+  </div>
+</template>
+
+<script setup>
+defineOptions({ name: 'TalentMapStoreIndex' })
+import resumePage from './resume/index.vue'
+import cardPage from './businessCard/index.vue'
+import webPageParsing from './webPageParsing/index.vue'
+
+const activeName = ref('resume')
+</script>
+
+<style lang="scss" scoped>
+</style>

+ 1 - 2
src/views/menduner/system/talentMap/maintenance/gather/index2.vue

@@ -130,7 +130,6 @@
         <div class="analysisFile">
           <!-- 门墩儿人才库 -->
           <template v-if="radioValue === 'menduner'">
-
             <el-tabs v-model="activeName" type="border-card">
               <el-tab-pane label="基本信息" name="info">
                 <el-card shadow="never" class="m-b-20px">
@@ -213,7 +212,7 @@
           </template>
           <!-- 网页解析 -->
           <template v-if="radioValue === 'web'">
-            <webAnalysis v-if="showWebAnalysis"/>
+            <webAnalysis v-if="showWebAnalysis" @analysis="val => formData = val" />
           </template>
         </div>
         <FormPage ref="FormPageRef" :analysisType="analysisType" :itemData="formData" />

+ 27 - 31
src/views/menduner/system/talentMap/maintenance/gather/webPageParsing/index.vue

@@ -13,11 +13,11 @@
 					class="!w-50vw"
 					type="textarea"
 					:rows="2"
-					placeholder="请输入需要提取的页面,多个页面请用 ',' 隔开"
+					placeholder="请输入需要提取的页面"
 				/>
 			</el-form-item>
 			<el-form-item>
-				<el-button type="primary" plain :loading="loading" @click="handleAnalysis">解析</el-button>
+				<el-button type="primary" plain :loading="loading" @click="handleAnalysis">提取</el-button>
 			</el-form-item>
 		</el-form>
 	</ContentWrap>
@@ -25,20 +25,19 @@
 	<ContentWrap v-if="contents.length">
 		<el-row gutter="20">
 			<el-col v-for="(content, index) in contents" :key="index" :span="12">
-				<el-card class="!h-500px" v-loading="!content.data">
+				<el-card class="!h-500px" v-loading="!content.markdown_text">
 					<template #header>
 						<div class="flex items-center justify-between">
 							<el-text class="flex-1" truncated>{{ content.url }}</el-text>
-							<!-- <div class="!w-40px">
+							<div class="!w-40px">
 								<Icon icon="ep:view" size="25" class="ml-10px cursor-pointer" color="#409eff" @click="showPage(content)" />
-							</div> -->
+							</div>
 						</div>
 					</template>
-          <div class="overflow-y-auto !h-360px" v-if="content.data">
-            <pre>{{ content.data }}</pre>
+          <div class="overflow-y-auto !h-360px" v-if="content.markdown_text">
+            <pre>{{ content.markdown_text }}</pre>
           </div>
-					<el-button type="primary" plain class="mt-10px" @click="showPage(content)">预览</el-button>
-					<el-button type="primary" plain class="mt-10px" @click="handleConvert(content)">转换为markdown格式</el-button>
+					<el-button type="primary" plain class="mt-10px" @click="handleSubmit(content.markdown_text)">解析</el-button>
 				</el-card>
 			</el-col>
 		</el-row>
@@ -56,19 +55,6 @@
 			<el-button type="primary" class="!w-100px" @click="drawer = false">关 闭</el-button>
 		</div>
 	</el-drawer>
-
-	<el-drawer
-		v-model="showMarkDown"
-		class="!w-50vw"
-		:with-header="false"
-		:modal="true"
-	>
-		<pre>{{ markdownContent }}</pre>
-		<el-divider class="!ma-0" />
-		<div class="position-sticky left-20px !h-50px lh-50px">
-			<el-button type="primary" class="!w-100px" @click="showMarkDown = false; markdownContent = ''">关 闭</el-button>
-		</div>
-	</el-drawer>
 </template>
 
 <script setup>
@@ -76,6 +62,7 @@
 defineOptions({ name: 'WebPageParsing' })
 import axios from 'axios'
 import TurndownService from 'turndown'
+import { talentWebParsingApi } from '@/api/menduner/system/talentMap/webParsing.ts'
 
 const message = useMessage() // 消息弹窗
 const { t } = useI18n() // 国际化
@@ -129,15 +116,11 @@ const wechatHtmlToMarkdown = (html) => {
 }
 
 // 转换为markdown格式
-const showMarkDown = ref(false)
-const markdownContent = ref(null)
 const handleConvert = (res) => {
 	if (!res.data) return
 	const result = wechatHtmlToMarkdown(res.data)
-	// console.log(result, 'markdown格式')
 	if (!result) return message.warning('转换失败')
-	markdownContent.value = result
-	showMarkDown.value = true
+	return result
 }
 
 // 查看原网页
@@ -186,12 +169,18 @@ const handleAnalysis = async () => {
 	}
 
 	const base_url = import.meta.env.VITE_NODE_BASE_URL
-	console.log(base_url, 'base_url')
-	// const base_url = 'http://menduner.citupro.com:3300'
 
+	contents.value = []
 	axios.post(`${base_url}/process-urls`, { urlArr }, { timeout: 60000 }).then(res => {
-		console.log(res, '解析内容')
-		contents.value = res?.data?.contents ?? []
+		if (!res?.data || !res?.data?.contents || !res?.data?.contents.length) return
+		const list = res?.data?.contents
+		list.forEach(e => {
+			contents.value.push({
+				...e,
+				markdown_text: handleConvert(e)
+			})
+		})
+
 	}).catch(err => {
 		console.log(err, 'error');
 		message.error(err.message)
@@ -199,6 +188,13 @@ const handleAnalysis = async () => {
 		loading.value = false
 	})
 }
+
+const handleSubmit = async (markdown_text) => {
+	try {
+		const data = await talentWebParsingApi.saveMarkdownContent({ markdown_text })
+		console.log(data, markdown_text, 'submit')
+	} catch {}
+}
 </script>
 
 <style scoped>