This commit is contained in:
张成
2025-11-24 13:23:42 +08:00
commit 5d7444cd65
156 changed files with 50653 additions and 0 deletions

View File

@@ -0,0 +1,653 @@
<template>
<div class="chat-list-container">
<!-- 左侧会话列表 -->
<div class="conversation-list">
<div class="conversation-header">
<h3>聊天列表</h3>
<div class="header-actions">
<Select v-model="conversationFilter.platform" style="width: 120px" placeholder="平台" clearable @on-change="loadConversations">
<Option value="boss">Boss直聘</Option>
<Option value="liepin">猎聘</Option>
</Select>
<Input
v-model="conversationFilter.search"
placeholder="搜索公司/职位"
search
style="width: 200px; margin-left: 10px"
@on-search="loadConversations"
/>
</div>
</div>
<div class="conversation-items">
<div
v-for="conversation in conversations"
:key="conversation.conversationId"
:class="['conversation-item', { active: activeConversation?.conversationId === conversation.conversationId }]"
@click="selectConversation(conversation)"
>
<div class="conversation-avatar">
<Avatar :style="{ background: conversation.platform === 'boss' ? '#00b38a' : '#00a6ff' }">
{{ conversation.companyName ? conversation.companyName.substring(0, 1) : 'C' }}
</Avatar>
<Badge v-if="conversation.unreadCount > 0" :count="conversation.unreadCount" />
</div>
<div class="conversation-info">
<div class="conversation-title">
<strong>{{ conversation.companyName || '未知公司' }}</strong>
<Tag :color="conversation.platform === 'boss' ? 'success' : 'primary'" size="small">
{{ conversation.platform === 'boss' ? 'Boss' : '猎聘' }}
</Tag>
</div>
<div class="conversation-subtitle">{{ conversation.jobTitle || '未知职位' }}</div>
<div class="conversation-last-message">
{{ conversation.lastMessage || '暂无消息' }}
</div>
<div class="conversation-time">{{ formatTime(conversation.lastMessageTime) }}</div>
</div>
</div>
<div v-if="conversations.length === 0" class="empty-state">
<p>暂无聊天记录</p>
</div>
</div>
</div>
<!-- 右侧聊天窗口 -->
<div class="chat-window">
<div v-if="activeConversation" class="chat-content">
<!-- 聊天头部 -->
<div class="chat-header">
<div class="chat-header-info">
<h3>{{ activeConversation.companyName || '未知公司' }}</h3>
<p>{{ activeConversation.jobTitle || '未知职位' }} - {{ activeConversation.hrName || 'HR' }}</p>
</div>
<div class="chat-header-actions">
<Button icon="md-refresh" @click="loadChatMessages">刷新</Button>
<Button icon="md-information-circle" @click="showJobDetail">职位详情</Button>
</div>
</div>
<!-- 聊天消息列表 -->
<div class="chat-messages" ref="chatMessages">
<div
v-for="message in chatMessages"
:key="message.id"
:class="['message-item', message.direction === 'sent' ? 'message-sent' : 'message-received']"
>
<div class="message-avatar">
<Avatar v-if="message.direction === 'received'" icon="ios-person" />
<Avatar v-else icon="ios-chatbubbles" />
</div>
<div class="message-content">
<div class="message-meta">
<span class="message-sender">
{{ message.direction === 'sent' ? '我' : (message.hrName || 'HR') }}
</span>
<span class="message-time">{{ formatTime(message.sendTime || message.receiveTime) }}</span>
<Tag v-if="message.isAiGenerated" color="purple" size="small">AI生成</Tag>
</div>
<div class="message-bubble">
{{ message.content }}
</div>
<div v-if="message.isInterviewInvitation" class="interview-invitation">
<Icon type="md-calendar" />
<span>面试邀约: {{ message.interviewType === 'online' ? '线上' : '线下' }}</span>
<span v-if="message.interviewTime">{{ formatTime(message.interviewTime) }}</span>
</div>
</div>
</div>
<div v-if="chatMessages.length === 0" class="empty-messages">
<p>暂无聊天消息</p>
</div>
</div>
<!-- 消息输入框 -->
<div class="chat-input">
<Input
v-model="messageInput"
type="textarea"
:rows="3"
placeholder="输入消息内容..."
@on-enter="handleSendMessage"
/>
<div class="chat-input-actions">
<Button type="primary" icon="md-send" @click="handleSendMessage" :loading="sending">发送</Button>
<Button icon="md-bulb" @click="generateAiMessage">AI生成</Button>
</div>
</div>
</div>
<!-- 未选择会话的占位 -->
<div v-else class="chat-placeholder">
<Icon type="ios-chatbubbles" size="64" color="#dcdee2" />
<p>请选择一个会话开始聊天</p>
</div>
</div>
</div>
</template>
<script>
import chatRecordsServer from '@/api/operation/chat_records_server.js'
export default {
name: 'ChatList',
data() {
return {
// 会话列表
conversations: [],
activeConversation: null,
conversationFilter: {
platform: null,
search: ''
},
// 聊天消息
chatMessages: [],
messageInput: '',
sending: false,
// 定时刷新
refreshTimer: null,
refreshInterval: 10000, // 10秒刷新一次
}
},
mounted() {
this.loadConversations()
this.startAutoRefresh()
},
beforeDestroy() {
this.stopAutoRefresh()
},
methods: {
/**
* 加载会话列表
*/
async loadConversations() {
try {
const res = await chatRecordsServer.page({
seachOption: {
platform: this.conversationFilter.platform,
key: 'companyName',
value: this.conversationFilter.search
},
pageOption: {
page: 1,
pageSize: 100
}
})
// 按 conversationId 分组聊天记录
const conversationMap = new Map()
const rows = res.data?.rows || res.data?.list || []
rows.forEach(record => {
const convId = record.conversationId || `${record.jobId}_${record.sn_code}`
if (!conversationMap.has(convId)) {
conversationMap.set(convId, {
conversationId: convId,
jobId: record.jobId,
sn_code: record.sn_code,
platform: record.platform,
companyName: record.companyName,
jobTitle: record.jobTitle,
hrName: record.hrName,
lastMessage: record.content,
lastMessageTime: record.sendTime || record.receiveTime || new Date(),
unreadCount: 0,
messages: []
})
}
const conv = conversationMap.get(convId)
conv.messages.push(record)
// 更新最后一条消息
const lastTime = new Date(conv.lastMessageTime).getTime()
const currentTime = new Date(record.sendTime || record.receiveTime || new Date()).getTime()
if (currentTime > lastTime) {
conv.lastMessage = record.content
conv.lastMessageTime = record.sendTime || record.receiveTime
}
})
this.conversations = Array.from(conversationMap.values())
.sort((a, b) => new Date(b.lastMessageTime) - new Date(a.lastMessageTime))
} catch (error) {
console.error('加载会话列表失败:', error)
this.$Message.error('加载会话列表失败: ' + (error.message || '未知错误'))
}
},
/**
* 选择会话
*/
selectConversation(conversation) {
this.activeConversation = conversation
this.loadChatMessages()
},
/**
* 加载聊天消息
*/
async loadChatMessages() {
if (!this.activeConversation) return
try {
const res = await chatRecordsServer.getByJobId({
jobId: this.activeConversation.jobId,
sn_code: this.activeConversation.sn_code
})
this.chatMessages = res.data.sort((a, b) => {
const timeA = new Date(a.sendTime || a.receiveTime).getTime()
const timeB = new Date(b.sendTime || b.receiveTime).getTime()
return timeA - timeB
})
// 滚动到底部
this.$nextTick(() => {
this.scrollToBottom()
})
} catch (error) {
this.$Message.error('加载聊天消息失败: ' + (error.message || '未知错误'))
}
},
/**
* 发送消息
*/
async handleSendMessage() {
if (!this.messageInput.trim()) {
this.$Message.warning('请输入消息内容')
return
}
if (!this.activeConversation) {
this.$Message.warning('请先选择一个会话')
return
}
this.sending = true
try {
await chatRecordsServer.sendMessage({
sn_code: this.activeConversation.sn_code,
jobId: this.activeConversation.jobId,
content: this.messageInput,
chatType: 'reply',
platform: this.activeConversation.platform
})
this.$Message.success('消息发送成功')
this.messageInput = ''
// 重新加载消息
await this.loadChatMessages()
} catch (error) {
this.$Message.error('消息发送失败: ' + (error.message || '未知错误'))
} finally {
this.sending = false
}
},
/**
* AI生成消息
*/
generateAiMessage() {
this.$Message.info('AI消息生成功能开发中...')
// TODO: 调用AI接口生成消息
},
/**
* 显示职位详情
*/
showJobDetail() {
if (!this.activeConversation) return
this.$Modal.info({
title: '职位详情',
width: 600,
render: (h) => {
return h('div', [
h('p', [h('strong', '公司名称: '), this.activeConversation.companyName]),
h('p', [h('strong', '职位名称: '), this.activeConversation.jobTitle]),
h('p', [h('strong', 'HR: '), this.activeConversation.hrName || '未知']),
h('p', [h('strong', '平台: '), this.activeConversation.platform === 'boss' ? 'Boss直聘' : '猎聘']),
h('p', [h('strong', '职位ID: '), this.activeConversation.jobId])
])
}
})
},
/**
* 格式化时间
*/
formatTime(time) {
if (!time) return ''
const date = new Date(time)
const now = new Date()
const diff = now - date
// 1分钟内
if (diff < 60000) {
return '刚刚'
}
// 1小时内
if (diff < 3600000) {
return `${Math.floor(diff / 60000)}分钟前`
}
// 今天
if (date.toDateString() === now.toDateString()) {
return date.toTimeString().substring(0, 5)
}
// 昨天
const yesterday = new Date(now)
yesterday.setDate(yesterday.getDate() - 1)
if (date.toDateString() === yesterday.toDateString()) {
return '昨天 ' + date.toTimeString().substring(0, 5)
}
// 其他
return `${date.getMonth() + 1}-${date.getDate()} ${date.toTimeString().substring(0, 5)}`
},
/**
* 滚动到底部
*/
scrollToBottom() {
const messagesEl = this.$refs.chatMessages
if (messagesEl) {
messagesEl.scrollTop = messagesEl.scrollHeight
}
},
/**
* 开始自动刷新
*/
startAutoRefresh() {
// 启动定时器,每10秒刷新一次
this.refreshTimer = setInterval(() => {
// 如果有选中的会话,刷新消息
if (this.activeConversation) {
this.loadChatMessages()
}
// 刷新会话列表
this.loadConversations()
}, this.refreshInterval)
},
/**
* 停止自动刷新
*/
stopAutoRefresh() {
if (this.refreshTimer) {
clearInterval(this.refreshTimer)
this.refreshTimer = null
}
}
}
}
</script>
<style scoped>
.chat-list-container {
display: flex;
height: calc(100vh - 120px);
background: #fff;
border-radius: 4px;
overflow: hidden;
}
/* 左侧会话列表 */
.conversation-list {
width: 320px;
border-right: 1px solid #e8eaec;
display: flex;
flex-direction: column;
}
.conversation-header {
padding: 15px;
border-bottom: 1px solid #e8eaec;
}
.conversation-header h3 {
margin: 0 0 10px 0;
font-size: 16px;
}
.header-actions {
display: flex;
gap: 10px;
}
.conversation-items {
flex: 1;
overflow-y: auto;
}
.conversation-item {
display: flex;
padding: 12px 15px;
cursor: pointer;
border-bottom: 1px solid #f0f0f0;
transition: background-color 0.2s;
}
.conversation-item:hover {
background-color: #f5f7fa;
}
.conversation-item.active {
background-color: #e8f4ff;
}
.conversation-avatar {
position: relative;
margin-right: 12px;
}
.conversation-info {
flex: 1;
min-width: 0;
}
.conversation-title {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.conversation-title strong {
font-size: 14px;
color: #333;
}
.conversation-subtitle {
font-size: 12px;
color: #999;
margin-bottom: 4px;
}
.conversation-last-message {
font-size: 13px;
color: #666;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.conversation-time {
font-size: 11px;
color: #999;
margin-top: 4px;
}
.empty-state {
padding: 40px;
text-align: center;
color: #999;
}
/* 右侧聊天窗口 */
.chat-window {
flex: 1;
display: flex;
flex-direction: column;
}
.chat-content {
display: flex;
flex-direction: column;
height: 100%;
}
.chat-header {
padding: 15px 20px;
border-bottom: 1px solid #e8eaec;
display: flex;
justify-content: space-between;
align-items: center;
}
.chat-header-info h3 {
margin: 0 0 5px 0;
font-size: 16px;
}
.chat-header-info p {
margin: 0;
font-size: 12px;
color: #999;
}
.chat-header-actions {
display: flex;
gap: 10px;
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 20px;
background-color: #f5f7fa;
}
.message-item {
display: flex;
margin-bottom: 20px;
}
.message-item.message-sent {
flex-direction: row-reverse;
}
.message-avatar {
margin: 0 10px;
}
.message-content {
max-width: 60%;
}
.message-sent .message-content {
display: flex;
flex-direction: column;
align-items: flex-end;
}
.message-meta {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 5px;
font-size: 12px;
color: #999;
}
.message-bubble {
padding: 10px 15px;
border-radius: 8px;
background-color: #fff;
word-break: break-word;
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
}
.message-sent .message-bubble {
background-color: #2d8cf0;
color: #fff;
}
.interview-invitation {
margin-top: 8px;
padding: 8px 12px;
background-color: #fff9e6;
border-left: 3px solid #ff9900;
border-radius: 4px;
font-size: 12px;
display: flex;
align-items: center;
gap: 6px;
}
.empty-messages {
text-align: center;
padding: 40px;
color: #999;
}
.chat-input {
padding: 15px 20px;
border-top: 1px solid #e8eaec;
background-color: #fff;
}
.chat-input-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 10px;
}
.chat-placeholder {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #999;
}
.chat-placeholder p {
margin-top: 20px;
font-size: 14px;
}
/* 滚动条样式 */
.conversation-items::-webkit-scrollbar,
.chat-messages::-webkit-scrollbar {
width: 6px;
}
.conversation-items::-webkit-scrollbar-thumb,
.chat-messages::-webkit-scrollbar-thumb {
background-color: #dcdee2;
border-radius: 3px;
}
.conversation-items::-webkit-scrollbar-track,
.chat-messages::-webkit-scrollbar-track {
background-color: #f5f7fa;
}
</style>

View File

@@ -0,0 +1,330 @@
<template>
<div class="content-view">
<div class="table-head-tool">
<Button type="primary" @click="showAddWarp">新增聊天记录</Button>
<Form ref="formInline" :model="gridOption.param.seachOption" inline :label-width="80">
<FormItem :label-width="20" class="flex">
<Select v-model="gridOption.param.seachOption.key" style="width: 120px"
:placeholder="seachTypePlaceholder">
<Option v-for="item in seachTypes" :value="item.key" :key="item.key">{{ item.value }}</Option>
</Select>
<Input class="ml10" v-model="gridOption.param.seachOption.value" style="width: 200px" search
placeholder="请输入关键字" @on-search="query(1)" />
</FormItem>
<FormItem label="平台">
<Select v-model="gridOption.param.seachOption.platform" style="width: 120px" clearable @on-change="query(1)">
<Option value="boss">Boss直聘</Option>
<Option value="liepin">猎聘</Option>
</Select>
</FormItem>
<FormItem label="消息类型">
<Select v-model="gridOption.param.seachOption.messageType" style="width: 120px" clearable @on-change="query(1)">
<Option value="sent">发送</Option>
<Option value="received">接收</Option>
<Option value="system">系统</Option>
</Select>
</FormItem>
<FormItem>
<Button type="primary" @click="query(1)">查询</Button>
<Button type="default" @click="resetQuery" class="ml10">重置</Button>
<Button type="default" @click="exportCsv">导出</Button>
</FormItem>
</Form>
</div>
<div class="table-body">
<tables :columns="listColumns" :value="gridOption.data" :pageOption="gridOption.param.pageOption"
@changePage="query"></tables>
</div>
<editModal ref="editModal" :columns="editColumns" :rules="gridOption.rules"></editModal>
</div>
</template>
<script>
import chatRecordsServer from '@/api/operation/chat_records_server.js'
export default {
data() {
let rules = {}
rules["sn_code"] = [{ required: true, message: '请填写设备SN码', trigger: 'blur' }]
rules["platform"] = [{ required: true, message: '请选择平台', trigger: 'change' }]
rules["messageContent"] = [{ required: true, message: '请填写消息内容', trigger: 'blur' }]
return {
replyContent: '',
seachTypes: [
{ key: 'companyName', value: '公司名称' },
{ key: 'contactName', value: '联系人' },
{ key: 'sn_code', value: '设备SN码' }
],
gridOption: {
param: {
seachOption: {
key: 'companyName',
value: '',
platform: null,
messageType: null
},
pageOption: {
page: 1,
pageSize: 20
}
},
data: [],
rules: rules
},
listColumns: [
{ title: 'ID', key: 'id', minWidth: 80 },
{ title: '设备SN码', key: 'sn_code', minWidth: 120 },
{
title: '平台',
key: 'platform',
minWidth: 100,
render: (h, params) => {
const platformMap = {
'boss': { text: 'Boss直聘', color: 'blue' },
'liepin': { text: '猎聘', color: 'green' }
}
const platform = platformMap[params.row.platform] || { text: params.row.platform, color: 'default' }
return h('Tag', { props: { color: platform.color } }, platform.text)
}
},
{ title: '岗位名称', key: 'jobTitle', minWidth: 150 },
{ title: '公司名称', key: 'companyName', minWidth: 150 },
{ title: '联系人', key: 'contactName', minWidth: 120 },
{
title: '消息类型',
key: 'messageType',
minWidth: 100,
render: (h, params) => {
const typeMap = {
'sent': { text: '发送', color: 'blue' },
'received': { text: '接收', color: 'success' },
'system': { text: '系统', color: 'default' }
}
const type = typeMap[params.row.messageType] || { text: params.row.messageType, color: 'default' }
return h('Tag', { props: { color: type.color } }, type.text)
}
},
{ title: '消息内容', key: 'messageContent', minWidth: 250 },
{ title: '发送时间', key: 'sendTime', minWidth: 150 },
{
title: '操作',
key: 'action',
width: 300,
type: 'template',
render: (h, params) => {
let btns = [
{
title: '查看详情',
type: 'info',
click: () => {
this.showChatDetail(params.row)
},
},
{
title: '回复',
type: 'success',
click: () => {
this.replyMessage(params.row)
},
},
{
title: '编辑',
type: 'primary',
click: () => {
this.showEditWarp(params.row)
},
},
{
title: '删除',
type: 'error',
click: () => {
this.delConfirm(params.row)
},
},
]
return window.framework.uiTool.getBtn(h, btns)
},
}
],
editColumns: [
{ title: '设备SN码', key: 'sn_code', type: 'text', required: true },
{ title: '平台', key: 'platform', type: 'select', required: true, options: [
{ value: 'boss', label: 'Boss直聘' },
{ value: 'liepin', label: '猎聘' }
]},
{ title: '投递记录ID', key: 'applyId', type: 'text' },
{ title: '岗位名称', key: 'jobTitle', type: 'text' },
{ title: '公司名称', key: 'companyName', type: 'text' },
{ title: '联系人', key: 'contactName', type: 'text' },
{ title: '消息类型', key: 'messageType', type: 'select', options: [
{ value: 'sent', label: '发送' },
{ value: 'received', label: '接收' },
{ value: 'system', label: '系统' }
]},
{ title: '消息内容', key: 'messageContent', type: 'textarea', required: true },
{ title: '是否已读', key: 'isRead', type: 'switch' }
]
}
},
mounted() {
this.query(1)
},
methods: {
query(page) {
this.gridOption.param.pageOption.page = page
chatRecordsServer.page(this.gridOption.param).then(res => {
this.gridOption.data = res.data.rows
this.gridOption.param.pageOption.total = res.data.count
})
},
showAddWarp() {
this.$refs.editModal.showModal()
},
showEditWarp(row) {
this.$refs.editModal.showModal(row)
},
delConfirm(row) {
window.framework.uiTool.delConfirm(async () => {
await chatRecordsServer.del(row)
this.$Message.success('删除成功!')
this.query(1)
})
},
exportCsv() {
chatRecordsServer.exportCsv(this.gridOption.param).then(res => {
window.framework.funTool.downloadFile(res, '聊天记录.csv')
})
},
resetQuery() {
this.gridOption.param.seachOption = {
key: 'companyName',
value: '',
platform: null,
messageType: null
}
this.query(1)
},
showChatDetail(row) {
this.$Modal.info({
title: '聊天记录详情',
width: 800,
render: (h) => {
return h('div', { style: { maxHeight: '500px', overflowY: 'auto' } }, [
h('h3', { style: { marginBottom: '15px', color: '#2d8cf0' } }, '基本信息'),
h('p', [h('strong', '设备SN码: '), row.sn_code || '未知']),
h('p', [h('strong', '平台: '), row.platform === 'boss' ? 'Boss直聘' : row.platform === 'liepin' ? '猎聘' : row.platform]),
h('p', [h('strong', '岗位名称: '), row.jobTitle || '未知']),
h('p', [h('strong', '公司名称: '), row.companyName || '未知']),
h('p', [h('strong', '联系人: '), row.contactName || '未知']),
h('h3', { style: { marginTop: '20px', marginBottom: '15px', color: '#2d8cf0' } }, '消息信息'),
h('p', [h('strong', '消息类型: '), this.getMessageTypeText(row.messageType)]),
h('p', [h('strong', '发送时间: '), row.sendTime || '未知']),
h('p', [h('strong', '是否已读: '), row.isRead ? '是' : '否']),
h('div', { style: { marginTop: '20px' } }, [
h('strong', '消息内容:'),
h('div', {
style: {
whiteSpace: 'pre-wrap',
marginTop: '10px',
padding: '15px',
backgroundColor: '#f5f5f5',
borderRadius: '4px',
border: '1px solid #e8e8e8'
}
}, row.messageContent)
])
])
}
})
},
replyMessage(row) {
this.$Modal.confirm({
title: '回复消息',
render: (h) => {
return h('div', [
h('p', { style: { marginBottom: '10px' } }, [
h('strong', '回复给: '),
`${row.companyName} - ${row.contactName || 'HR'}`
]),
h('p', { style: { marginBottom: '10px' } }, [
h('strong', '原消息: '),
row.messageContent
]),
h('Input', {
props: {
type: 'textarea',
rows: 4,
placeholder: '请输入回复内容'
},
on: {
input: (val) => {
this.replyContent = val
}
}
})
])
},
onOk: async () => {
if (!this.replyContent) {
this.$Message.warning('请输入回复内容')
return
}
try {
const loading = this.$Message.loading({
content: '正在发送消息...',
duration: 0
})
const sn_code = row.sn_code || localStorage.getItem('current_sn_code') || 'GHJU'
await chatRecordsServer.sendMessage({
sn_code: sn_code,
jobId: row.jobId,
content: this.replyContent,
chatType: 'reply',
platform: row.platform
})
loading()
this.$Message.success('消息发送成功!')
this.replyContent = ''
this.query(this.gridOption.param.pageOption.page)
} catch (error) {
this.$Message.error('消息发送失败:' + (error.message || '未知错误'))
}
}
})
},
getMessageTypeText(type) {
const typeMap = {
'sent': '发送',
'received': '接收',
'system': '系统'
}
return typeMap[type] || type
}
},
computed: {
seachTypePlaceholder() {
const selected = this.seachTypes.find(item => item.key === this.gridOption.param.seachOption.key)
return selected ? selected.value : '请选择搜索类型'
}
}
}
</script>
<style scoped>
.ml10 {
margin-left: 10px;
}
.flex {
display: flex;
align-items: center;
}
</style>