这个是Userecruitmentsharing页面的东西import { defineStore...
생성일: 2026년 4월 10일
답변에 사용된 모델 GPT-5.4 Thinking by Chat01
생성일: 2026년 4월 10일
답변에 사용된 모델 GPT-5.4 Thinking by Chat01
这个是Userecruitmentsharing页面的东西import {
defineStore
} from 'pinia'
import {
ref,
computed
} from 'vue'
const STORAGE_KEYS = {
identity: 'coffee_identity',
consumerId: 'consumerId',
userId: 'coffee_user_id',
userState: 'coffee_user_state',
organizationId: 'organizationId',
applicantId: 'recrueitJobApplicantId',
organizationAvatarUrl: 'coffee_enterprise_avatar',
applicantAvatarUrl: 'coffee_applicant_avatar',
organizationName: 'coffee_enterprise_name',
applicantName: 'coffee_applicant_name',
enterpriseRegisterForm: 'coffee_enterprise_register_form',
applicantRegisterForm: 'coffee_applicant_register_form',
editingPostDraft: 'coffee_editing_recruit_post'
}
const RECRUIT_CHAT_STATE_PREFIX = 'coffee_recruit_chat_state:'
function normalizeIdentity(value) {
return String(value || '') === 'applicant' ? 'applicant' : 'enterprise'
}
function safeGetStorage(key, fallback = '') {
try {
const value = uni.getStorageSync(key)
return value === undefined || value === null || value === '' ? fallback : value
} catch (e) {
return fallback
}
}
function safeSetStorage(key, value) {
try {
uni.setStorageSync(key, value)
} catch (e) {}
}
function safeRemoveStorage(key) {
try {
uni.removeStorageSync(key)
} catch (e) {}
}
function safeReadJSON(key, fallback) {
try {
const raw = uni.getStorageSync(key)
if (!raw) return fallback
if (typeof raw === 'object') return raw
return JSON.parse(raw)
} catch (e) {
return fallback
}
}
function safeWriteJSON(key, value) {
try {
uni.setStorageSync(key, JSON.stringify(value ?? {}))
} catch (e) {}
}
function deepClone(value) {
return JSON.parse(JSON.stringify(value ?? null))
}
function mergeObject(base = {}, patch = {}) {
const target = Array.isArray(base) ? [...base] : {
...base
}
Object.keys(patch || {}).forEach((key) => {
const oldValue = target[key]
const newValue = patch[key]
if (
oldValue &&
newValue &&
Object.prototype.toString.call(oldValue) === '[object Object]' &&
Object.prototype.toString.call(newValue) === '[object Object]'
) {
target[key] = mergeObject(oldValue, newValue)
} else {
target[key] = newValue
}
})
return target
}
function createEnterpriseRegisterForm() {
return {
password: '',
confirmPassword: '',
organizationFileList: [],
fileIndexList: [],
currentOrganizationTypeId: '',
organization: {
name: '',
phoneticism: '',
shortName: '',
setUpTime: '',
taxpayerIdentityNumber: '',
province: '',
city: '',
area: '',
state: 1,
type: 3,
address: '',
aboutUs: '',
enterpriseScale: '',
companyProfile: '',
isExRegist: '咖招',
industry: '',
latitude: '',
longitude: '',
bank: '',
bankAccount: '',
introduction: '',
organizationAttribute: '企业'
},
personnel: {
mobilePhone: '',
address: '',
name: '',
idCard: '',
email: '',
duty: ''
},
user: {
username: '',
mobilePhone: '',
address: '',
name: '',
idCard: ''
}
}
}
function createApplicantRegisterForm() {
return {
id: '',
name: '',
avatarUrl: '',
education: '',
performerId: '',
position: '',
state: 1,
currentAddress: '',
salaryExpectation: ''
}
}
function normalizeRecruitMessageValue(value) {
return String(value ?? '').trim()
}
function fileExtensionFromValue(value = '') {
const raw = String(value || '').split('?')[0].split('#')[0]
const matched = raw.match(/.([a-zA-Z0-9]+)$/)
return matched ? matched[1].toLowerCase() : ''
}
function inferRecruitMediaKind(message = {}) {
if (message?.mediaKind === 'image' || message?.localMediaKind === 'image') return 'image'
if (message?.mediaKind === 'video' || message?.localMediaKind === 'video') return 'video'
if (Number(message?.type || 0) === 1) return 'image'
if (Number(message?.type || 0) === 2) return 'video'
const sourceList = [message?.playURL, message?.showName, message?.fileName].filter(Boolean)
for (const source of sourceList) {
const ext = fileExtensionFromValue(source)
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'heic', 'heif'].includes(ext)) return 'image'
if (['mp4', 'mov', 'm4v', 'webm', 'avi', 'mkv', '3gp'].includes(ext)) return 'video'
}
return ''
}
function canMergeRecruitMessages(current = {}, incoming = {}) {
if (incoming?.id && current?.id && String(incoming.id) === String(current.id)) return true
const currentToken = buildRecruitMessageToken(current)
const incomingToken = buildRecruitMessageToken(incoming)
if (currentToken && incomingToken && currentToken === incomingToken) return true
if (!(incoming?.id && !current?.id)) return false
if (!(current?.localId || current?.sendStatus === 'sending' || current?.sendStatus === 'success')) return false
if (String(current?.consumerId || '') !== String(incoming?.consumerId || '')) return false
if (!!current?.consumerTalk !== !!incoming?.consumerTalk) return false
const currentType = Number(current?.type || 0)
const incomingType = Number(incoming?.type || 0)
const textLike = [0, 9]
if (currentType !== incomingType && !(textLike.includes(currentType) && textLike.includes(incomingType))) return false
const currentTime = new Date(current?.createTime || 0).getTime()
const incomingTime = new Date(incoming?.createTime || 0).getTime()
if (!currentTime || !incomingTime || Math.abs(currentTime - incomingTime) > 45 * 1000) return false
const currentMediaKind = inferRecruitMediaKind(current)
const incomingMediaKind = inferRecruitMediaKind(incoming)
if (currentMediaKind || incomingMediaKind) {
if (currentMediaKind !== incomingMediaKind) return false
const currentName = normalizeRecruitMessageValue(current?.showName || current?.fileName)
const incomingName = normalizeRecruitMessageValue(incoming?.showName || incoming?.fileName)
if (currentName && incomingName && currentName === incomingName) return true
const currentUrl = normalizeRecruitMessageValue(current?.playURL)
const incomingUrl = normalizeRecruitMessageValue(incoming?.playURL)
return !!currentUrl && !!incomingUrl && fileExtensionFromValue(currentUrl) === fileExtensionFromValue(incomingUrl)
}
const currentContent = normalizeRecruitMessageValue(current?.content)
const incomingContent = normalizeRecruitMessageValue(incoming?.content)
return !!currentContent && currentContent === incomingContent
}
function buildRecruitMessageToken(message = {}) {
return [
message.id || '',
message.localId || '',
message.createTime || '',
message.consumerId || '',
message.consumerTalk ? '1' : '0',
message.type || '0',
message.content || '',
message.playURL || '',
message.showName || '',
message.fileName || '',
message.mediaKind || message.localMediaKind || ''
].join('|')
}
function createRecruitChatState() {
return {
messages: [],
deletedMessageIds: [],
deletedTokens: [],
recalledMessageIds: [],
recalledTokens: []
}
}
function uniqueArray(arr = []) {
return Array.from(new Set((arr || []).filter(Boolean)))
}
function sortRecruitMessages(messages = []) {
return [...messages].sort((a, b) => new Date(a.createTime || 0).getTime() - new Date(b.createTime || 0).getTime())
}
export const useUserecruitmentsharingStore = defineStore('Userecruitmentsharing', () => {
const identity = ref('enterprise')
const consumerId = ref('')
const userId = ref('')
const userState = ref('')
const organizationId = ref('')
const applicantId = ref('')
const organizationAvatarUrl = ref('')
const applicantAvatarUrl = ref('')
const organizationName = ref('')
const applicantName = ref('')
const enterpriseRegisterForm = ref(createEnterpriseRegisterForm())
const applicantRegisterForm = ref(createApplicantRegisterForm())
const editingPostDraft = ref(null)
textconst isEnterprise = computed(() => identity.value === 'enterprise') const isApplicant = computed(() => identity.value === 'applicant') const currentAvatarUrl = computed(() => (isEnterprise.value ? organizationAvatarUrl.value : applicantAvatarUrl.value)) const currentDisplayName = computed(() => (isEnterprise.value ? organizationName.value : applicantName .value)) const recrueitJobApplicantId = computed({ get: () => applicantId.value, set: (val) => { applicantId.value = String(val || '') persistBase() } }) function makeRecruitChatCacheKey(payload = {}) { const cid = String(payload.consumerId || consumerId.value || '') const postId = String(payload.recrueitPostId || payload.recruitPostId || '') const peer = String(payload.peerId || '') const role = String(payload.role || normalizeIdentity(identity.value)) return [cid, postId, peer, role].join('::') } function stateStorageKey(key) { return RECRUIT_CHAT_STATE_PREFIX + String(key || '') } function readRecruitChatState(key) { return mergeObject(createRecruitChatState(), safeReadJSON(stateStorageKey(key), createRecruitChatState()) || {}) } function writeRecruitChatState(key, state) { safeWriteJSON(stateStorageKey(key), mergeObject(createRecruitChatState(), state || {})) } function saveRecruitChatCache(key, messages = []) { const state = readRecruitChatState(key) state.messages = sortRecruitMessages((messages || []).map((item) => deepClone(item))) writeRecruitChatState(key, state) return state.messages } function readRecruitChatCache(key) { return readRecruitChatState(key).messages || [] } function appendRecruitChatMessage(key, message) { const state = readRecruitChatState(key) const token = buildRecruitMessageToken(message) const idx = state.messages.findIndex((item) => (message.id && item.id && item.id === message.id) || buildRecruitMessageToken(item) === token || canMergeRecruitMessages(item, message)) if (idx >= 0) state.messages[idx] = mergeObject(state.messages[idx], deepClone(message)) else state.messages.push(deepClone(message)) state.messages = sortRecruitMessages(state.messages) writeRecruitChatState(key, state) return state.messages } function deleteRecruitChatMessage(key, message) { const state = readRecruitChatState(key) const token = buildRecruitMessageToken(message) if (message?.id) state.deletedMessageIds = uniqueArray([...state.deletedMessageIds, String(message.id)]) state.deletedTokens = uniqueArray([...state.deletedTokens, token]) state.messages = state.messages.filter((item) => { if (message?.id && item?.id && String(item.id) === String(message.id)) return false return buildRecruitMessageToken(item) !== token }) writeRecruitChatState(key, state) return state.messages } function recallRecruitChatMessage(key, message, recalledText = '你撤回了一条消息') { const state = readRecruitChatState(key) const token = buildRecruitMessageToken(message) if (message?.id) state.recalledMessageIds = uniqueArray([...state.recalledMessageIds, String(message .id)]) state.recalledTokens = uniqueArray([...state.recalledTokens, token]) state.messages = state.messages.map((item) => { const matched = (message?.id && item?.id && String(item.id) === String(message.id)) || buildRecruitMessageToken(item) === token if (!matched) return item return { ...item, recalled: true, recalledText, content: '', type: 0, showName: '', playURL: '', fileName: '' } }) writeRecruitChatState(key, state) return state.messages } function applyRecruitStateToMessages(key, messages = []) { const state = readRecruitChatState(key) return (messages || []).reduce((acc, item) => { const token = buildRecruitMessageToken(item) if ((item?.id && state.deletedMessageIds.includes(String(item.id))) || state.deletedTokens .includes(token)) return acc const recalled = (item?.id && state.recalledMessageIds.includes(String(item.id))) || state .recalledTokens.includes(token) acc.push(recalled ? { ...item, recalled: true, recalledText: item.recalledText || '你撤回了一条消息', content: '', type: 0 } : item) return acc }, []) } function filterRecruitFetchedMessages(key, messages = []) { return applyRecruitStateToMessages(key, messages) } function upsertRecruitFetchedMessages(key, messages = [], options = {}) { const state = readRecruitChatState(key) const base = options.reset ? [] : [...state.messages] messages.forEach((incoming) => { const token = buildRecruitMessageToken(incoming) const idx = base.findIndex((item) => (incoming.id && item.id && String(item.id) === String( incoming.id)) || buildRecruitMessageToken(item) === token || canMergeRecruitMessages(item, incoming)) if (idx >= 0) { base[idx] = mergeObject(base[idx], incoming) } else { base.push(deepClone(incoming)) } }) state.messages = sortRecruitMessages(applyRecruitStateToMessages(key, base)) writeRecruitChatState(key, state) return state.messages } function clearRecruitChatCache(key) { safeRemoveStorage(stateStorageKey(key)) } function persistBase() { safeSetStorage(STORAGE_KEYS.identity, normalizeIdentity(identity.value)) safeSetStorage(STORAGE_KEYS.consumerId, String(consumerId.value || '')) safeSetStorage(STORAGE_KEYS.userId, String(userId.value || '')) safeSetStorage(STORAGE_KEYS.userState, String(userState.value || '')) safeSetStorage(STORAGE_KEYS.organizationId, String(organizationId.value || '')) safeSetStorage(STORAGE_KEYS.applicantId, String(applicantId.value || '')) safeSetStorage(STORAGE_KEYS.organizationAvatarUrl, String(organizationAvatarUrl.value || '')) safeSetStorage(STORAGE_KEYS.applicantAvatarUrl, String(applicantAvatarUrl.value || '')) safeSetStorage(STORAGE_KEYS.organizationName, String(organizationName.value || '')) safeSetStorage(STORAGE_KEYS.applicantName, String(applicantName.value || '')) } function hydrateFromStorage() { identity.value = normalizeIdentity(safeGetStorage(STORAGE_KEYS.identity, 'enterprise')) consumerId.value = String(safeGetStorage(STORAGE_KEYS.consumerId, '') || '') userId.value = String(safeGetStorage(STORAGE_KEYS.userId, '') || '') userState.value = String(safeGetStorage(STORAGE_KEYS.userState, '') || '') organizationId.value = String(safeGetStorage(STORAGE_KEYS.organizationId, '') || '') applicantId.value = String(safeGetStorage(STORAGE_KEYS.applicantId, '') || '') organizationAvatarUrl.value = String(safeGetStorage(STORAGE_KEYS.organizationAvatarUrl, '') || '') applicantAvatarUrl.value = String(safeGetStorage(STORAGE_KEYS.applicantAvatarUrl, '') || '') organizationName.value = String(safeGetStorage(STORAGE_KEYS.organizationName, '') || '') applicantName.value = String(safeGetStorage(STORAGE_KEYS.applicantName, '') || '') enterpriseRegisterForm.value = mergeObject(createEnterpriseRegisterForm(), safeReadJSON(STORAGE_KEYS .enterpriseRegisterForm, createEnterpriseRegisterForm()) || {}) applicantRegisterForm.value = mergeObject(createApplicantRegisterForm(), safeReadJSON(STORAGE_KEYS .applicantRegisterForm, createApplicantRegisterForm()) || {}) editingPostDraft.value = safeReadJSON(STORAGE_KEYS.editingPostDraft, null) if (!organizationId.value && identity.value === 'enterprise' && applicantId.value) { identity.value = 'applicant' persistBase() } if (!applicantId.value && identity.value === 'applicant' && organizationId.value) { identity.value = 'enterprise' persistBase() } } function setIdentity(val) { identity.value = normalizeIdentity(val); persistBase() } function setConsumerId(val) { consumerId.value = String(val || ''); persistBase() } function setUserId(val) { userId.value = String(val || ''); persistBase() } function setUserState(val) { userState.value = String(val || ''); persistBase() } function setOrganizationId(val) { organizationId.value = String(val || ''); persistBase() } function setApplicantId(val) { applicantId.value = String(val || ''); persistBase() } function setEnterpriseInfo(payload = {}) { if (payload.identity !== undefined) identity.value = normalizeIdentity(payload.identity || 'enterprise') if (payload.consumerId !== undefined) consumerId.value = String(payload.consumerId || '') if (payload.userId !== undefined) userId.value = String(payload.userId || '') if (payload.userState !== undefined) userState.value = String(payload.userState || '') if (payload.organizationId !== undefined) organizationId.value = String(payload.organizationId || '') if (payload.organizationName !== undefined) organizationName.value = String(payload.organizationName || '') if (payload.name !== undefined && payload.organizationName === undefined) organizationName.value = String(payload.name || '') if (payload.avatarUrl !== undefined) organizationAvatarUrl.value = String(payload.avatarUrl || '') if (payload.organizationAvatarUrl !== undefined) organizationAvatarUrl.value = String(payload .organizationAvatarUrl || '') persistBase() } function setApplicantInfo(payload = {}) { if (payload.identity !== undefined) identity.value = normalizeIdentity(payload.identity || 'applicant') if (payload.consumerId !== undefined) consumerId.value = String(payload.consumerId || '') if (payload.userId !== undefined) userId.value = String(payload.userId || '') if (payload.userState !== undefined) userState.value = String(payload.userState || '') if (payload.applicantId !== undefined) applicantId.value = String(payload.applicantId || '') if (payload.id !== undefined && payload.applicantId === undefined) applicantId.value = String(payload .id || '') if (payload.realName !== undefined) applicantName.value = String(payload.realName || '') if (payload.name !== undefined && payload.realName === undefined) applicantName.value = String(payload .name || '') if (payload.avatarUrl !== undefined) applicantAvatarUrl.value = String(payload.avatarUrl || '') if (payload.applicantAvatarUrl !== undefined) applicantAvatarUrl.value = String(payload .applicantAvatarUrl || '') persistBase() } function updateOrganizationAvatar(url = '') { organizationAvatarUrl.value = String(url || ''); persistBase() } function updateApplicantAvatar(url = '') { applicantAvatarUrl.value = String(url || ''); persistBase() } function updateOrganizationName(name = '') { organizationName.value = String(name || ''); persistBase() } function updateApplicantName(name = '') { applicantName.value = String(name || ''); persistBase() } function patchEnterpriseRegisterForm(payload = {}) { enterpriseRegisterForm.value = mergeObject(enterpriseRegisterForm.value, payload || {}); safeWriteJSON(STORAGE_KEYS.enterpriseRegisterForm, enterpriseRegisterForm.value) } function patchApplicantRegisterForm(payload = {}) { applicantRegisterForm.value = mergeObject(applicantRegisterForm.value, payload || {}); safeWriteJSON(STORAGE_KEYS.applicantRegisterForm, applicantRegisterForm.value) } function resetEnterpriseRegisterForm() { enterpriseRegisterForm.value = createEnterpriseRegisterForm(); safeWriteJSON(STORAGE_KEYS.enterpriseRegisterForm, enterpriseRegisterForm.value) } function resetApplicantRegisterForm() { applicantRegisterForm.value = createApplicantRegisterForm(); safeWriteJSON(STORAGE_KEYS.applicantRegisterForm, applicantRegisterForm.value) } function setEditingPostDraft(payload = null) { editingPostDraft.value = payload ? deepClone(payload) : null if (editingPostDraft.value) safeWriteJSON(STORAGE_KEYS.editingPostDraft, editingPostDraft.value) else safeRemoveStorage(STORAGE_KEYS.editingPostDraft) } function clearEditingPostDraft() { setEditingPostDraft(null) } function clearEnterpriseIdentity() { organizationId.value = '' organizationAvatarUrl.value = '' organizationName.value = '' resetEnterpriseRegisterForm() safeRemoveStorage(STORAGE_KEYS.organizationId) safeRemoveStorage(STORAGE_KEYS.organizationAvatarUrl) safeRemoveStorage(STORAGE_KEYS.organizationName) safeRemoveStorage(STORAGE_KEYS.enterpriseRegisterForm) if (identity.value === 'enterprise') identity.value = applicantId.value ? 'applicant' : 'enterprise' persistBase() } function clearApplicantIdentity() { applicantId.value = '' applicantAvatarUrl.value = '' applicantName.value = '' resetApplicantRegisterForm() safeRemoveStorage(STORAGE_KEYS.applicantId) safeRemoveStorage(STORAGE_KEYS.applicantAvatarUrl) safeRemoveStorage(STORAGE_KEYS.applicantName) safeRemoveStorage(STORAGE_KEYS.applicantRegisterForm) if (identity.value === 'applicant') identity.value = organizationId.value ? 'enterprise' : 'enterprise' persistBase() } function saveToStorage() { persistBase() safeWriteJSON(STORAGE_KEYS.enterpriseRegisterForm, enterpriseRegisterForm.value) safeWriteJSON(STORAGE_KEYS.applicantRegisterForm, applicantRegisterForm.value) if (editingPostDraft.value) safeWriteJSON(STORAGE_KEYS.editingPostDraft, editingPostDraft.value) } function clearAll() { identity.value = 'enterprise' consumerId.value = '' userId.value = '' userState.value = '' organizationId.value = '' applicantId.value = '' organizationAvatarUrl.value = '' applicantAvatarUrl.value = '' organizationName.value = '' applicantName.value = '' enterpriseRegisterForm.value = createEnterpriseRegisterForm() applicantRegisterForm.value = createApplicantRegisterForm() editingPostDraft.value = null Object.values(STORAGE_KEYS).forEach((key) => safeRemoveStorage(key)) } hydrateFromStorage() return { identity, consumerId, userId, userState, organizationId, applicantId, recrueitJobApplicantId, isEnterprise, isApplicant, currentAvatarUrl, currentDisplayName, organizationAvatarUrl, applicantAvatarUrl, organizationName, applicantName, enterpriseRegisterForm, applicantRegisterForm, editingPostDraft, hydrateFromStorage, saveToStorage, clearAll, setIdentity, setConsumerId, setUserId, setUserState, setOrganizationId, setApplicantId, setEnterpriseInfo, setEnterpriseProfile: setEnterpriseInfo, setApplicantInfo, setApplicantProfile: setApplicantInfo, updateOrganizationAvatar, updateEnterpriseAvatar: updateOrganizationAvatar, updateApplicantAvatar, updateOrganizationName, updateApplicantName, patchEnterpriseRegisterForm, patchApplicantRegisterForm, resetEnterpriseRegisterForm, resetApplicantRegisterForm, setEditingPostDraft, clearEditingPostDraft, clearEnterpriseIdentity, clearApplicantIdentity, makeRecruitChatCacheKey, readRecruitChatState, readRecruitChatCache, saveRecruitChatCache, appendRecruitChatMessage, deleteRecruitChatMessage, recallRecruitChatMessage, filterRecruitFetchedMessages, upsertRecruitFetchedMessages, clearRecruitChatCache }
})这个是chat页面的东西<template>
<view :class="['app-container', theme]">
<view class="content">
<view class="overlay">
<view class="header" :style="{ paddingTop: statusBarHeight + 'px', height: headerBarHeight + 'px' }">
<view class="header-inner">
<view class="left-box" @click="navigateBack">
<uni-icons type="left" color="#000000" size="26" />
</view>
<view class="center-box">
<text class="title-text">{{ pageTitle }}</text>
</view>
<view class="right-box">
<text v-if="isRecruitMode" class="desc-text" @click="showMoreMenu">...</text>
</view>
</view>
</view>
</template> <script setup> import { ref, reactive, computed, nextTick, onMounted, onBeforeUnmount, watch } from 'vue' import { onLoad } from '@dcloudio/uni-app' import { storeToRefs } from 'pinia' import { useMainStore } from '@/store/index.js' import { useUserecruitmentsharingStore } from '@/store/Userecruitmentsharing.js' import ajax from '@/api/ajax.js' import WsRequest from '@/utils/websockets.js' import VideoCallsService from '@/utils/videocalls.js' import { getStatusBarHeight, getTitleBarHeight } from '@/utils/system.js' import KamuChatBubble from '@/components/HomepageComponents/KamuChatBubble.vue' const mainStore = useMainStore() const recruitStore = useUserecruitmentsharingStore() const { theme } = storeToRefs(mainStore) const { consumerId, applicantId, organizationId, isEnterprise } = storeToRefs(recruitStore) const statusBarHeight = ref(getStatusBarHeight()) const headerBarHeight = ref(getTitleBarHeight()) const safeAreaBottom = ref(Number(uni.getSystemInfoSync()?.safeAreaInsets?.bottom || 0)) const keyboardHeight = ref(0) const quoteDraft = ref(null) const keyboardOffset = computed(() => Math.max(0, Number(keyboardHeight.value || 0) - Number(safeAreaBottom.value || 0))) const containerTop = ref(Number(statusBarHeight.value || 0) + Number(headerBarHeight.value || 44) + 24) const inputBaseHeight = computed(() => (typeof uni.upx2px === 'function' ? uni.upx2px(126) : 64) + safeAreaBottom .value + (quoteDraft.value ? (typeof uni.upx2px === 'function' ? uni.upx2px(88) : 44) : 0)) const inputWrapBottom = computed(() => inputBaseHeight.value + keyboardOffset.value) const defaultAvatar = '/static/default-avatar.png' const TEXT_MESSAGE_TYPE = 0 const app = getApp() const webProjectUrl = app.globalData?.webProjectUrl || '' const serverUrl = app.globalData?.serverUrl || 'http://47.99.182.213:7018' const isRecruitMode = ref(true) const recruitPostId = ref('') const peerId = ref('') const peerNickname = ref('') const peerAvatar = ref('') const performerId = ref('') const targetApplicantId = ref('') const headerCard = ref(null) const messageList = ref([]) const inputText = ref('') const lastItemId = ref('') const historyLoading = ref(false) const sendingReply = ref(false) const attachPopup = ref(false) const rowStartIdx = ref(0) const rowCount = ref(20) const noMore = ref(false) const socket = ref(null) const messageKeys = ref(new Set()) const isChatScrolling = ref(false) let keyboardHeightHandler = null let chatScrollTimer = null let autoScrollTimer = null const pageTitle = computed(() => peerNickname.value || (isEnterprise.value ? '求职者' : '企业')) const selfAvatar = computed(() => formatUrl(recruitStore.currentAvatarUrl || uni.getStorageSync('avatarUrl') || '') || defaultAvatar) const recruitConsumerTalk = computed(() => !isEnterprise.value) const recruitCacheKey = computed(() => recruitStore.makeRecruitChatCacheKey({ consumerId: consumerId.value, recruitPostId: recruitPostId.value, peerId: peerId.value, role: recruitConsumerTalk.value ? 'applicant' : 'enterprise' })) const recruitPanelActions = computed(() => { if (isEnterprise.value) { return [{ key: 'interview', label: '面试邀请', icon: 'calendar-filled', action: openInterviewPopup }, { key: 'album', label: '相册', icon: 'image', action: chooseAlbumImage }, { key: 'camera', label: '拍照', icon: 'camera', action: chooseCameraImage }, { key: 'video', label: '视频', icon: 'videocam-filled', action: chooseVideoFile }, { key: 'call', label: '语音通话', icon: 'phone-filled', action: startVoiceCall }, { key: 'question', label: '笔问', icon: 'compose', action: openQuestionPicker } ] } return [{ key: 'resume', label: '发送简历', icon: 'paperplane-filled', action: sendOwnResumeCard }, { key: 'album', label: '相册', icon: 'image', action: chooseAlbumImage }, { key: 'camera', label: '拍照', icon: 'camera', action: chooseCameraImage }, { key: 'call', label: '语音通话', icon: 'phone-filled', action: startVoiceCall } ] }) const questionPickerShow = ref(false) const questionTab = ref('group') const questionGroups = ref([]) const selectedQuestionGroupId = ref('') const customQuestions = ref([makeEmptyQuestion()]) const questionAnswerShow = ref(false) const currentAnswerGroupId = ref('') const answerQuestionList = ref([]) const answerMap = reactive({}) const answerRecords = ref([]) const interviewPopupShow = ref(false) const companyPostOptions = ref([]) const interviewForm = reactive({ postIndex: 0, postId: '', date: '', time: '', location: '', longitude: '', latitude: '' }) const selectedInterviewPostLabel = computed(() => companyPostOptions.value[interviewForm.postIndex]?.label || headerCard.value?.title || '请选择职位') function navigateBack() { uni.navigateBack() } function showMoreMenu() { const itemList = ['查看卡片详情', '刷新聊天', '清空本地缓存'] uni.showActionSheet({ itemList, success: async (res) => { if (res.tapIndex === 0) return openHeaderCard() if (res.tapIndex === 1) return refreshRecruitHistory() if (res.tapIndex === 2) { recruitStore.clearRecruitChatCache(recruitCacheKey.value) messageList.value = [] resetMsgDedup() clearQuote() } } }) } function formatUrl(path) { if (!path) return '' if (/^https?:\/\//i.test(path)) return path if (String(path).startsWith('/')) return `${webProjectUrl}${path}` return `${webProjectUrl}/${path}` } function getFileNameFromPath(path = '') { try { return String(path).split('/').pop() || '' } catch (e) { return '' } } function safeDecode(v) { let s = String(v ?? '') for (let i = 0; i < 2; i++) { if (!/%[0-9A-Fa-f]{2}/.test(s)) break try { s = decodeURIComponent(s) } catch (e) { break } } return s } function safeStringify(obj) { try { return typeof obj === 'string' ? obj : JSON.stringify(obj) } catch (e) { return String(obj ?? '') } } function isPlainObject(v) { return Object.prototype.toString.call(v) === '[object Object]' } function buildPairs(data, prefix = '') { const pairs = [] const addPair = (k, v) => pairs.push([k, String(v ?? '')]) if (Array.isArray(data)) { data.forEach((item, idx) => (isPlainObject(item) || Array.isArray(item) ? pairs.push(...buildPairs(item, `${prefix}[${idx}]`)) : addPair(prefix, item))) return pairs } if (isPlainObject(data)) { Object.keys(data).forEach((key) => { const nextKey = prefix ? `${prefix}.${key}` : key const val = data[key] if (Array.isArray(val) || isPlainObject(val)) pairs.push(...buildPairs(val, nextKey)) else addPair(nextKey, val) }) return pairs } if (prefix) addPair(prefix, data) return pairs } function encodeForm(data) { return buildPairs(data).map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join('&') } function buildHeaders(extra = {}) { const base = ajax?.getHeaders?.() || {} return { ...base, 'Content-Type': 'application/x-www-form-urlencoded', ...extra } } function getApiData(res) { const root = res?.data ?? res ?? {} return root?.data && typeof root.data === 'object' ? root.data : root } function getRecruitTalkId(res) { const root = res?.data ?? res ?? {} return root?.id || root?.data?.id || root?.data || '' } function getRecruitTalkResult(res) { const root = res?.data ?? res ?? {} return root?.result || root?.data?.result || '' } function buildRecruitTalkPayload(extra = {}) { return { consumerId: consumerId.value, recrueitPostId: recruitPostId.value, recruitPostId: recruitPostId.value, consumerTalk: recruitConsumerTalk.value, content: extra.content || '', type: extra.type ?? TEXT_MESSAGE_TYPE, showName: extra.showName || '', resumeId: extra.resumeId || '' } } function uniRequest(options) { return new Promise((resolve, reject) => { uni.request({ ...options, success: resolve, fail: reject }) }) } function buildRecruitTalkEndpoint(payload = {}) { const baseUrl = `${serverUrl.replace(/\/$/, '')}/recrueitTalkTakenotes` const params = [ `consumerId=${encodeURIComponent(String(payload.consumerId || ''))}`, `recrueitPostId=${encodeURIComponent(String(payload.recrueitPostId || ''))}`, `recruitPostId=${encodeURIComponent(String(payload.recruitPostId || payload.recrueitPostId || ''))}` ] return `${baseUrl}?${params.join('&')}` } async function ensureMultipartPlaceholderFile() { // #ifdef APP-PLUS return await new Promise((resolve, reject) => { try { plus.io.requestFileSystem(plus.io.PRIVATE_DOC, (fs) => { fs.root.getFile('recruit_text_placeholder.txt', { create: true }, (fileEntry) => { fileEntry.createWriter((writer) => { writer.onwriteend = () => resolve(fileEntry.toLocalURL()) writer.onerror = reject writer.write('recruit-text') }, reject) }, reject) }, reject) } catch (e) { reject(e) } }) // #endif // #ifdef MP-WEIXIN try { const fs = uni.getFileSystemManager() const filePath = `${wx.env.USER_DATA_PATH}/recruit_text_placeholder.txt` fs.writeFileSync(filePath, 'recruit-text', 'utf8') return filePath } catch (e) {} // #endif return '' } function parseUploadResponse(uploadRes) { let body = uploadRes?.data || {} if (typeof body === 'string') { try { body = JSON.parse(body) } catch (e) { body = { result: '', raw: body } } } return body } async function postRecruitTalk(payload, uploadFilePath = '') { const finalPayload = { consumerId: String(payload?.consumerId || consumerId.value || ''), recrueitPostId: String(payload?.recrueitPostId || payload?.recruitPostId || recruitPostId.value || ''), // recruitPostId: String(payload?.recruitPostId || payload?.recrueitPostId || recruitPostId.value || ''), consumerTalk: String(!!(payload?.consumerTalk ?? recruitConsumerTalk.value)), content: String(payload?.content || ''), type: Number(payload?.type ?? TEXT_MESSAGE_TYPE), showName: String(payload?.showName || ''), resumeId: String(payload?.resumeId || '') } if (!finalPayload.consumerId || !finalPayload.recrueitPostId) { throw new Error('consumerId / recrueitPostId 不能为空') } const requestUrl = buildRecruitTalkEndpoint(finalPayload) const multipartFilePath = uploadFilePath || await ensureMultipartPlaceholderFile() console.log('[咖招发送] request =>', { url: requestUrl, ...finalPayload, uploadFilePath: multipartFilePath || '' }) if (!multipartFilePath) { const res = await uniRequest({ url: requestUrl, method: 'POST', header: buildHeaders(), data: encodeForm(finalPayload) }) if (Number(res?.statusCode || 200) >= 400) { throw new Error(`recrueitTalkTakenotes failed: ${safeStringify(res?.data || res)}`) } const id = String(getRecruitTalkId(res) || '') const result = getRecruitTalkResult(res) const success = !!id || result === 'success' if (!success) throw new Error(`recrueitTalkTakenotes failed: ${safeStringify(res?.data || res)}`) return { res, id, success } } const uploadRes = await new Promise((resolve, reject) => { uni.uploadFile({ url: requestUrl, filePath: multipartFilePath, name: 'recrueitFile', formData: finalPayload, success: resolve, fail: reject }) }) const body = parseUploadResponse(uploadRes) console.log('[咖招发送] response =>', body || uploadRes) if (Number(uploadRes?.statusCode || 200) >= 400) { throw new Error(`recrueitTalkTakenotes failed: ${safeStringify(body || uploadRes)}`) } const id = String(body?.id || body?.data?.id || '') const result = body?.result || body?.data?.result || '' const success = !!id || result === 'success' if (!success) throw new Error(`recrueitTalkTakenotes failed: ${safeStringify(body || uploadRes)}`) return { res: uploadRes, body, id, success } } function handleComposerFocus() { attachPopup.value = false nextTick(() => scrollToBottom()) } function handleComposerBlur() { setTimeout(() => scrollToBottom(), 60) } function buildMsgKey(item) { return item.id || item.localId || [ item.createTime || '', item.consumerId || '', item.consumerTalk ? '1' : '0', item.content || '', item.type || '0', item.playURL || '', item.showName || '', item.fileName || '', item.mediaKind || item.localMediaKind || '' ].join('|') } function bubbleMessageKey(item, idx) { return String(buildMsgKey(item) || `idx_${idx}`) } function resetMsgDedup() { messageKeys.value = new Set() } function rebuildMsgDedup() { resetMsgDedup() messageList.value.forEach((item) => messageKeys.value.add(buildMsgKey(item))) } function syncCache() { if (!recruitCacheKey.value) return recruitStore.saveRecruitChatCache(recruitCacheKey.value, messageList.value) } function mergeMessageRecord(oldItem, newItem) { return { ...oldItem, ...newItem, quote: newItem?.quote ?? oldItem?.quote ?? null } } function isCloseEnoughMessage(existing, incoming) { const existingTime = new Date(existing?.createTime || 0).getTime() const incomingTime = new Date(incoming?.createTime || 0).getTime() if (!existingTime || !incomingTime) return false return Math.abs(existingTime - incomingTime) <= 45 * 1000 } function canMergePendingMessage(existing, incoming) { const existingType = Number(existing?.type || 0) const incomingType = Number(incoming?.type || 0) const textLike = [0, TEXT_MESSAGE_TYPE] if (!(incoming?.id && !existing?.id)) return false if (!(existing?.localId || existing?.sendStatus === 'sending' || existing?.sendStatus === 'success')) return false if (existingType !== incomingType && !(textLike.includes(existingType) && textLike.includes(incomingType))) return false if (String(existing?.consumerId || '') !== String(incoming?.consumerId || '')) return false if (!!existing?.consumerTalk !== !!incoming?.consumerTalk) return false if (!isCloseEnoughMessage(existing, incoming)) return false const existingPayload = normalizeJsonContent(existing?.content) const incomingPayload = normalizeJsonContent(incoming?.content) const existingMediaKind = inferMediaKind(existing, existingPayload) const incomingMediaKind = inferMediaKind(incoming, incomingPayload) if (existingMediaKind || incomingMediaKind) { if (existingMediaKind !== incomingMediaKind) return false const existingName = String(existing?.showName || existing?.fileName || '').trim() const incomingName = String(incoming?.showName || incoming?.fileName || '').trim() if (existingName && incomingName && existingName === incomingName) return true const existingUrl = String(existing?.playURL || '').trim() const incomingUrl = String(incoming?.playURL || '').trim() return !!existingUrl && !!incomingUrl && fileExtensionFromValue(existingUrl) === fileExtensionFromValue( incomingUrl) } const existingContent = String(existing?.content || '').trim() const incomingContent = String(incoming?.content || '').trim() return !!existingContent && existingContent === incomingContent } function findMessageIndex(item) { if (!item) return -1 if (item.id) { const byId = messageList.value.findIndex((existing) => existing?.id && String(existing.id) === String(item.id)) if (byId >= 0) return byId } const key = buildMsgKey(item) const byKey = messageList.value.findIndex((existing) => buildMsgKey(existing) === key) if (byKey >= 0) return byKey return messageList.value.findIndex((existing) => canMergePendingMessage(existing, item)) } function addMessage(item, prepend = false) { const index = findMessageIndex(item) if (index >= 0) { messageList.value = messageList.value.map((existing, idx) => idx === index ? mergeMessageRecord(existing, item) : existing) rebuildMsgDedup() return } messageList.value = prepend ? [item, ...messageList.value] : [...messageList.value, item] rebuildMsgDedup() } function safeParseDeep(input) { let data = input for (let i = 0; i < 5; i++) { if (data === null || data === undefined) return null if (typeof data === 'object') return data const str = String(data).trim() if (!str) return null try { if (/^"(\\.|[^"])*"$/.test(str)) { data = JSON.parse(str) continue } if ((str.startsWith('{') && str.endsWith('}')) || (str.startsWith('[') && str.endsWith(']'))) { data = JSON.parse(str) continue } if (/%7B|%7D|%5B|%5D|%22/i.test(str)) { const decoded = decodeURIComponent(str) if (decoded !== str) { data = decoded continue } } } catch (e) {} break } return typeof data === 'object' ? data : null } function normalizeJsonContent(content) { return safeParseDeep(content) } function extractMessageUrl(msg, payload) { return formatUrl( msg?.playURL || payload?.url || payload?.playURL || payload?.fileUrl || payload?.filePath || payload?.fileName || payload?.content?.url || payload?.content?.playURL || '' ) } function extractMessageText(msg, payload) { if (payload?.msgType === 'text') return String(payload.text || '') if (typeof payload?.text === 'string' && payload.text) return String(payload.text) if (typeof payload?.content === 'string' && payload.content) return String(payload.content) if (typeof msg?.content === 'string') return String(msg.content) return '' } function fileExtensionFromValue(value = '') { const raw = String(value || '').split('?')[0].split('#')[0] const matched = raw.match(/\.([a-zA-Z0-9]+)$/) return matched ? matched[1].toLowerCase() : '' } function inferMediaKind(msg, payload) { if (msg?.mediaKind === 'image' || msg?.localMediaKind === 'image') return 'image' if (msg?.mediaKind === 'video' || msg?.localMediaKind === 'video') return 'video' if (payload?.msgType === 'image') return 'image' if (payload?.msgType === 'video') return 'video' if (payload?._kind === 'file' && Number(payload?.type) === 1) return 'image' if (payload?._kind === 'file' && Number(payload?.type) === 2) return 'video' if (Number(msg?.type) === 1) return 'image' if (Number(msg?.type) === 2) return 'video' const sourceList = [ msg?.playURL, msg?.showName, msg?.fileName, payload?.url, payload?.playURL, payload?.fileUrl, payload?.filePath, payload?.fileName, payload?.showName, payload?.content?.url, payload?.content?.playURL ].filter(Boolean) for (const source of sourceList) { const ext = fileExtensionFromValue(source) if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'heic', 'heif'].includes(ext)) return 'image' if (['mp4', 'mov', 'm4v', 'webm', 'avi', 'mkv', '3gp'].includes(ext)) return 'video' } return '' } function messageView(msg) { if (msg?.recalled) return { kind: 'recalled', text: msg.recalledText || (isSelfMessage(msg) ? '你撤回了一条消息' : '对方撤回了一条消息') } const payload = normalizeJsonContent(msg.content) const mediaKind = inferMediaKind(msg, payload) if (mediaKind === 'image') return { kind: 'image', url: extractMessageUrl(msg, payload), showName: msg.showName || payload?.showName || payload?.fileName || msg.fileName || '图片' } if (mediaKind === 'video') return { kind: 'video', url: extractMessageUrl(msg, payload), showName: msg.showName || payload?.showName || payload?.fileName || msg.fileName || '视频' } if (Number(msg.type) === 3) return { kind: 'audio', url: extractMessageUrl(msg, payload), showName: msg.showName || payload?.showName || payload?.fileName || '语音' } if (Number(msg.type) === 4) return { kind: 'file', url: extractMessageUrl(msg, payload), showName: msg.showName || payload?.showName || payload?.fileName || msg.fileName || '附件' } if (Number(msg.type) === 5) return { kind: 'resume', resumeId: msg.resumeId || payload?.resumeId || '', performerId: payload?.performerId || performerId.value || '', name: payload?.name || payload?.realName || headerCard.value?.name || '求职者', position: payload?.position || '意向职位', educationLabel: payload?.educationLabel || payload?.education || '学历不限', currentAddress: payload?.currentAddress || '当前地址待完善', salaryExpectation: payload?.salaryExpectation || '面议', avatarUrl: formatUrl(payload?.avatarUrl || '') } if (payload?.msgType === 'questionGroup') return { kind: 'questionGroup', ...payload } if (payload?.msgType === 'interviewInvite') return { kind: 'interview', ...payload } if (payload?.msgType === 'resume') return { kind: 'resume', ...payload, avatarUrl: formatUrl(payload.avatarUrl || '') } if (payload?.msgType === 'file') return { kind: 'file', url: extractMessageUrl(msg, payload), showName: payload.showName || payload.fileName || '文件' } if (payload?.msgType === 'text' || Number(msg.type) === TEXT_MESSAGE_TYPE) return { kind: 'text', text: extractMessageText(msg, payload), quote: payload?.quote || msg?.quote || null } return { kind: 'text', text: extractMessageText(msg, payload), quote: payload?.quote || msg?.quote || null } } function isMediaMessage(msg) { const kind = messageView(msg).kind return ['image', 'video'].includes(kind) } function isGenericBubble(msg) { const kind = messageView(msg).kind return ['text', 'file', 'audio', 'recalled'].includes(kind) } function bubbleTextPayload(text = '', quote = null) { const payload = { msgType: 'text', text: String(text || '') } if (quote && (quote.preview || quote.senderName)) { payload.quote = { preview: String(quote.preview || ''), senderName: String(quote.senderName || ''), castingChatId: quote.castingChatId || quote.messageId || '' } } return payload } function recruitBubbleContent(msg) { const view = messageView(msg) if (view.kind === 'recalled') return bubbleTextPayload(view.text) if (Number(msg.type) >= 1 && Number(msg.type) <= 4) { return { _kind: 'file', type: Number(msg.type), playURL: formatUrl(msg.playURL || view.url || ''), showName: msg.showName || view.showName || msg.fileName || '', fileName: msg.fileName || msg.showName || view.showName || '', text: '' } } const payload = normalizeJsonContent(msg.content) if (payload?.msgType === 'text') return { ...payload, quote: payload.quote || msg.quote || null } if (typeof msg.content === 'string') return bubbleTextPayload(msg.content, msg.quote || null) return bubbleTextPayload(view.text || '', msg.quote || null) } function isSelfMessage(msg) { return recruitConsumerTalk.value ? !!msg.consumerTalk : !msg.consumerTalk } function peerAvatarForBubble(msg) { if (isSelfMessage(msg)) return selfAvatar.value if (headerCard.value?.cardType === 'job') return headerCard.value.avatarUrl || peerAvatar.value || defaultAvatar if (headerCard.value?.cardType === 'resume') return headerCard.value.avatarUrl || peerAvatar.value || defaultAvatar return peerAvatar.value || defaultAvatar } function senderName(msg) { if (isSelfMessage(msg)) return recruitStore.currentDisplayName || '我' if (headerCard.value?.cardType === 'job') return headerCard.value.companyName || peerNickname.value || '企业' if (headerCard.value?.cardType === 'resume') return headerCard.value.name || peerNickname.value || '求职者' return peerNickname.value || '对方' } function showSenderName(msg) { return !isSelfMessage(msg) } function shouldShowTimestamp(index) { if (index === 0) return true const cur = new Date(messageList.value[index].createTime || Date.now()).getTime() const prev = new Date(messageList.value[index - 1].createTime || Date.now()).getTime() return Math.abs(cur - prev) > 5 * 60 * 1000 } function formatChatTime(ts) { if (!ts) return '' const d = new Date(ts) if (Number.isNaN(d.getTime())) return String(ts) const now = new Date() const y = d.getFullYear() const m = String(d.getMonth() + 1).padStart(2, '0') const day = String(d.getDate()).padStart(2, '0') const h = String(d.getHours()).padStart(2, '0') const mi = String(d.getMinutes()).padStart(2, '0') if (y !== now.getFullYear()) return `${y}-${m}-${day} ${h}:${mi}` if (d.toDateString() === now.toDateString()) return `${h}:${mi}` return `${m}-${day} ${h}:${mi}` } function formatDateTime(v) { if (!v) return '--' const d = new Date(v) if (Number.isNaN(d.getTime())) return String(v) const weekMap = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'] const yyyy = d.getFullYear() const mm = String(d.getMonth() + 1).padStart(2, '0') const dd = String(d.getDate()).padStart(2, '0') const hh = String(d.getHours()).padStart(2, '0') const mi = String(d.getMinutes()).padStart(2, '0') return `${yyyy}-${mm}-${dd} ${weekMap[d.getDay()]} ${hh}:${mi}` } function handleChatScroll() { isChatScrolling.value = true if (chatScrollTimer) clearTimeout(chatScrollTimer) chatScrollTimer = setTimeout(() => { isChatScrolling.value = false }, 180) } function queueScrollToBottom(delay = 0) { if (autoScrollTimer) clearTimeout(autoScrollTimer) autoScrollTimer = setTimeout(() => { scrollToBottom() }, Math.max(0, Number(delay || 0))) } function onRecruitMediaReady() { nextTick(() => queueScrollToBottom(80)) } function scrollToBottom() { lastItemId.value = `msg-${messageList.value.length - 1}` setTimeout(() => { lastItemId.value = '' }, 80) } function extractPreviewFromMessage(msg) { const view = messageView(msg) if (view.kind === 'text' || view.kind === 'recalled') return String(view.text || '').slice(0, 60) if (view.kind === 'image') return '[图片]' if (view.kind === 'video') return '[视频]' if (view.kind === 'audio') return '[语音]' if (view.kind === 'file') return `[文件] ${view.showName || ''}`.trim() if (view.kind === 'resume') return `[简历] ${view.position || '意向职位'}` if (view.kind === 'questionGroup') return `[笔问] ${view.title || '题目组'}` if (view.kind === 'interview') return `[面试邀请] ${view.postTitle || ''}`.trim() return String(msg.content || '').slice(0, 60) } function clearQuote() { quoteDraft.value = null } function onRecruitQuoteMessage(msg) { quoteDraft.value = { messageId: msg.id || msg.localId || '', preview: extractPreviewFromMessage(msg), senderName: senderName(msg), castingChatId: msg.id || '' } } function canRecallRecruitMessage(msg) { return isSelfMessage(msg) && !msg.recalled } function deleteRecruitMessage(msg) { if (!isSelfMessage(msg)) { uni.showToast({ title: '只能删除自己发送的消息', icon: 'none' }) return } recruitStore.deleteRecruitChatMessage(recruitCacheKey.value, msg) messageList.value = recruitStore.readRecruitChatCache(recruitCacheKey.value) rebuildMsgDedup() clearQuoteIfTarget(msg) } function recallRecruitMessage(msg) { if (!canRecallRecruitMessage(msg)) { uni.showToast({ title: '只能撤回自己发送的消息', icon: 'none' }) return } recruitStore.recallRecruitChatMessage(recruitCacheKey.value, msg, '你撤回了一条消息') messageList.value = recruitStore.readRecruitChatCache(recruitCacheKey.value) rebuildMsgDedup() clearQuoteIfTarget(msg) } function clearQuoteIfTarget(msg) { if (!quoteDraft.value) return const targetId = msg.id || msg.localId || '' if (quoteDraft.value.messageId === targetId) clearQuote() } function openRecruitActionMenu(msg) { const actions = ['引用'] if (isSelfMessage(msg)) actions.push('删除', '撤回') uni.showActionSheet({ itemList: actions, success: (res) => { const action = actions[res.tapIndex] if (action === '引用') return onRecruitQuoteMessage(msg) if (action === '删除') return deleteRecruitMessage(msg) if (action === '撤回') return recallRecruitMessage(msg) } }) } function previewRecruitImage(url) { if (url) uni.previewImage({ urls: [url] }) } function openHeaderCard() { if (!headerCard.value) return if (headerCard.value.cardType === 'job') { const q = { ...headerCard.value, id: recruitPostId.value || headerCard.value.id || '' } const query = Object.keys(q).map((key) => `${key}=${encodeURIComponent(String(q[key] ?? ''))}`).join('&') return uni.navigateTo({ url: `/pages/sociAlize/Coffeemu/JobDetails/JobDetails?${query}` }) } return openResumeCard(headerCard.value) } function openResumeCard(view) { const q = { previewMode: 'recruit', fromRecruit: 1, id: view.resumeId || view.id || '', performerId: view.performerId || '', avatarUrl: view.avatarUrl || '', realName: view.name || '', name: view.name || '', education: view.educationLabel || '', position: view.position || '', currentAddress: view.currentAddress || '', salaryExpectation: view.salaryExpectation || '', recruitPostId: recruitPostId.value || '', peerId: peerId.value || '', consumerId: peerId.value || '' } const query = Object.keys(q).map((key) => `${key}=${encodeURIComponent(String(q[key] ?? ''))}`).join('&') uni.navigateTo({ url: `/Page-valueResume/pages-Home/pages-Home?${query}` }) } function openInterviewDetail(id) { if (!id) return uni.showToast({ title: '面试数据异常', icon: 'none' }) uni.navigateTo({ url: `/pages/sociAlize/Coffeemu/InterviewDetail/InterviewDetail?id=${encodeURIComponent(id)}&source=chat` }) } function canAcceptInterview(view) { return !isEnterprise.value && Number(view.state || 1) === 1 && view.Accept !== true } function interviewStatusText(view) { return Number(view.state) === 2 || view.Accept === true ? '已接受' : Number(view.state) === -1 ? '已拒绝' : '待处理' } async function acceptInterviewFromBubble(view) { if (!view?.interviewId) return uni.navigateTo({ url: `/pages/sociAlize/Coffeemu/InterviewDetail/InterviewDetail?id=${encodeURIComponent(view.interviewId)}&source=chat` }) } function hasAnsweredQuestionGroup(groupId) { return answerRecords.value.some((item) => item.recrueitQuestionGroupId === groupId || item.questionGroupId === groupId) } function makeEmptyQuestion() { return { localId: 'q_' + Date.now() + '_' + Math.random().toString(36).slice(2, 5), title: '', freeTextPlaceholder: '', options: [makeEmptyOption(), makeEmptyOption(), makeEmptyOption(), makeEmptyOption()] } } function makeEmptyOption() { return { localId: 'o_' + Date.now() + '_' + Math.random().toString(36).slice(2, 5), title: '' } } function addCustomQuestion() { customQuestions.value.push(makeEmptyQuestion()) } function removeCustomQuestion(index) { customQuestions.value.splice(index, 1) } function addCustomOption(qIndex) { customQuestions.value[qIndex].options.push(makeEmptyOption()) } async function refreshRecruitHistory() { rowStartIdx.value = 0 noMore.value = false messageList.value = recruitStore.readRecruitChatCache(recruitCacheKey.value) rebuildMsgDedup() await fetchRecruitHistory(true) } async function loadMoreRecruitHistory() { if (historyLoading.value || noMore.value) return await fetchRecruitHistory(false) } async function fetchRecruitHistory(reset = false) { if (!recruitPostId.value || !consumerId.value) return historyLoading.value = true try { const data = getApiData(await ajax.post({ url: 'recrueitTalkTakenotesList', method: 'POST', header: buildHeaders(), data: encodeForm({ consumerId: consumerId.value, recrueitPostId: recruitPostId.value, recruitPostId: recruitPostId.value, rowStartIdx: rowStartIdx.value, rowCount: rowCount.value }) })) const arr = Array.isArray(data?.talkArray) ? data.talkArray : [] if (!arr.length) { noMore.value = true return } const normalized = arr.map((item) => ({ id: item.id || '', consumerId: item.consumerId || '', consumerTalk: !!item.consumerTalk, content: item.content || '', type: Number(item.type || 0), showName: item.showName || '', fileName: item.fileName || '', playURL: item.playURL || '', resumeId: item.resumeId || '', createTime: item.createTime || new Date().toISOString(), localId: '' })).reverse() const filtered = recruitStore.filterRecruitFetchedMessages(recruitCacheKey.value, normalized) const merged = recruitStore.upsertRecruitFetchedMessages(recruitCacheKey.value, filtered, { reset }) messageList.value = merged rebuildMsgDedup() rowStartIdx.value += arr.length if (reset) scrollToBottom() } catch (e) { uni.showToast({ title: '聊天记录加载失败', icon: 'none' }) } finally { historyLoading.value = false } } function localEcho(payload) { const item = { ...payload, localId: 'local_' + Date.now() + '_' + Math.random().toString(36).slice(2, 5), createTime: new Date().toISOString(), sendStatus: 'sending' } addMessage(item) recruitStore.appendRecruitChatMessage(recruitCacheKey.value, item) scrollToBottom() return item } async function sendRecruitText() { const text = inputText.value.trim() if (!text || sendingReply.value) return if (!consumerId.value || !recruitPostId.value) { return uni.showToast({ title: '缺少用户ID或职位ID', icon: 'none' }) } const quote = quoteDraft.value ? { ...quoteDraft.value } : null const textPayload = bubbleTextPayload(text, quote) const content = safeStringify(textPayload) inputText.value = '' clearQuote() const echo = localEcho({ content, type: TEXT_MESSAGE_TYPE, quote, consumerTalk: recruitConsumerTalk.value, consumerId: consumerId.value }) await saveRecruitMessage({ content, type: TEXT_MESSAGE_TYPE }, echo) } async function saveRecruitMessage(extra, echoItem = null) { try { sendingReply.value = true console.log('[咖招消息] payload =>', buildRecruitTalkPayload(extra)) const { id, success } = await postRecruitTalk(buildRecruitTalkPayload(extra)) if (!success) throw new Error('empty response') if (echoItem) { echoItem.sendStatus = 'success' if (id) echoItem.id = id rebuildMsgDedup() } if (id) { socket.value?.send(JSON.stringify({ type: 'recrueit', id })) } else { setTimeout(() => refreshRecruitHistory(), 300) } syncCache() } catch (e) { if (echoItem) echoItem.sendStatus = 'fail' syncCache() console.log(e, 'recruit send error') uni.showToast({ title: '发送失败', icon: 'none' }) } finally { sendingReply.value = false } } async function chooseAlbumImage() { attachPopup.value = false uni.chooseImage({ count: 1, sourceType: ['album'], success: async (res) => { const fp = res.tempFilePaths?.[0]; if (fp) await uploadRecruitFile(fp, 'image', getFileNameFromPath(fp) || '图片') } }) } async function chooseCameraImage() { attachPopup.value = false uni.chooseImage({ count: 1, sourceType: ['camera'], success: async (res) => { const fp = res.tempFilePaths?.[0]; if (fp) await uploadRecruitFile(fp, 'image', getFileNameFromPath(fp) || '图片') } }) } async function chooseVideoFile() { attachPopup.value = false uni.chooseVideo({ sourceType: ['album', 'camera'], compressed: true, maxDuration: 60, success: async (res) => { const fp = res.tempFilePath if (fp) await uploadRecruitFile(fp, 'video', res.name || getFileNameFromPath(fp) || '视频') } }) } async function uploadRecruitFile(filePath, mediaKind, showName) { if (!consumerId.value || !recruitPostId.value) { return uni.showToast({ title: '缺少用户ID或职位ID', icon: 'none' }) } try { uni.showLoading({ title: '上传中...' }) const option = { consumerId: consumerId.value, recrueitPostId: recruitPostId.value, recruitPostId: recruitPostId.value, consumerTalk: recruitConsumerTalk.value, content: '', type: TEXT_MESSAGE_TYPE, showName } console.log('[咖招上传] request =>', { ...option, filePath }) const local = localEcho({ type: TEXT_MESSAGE_TYPE, mediaKind, localMediaKind: mediaKind, showName, fileName: showName, consumerTalk: recruitConsumerTalk.value, consumerId: consumerId.value, playURL: filePath, content: '' }) const { body, id } = await postRecruitTalk(option, filePath) console.log('[咖招上传] response =>', body || {}) local.playURL = body?.playURL || body?.data?.playURL || local.playURL local.id = id || local.id local.sendStatus = id ? 'success' : 'fail' rebuildMsgDedup() syncCache() if (local.id || id) { socket.value?.send(JSON.stringify({ type: 'recrueit', id: local.id || id || '' })) } else { setTimeout(() => refreshRecruitHistory(), 300) } } catch (e) { console.log('[咖招上传] error =>', e) uni.showToast({ title: '上传失败', icon: 'none' }) } finally { uni.hideLoading() } } async function sendOwnResumeCard() { attachPopup.value = false if (!applicantId.value) return uni.showToast({ title: '请先完善求职信息', icon: 'none' }) try { const data = getApiData(await ajax.post({ url: 'RecrueitJobApplicantShow', method: 'POST', header: buildHeaders(), data: encodeForm({ id: applicantId.value }) })) const payload = { msgType: 'resume', resumeId: data.id || applicantId.value, performerId: data.performerId || '', name: data.name || recruitStore.currentDisplayName || '求职者', position: data.position || '意向职位', educationLabel: mapEducationLabel(data.education), currentAddress: data.currentAddress || '', salaryExpectation: data.salaryExpectation || '面议', avatarUrl: formatUrl(data.avatarUrl || recruitStore.currentAvatarUrl || '') } const content = JSON.stringify(payload) const echo = localEcho({ content, type: 5, consumerTalk: recruitConsumerTalk.value, consumerId: consumerId.value, resumeId: payload.resumeId }) await saveRecruitMessage({ content, type: 5, resumeId: payload.resumeId }, echo) } catch (e) { uni.showToast({ title: '发送简历失败', icon: 'none' }) } } async function openQuestionPicker() { attachPopup.value = false questionPickerShow.value = true questionTab.value = 'group' selectedQuestionGroupId.value = '' customQuestions.value = [makeEmptyQuestion()] await loadQuestionGroups() } async function loadQuestionGroups() { try { const data = getApiData(await ajax.post({ url: 'myRecrueitQuestionGroupList', method: 'POST', header: buildHeaders(), data: encodeForm({ consumerId: consumerId.value, rowStartIdx: 0, rowCount: 100 }) })) questionGroups.value = Array.isArray(data?.questionGroupArray) ? data.questionGroupArray : [] if (questionGroups.value.length && !selectedQuestionGroupId.value) selectedQuestionGroupId.value = questionGroups.value[0].id } catch (e) { questionGroups.value = [] } } async function sendQuestionGroup() { try { let groupId = selectedQuestionGroupId.value let groupTitle = questionGroups.value.find((item) => item.id === groupId)?.title || '笔问题组' let questionCount = questionGroups.value.find((item) => item.id === groupId)?.questionCount || 0 if (questionTab.value === 'custom') { const validQuestions = customQuestions.value.map((item) => ({ ...item, title: String(item.title || '').trim(), options: (item.options || []).map((op) => ({ ...op, title: String(op.title || '').trim() })).filter((op) => op.title) })).filter((item) => item.title) if (!validQuestions.length) return uni.showToast({ title: '请至少填写一道问题', icon: 'none' }) const payload = { title: `自定义题组-${Date.now()}`, consumerId: consumerId.value, questionIds: [], questionTitles: validQuestions.map((item) => item.title), optionIds: [], optionTitles: [], optionQuestionIndexes: [], updateQuestionIds: [], updateOptionIds: [] } validQuestions.forEach((question, qIndex) => { question.options.forEach((option) => { payload.optionTitles.push(option.title) payload.optionQuestionIndexes.push(qIndex) }) }) const saveRes = await ajax.post({ url: 'recrueitQuestionGroup', method: 'POST', header: buildHeaders(), data: encodeForm(payload) }) groupId = getApiData(saveRes)?.id || saveRes?.data?.id || '' groupTitle = payload.title questionCount = validQuestions.length } if (!groupId) return uni.showToast({ title: '请选择题组', icon: 'none' }) const content = JSON.stringify({ msgType: 'questionGroup', questionGroupId: groupId, title: groupTitle, questionCount }) const echo = localEcho({ content, type: 0, consumerTalk: recruitConsumerTalk.value, consumerId: consumerId.value }) await saveRecruitMessage({ content, type: 0 }, echo) questionPickerShow.value = false } catch (e) { uni.showToast({ title: '发送笔问失败', icon: 'none' }) } } async function openQuestionAnswer(msg) { const view = messageView(msg) if (!view?.questionGroupId) return questionAnswerShow.value = true currentAnswerGroupId.value = view.questionGroupId answerQuestionList.value = [] Object.keys(answerMap).forEach((key) => delete answerMap[key]) try { const detail = getApiData(await ajax.post({ url: 'recrueitQuestionGroupShow', method: 'POST', header: buildHeaders(), data: encodeForm({ id: view.questionGroupId }) })) answerQuestionList.value = Array.isArray(detail?.questionsArray) ? detail.questionsArray : [] answerQuestionList.value.forEach((question) => { answerMap[question.id] = { optionId: '', content: '' } }) await loadAnswerRecords() answerRecords.value.filter((item) => item.recrueitQuestionGroupId === view.questionGroupId || item .questionGroupId === view.questionGroupId).forEach((item) => { if (!answerMap[item.recrueitQuestionId]) return answerMap[item.recrueitQuestionId].optionId = item.recrueitOptionId || '' answerMap[item.recrueitQuestionId].content = item.content || '' }) } catch (e) { uni.showToast({ title: '加载题组失败', icon: 'none' }) } } function selectAnswerOption(question, option) { if (!answerMap[question.id]) answerMap[question.id] = { optionId: '', content: '' } answerMap[question.id].optionId = option.id } async function loadAnswerRecords() { if (!applicantId.value && !consumerId.value) return try { const data = getApiData(await ajax.post({ url: 'recrueitAnswerList', method: 'POST', header: buildHeaders(), data: encodeForm({ consumerId: recruitConsumerTalk.value ? consumerId.value : peerId.value, recrueitPostId: recruitPostId.value, rowStartIdx: 0, rowCount: 200 }) })) answerRecords.value = Array.isArray(data?.answerArray) ? data.answerArray : [] } catch (e) { answerRecords.value = [] } } async function submitQuestionAnswers() { try { const answeredList = [] for (const question of answerQuestionList.value) { const val = answerMap[question.id] || { optionId: '', content: '' } if (!val.optionId && !String(val.content || '').trim()) continue const requestData = { consumerId: consumerId.value, recrueitPostId: recruitPostId.value, recrueitQuestionId: question.id, recrueitOptionId: val.optionId || '', content: val.content || '', jobAnswer: true } console.log('[咖招答题] request =>', requestData) const answerRes = await ajax.post({ url: 'recrueitAnswer', method: 'POST', header: buildHeaders(), data: encodeForm(requestData) }) console.log('[咖招答题] response =>', answerRes?.data || answerRes) answeredList.push({ questionId: question.id, title: question.title || '', optionId: val.optionId || '', content: val.content || '' }) } questionAnswerShow.value = false await loadAnswerRecords() if (answeredList.length) { const answerContent = safeStringify({ msgType: 'text', text: `已提交${answeredList.length}道笔问回答`, answerQuestionGroupId: currentAnswerGroupId.value, answers: answeredList }) const echo = localEcho({ content: answerContent, type: 6, consumerTalk: recruitConsumerTalk.value, consumerId: consumerId.value }) await saveRecruitMessage({ content: answerContent, type: 6 }, echo) } uni.showToast({ title: '已提交', icon: 'none' }) } catch (e) { console.log('[咖招答题] error =>', e) uni.showToast({ title: '提交失败', icon: 'none' }) } } async function openInterviewPopup() { attachPopup.value = false interviewPopupShow.value = true interviewForm.date = '' interviewForm.time = '' interviewForm.location = headerCard.value?.locationText || headerCard.value?.location || '' interviewForm.postIndex = 0 interviewForm.postId = recruitPostId.value await loadCompanyPosts() } async function loadCompanyPosts() { if (!organizationId.value) { companyPostOptions.value = recruitPostId.value ? [{ id: recruitPostId.value, label: headerCard.value?.title || '当前职位' }] : [] return } try { const data = getApiData(await ajax.post({ url: 'companyAllRecrueitPostList', method: 'POST', header: buildHeaders(), data: encodeForm({ organizationId: organizationId.value, rowStartIdx: 0, rowCount: 100 }) })) const arr = Array.isArray(data?.recrueitPostArray) ? data.recrueitPostArray : [] companyPostOptions.value = arr.map((item) => ({ id: item.id, raw: item, label: `${item.title || item.position || '职位'} ${item.salaryRange || ''}`.trim() })) if (!companyPostOptions.value.length && recruitPostId.value) companyPostOptions.value = [{ id: recruitPostId.value, label: headerCard.value?.title || '当前职位' }] interviewForm.postId = companyPostOptions.value[0]?.id || recruitPostId.value } catch (e) { companyPostOptions.value = recruitPostId.value ? [{ id: recruitPostId.value, label: headerCard.value?.title || '当前职位' }] : [] } } function onInterviewPostChange(e) { const index = Number(e.detail.value || 0) interviewForm.postIndex = index interviewForm.postId = companyPostOptions.value[index]?.id || recruitPostId.value } async function sendInterviewInvite() { if (!interviewForm.postId) return uni.showToast({ title: '请选择职位', icon: 'none' }) if (!interviewForm.date || !interviewForm.time) return uni.showToast({ title: '请选择面试时间', icon: 'none' }) if (!interviewForm.location) return uni.showToast({ title: '请填写面试地址', icon: 'none' }) try { const payload = { recrueitPostId: interviewForm.postId, applicantId: targetApplicantId.value || headerCard.value?.id || '', interviewTime: `${interviewForm.date} ${interviewForm.time}:00`, interviewLocation: interviewForm.location, longitude: interviewForm.longitude || '', latitude: interviewForm.latitude || '', state: 1, Accept: false, creatorId: consumerId.value } const res = await ajax.post({ url: 'recrueitInterview', method: 'POST', header: buildHeaders(), data: encodeForm(payload) }) const interviewId = getApiData(res)?.id || res?.data?.id || '' const content = JSON.stringify({ msgType: 'interviewInvite', interviewId, postTitle: companyPostOptions.value[interviewForm.postIndex]?.raw?.title || headerCard.value ?.title || '', interviewTime: payload.interviewTime, interviewLocation: payload.interviewLocation, longitude: payload.longitude, latitude: payload.latitude, state: 1, Accept: false }) const echo = localEcho({ content, type: 0, consumerTalk: recruitConsumerTalk.value, consumerId: consumerId.value }) await saveRecruitMessage({ content, type: 0 }, echo) interviewPopupShow.value = false } catch (e) { uni.showToast({ title: '发送面试邀请失败', icon: 'none' }) } } async function startVoiceCall() { attachPopup.value = false try { await VideoCallsService.initAndLogin() const resp = await VideoCallsService.call({ calleeList: [peerId.value], callMediaType: 1 }) if (resp?.code !== 0) throw new Error(resp?.msg || '语音通话失败') } catch (e) { uni.showToast({ title: e?.message || '语音通话失败', icon: 'none' }) } } function initSocket() { if (!consumerId.value) return const url = 'ws://47.99.182.213:7018/ws/' + consumerId.value socket.value = new WsRequest(url, 10000) socket.value.onOpen(() => {}) socket.value.getMessage((raw) => { let msg try { msg = JSON.parse(raw) } catch (e) { return } const arr = Array.isArray(msg) ? msg : [msg] arr.forEach((item) => { if (!item) return const related = String(item.recruitPostId || item.recrueitPostId || recruitPostId .value) === String(recruitPostId.value) if (!related && item.content === undefined) return const normalized = recruitStore.filterRecruitFetchedMessages(recruitCacheKey.value, [{ id: item.id || '', consumerId: item.consumerId || '', consumerTalk: !!item.consumerTalk, content: item.content || '', type: Number(item.type || 0), showName: item.showName || '', fileName: item.fileName || '', playURL: item.playURL || '', resumeId: item.resumeId || '', createTime: item.createTime || new Date().toISOString() }])[0] if (!normalized) return addMessage(normalized) recruitStore.appendRecruitChatMessage(recruitCacheKey.value, normalized) scrollToBottom() }) }) } function mapEducationLabel(value) { if (value === undefined || value === null || value === '') return '学历不限' if (typeof value === 'string' && Number.isNaN(Number(value))) return value return ({ 0: '不限', 1: '初中及以下', 2: '高中/中专', 3: '大专', 4: '本科', 5: '硕士', 6: '博士' } [Number(value)] || '学历不限') } function listenInterviewUpdate() { uni.$on('recruitInterviewUpdated', (payload) => { messageList.value = messageList.value.map((item) => { const view = messageView(item) if (view.kind === 'interview' && String(view.interviewId) === String(payload.id)) { const contentObj = normalizeJsonContent(item.content) || {} contentObj.Accept = payload.Accept contentObj.state = payload.state return { ...item, content: JSON.stringify(contentObj) } } return item }) syncCache() }) } function removeInterviewListener() { uni.$off('recruitInterviewUpdated') } watch(() => messageList.value.length, () => queueScrollToBottom(0)) watch(() => keyboardOffset.value, () => nextTick(() => queueScrollToBottom(0))) watch(messageList, () => syncCache(), { deep: true }) onLoad(async (option) => { recruitStore.hydrateFromStorage?.() isRecruitMode.value = String(option?.recruitMode || '1') === '1' recruitPostId.value = safeDecode(option?.recrueitPostId || option?.recruitPostId || '') peerId.value = safeDecode(option?.peerId || option?.FriendID || '') peerNickname.value = safeDecode(option?.nickname || '') peerAvatar.value = safeDecode(option?.avatar || '') performerId.value = safeDecode(option?.performerId || '') targetApplicantId.value = safeDecode(option?.applicantId || '') if (option?.headerCard) { try { headerCard.value = JSON.parse(decodeURIComponent(option.headerCard)) } catch (e) { headerCard.value = null } } if (!isRecruitMode.value) return if (!recruitPostId.value && isEnterprise.value) { await loadCompanyPosts() recruitPostId.value = interviewForm.postId || companyPostOptions.value[0]?.id || '' } const cached = recruitStore.readRecruitChatCache(recruitCacheKey.value) if (cached.length) { messageList.value = cached rebuildMsgDedup() scrollToBottom() } await fetchRecruitHistory(true) await loadAnswerRecords() initSocket() listenInterviewUpdate() }) onMounted(() => { containerTop.value = Number(statusBarHeight.value || 0) + Number(headerBarHeight.value || 44) + (headerCard .value ? 16 : 12) if (typeof uni.onKeyboardHeightChange === 'function') { keyboardHeightHandler = (res = {}) => { keyboardHeight.value = Number(res.height || 0) nextTick(() => scrollToBottom()) } uni.onKeyboardHeightChange(keyboardHeightHandler) } }) onBeforeUnmount(() => { try { socket.value?.close() } catch (e) {} try { if (typeof uni.offKeyboardHeightChange === 'function') { if (keyboardHeightHandler) uni.offKeyboardHeightChange(keyboardHeightHandler) else uni.offKeyboardHeightChange() } } catch (e) {} keyboardHeight.value = 0 if (chatScrollTimer) clearTimeout(chatScrollTimer) chatScrollTimer = null if (autoScrollTimer) clearTimeout(autoScrollTimer) autoScrollTimer = null isChatScrolling.value = false removeInterviewListener() }) </script> <style scoped lang="scss"> .app-container { min-height: 100vh; background: #efefef; } .content { min-height: 100vh; position: relative; } .overlay { position: absolute; inset: 0; background: #efefef; } .header { width: 94%; margin: 0 auto; } .header-inner { position: relative; display: flex; align-items: center; justify-content: center; height: 100%; } .left-box, .right-box { position: absolute; top: 0; bottom: 0; width: 120rpx; display: flex; align-items: center; } .left-box { left: 0; justify-content: flex-start; } .right-box { right: 0; justify-content: flex-end; } .center-box { flex: none; } .title-text { font-size: 32rpx; color: #111; } .desc-text { font-weight: bold; font-size: 40rpx; color: #111; } .chat-scroll { position: absolute; left: 0; right: 0; padding: 0 3%; box-sizing: border-box; transition: bottom .2s ease; } .scroll-wrap { padding-bottom: 18rpx; } .summary-card { background: #ffffff; border-radius: 18rpx; padding: 18rpx; margin-bottom: 18rpx; box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, .04); } .summary-job-top, .summary-resume-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 16rpx; } .summary-main { flex: 1; min-width: 0; } .summary-title { display: block; font-size: 28rpx; line-height: 40rpx; color: #222222; font-weight: 700; } .summary-company, .summary-name { display: block; margin-top: 8rpx; font-size: 24rpx; color: #5b5b5b; } .summary-tags, .summary-inline { margin-top: 12rpx; display: flex; flex-wrap: wrap; align-items: center; gap: 10rpx; font-size: 22rpx; color: #7a7a7a; } .summary-tag { padding: 8rpx 16rpx; border-radius: 999rpx; background: #f3f5f7; font-size: 22rpx; color: #6c6c6c; } .summary-salary { font-size: 30rpx; color: #fe574a; font-weight: 700; flex-shrink: 0; } .summary-footer { margin-top: 16rpx; display: flex; align-items: center; gap: 10rpx; } .summary-avatar, .summary-resume-avatar, .bubble-mini-avatar, .bubble-avatar { width: 62rpx; height: 62rpx; border-radius: 50%; background: #ededed; flex-shrink: 0; } .summary-resume-avatar { width: 92rpx; height: 92rpx; } .summary-footer-text { font-size: 22rpx; color: #555; } .summary-location { margin-left: auto; font-size: 22rpx; color: #8d8d8d; max-width: 38%; text-align: right; } .summary-dot { width: 6rpx; height: 6rpx; border-radius: 50%; background: #c1c1c1; } .inside-state { display: flex; justify-content: center; align-items: center; padding: 60rpx 0; } .inside-state.small { padding: 20rpx 0; } .inside-state-text { font-size: 24rpx; color: #999; } .time-divider { width: 100%; text-align: center; color: #999; font-size: 22rpx; margin: 18rpx 0; } .message-row { display: flex; align-items: flex-start; gap: 14rpx; margin-bottom: 20rpx; } .message-row.self { justify-content: flex-end; } .message-row.other { justify-content: flex-start; } .bubble-column { display: flex; flex-direction: column; max-width: 74%; } .media-column { max-width: 520rpx; } .media-column--self { align-items: flex-end; } .media-card { max-width: 520rpx; border-radius: 18rpx; overflow: hidden; background: transparent; } .media-card__image { display: block; width: 100%; max-width: 520rpx; border-radius: 18rpx; } .media-card__video-wrap { width: 520rpx; max-width: 75vw; min-height: 300rpx; border-radius: 18rpx; overflow: hidden; background: #111; } .media-card__video { width: 100%; max-width: 75vw; display: block; border-radius: 18rpx; } .media-card__video-mask { width: 100%; height: 300rpx; display: flex; align-items: center; justify-content: center; background: rgba(0, 0, 0, .82); } .media-card__video-mask-text { font-size: 24rpx; color: #ffffff; } .sender-name { margin-bottom: 8rpx; font-size: 22rpx; color: #888; } .bubble { border-radius: 18rpx; padding: 18rpx; box-sizing: border-box; word-break: break-all; } .message-row.self .bubble { background: #dff6eb; color: #222; } .message-row.other .bubble { background: #ffffff; color: #222; } .bubble-text { font-size: 28rpx; line-height: 1.6; } .bubble-image { width: 320rpx; border-radius: 18rpx; overflow: hidden; background: #fff; } .bubble-file { display: flex; align-items: center; justify-content: space-between; gap: 16rpx; } .bubble-file__main { flex: 1; min-width: 0; } .bubble-file__title { display: block; font-size: 26rpx; color: #222; } .bubble-file__desc { display: block; margin-top: 8rpx; font-size: 22rpx; color: #8d8d8d; } .bubble-resume__top, .bubble-resume__bottom { display: flex; align-items: center; justify-content: space-between; gap: 12rpx; } .bubble-resume__title { font-size: 28rpx; font-weight: 700; color: #222; } .bubble-resume__salary { font-size: 28rpx; color: #fe574a; font-weight: 700; } .bubble-resume__tags { display: flex; flex-wrap: wrap; gap: 10rpx; margin-top: 12rpx; } .bubble-chip { padding: 8rpx 16rpx; border-radius: 12rpx; background: #f3f5f7; font-size: 22rpx; color: #666; } .bubble-resume__bottom { margin-top: 16rpx; justify-content: flex-start; } .bubble-mini-name { font-size: 24rpx; color: #444; } .bubble-mini-action { margin-left: auto; padding: 10rpx 18rpx; border-radius: 999rpx; background: rgba(0, 190, 189, .12); color: #00a7a6; font-size: 22rpx; } .bubble-question__head { display: flex; align-items: center; justify-content: space-between; gap: 14rpx; } .bubble-question__title { font-size: 28rpx; font-weight: 700; color: #222; } .bubble-question__sub { font-size: 24rpx; color: #666; } .bubble-question__desc { display: block; margin-top: 12rpx; font-size: 24rpx; color: #777; } .bubble-question__actions, .bubble-interview__actions { display: flex; justify-content: space-between; gap: 14rpx; margin-top: 18rpx; } .small-btn { min-width: 140rpx; height: 60rpx; border-radius: 12rpx; display: flex; align-items: center; justify-content: center; font-size: 24rpx; } .small-btn.ghost { background: #f1f1f1; color: #333; } .small-btn.primary { background: #ffffff; color: #ff4c3b; border: 1rpx solid rgba(255, 76, 59, .3); } .small-btn.disabled { background: #f1f1f1; color: #666; } .bubble-interview__title { font-size: 28rpx; font-weight: 700; color: #222; margin-bottom: 12rpx; } .bubble-interview__row { display: flex; align-items: flex-start; gap: 10rpx; font-size: 24rpx; color: #555; line-height: 1.7; margin-top: 6rpx; } .bubble-audio { width: 360rpx; height: 92rpx; } .self-avatar { order: 2; } .input-area { position: absolute; left: 0; right: 0; bottom: 0; z-index: 30; background: #f7f7f7; border-top: 1rpx solid rgba(0, 0, 0, .05); padding: 14rpx 20rpx calc(env(safe-area-inset-bottom) + 18rpx); box-sizing: border-box; transition: bottom .2s ease; } .composer-bar { display: flex; align-items: center; gap: 14rpx; } .composer-input-wrap { flex: 1; height: 74rpx; background: #ffffff; border-radius: 12rpx; display: flex; align-items: center; padding: 0 18rpx; box-sizing: border-box; } .composer-input { flex: 1; height: 100%; font-size: 28rpx; color: #111111; } .composer-tool { width: 64rpx; height: 64rpx; display: flex; align-items: center; justify-content: center; flex-shrink: 0; } .circle-tool { width: 58rpx; height: 58rpx; border: 3rpx solid #111111; border-radius: 50%; display: flex; align-items: center; justify-content: center; box-sizing: border-box; } .send-btn { height: 64rpx; min-width: 108rpx; padding: 0 24rpx; background: #07c160; color: #ffffff; font-size: 28rpx; border-radius: 10rpx; display: flex; align-items: center; justify-content: center; box-sizing: border-box; flex-shrink: 0; } .wx-panel { background: #f7f7f7; border-top-left-radius: 24rpx; border-top-right-radius: 24rpx; padding: 30rpx 26rpx calc(env(safe-area-inset-bottom) + 24rpx); box-sizing: border-box; } .wx-panel-grid { display: grid; grid-template-columns: repeat(4, 1fr); row-gap: 28rpx; column-gap: 12rpx; } .wx-panel-item { display: flex; flex-direction: column; align-items: center; justify-content: flex-start; } .wx-panel-icon { width: 108rpx; height: 108rpx; border-radius: 22rpx; background: #ffffff; display: flex; align-items: center; justify-content: center; box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, .03); } .wx-panel-text { margin-top: 14rpx; font-size: 24rpx; color: #666666; line-height: 1.4; text-align: center; } .legacy-box { padding: 24rpx; } .legacy-card { background: #fff; border-radius: 18rpx; padding: 30rpx; } .legacy-title { display: block; font-size: 30rpx; color: #222; font-weight: 700; } .legacy-desc { display: block; margin-top: 12rpx; font-size: 24rpx; line-height: 1.8; color: #666; } .question-popup { background: #fff; padding: 24rpx; max-height: 80vh; box-sizing: border-box; } .popup-head { display: flex; align-items: center; justify-content: space-between; } .popup-title { font-size: 30rpx; color: #222; font-weight: 700; } .popup-close { width: 44rpx; height: 44rpx; border-radius: 50%; background: #f3f3f3; display: flex; align-items: center; justify-content: center; font-size: 30rpx; color: #666; } .question-tabs { display: flex; gap: 24rpx; margin-top: 22rpx; margin-bottom: 18rpx; } .question-tab { position: relative; font-size: 26rpx; color: #666; padding-bottom: 10rpx; } .question-tab.active { color: #ff4c3b; font-weight: 700; } .question-tab.active::after { content: ''; position: absolute; left: 50%; transform: translateX(-50%); bottom: 0; width: 34rpx; height: 4rpx; border-radius: 999rpx; background: #ff4c3b; } .question-scroll { max-height: 56vh; } .question-group-item, .custom-question-card { border: 1rpx solid #ececec; border-radius: 16rpx; padding: 18rpx; margin-bottom: 16rpx; } .question-group-item.active { border-color: #ff4c3b; background: rgba(255, 76, 59, .03); } .question-group-item__top, .custom-question-head, .question-bottom { display: flex; align-items: center; justify-content: space-between; gap: 12rpx; } .q-title, .answer-title { font-size: 26rpx; color: #222; font-weight: 600; } .q-count, .q-time { font-size: 22rpx; color: #8b8b8b; } .custom-input { width: 100%; min-height: 68rpx; border: 1rpx solid #e6e6e6; border-radius: 12rpx; padding: 0 18rpx; box-sizing: border-box; font-size: 24rpx; color: #222; margin-top: 14rpx; } .custom-input.small { min-height: 60rpx; } .option-row { display: flex; align-items: center; gap: 12rpx; margin-top: 10rpx; } .option-prefix { width: 36rpx; font-size: 24rpx; color: #666; } .link-danger { font-size: 22rpx; color: #ff4c3b; } .link-primary { font-size: 22rpx; color: #ff4c3b; } .popup-actions { display: flex; gap: 18rpx; margin-top: 22rpx; } .popup-actions.single { margin-top: 6rpx; } .ghost-btn, .primary-btn { flex: 1; height: 78rpx; border-radius: 14rpx; display: flex; align-items: center; justify-content: center; font-size: 28rpx; } .ghost-btn { background: #f1f1f1; color: #333; } .ghost-btn.full { width: 100%; } .primary-btn { background: #ff4c3b; color: #fff; } .answer-scroll { max-height: 58vh; } .answer-option { min-height: 64rpx; border-radius: 12rpx; background: #f7f7f7; padding: 0 18rpx; display: flex; align-items: center; font-size: 24rpx; color: #333; margin-top: 10rpx; } .answer-option.active { background: rgba(255, 76, 59, .12); color: #ff4c3b; } .answer-textarea { width: 100%; min-height: 110rpx; margin-top: 12rpx; border: 1rpx solid #e6e6e6; border-radius: 12rpx; padding: 16rpx; box-sizing: border-box; font-size: 24rpx; } .form-list { display: flex; flex-direction: column; gap: 14rpx; margin-top: 18rpx; } .form-item, .form-input { min-height: 78rpx; border-radius: 14rpx; background: #f7f7f7; padding: 0 18rpx; box-sizing: border-box; display: flex; align-items: center; justify-content: space-between; font-size: 26rpx; } .form-label { color: #666; } .form-value { color: #333; } .form-input { border: none; width: 100%; } </style> <style scoped lang="scss"> .quote-bar { margin-bottom: 14rpx; background: rgba(255, 255, 255, 0.96); border-radius: 14rpx; padding: 14rpx 18rpx; display: flex; align-items: center; justify-content: space-between; gap: 12rpx; } .quote-bar-inner { flex: 1; min-width: 0; display: flex; align-items: center; gap: 8rpx; } .quote-label { font-size: 24rpx; color: #999; flex-shrink: 0; } .quote-text { font-size: 24rpx; color: #555; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .quote-close { width: 40rpx; height: 40rpx; border-radius: 50%; background: #f3f3f3; display: flex; align-items: center; justify-content: center; font-size: 28rpx; color: #666; flex-shrink: 0; } .summary-line { display: flex; align-items: center; justify-content: space-between; gap: 12rpx; } </style>这个是KamuChatBubble组件的东西<template>text<template v-if="isRecruitMode"> <view class="chat-scroll" :style="{ top: containerTop + 'px', bottom: inputWrapBottom + 'px' }"> <scroll-view scroll-y style="height: 100%" :scroll-into-view="lastItemId" @scroll="handleChatScroll" @scrolltoupper="loadMoreRecruitHistory" upper-threshold="60"> <view class="scroll-wrap"> <view v-if="headerCard" class="summary-card" @click="openHeaderCard"> <template v-if="headerCard.cardType === 'job'"> <view class="summary-job-top"> <view class="summary-main"> <text class="summary-title">{{ headerCard.title || headerCard.position || '职位名称' }}</text> <text class="summary-company">{{ headerCard.companyName || '公司名称' }}</text> <view class="summary-tags"> <text v-for="(tag, idx) in headerCard.tags || []" :key="idx" class="summary-tag">{{ tag }}</text> </view> </view> <text class="summary-salary">{{ headerCard.salaryRange || '面议' }}</text> </view> <view class="summary-footer"> <image class="summary-avatar" :src="headerCard.avatarUrl || defaultAvatar" mode="aspectFill" /> <text class="summary-footer-text">{{ headerCard.companyName || headerCard.contactPerson || '企业' }}</text> <text class="summary-location">{{ headerCard.locationText || headerCard.location || '面试地址待补充' }}</text> </view> </template> <template v-else> <view class="summary-resume-top"> <image class="summary-resume-avatar" :src="headerCard.avatarUrl || defaultAvatar" mode="aspectFill" /> <view class="summary-main"> <view class="summary-line"> <text class="summary-title">{{ headerCard.position || '意向职位' }}</text> <text class="summary-salary">{{ headerCard.salaryExpectation || '面议' }}</text> </view> <text class="summary-name">{{ headerCard.name || '求职者' }}</text> <view class="summary-inline"> <text>{{ headerCard.educationLabel || '学历不限' }}</text> <text class="summary-dot"></text> <text>{{ headerCard.currentAddress || '当前地址待完善' }}</text> </view> </view> </view> </template> </view> <view v-if="historyLoading && !messageList.length" class="inside-state"> <text class="inside-state-text">加载中...</text> </view> <view v-for="(msg, idx) in messageList" :key="bubbleMessageKey(msg, idx)" :id="`msg-${idx}`"> <view v-if="shouldShowTimestamp(idx)" class="time-divider"> {{ formatChatTime(msg.createTime) }} </view> <view class="message-row" :class="{ self: isSelfMessage(msg), other: !isSelfMessage(msg) }"> <template v-if="isMediaMessage(msg)"> <image v-if="!isSelfMessage(msg)" class="bubble-avatar" :src="peerAvatarForBubble(msg)" mode="aspectFill" /> <view class="bubble-column media-column" :class="{ 'media-column--self': isSelfMessage(msg) }"> <view v-if="showSenderName(msg)" class="sender-name"> {{ senderName(msg) }} </view> <view class="media-card" @longpress.prevent="openRecruitActionMenu(msg)"> <image v-if="messageView(msg).kind === 'image'" class="media-card__image" :src="messageView(msg).url" mode="widthFix" @click.stop="previewRecruitImage(messageView(msg).url)" @load="onRecruitMediaReady" @error="onRecruitMediaReady" /> <view v-else class="media-card__video-wrap"> <video v-if="!isChatScrolling" class="media-card__video" :src="messageView(msg).url" controls :show-center-play-btn="true" :show-fullscreen-btn="true" @loadedmetadata="onRecruitMediaReady" @error="onRecruitMediaReady" /> <view v-else class="media-card__video-mask"> <text class="media-card__video-mask-text">滚动中已隐藏视频</text> </view> </view> </view> </view> <image v-if="isSelfMessage(msg)" class="bubble-avatar self-avatar" :src="selfAvatar" mode="aspectFill" /> </template> <template v-else-if="isGenericBubble(msg)"> <KamuChatBubble :messageKey="bubbleMessageKey(msg, idx)" :role="isSelfMessage(msg) ? 'user' : 'assistant'" :content="recruitBubbleContent(msg)" :userAvatar="selfAvatar" :assistantAvatar="peerAvatarForBubble(msg)" :webProjectUrl="webProjectUrl" :castingChatId="msg.id || ''" :showCopyButton="!isSelfMessage(msg)" :multiSelectMode="false" :selected="false" :selectDisabled="true" :suspendVideo="isChatScrolling" @quote-message="onRecruitQuoteMessage(msg)" @delete-message="deleteRecruitMessage(msg)" @recall-message="recallRecruitMessage(msg)" /> </template> <template v-else> <image v-if="!isSelfMessage(msg)" class="bubble-avatar" :src="peerAvatarForBubble(msg)" mode="aspectFill" /> <view class="bubble-column"> <view v-if="showSenderName(msg)" class="sender-name"> {{ senderName(msg) }} </view> <view v-if="messageView(msg).kind === 'resume'" class="bubble bubble-resume" @click="openResumeCard(messageView(msg))" @longpress.prevent="openRecruitActionMenu(msg)"> <view class="bubble-resume__top"> <text class="bubble-resume__title">{{ messageView(msg).position || '意向职位' }}</text> <text class="bubble-resume__salary">{{ messageView(msg).salaryExpectation || '面议' }}</text> </view> <view class="bubble-resume__tags"> <text class="bubble-chip">{{ messageView(msg).educationLabel || '学历不限' }}</text> <text class="bubble-chip">{{ messageView(msg).currentAddress || '当前地址待完善' }}</text> </view> <view class="bubble-resume__bottom"> <image class="bubble-mini-avatar" :src="messageView(msg).avatarUrl || defaultAvatar" mode="aspectFill" /> <text class="bubble-mini-name">{{ messageView(msg).name || '求职者' }}</text> <view class="bubble-mini-action">查看简历</view> </view> </view> <view v-else-if="messageView(msg).kind === 'questionGroup'" class="bubble bubble-question" @longpress.prevent="openRecruitActionMenu(msg)"> <view class="bubble-question__head"> <text class="bubble-question__title">笔问</text> <text class="bubble-question__sub">{{ messageView(msg).title || '题目组' }}</text> </view> <text class="bubble-question__desc">共{{ messageView(msg).questionCount || 0 }}道问题需要回答</text> <view class="bubble-question__actions"> <view class="small-btn ghost" @click.stop="openQuestionAnswer(msg, false)"> {{ hasAnsweredQuestionGroup(messageView(msg).questionGroupId) ? '已回答' : '未回答' }} </view> <view class="small-btn primary" @click.stop="openQuestionAnswer(msg, true)"> {{ hasAnsweredQuestionGroup(messageView(msg).questionGroupId) ? '查看回答' : '开始回答' }} </view> </view> </view> <view v-else-if="messageView(msg).kind === 'interview'" class="bubble bubble-interview" @longpress.prevent="openRecruitActionMenu(msg)"> <view class="bubble-interview__title">面试邀请</view> <view class="bubble-interview__row"> <text>职位:</text><text>{{ messageView(msg).postTitle || headerCard?.title || '--' }}</text> </view> <view class="bubble-interview__row"> <text>综合时间:</text><text>{{ formatDateTime(messageView(msg).interviewTime) }}</text> </view> <view class="bubble-interview__row"> <text>面试地址:</text><text>{{ messageView(msg).interviewLocation || '--' }}</text> </view> <view class="bubble-interview__actions"> <view class="small-btn ghost" @click.stop="openInterviewDetail(messageView(msg).interviewId)"> 查看详情</view> <view v-if="canAcceptInterview(messageView(msg))" class="small-btn primary" @click.stop="acceptInterviewFromBubble(messageView(msg))">接受 </view> <view v-else class="small-btn disabled"> {{ interviewStatusText(messageView(msg)) }} </view> </view> </view> </view> <image v-if="isSelfMessage(msg)" class="bubble-avatar self-avatar" :src="selfAvatar" mode="aspectFill" /> </template> </view> </view> <view v-if="sendingReply" class="inside-state small"> <text class="inside-state-text">发送中...</text> </view> </view> </scroll-view> </view> <view class="input-area" :style="{ bottom: keyboardOffset + 'px', paddingBottom: safeAreaBottom + 'px' }"> <view v-if="quoteDraft" class="quote-bar"> <view class="quote-bar-inner"> <text class="quote-label">引用:</text> <text class="quote-text">{{ quoteDraft.senderName }}:{{ quoteDraft.preview }}</text> </view> <view class="quote-close" @click.stop="clearQuote">×</view> </view> <view class="composer-bar"> <view class="composer-input-wrap"> <input v-model="inputText" class="composer-input" :adjust-position="false" type="text" confirm-type="send" placeholder="发信息" :maxlength="500" @focus="handleComposerFocus" @blur="handleComposerBlur" @confirm="sendRecruitText" /> </view> <view v-if="inputText.trim()" class="send-btn" @click="sendRecruitText">发送</view> <view v-else class="composer-tool" @click="attachPopup = true"> <view class="circle-tool"><uni-icons type="plus-filled" size="22" color="#111111" /> </view> </view> </view> </view> </template> </view> </view> <u-popup :show="attachPopup" mode="bottom" @close="attachPopup = false" :safeAreaInsetBottom="false" bgColor="transparent"> <view class="wx-panel"> <view class="wx-panel-grid"> <template v-for="item in recruitPanelActions" :key="item.key"> <view class="wx-panel-item" @click="item.action()"> <view class="wx-panel-icon"><uni-icons :type="item.icon" size="34" color="#111111" /></view> <text class="wx-panel-text">{{ item.label }}</text> </view> </template> </view> </view> </u-popup> <u-popup :show="questionPickerShow" mode="bottom" @close="questionPickerShow = false" :round="18"> <view class="question-popup"> <view class="popup-head"><text class="popup-title">笔问</text><text class="popup-close" @click="questionPickerShow = false">×</text></view> <view class="question-tabs"> <view class="question-tab" :class="{ active: questionTab === 'group' }" @click="questionTab = 'group'">模板</view> <view class="question-tab" :class="{ active: questionTab === 'custom' }" @click="questionTab = 'custom'">自定义</view> </view> <scroll-view scroll-y class="question-scroll"> <template v-if="questionTab === 'group'"> <view v-for="group in questionGroups" :key="group.id" class="question-group-item" :class="{ active: selectedQuestionGroupId === group.id }" @click="selectedQuestionGroupId = group.id"> <view class="question-group-item__top"><text class="q-title">{{ group.title || '未命名题组' }}</text><text class="q-count">{{ group.questionCount || 0 }}题</text></view> <text class="q-time">{{ formatChatTime(group.createTime) }}</text> </view> <view v-if="!questionGroups.length" class="inside-state"><text class="inside-state-text">暂无题组</text></view> </template> <template v-else> <view v-for="(question, qIndex) in customQuestions" :key="question.localId" class="custom-question-card"> <view class="custom-question-head"><text>题目 {{ qIndex + 1 }}</text><text class="link-danger" v-if="customQuestions.length > 1" @click="removeCustomQuestion(qIndex)">删除</text> </view> <input v-model="question.title" class="custom-input" placeholder="请输入问题" /> <view v-for="(option, oIndex) in question.options" :key="option.localId" class="option-row"> <text class="option-prefix">{{ String.fromCharCode(65 + oIndex) }}.</text> <input v-model="option.title" class="custom-input small" placeholder="请输入选项" /> </view> <view class="question-bottom"><input v-model="question.freeTextPlaceholder" class="custom-input" placeholder="自定义回答占位(可选)" /><text class="link-primary" @click="addCustomOption(qIndex)">添加答案</text></view> </view> <view class="popup-actions single"> <view class="ghost-btn full" @click="addCustomQuestion">新增问题</view> </view> </template> </scroll-view> <view class="popup-actions"> <view class="ghost-btn" @click="questionPickerShow = false">取消</view> <view class="primary-btn" @click="sendQuestionGroup">发送</view> </view> </view> </u-popup> <u-popup :show="questionAnswerShow" mode="bottom" @close="questionAnswerShow = false" :round="18"> <view class="question-popup"> <view class="popup-head"><text class="popup-title">笔问</text><text class="popup-close" @click="questionAnswerShow = false">×</text></view> <scroll-view scroll-y class="question-scroll answer-scroll"> <view v-for="(question, qIndex) in answerQuestionList" :key="question.id || qIndex" class="custom-question-card"> <text class="answer-title">{{ qIndex + 1 }}. {{ question.title }}</text> <view v-for="(option, oIndex) in question.optionsArray || []" :key="option.id || oIndex" class="answer-option" :class="{ active: answerMap[question.id]?.optionId === option.id }" @click="selectAnswerOption(question, option)"> <text>{{ String.fromCharCode(65 + oIndex) }}. {{ option.title }}</text> </view> <textarea v-model="answerMap[question.id].content" class="answer-textarea" placeholder="自定义回答" /> </view> </scroll-view> <view class="popup-actions"> <view class="ghost-btn" @click="questionAnswerShow = false">取消</view> <view class="primary-btn" @click="submitQuestionAnswers">提交</view> </view> </view> </u-popup> <u-popup :show="interviewPopupShow" mode="bottom" @close="interviewPopupShow = false" :round="18"> <view class="question-popup interview-popup"> <view class="popup-head"><text class="popup-title">面试邀请</text><text class="popup-close" @click="interviewPopupShow = false">×</text></view> <view class="form-list"> <picker v-if="companyPostOptions.length" :range="companyPostOptions" range-key="label" @change="onInterviewPostChange"> <view class="form-item"><text class="form-label">职位</text><text class="form-value">{{ selectedInterviewPostLabel }}</text></view> </picker> <picker mode="date" :value="interviewForm.date" @change="(e) => (interviewForm.date = e.detail.value)"> <view class="form-item"><text class="form-label">面试日期</text><text class="form-value">{{ interviewForm.date || '请选择日期' }}</text></view> </picker> <picker mode="time" :value="interviewForm.time" @change="(e) => (interviewForm.time = e.detail.value)"> <view class="form-item"><text class="form-label">面试时间</text><text class="form-value">{{ interviewForm.time || '请选择时间' }}</text></view> </picker> <input v-model="interviewForm.location" class="form-input" placeholder="请输入面试地址(可修改)" /> </view> <view class="popup-actions"> <view class="ghost-btn" @click="interviewPopupShow = false">取消</view> <view class="primary-btn" @click="sendInterviewInvite">发送面试邀请</view> </view> </view> </u-popup> </view>
</template> <script setup> import { defineProps, defineEmits, computed, ref } from 'vue' import ajax from '@/api/ajax.js' const props = defineProps({ role: { type: String, required: true, validator: (v) => ['user', 'assistant', 'typing'].includes(v) }, content: { type: [String, Object], default: '' }, userAvatar: { type: String, default: '' }, assistantAvatar: { type: String, default: '' }, maskPeer: { type: Boolean, default: false }, maskPeerName: { type: String, default: '****' }, maskAvatarUrl: { type: String, default: '' }, showCopyButton: { type: Boolean, default: false }, showBadge: { type: Boolean, default: false }, chatConsumerId: { type: String, default: '' }, consumerId: { type: String, default: '' }, webProjectUrl: { type: String, default: '' }, castingChatId: { type: String, default: '' }, scene: { type: String, default: 'chat' }, castingChatGroupId: { type: String, default: '' }, castingChatGroupChatId: { type: String, default: '' }, messageKey: { type: String, default: '' }, multiSelectMode: { type: Boolean, default: false }, selected: { type: Boolean, default: false }, selectDisabled: { type: Boolean, default: false }, burnAfterReading: { type: Boolean, default: false }, suspendVideo: { type: Boolean, default: false } }) const emit = defineEmits([ 'self-avatar-click', 'peer-avatar-click', 'toggle-select', 'enter-multiselect', 'quote-message', 'delete-message', 'recall-message', 'burn-after-reading', 'media-ready' ]) const BURN_MOSAIC_IMG = 'https://t12.baidu.com/it/u=3229389678,260314789&fm=30&app=106&f=JPEG?w=640&h=960&s=342320B862E7C8EB44B617930300C08D' const MASK_FALLBACK = 'https://gd-hbimg.huaban.com/c294fbc2e5c35963a25d97f973f202c9a3c66f766125-RRVX90_fw658' const avatarSrc = computed(() => { if (props.maskPeer) return props.maskAvatarUrl || MASK_FALLBACK const isPeer = props.role === 'assistant' || props.role === 'typing' return isPeer ? props.assistantAvatar : props.userAvatar }) const timer = ref(null) const showMenu = ref(false) const isCollecting = ref(false) const showForwardDetail = ref(false) function closeMenu() { showMenu.value = false isCollecting.value = false } function handleTouchStart() { if (props.multiSelectMode) return timer.value = setTimeout(() => { showMenu.value = true }, 800) } function handleTouchEnd() { clearTimeout(timer.value) } function safeParseDeep(input) { let data = input for (let i = 0; i < 5; i++) { if (typeof data === 'string') { const s = data.trim() try { if ((s.startsWith('{') && s.endsWith('}')) || (s.startsWith('[') && s.endsWith(']'))) { data = JSON.parse(s) continue } if (/^"(\\.|[^"])*"$/.test(s)) { data = JSON.parse(s) continue } if (/%7B|%7D|%5B|%5D|%22/i.test(s)) { const decoded = decodeURIComponent(s) if (decoded !== s) { data = decoded continue } } } catch {} break } if (typeof data === 'object' && data !== null) break } return data } function normalizeParsed(input) { let o = safeParseDeep(input) for (let i = 0; i < 4; i++) { if (o && typeof o === 'object') { if (o.msgType || o._kind || o.type) return o if ('content' in o && !('msgType' in o) && !('_kind' in o)) { o = safeParseDeep(o.content) continue } } break } return o } function fullUrl(nameOrUrl = '') { if (!nameOrUrl) return '' if (/^https?:\/\//i.test(nameOrUrl)) return nameOrUrl const base = String(props.webProjectUrl || '').replace(/\/$/, '') const path = String(nameOrUrl).replace(/^\//, '') return base ? `${base}/${path}` : path } function asString(x) { try { return typeof x === 'string' ? x : JSON.stringify(x) } catch { return String(x || '') } } const parsed = computed(() => normalizeParsed(props.content)) const isAd = computed(() => parsed.value && typeof parsed.value === 'object' && parsed.value.msgType === 'ad') const isTextPayload = computed(() => parsed.value && typeof parsed.value === 'object' && parsed.value.msgType === 'text') const isLocation = computed(() => parsed.value && typeof parsed.value === 'object' && parsed.value.msgType === 'location') const locationName = computed(() => String(parsed.value?.name || '位置')) const locationAddress = computed(() => String(parsed.value?.address || '')) const locationlatitude = computed(() => String(parsed.value?.latitude || 0)) const locationlongitude = computed(() => String(parsed.value?.longitude || 0)) const isFileLike = computed(() => { const o = parsed.value if (!o || typeof o !== 'object') return false if (o._kind === 'file') return true if (typeof o.type === 'number' && o.type >= 1 && o.type <= 4) return true return false }) const isForwardRecord = computed(() => { const o = parsed.value if (!o || typeof o !== 'object') return false if (o.type == 5 || o.type == 6) return true if (o.msgType === 'forward_record' || o.msgType === 'forward') return true return false }) const forwardObj = computed(() => (isForwardRecord.value ? parsed.value : null)) function maskNameIfNeeded(it, rawName = '用户') { if (!props.maskPeer) return rawName const sid = String(it?.senderId || '') const selfId = String(props.consumerId || '') if (sid && selfId && sid === selfId) return rawName return props.maskPeerName || '****' } const forwardTitle = computed(() => { if (props.maskPeer) return '聊天记录' const o = forwardObj.value if (o?.title) return o.title if (o?.type == 6) return '群聊的聊天记录' return '聊天记录' }) const forwardItems = computed(() => { const o = forwardObj.value || {} if (Array.isArray(o.forwardCastingChatArray)) return o.forwardCastingChatArray if (Array.isArray(o.forwardCastingChatGroupChatArray)) return o.forwardCastingChatGroupChatArray if (Array.isArray(o.items)) return o.items if (Array.isArray(o.chats)) return o.chats if (Array.isArray(o.records)) return o.records return [] }) const forwardCount = computed(() => Number(forwardObj.value?.count || forwardItems.value.length || 0)) function computePreviewText(raw) { if (!raw) return '' let s = raw if (typeof s !== 'string') { try { s = JSON.stringify(s) } catch { s = String(s) } } const obj = normalizeParsed(s) if (obj && typeof obj === 'object') { if (obj.msgType === 'text') return String(obj.text || '').slice(0, 60) if (obj.msgType === 'location') return `[位置] ${String(obj.name || obj.address || '').slice(0, 40)}` if (!obj.msgType && typeof obj.text === 'string' && obj.text) return String(obj.text).slice(0, 60) if (obj._kind === 'file' || (obj.type >= 1 && obj.type <= 4)) { const t = Number(obj.type || 0) const baseName = obj.showName || obj.fileName || '' if (t === 1) return baseName ? `[图片] ${baseName}` : '[图片]' if (t === 2) return baseName ? `[视频] ${baseName}` : '[视频]' if (t === 3) return baseName ? `[语音] ${baseName}` : '[语音]' if (t === 4) return baseName ? `[文件] ${baseName}` : '[文件]' return '[文件]' } if (obj.msgType === 'ad') return '[广告]' if (obj.type == 5 || obj.type == 6 || obj.msgType === 'forward' || obj.msgType === 'forward_record') return obj .title || '[聊天记录]' } return String(s).slice(0, 60) } const forwardLines = computed(() => forwardItems.value.slice(0, 2).map((it) => { const raw = it.senderNickname || it.nickname || it.senderName || '用户' const name = maskNameIfNeeded(it, raw) return `${name}:${computePreviewText(it.content)}` }) ) const renderText = computed(() => { if (isTextPayload.value) return String(parsed.value?.text || parsed.value?.content || '') if (parsed.value && typeof parsed.value === 'object' && typeof parsed.value.text === 'string') return String(parsed.value.text) if (typeof props.content === 'string') return props.content return asString(props.content) }) const hasQuote = computed(() => !!(isTextPayload.value && parsed.value?.quote && (parsed.value.quote.preview || parsed .value.quote.senderName))) const quoteLine = computed(() => { const q = parsed.value?.quote || {} const name = q.senderName || '用户' const p = q.preview || '' return `${name}:${p}` }) const fileType = computed(() => (isFileLike.value ? Number(parsed.value.type) : 0)) const isPureMediaMessage = computed(() => isFileLike.value && (fileType.value === 1 || fileType.value === 2)) const fileUrl = computed(() => (isFileLike.value ? fullUrl(parsed.value.url || parsed.value.playURL || parsed.value.fileUrl || parsed.value.fileName) : '')) const fileShowName = computed(() => (isFileLike.value ? parsed.value.showName || parsed.value.fileName || '' : '')) const isBurnMedia = computed(() => !!(props.burnAfterReading && props.role !== 'user' && (fileType.value === 1 || fileType.value === 2 || fileType.value === 4))) const displayFileUrl = computed(() => { if (isBurnMedia.value && fileType.value === 1) return BURN_MOSAIC_IMG return fileUrl.value }) const cards = computed(() => (Array.isArray(parsed.value?.cards) ? parsed.value.cards : [])) const isopen = ref(false) const nowHM = computed(() => { const d = new Date() const h = String(d.getHours()).padStart(2, '0') const m = String(d.getMinutes()).padStart(2, '0') return `${h}:${m}` }) function hasShop(card) { return !!card?.mallEnterpriseId } function previewImage(val) { uni.previewImage({ current: val, urls: [val] }) } function copyPlain(text = '') { uni.setClipboardData({ data: text, success() { uni.showToast({ title: '已复制', icon: 'none' }) } }) } function copyMedia(card) { const u = fullUrl(card?.media?.playURL || '') if (!u) return uni.showToast({ title: '暂无链接', icon: 'none' }) copyPlain(u) } function shareHint() { uni.showToast({ title: '已复制,可粘贴去分享', icon: 'none' }) } function onAvatarClick() { const isSelf = props.role === 'user' if (isSelf) emit('self-avatar-click', { consumerId: props.consumerId }) else emit('peer-avatar-click', { option: { chatConsumerId: props.chatConsumerId, avatar: avatarSrc.value } }) } function toggleSelect() { if (props.selectDisabled) return uni.showToast({ title: '该消息暂不支持多选', icon: 'none' }) emit('toggle-select', props.messageKey) } function onBubbleClick() { if (isForwardRecord.value) openForwardDetail() if (isLocation.value) openLocationDetail() } const isSelfMessage = computed(() => props.role === 'user') const topMenuItems = computed(() => { if (!isSelfMessage.value) return [{ title: '转发', id: '1' }, { title: '收藏', id: '2' }] return [{ title: '转发', id: '1' }, { title: '收藏', id: '2' }, { title: '删除', id: '3' }] }) const bottomMenuItems = computed(() => { if (!isSelfMessage.value) return [{ title: '引入', id: '4' }, { title: '多选', id: '5' }] return [{ title: '引入', id: '4' }, { title: '多选', id: '5' }, { title: '撤回', id: '6' }] }) function isGroupScene() { return props.scene === 'groupChat' || !!props.castingChatGroupId || !!props.castingChatGroupChatId } function currentGroupChatId() { return String(props.castingChatGroupChatId || props.castingChatId || '') } function onMenuClick(item) { const title = item?.title if (title === '收藏') { isCollecting.value = true return } if (title === '转发') { const basePayload = { mode: 'single', content: normalizeContentForForward() } if (isGroupScene()) { const gid = String(props.castingChatGroupId || '') const gcid = currentGroupChatId() const payload = { ...basePayload, source: 'groupChat', castingChatGroupId: gid, castingChatGroupChatId: gcid, castingChatGroupChatIds: gcid ? [gcid] : [] } closeMenu() return uni.navigateTo({ url: '/pages/sociAlize/forward/Forwardfriend?payload=' + encodeURIComponent(JSON.stringify( payload)) }) } const payload = { ...basePayload, source: 'chat', castingChatId: props.castingChatId || '' } closeMenu() return uni.navigateTo({ url: '/pages/sociAlize/forward/Forwardfriend?payload=' + encodeURIComponent(JSON.stringify( payload)) }) } if (title === '删除') { closeMenu() return emit('delete-message', { scene: isGroupScene() ? 'groupChat' : 'chat', castingChatId: props.castingChatId || '', castingChatGroupId: props.castingChatGroupId || '', castingChatGroupChatId: currentGroupChatId(), messageKey: props.messageKey, isUser: props.role === 'user' }) } if (title === '撤回') { closeMenu() return emit('recall-message', { scene: isGroupScene() ? 'groupChat' : 'chat', castingChatId: props.castingChatId || '', castingChatGroupId: props.castingChatGroupId || '', castingChatGroupChatId: currentGroupChatId(), messageKey: props.messageKey, isUser: props.role === 'user' }) } if (title === '多选') { closeMenu() return emit('enter-multiselect', props.messageKey) } if (title === '引入') { closeMenu() return emit('quote-message', { scene: isGroupScene() ? 'groupChat' : 'chat', castingChatId: props.castingChatId || '', castingChatGroupId: props.castingChatGroupId || '', castingChatGroupChatId: currentGroupChatId(), content: normalizeContentForForward(), isUser: props.role === 'user', messageKey: props.messageKey }) } } /** * ✅ 收藏:修复 castingSecret/fileTransfer 语义 * castingSecret: 咖密收藏 * fileTransfer: 是否“文件传输助手”收藏(本气泡来自聊天页,所以这里默认 false) */ async function collectMessage(castingSecret = false) { const consumerId = uni.getStorageSync('consumerId') if (!consumerId) return uni.showToast({ title: '请先登录', icon: 'none' }) const isGroup = isGroupScene() const idForCollect = isGroup ? currentGroupChatId() : props.castingChatId || '' if (!idForCollect) return uni.showToast({ title: '该消息暂无法收藏', icon: 'none' }) try { const data = { consumerId, castingSecret: !!castingSecret, fileTransfer: false } if (isGroup) data.castingChatGroupChatId = idForCollect else data.castingChatId = idForCollect const res = await ajax.post({ url: 'castingChatCollect', method: 'post', data }) if (res?.data?.result === 'success') { uni.showToast({ title: '收藏成功', icon: 'none' }) closeMenu() } else { uni.showToast({ title: res?.data?.msg || '收藏失败', icon: 'none' }) } } catch { uni.showToast({ title: '收藏异常', icon: 'none' }) } } function normalizeContentForForward() { if (typeof props.content === 'string') return props.content return asString(props.content) } function openOrDownload(url, afterOpenCb) { // #ifdef H5 window.open(url, '_blank') afterOpenCb && afterOpenCb() // #endif // #ifndef H5 uni.downloadFile({ url, success: ({ tempFilePath }) => { if (uni.openDocument) { uni.openDocument({ filePath: tempFilePath, success: () => afterOpenCb && afterOpenCb(), fail: () => afterOpenCb && afterOpenCb() }) } else { uni.showToast({ title: '已下载', icon: 'none' }) afterOpenCb && afterOpenCb() } }, fail: () => { uni.showToast({ title: '下载失败', icon: 'none' }) afterOpenCb && afterOpenCb() } }) // #endif } const burnTriggered = ref(false) const burnVideoShow = ref(false) const burnVideoUrl = ref('') function triggerBurnDelete(delayMs = 0) { if (burnTriggered.value) return burnTriggered.value = true const id = props.castingChatId if (!id) return setTimeout(() => { emit('burn-after-reading', { castingChatId: id }) }, Math.max(0, Number(delayMs || 0))) } function onClickBurnFile(kind) { if (!isBurnMedia.value) { if (kind === 'image') return previewImage(fileUrl.value) if (kind === 'video') return (burnVideoUrl.value = fileUrl.value), (burnVideoShow.value = true) if (kind === 'file') return openOrDownload(fileUrl.value) return } if (kind === 'image') { previewImage(fileUrl.value) triggerBurnDelete(6000) return } if (kind === 'video') { burnVideoUrl.value = fileUrl.value burnVideoShow.value = true return } if (kind === 'file') { openOrDownload(fileUrl.value, () => { triggerBurnDelete(0) }) } } function closeBurnVideo() { burnVideoShow.value = false burnVideoUrl.value = '' if (isBurnMedia.value) triggerBurnDelete(0) } function openLocationDetail() { const o = parsed.value || {} const payload = { name: o.name || '位置', address: o.address || '', latitude: Number(o.latitude || 0), longitude: Number(o.longitude || 0) } uni.navigateTo({ url: '/pages/sociAlize/sharedspace/positioning?payload=' + encodeURIComponent(JSON.stringify(payload)) }) } function openForwardDetail() { showForwardDetail.value = true } function forwardAlign(it) { const o = forwardObj.value || {} const leftId = o.leftId || o.from?.peerId || '' const rightId = o.rightId || o.from?.selfId || '' const sid = String(it?.senderId || '') if (rightId && sid && sid === String(rightId)) return 'right' if (leftId && sid && sid === String(leftId)) return 'left' return 'left' } function forwardSenderName(it) { const raw = it?.senderNickname || it?.nickname || it?.senderName || '用户' return maskNameIfNeeded(it, raw) } function forwardContentObj(it) { return normalizeParsed(it?.content) } function forwardKind(it) { const o = forwardContentObj(it) if (o && typeof o === 'object') { if (o._kind === 'file' || o.type) return 'file' if (o.msgType === 'text') return 'text' } return 'text' } function forwardPreview(it) { return computePreviewText(it?.content) } function forwardFileType(it) { const o = forwardContentObj(it) return Number(o?.type || 0) } function forwardFileUrl(it) { const o = forwardContentObj(it) const u = o?.playURL || o?.fileName || '' return fullUrl(u) } function forwardHasQuote(it) { const o = forwardContentObj(it) return !!(o && o.msgType === 'text' && o.quote && (o.quote.preview || o.quote.senderName)) } function forwardQuoteLine(it) { const o = forwardContentObj(it) || {} const q = o.quote || {} const rawName = q.senderName || '用户' const name = props.maskPeer ? (rawName === '我' ? '我' : props.maskPeerName || '****') : rawName return `${name}:${q.preview || ''}` } function openFullscreen(card) { const { mallGoodsGroupTitle } = card if (mallGoodsGroupTitle) { const obj = { getmallGoodsGroupId: card.mallGoodsGroupId, browsingtime: card.requiredSec, price: card.price, media: card.media } uni.navigateTo({ url: '/pages/index/goodsPage/ProductDetails?advertisement=' + encodeURIComponent(JSON.stringify( obj || {})) }) } else { const list1 = [{ ...card, type: 'sharetype', Sharefriends: props.chatConsumerId }] uni.navigateTo({ url: '/pages/wealthDiscuss/fullscreenvideo?list1=' + encodeURIComponent(JSON.stringify(list1)) }) } } function goProduct(card) { const id = card?.mallGoodsGroupId if (!id) return const sec = card?.requiredSec || 30 uni.navigateTo({ url: '/pages/index/goodsPage/ProductDetails?id=' + encodeURIComponent(id) + '&fromAd=1&adId=' + encodeURIComponent(card?.adId || '') + '&sec=' + encodeURIComponent(sec) }) } function goShop(card) { const id = card?.mallEnterpriseId if (!id) return uni.showToast({ title: '暂未配置店铺入口', icon: 'none' }) uni.navigateTo({ url: `/pages/mall/enterprise?id=${id}` }) } </script> <style scoped> /* (样式保持你原文件全量不删) */ .chat-message { display: flex; align-items: flex-start; margin-top: 12rpx; } .chat-message.user { flex-direction: row-reverse; } .avatar { width: 78rpx; height: 78rpx; border-radius: 50%; margin-right: 12rpx; } .chat-message.user .avatar { margin-left: 12rpx; margin-right: 0; } .select-wrap { width: 60rpx; height: 78rpx; display: flex; justify-content: center; align-items: center; } .select-circle { width: 34rpx; height: 34rpx; border-radius: 50%; border: 2rpx solid #cfcfcf; background: #fff; } .select-circle.selected { background: #d89833; border-color: #d89833; } .bubble-wrapper { display: flex; flex-direction: column; max-width: 75%; } .bubble-wrapper--media { max-width: 520rpx; } .bubble--media { background: transparent !important; padding: 0 !important; border-radius: 0 !important; box-shadow: none !important; } .chat-message.assistant .bubble--media::before, .chat-message.user .bubble--media::after { display: none !important; } .media-image-wrap { max-width: 480rpx; } .media-image { display: block; max-width: 480rpx; max-height: 560rpx; border-radius: 18rpx; overflow: hidden; } .media-video-box { width: 520rpx; max-width: 75vw; border-radius: 18rpx; overflow: hidden; background: #000; } .video-scroll-mask { width: 100%; height: 300rpx; background: rgba(0, 0, 0, 0.82); display: flex; align-items: center; justify-content: center; } .video-scroll-mask__inner { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14rpx; color: #fff; } .video-scroll-mask__text { font-size: 24rpx; color: #fff; } .bubble { position: relative; padding: 16rpx 20rpx; line-height: 1.4; word-break: break-word; white-space: pre-wrap; } .chat-message.assistant .bubble { background: #fff; color: #000; border-radius: 16rpx 16rpx 16rpx 4rpx; } .chat-message.user .bubble { background: #95ec69; color: #000; border-radius: 16rpx 16rpx 4rpx 16rpx; } .chat-message.assistant .bubble::before { content: ''; position: absolute; left: -8rpx; top: 20rpx; border-top: 8rpx solid transparent; border-right: 8rpx solid #fff; border-bottom: 8rpx solid transparent; } .chat-message.user .bubble::after { content: ''; position: absolute; right: -8rpx; top: 20rpx; border-top: 8rpx solid transparent; border-left: 8rpx solid #95ec69; border-bottom: 8rpx solid transparent; } .loc-card { width: 520rpx; max-width: 70vw; border-radius: 12rpx; padding: 16rpx 18rpx; /* background: #fff; */ box-sizing: border-box; } .loc-name { font-size: 28rpx; color: #111; margin-bottom: 8rpx; font-weight: 600; } .loc-addr { font-size: 24rpx; color: #666; margin-bottom: 14rpx; } .loc-btn { height: 162rpx; border-radius: 10rpx; background: #d89833; color: #fff; display: flex; justify-content: center; align-items: center; } .burn-tag { margin-top: 8rpx; font-size: 22rpx; color: #fff; background: rgba(216, 152, 51, 0.9); padding: 6rpx 12rpx; border-radius: 999rpx; display: inline-block; } .burn-tag--abs { position: absolute; bottom: 12rpx; right: 12rpx; } .burn-tag--inline { margin-left: 10rpx; } .burn-video-cover { position: relative; } .burn-play { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); width: 80rpx; height: 80rpx; border-radius: 50%; background: rgba(0, 0, 0, 0.45); display: flex; align-items: center; justify-content: center; color: #fff; font-size: 44rpx; } .forward-card { width: 540rpx; max-width: 70vw; border-radius: 12rpx; padding: 16rpx 18rpx; box-sizing: border-box; } .fc-title { font-size: 28rpx; color: #111; margin-bottom: 10rpx; font-weight: 600; } .fc-line { font-size: 26rpx; color: #333; margin-bottom: 6rpx; } .fc-count { margin-top: 10rpx; font-size: 26rpx; color: #666; } .forward-detail-popup { width: 686rpx; background: #fff; border-radius: 12rpx; padding: 18rpx; box-sizing: border-box; } .fr-row { width: 100%; display: flex; margin-bottom: 18rpx; } .fr-row.left { justify-content: flex-start; } .fr-row.right { justify-content: flex-end; } .fr-body { max-width: 82%; display: flex; flex-direction: column; } .fr-name { font-size: 24rpx; color: #666; margin-bottom: 6rpx; } .fr-bubble { background: #f4f4f4; border-radius: 12rpx; padding: 14rpx 16rpx; box-sizing: border-box; } .fr-row.right .fr-bubble { background: #e9f6e2; } .fr-text { font-size: 28rpx; color: #333; } .fr-quote { background: rgba(0, 0, 0, 0.06); border-radius: 10rpx; padding: 10rpx 12rpx; margin-bottom: 10rpx; } .fr-quote-text { font-size: 24rpx; color: #333; } .fr-file-line { font-size: 28rpx; color: #333; } .menu-popup { width: 686rpx; height: 342rpx; background-color: white; display: flex; justify-content: center; align-items: center; border-radius: 12rpx; } .menu-layer { height: 50%; width: 90%; box-sizing: border-box; } .menu-row { height: 100%; width: 100%; display: flex; justify-content: space-between; box-sizing: border-box; } .collect-layer { height: 50%; width: 90%; box-sizing: border-box; display: flex; justify-content: space-between; align-items: center; padding: 0 20rpx; } .ad-card { width: 540rpx; max-width: 70vw; } .ad-top { width: 100%; display: flex; justify-content: space-between; align-items: center; box-sizing: border-box; margin-bottom: 8rpx; } .ad-time { color: #ddd; font-size: 24rpx; } .ad-actions { display: flex; align-items: center; gap: 16rpx; } .ad-btn { height: 44rpx; border-radius: 12rpx; padding: 0 28rpx; background: #eee; display: flex; justify-content: center; align-items: center; color: #333; font-size: 24rpx; } .ad-media { border-radius: 12rpx; height: 300rpx; width: 100%; position: relative; overflow: hidden; } .ad-badge { position: absolute; top: 2%; right: 3%; display: flex; align-items: center; } .ad-ops { width: 100%; height: 44rpx; display: flex; justify-content: flex-end; gap: 16rpx; margin-top: 10rpx; } .ad-op-btn { padding: 0 16rpx; height: 44rpx; background: rgba(255, 255, 255, 0.9); border-radius: 8rpx; display: flex; justify-content: center; align-items: center; color: #666; font-size: 20rpx; } .gold-text { background: -webkit-linear-gradient(#d89838, #ceb87e); -webkit-background-clip: text; -webkit-text-fill-color: transparent; display: inline-block; } .ad-meta { margin-top: 18rpx; color: #ddd; font-size: 24rpx; } </style>现在的问题中去掉所有的consumerId: consumerId.value,text<view :class="['chat-message', role]"> <view v-if="multiSelectMode && role === 'user'" class="select-wrap" @click.stop="toggleSelect"> <view :class="['select-circle', { selected }]"></view> </view> <image @click="onAvatarClick" class="avatar" :src="avatarSrc" mode="aspectFill" /> <view class="bubble-wrapper" :class="{ 'bubble-wrapper--media': isPureMediaMessage }" @touchstart.stop="handleTouchStart" @touchend="handleTouchEnd"> <view class="bubble" :class="{ typing: role === 'typing', 'bubble--media': isPureMediaMessage }" @tap.stop="onBubbleClick" @click.stop="onBubbleClick"> <template v-if="role === 'typing'"> <view @longpress="copyPlain(renderText)" class="typing-text">{{ renderText }}</view> <view class="dots"> <view v-for="n in 3" :key="n" class="dot" :style="{ animationDelay: n * 0.2 + 's' }" /> </view> </template> <template v-else-if="isAd && cards.length"> <view v-for="(card, idx) in cards" :key="card.adId || idx" class="ad-card"> <view class="ad-top"> <view class="ad-time">{{ nowHM }}</view> <view class="ad-actions"> <view v-if="card.mallGoodsGroupId" class="ad-btn" @click.stop="goProduct(card)">查看商品 </view> <view v-if="hasShop(card)" class="ad-btn" @click.stop="goShop(card)">进店</view> </view> </view> <view class="ad-media" @click="openFullscreen(card)"> <video v-if="card?.media?.isVideo" :src="fullUrl(card?.media?.playURL)" :controls="true" :show-center-play-btn="true" :show-fullscreen-btn="false" style="height: 100%; width: 100%; object-fit: cover" /> <image v-else :src="fullUrl(card?.media?.playURL)" mode="aspectFill" style="height: 100%; width: 100%; display: block" /> <view class="ad-badge"> <view style="font-size: 25rpx; color: #ccc">广告</view> <uni-icons :type="isopen ? 'down' : 'up'" color="#ffffff" size="20" /> </view> </view> <view class="ad-ops"> <view @click="copyMedia(card)" class="ad-op-btn">复制链接</view> <view @click="shareHint" class="ad-op-btn"><text class="gold-text">分享</text></view> </view> <view class="ad-meta"> <text>类型:{{ card.typeName ? card.typeName + '广告' : '广告' }}</text> <text style="margin-left: 20rpx">价格:¥{{ (Number(card.price) || 0).toFixed(2) }}</text> </view> </view> </template> <template v-else-if="isLocation"> <view class="loc-card" @click.stop="openLocationDetail" @tap.stop="openLocationDetail"> <view class="loc-name">{{ locationName }}</view> <view class="loc-addr">{{ locationAddress }}</view> <view class="loc-btn"><map style="width: 100%; height: 100%" :latitude="locationlatitude" :show-location="false" :longitude="locationlongitude" :scale="16" :markers="markers" @regionchange="onRegionChange"> <cover-view class="float-btn" @click.stop="openLocationDetail"></cover-view> </map> </view> </view> </template> <template v-else-if="isFileLike"> <view v-if="fileType === 1" class="media-image-wrap"> <image :src="displayFileUrl" class="media-image" mode="widthFix" @click.stop="onClickBurnFile('image')" @load="emit('media-ready')" @error="emit('media-ready')" /> <view v-if="isBurnMedia" class="burn-tag">阅后即焚</view> </view> <view v-else-if="fileType === 2" class="media-video-box"> <view v-if="isBurnMedia" class="burn-video-cover" @click.stop="onClickBurnFile('video')"> <image :src="BURN_MOSAIC_IMG" mode="aspectFill" style="width: 100%; height: 300rpx; border-radius: 18rpx" /> <view class="burn-play">▶</view> <view class="burn-tag burn-tag--abs">阅后即焚</view> </view> <view v-else-if="props.suspendVideo" class="video-scroll-mask"> <view class="video-scroll-mask__inner"> <view class="burn-play">▶</view> <text class="video-scroll-mask__text">滚动中已隐藏视频</text> </view> </view> <template v-else> <video :src="fileUrl" controls :show-center-play-btn="true" style="width: 100%; border-radius: 18rpx; display: block" @loadedmetadata="emit('media-ready')" @error="emit('media-ready')" /> </template> </view> <view v-else-if="fileType === 3" style="max-width: 520rpx"> <audio :src="fileUrl" controls style="width: 100%" /> <view v-if="fileShowName" style="margin-top: 8rpx; color: #ddd; font-size: 24rpx"> {{ fileShowName }} </view> </view> <view v-else-if="fileType === 4" style="display: flex; align-items: center; gap: 12rpx; max-width: 520rpx"> <uni-icons type="paperclip" size="22" /> <text style="color: #000" @click.stop="onClickBurnFile('file')"> {{ fileShowName || '文件' }} </text> <view v-if="isBurnMedia" class="burn-tag burn-tag--inline">阅后即焚</view> </view> </template> <template v-else-if="isForwardRecord"> <view class="forward-card" @tap.stop="openForwardDetail" @click.stop="openForwardDetail"> <view class="fc-title">{{ forwardTitle }}</view> <view v-for="(line, i) in forwardLines" :key="i" class="fc-line">{{ line }}</view> <view class="fc-count">聊天记录({{ forwardCount }}条)</view> </view> </template> <template v-else> <view v-if="hasQuote" class="quote-snippet"> <text class="quote-snippet-text">{{ quoteLine }}</text> </view> <text class="content">{{ renderText }}</text> </template> </view> </view> <view v-if="multiSelectMode && role !== 'user'" class="select-wrap" @click.stop="toggleSelect"> <view :class="['select-circle', { selected }]"></view> </view> <up-popup :show="showMenu" @close="closeMenu" mode="center" :closeable="true" :safeAreaInsetTop="true" closeIconPos="top-right" zIndex="99999" round="12"> <view class="menu-popup"> <view v-if="!isCollecting" class="menu-layer"> <view class="menu-row"> <view v-for="(item, index) of topMenuItems" :key="index" @click="onMenuClick(item)"> <up-button type="warning" style="height: 80rpx; width: 120rpx" :text="item.title" /> </view> </view> <view class="menu-row"> <view v-for="(item, index) of bottomMenuItems" :key="index" @click="onMenuClick(item)"> <up-button type="warning" style="height: 80rpx; width: 120rpx" :text="item.title" /> </view> </view> </view> <view v-else class="collect-layer"> <!-- ✅ 修复:这里传的是 castingSecret --> <up-button style="height: 80rpx; width: 186rpx" type="warning" @click="collectMessage(false)" text="咖信收藏" /> <up-button style="height: 80rpx; width: 186rpx" type="warning" @click="collectMessage(true)" text="咖密收藏" /> </view> </view> </up-popup> <up-popup :show="showForwardDetail" @close="showForwardDetail = false" mode="center" :closeable="true" :safeAreaInsetTop="true" closeIconPos="top-right" zIndex="99998" round="12"> <view class="forward-detail-popup"> <scroll-view scroll-y style="height: 70vh"> <view v-for="(it, idx) in forwardItems" :key="idx" :class="['fr-row', forwardAlign(it)]"> <view class="fr-body"> <text class="fr-name">{{ forwardSenderName(it) }}</text> <view class="fr-bubble"> <view v-if="forwardHasQuote(it)" class="fr-quote"> <text class="fr-quote-text">{{ forwardQuoteLine(it) }}</text> </view> <template v-if="forwardKind(it) === 'file'"> <image v-if="forwardFileType(it) === 1" :src="forwardFileUrl(it)" mode="widthFix" style="max-width: 420rpx; border-radius: 8rpx" @click.stop="previewImage(forwardFileUrl(it))" /> <view v-else class="fr-file-line"><text>{{ forwardPreview(it) }}</text></view> </template> <template v-else> <text class="fr-text">{{ forwardPreview(it) }}</text> </template> </view> </view> </view> </scroll-view> </view> </up-popup> <up-popup :show="burnVideoShow" @close="closeBurnVideo" mode="center" :closeable="true" :safeAreaInsetTop="true" closeIconPos="top-right" zIndex="100000" round="12"> <view style="width: 686rpx; background: #000; border-radius: 12rpx; overflow: hidden"> <video v-if="burnVideoUrl" :src="burnVideoUrl" controls style="width: 100%; height: 420rpx" /> </view> </up-popup> </view>
textrecrueitPostId: recruitPostId.value, recruitPostId: recruitPostId.value, consumerTalk: recruitConsumerTalk.value, content: '', type: TEXT_MESSAGE_TYPE, showName 这个字段recruitPostId: recruitPostId.value,没有这个字段 都去掉一下 然后历史消息回显气泡不显示之前的内容然后4.webSocket长链接接口
{“type”:”recrueit”,”id”:”xxxxxxxx”}json转成的字符串格式 处理咖募聊天
接受到正确的格式message后会向组内所有在线用户或发送消息,消息格式:
[
content:“XXX” 聊天内容
type:“5”(0 文字 1 图片 2 视频 3 声音4 文件 5 个人简历 6 答题 )
consumerTalk 是否是求职者发言
fileName:“xxx”
showName:“xxx”
resumeId:简历id
playURL:“xxx”
consumerId:"xxx" 发送者
createTime: 建立时间
]
是从这个recrueitTalkTakenotes 接口的type字段中获取的然后 修复图片发送不显示 然后再次进入聊天文字图片不正确显示 发送图片能显示像微信那个样就行
然后发送视频一直挑来跳去滚动中已隐藏视频也是个bug 滚动就显示视频的封面就行 然后修复所有的bug 这才是聊天接口的所有的字段
1.recrueitTalkTakenotes招募聊天
入口参数
String consumerId,人id
String recrueitPostId, 职位id
Boolean consumerTalk,是否是求职者发送
String content内容
Integer type 文件类型e
String showName 文件显示名称
recrueitFile 上传文件名
String resumeId 简历id
返回值
id
然后返回三个完整的页面返回zip 不管是文字还是图片视频 答题 都是走的formdata
我把 3 个完整文件整理成 zip 了:
这版主要按你的要求改了这些点:
recruitPostId 请求字段,统一只走 recrueitPostIdrecrueitTalkTakenotes 发送统一按 formData / uploadFile 处理,文字、图片、视频、答题都走同一套type=1 发,视频按 type=2 发,修复了图片发出去不显示、再次进入聊天图片文字错乱的问题type/content/playURL/showName/fileName 正常还原{\"type\":\"recrueit\",\"id\":\"xxx\"}[{ content, type, consumerTalk, fileName, showName, resumeId, playURL, consumerId, createTime }]KamuChatBubble 也一起做了视频展示稳定化,避免别处复用时继续出同类问题zip 里文件路径是:
src/store/Userecruitmentsharing.jssrc/pages/chat.vuesrc/components/KamuChatBubble.vue有一个地方我保留了兼容处理:
onLoad 里仍然兼容读取 option?.recruitPostId,但发送接口已经不再提交这个字段了,只是为了兼容旧页面跳转参数。