推送项目重构代码

This commit is contained in:
2026-05-30 15:32:57 +08:00
parent 3dafaceef2
commit a28ea44cab
53 changed files with 3525 additions and 731 deletions

View File

@@ -0,0 +1,270 @@
<template>
<div class="im-chat-panel" v-loading="loading">
<div v-if="errorMsg" class="im-error">
<i class="el-icon-warning-outline"></i>
{{ errorMsg }}
<el-button v-if="canRetry" type="text" @click="init">重试</el-button>
</div>
<div v-else class="im-layout">
<!-- 左侧会话列表 -->
<div class="conv-list">
<div v-if="!conversations.length && !loading" class="empty">暂无会话</div>
<div v-for="conv in conversations" :key="conv.conversationID"
class="conv-item" :class="{ active: current && current.conversationID === conv.conversationID }"
@click="openConv(conv)">
<div class="conv-avatar" :style="avatarStyle(conv)">{{ avatarText(conv) }}</div>
<div class="conv-body">
<div class="conv-title">
<span class="conv-name">{{ conv.showName || '未命名' }}</span>
<span class="conv-time">{{ formatTime(conv.latestMsgSendTime) }}</span>
</div>
<div class="conv-snippet">
<span class="snippet">{{ snippet(conv) }}</span>
<span v-if="conv.unreadCount > 0" class="badge">{{ conv.unreadCount > 99 ? '99+' : conv.unreadCount }}</span>
</div>
</div>
</div>
</div>
<!-- 右侧消息 -->
<div class="msg-pane">
<template v-if="current">
<div class="msg-header">{{ current.showName || '未命名' }}</div>
<div class="msg-list" ref="msgListRef">
<div v-for="m in messages" :key="m.clientMsgID"
class="msg-row" :class="{ mine: m.sendID === myUserId }">
<div class="msg-bubble">
<div class="msg-meta">{{ m.senderNickname || m.sendID }} · {{ formatTime(m.sendTime) }}</div>
<div class="msg-text">{{ renderText(m) }}</div>
</div>
</div>
<div v-if="!messages.length" class="empty">无消息</div>
</div>
<div class="msg-input">
<el-input v-model="draft" size="mini" placeholder="输入消息,回车发送"
@keyup.enter.native="send" />
<el-button type="primary" size="mini" :disabled="!draft.trim()" @click="send">发送</el-button>
</div>
</template>
<div v-else class="empty pick-conv">从左侧选择一个会话</div>
</div>
</div>
</div>
</template>
<script>
import { getImCredentials } from '@/api/system/im'
import { im, imBus, MessageType } from '@/utils/imClient'
export default {
name: 'ImChatPanel',
data () {
return {
loading: false,
errorMsg: '',
canRetry: false,
conversations: [],
current: null,
messages: [],
draft: '',
myUserId: ''
}
},
created () {
this.init()
imBus.$on('new-message', this.onNewMessage)
imBus.$on('conv-changed', this.refreshConversations)
imBus.$on('disconnected', () => { this.errorMsg = 'IM 断线,尝试重连…'; this.canRetry = true })
},
beforeDestroy () {
imBus.$off('new-message', this.onNewMessage)
imBus.$off('conv-changed', this.refreshConversations)
},
methods: {
async init () {
this.loading = true
this.errorMsg = ''
try {
const res = await getImCredentials()
if (!res || res.code !== 200 || !res.data) {
this.errorMsg = (res && res.msg) || '获取 IM 凭据失败'
this.canRetry = true
return
}
const cred = res.data
const ok = await im.login(cred)
if (!ok) {
const e = im.lastError
const detail = e && (e.errMsg || e.message || JSON.stringify(e))
this.errorMsg = '登录 IM 失败:' + (detail || '未知原因(查看 Console')
this.canRetry = true
return
}
this.myUserId = cred.imUserId
await this.refreshConversations()
} catch (e) {
this.errorMsg = e.message || '初始化失败'
this.canRetry = true
} finally {
this.loading = false
}
},
async refreshConversations () {
this.conversations = await im.getConversations(0, 50)
},
async openConv (conv) {
this.current = conv
this.messages = await im.getMessages(conv.conversationID, 30)
this.$nextTick(() => this.scrollBottom())
im.markRead(conv.conversationID)
},
async send () {
if (!this.current || !this.draft.trim()) return
const text = this.draft
this.draft = ''
try {
await im.sendText(this.current, text)
const newMessages = await im.getMessages(this.current.conversationID, 30)
this.messages = newMessages
this.$nextTick(() => this.scrollBottom())
} catch (e) {
this.$message.error('发送失败:' + (e.message || e))
}
},
onNewMessage (msg) {
// 当前会话的消息追加
if (this.current && msg && this.current.conversationID === msg.conversationID) {
this.messages.push(msg)
this.$nextTick(() => this.scrollBottom())
im.markRead(this.current.conversationID)
}
// 异步刷新会话列表(未读、最新消息)
this.refreshConversations()
},
scrollBottom () {
const el = this.$refs.msgListRef
if (el) el.scrollTop = el.scrollHeight
},
snippet (conv) {
const t = conv.latestMsg
if (!t) return ''
try {
const m = JSON.parse(t)
if (m.textElem) return m.textElem.content
if (m.pictureElem) return '[图片]'
if (m.voiceElem) return '[语音]'
if (m.fileElem) return '[文件]'
if (m.contentType === 110) return '[系统通知] ' + (m.customElem && JSON.parse(m.customElem.data || '{}').title || '')
return ''
} catch (e) { return '' }
},
renderText (m) {
if (!m) return ''
if (m.contentType === MessageType.TextMessage) return m.textElem && m.textElem.content
if (m.contentType === MessageType.PictureMessage) return '[图片]'
if (m.contentType === MessageType.VoiceMessage) return '[语音]'
if (m.contentType === MessageType.FileMessage) return '[文件] ' + (m.fileElem && m.fileElem.fileName || '')
if (m.contentType === 110) {
try {
const data = JSON.parse(m.customElem && m.customElem.data || '{}')
return '[系统通知] ' + (data.title || '') + ' · ' + (data.description || '')
} catch (e) { return '[系统通知]' }
}
return '[暂不支持的消息]'
},
avatarText (conv) {
const s = (conv.showName || '?').trim()
return s.charAt(s.length - 1) || '?'
},
avatarStyle (conv) {
const COLORS = ['#409eff', '#67c23a', '#e6a23c', '#f56c6c', '#909399', '#a06bf3']
const hash = (conv.conversationID || '').split('').reduce((a, c) => a + c.charCodeAt(0), 0)
return { background: COLORS[hash % COLORS.length] }
},
formatTime (t) {
if (!t) return ''
const d = new Date(typeof t === 'number' && t < 1e12 ? t * 1000 : t)
const now = new Date()
if (d.toDateString() === now.toDateString()) {
return d.getHours().toString().padStart(2, '0') + ':' + d.getMinutes().toString().padStart(2, '0')
}
return (d.getMonth() + 1) + '/' + d.getDate()
}
}
}
</script>
<style lang="scss" scoped>
.im-chat-panel { height: 100%; min-height: 240px; font-size: 12px; }
.im-error {
padding: 24px; text-align: center; color: #f56c6c;
i { font-size: 24px; display: block; margin-bottom: 6px; }
}
.im-layout { display: flex; height: 100%; gap: 8px; }
.conv-list {
flex: 0 0 38%;
border-right: 1px solid #ebeef5;
overflow-y: auto;
padding-right: 4px;
}
.empty { color: #c0c4cc; text-align: center; padding: 24px 0; }
.pick-conv { padding-top: 80px; }
.conv-item {
display: flex; gap: 6px; padding: 6px 4px; cursor: pointer;
border-radius: 4px;
transition: background .15s;
&:hover { background: #f5f7fa; }
&.active { background: #ecf5ff; }
}
.conv-avatar {
flex: 0 0 32px; width: 32px; height: 32px; border-radius: 50%;
color: #fff; font-size: 13px; font-weight: 600;
display: flex; align-items: center; justify-content: center;
}
.conv-body { flex: 1; min-width: 0; }
.conv-title {
display: flex; justify-content: space-between; align-items: center;
.conv-name { font-weight: 600; color: #303133; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.conv-time { color: #909399; font-size: 10px; flex-shrink: 0; margin-left: 4px; }
}
.conv-snippet {
display: flex; justify-content: space-between; align-items: center;
.snippet { color: #909399; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.badge {
background: #f56c6c; color: #fff;
border-radius: 9px; padding: 0 5px;
font-size: 10px; line-height: 14px; height: 14px; min-width: 14px;
text-align: center; flex-shrink: 0; margin-left: 4px;
}
}
.msg-pane {
flex: 1; display: flex; flex-direction: column; min-width: 0;
}
.msg-header {
padding: 6px 8px; border-bottom: 1px solid #ebeef5;
font-weight: 600; color: #303133;
}
.msg-list {
flex: 1; overflow-y: auto; padding: 8px 4px;
}
.msg-row {
margin-bottom: 6px;
display: flex;
}
.msg-row.mine { justify-content: flex-end; }
.msg-bubble {
max-width: 80%;
background: #f0f0f0;
border-radius: 4px;
padding: 4px 8px;
}
.msg-row.mine .msg-bubble { background: #ecf5ff; }
.msg-meta { color: #909399; font-size: 10px; margin-bottom: 2px; }
.msg-text { color: #303133; word-break: break-word; }
.msg-input {
display: flex; gap: 4px; padding: 6px 4px;
border-top: 1px solid #ebeef5;
}
.msg-input .el-input { flex: 1; }
</style>

View File

@@ -0,0 +1,143 @@
<template>
<div class="my-progress" v-loading="loading">
<div class="tab-row">
<span :class="['tab', filter === 'all' ? 'active' : '']" @click="setFilter('all')">
全部 <i class="cnt">{{ totalAll }}</i>
</span>
<span :class="['tab', filter === 'delayed' ? 'active' : '']" @click="setFilter('delayed')">
延期 <i class="cnt delayed">{{ totalDelayed }}</i>
</span>
<span :class="['tab', filter === 'soon' ? 'active' : '']" @click="setFilter('soon')">
将到期 <i class="cnt soon">{{ totalSoon }}</i>
</span>
</div>
<ul class="step-list">
<li v-for="row in displayList" :key="row.trackId" @click="goToProject(row)">
<div class="step-line1">
<span class="step-name">{{ row.stepName || '(未命名步骤)' }}</span>
<el-tag v-if="isDelayed(row)" type="danger" size="mini" effect="plain"> {{ delayDays(row) }}d</el-tag>
<el-tag v-else-if="isSoon(row)" type="warning" size="mini" effect="plain"> {{ leftDays(row) }}d</el-tag>
</div>
<div class="step-line2">
<span class="proj">{{ row.projectName || '—' }}</span>
<span class="end">{{ formatDate(row.planEnd) }}</span>
</div>
</li>
<li v-if="!displayList.length && !loading" class="empty">没有待做的步骤 🎉</li>
</ul>
</div>
</template>
<script>
import { listMyPage } from '@/api/oa/projectScheduleStep'
export default {
name: 'MyProgressList',
data () {
return {
loading: false,
list: [],
filter: 'all'
}
},
computed: {
totalAll () { return this.list.length },
totalDelayed () { return this.list.filter(this.isDelayed).length },
totalSoon () { return this.list.filter(this.isSoon).length },
displayList () {
if (this.filter === 'delayed') return this.list.filter(this.isDelayed)
if (this.filter === 'soon') return this.list.filter(this.isSoon)
// 默认按 延期 → 将到期 → 其他 排序
const score = r => this.isDelayed(r) ? 0 : (this.isSoon(r) ? 1 : 2)
return [...this.list].sort((a, b) => score(a) - score(b))
}
},
created () { this.fetch() },
methods: {
fetch () {
this.loading = true
listMyPage({ pageNum: 1, pageSize: 100, status: 0 })
.then(res => { this.list = res.rows || [] })
.finally(() => { this.loading = false })
},
setFilter (f) { this.filter = f },
daysUntil (date) {
if (!date) return null
const d = new Date(date); d.setHours(0, 0, 0, 0)
const today = new Date(); today.setHours(0, 0, 0, 0)
return Math.floor((d - today) / 86400000)
},
isDelayed (r) {
const n = this.daysUntil(r.planEnd)
return n !== null && n < 0 && r.status !== 2
},
isSoon (r) {
const n = this.daysUntil(r.planEnd)
return n !== null && n >= 0 && n <= 3 && r.status !== 2
},
delayDays (r) { return Math.abs(this.daysUntil(r.planEnd) || 0) },
leftDays (r) { return this.daysUntil(r.planEnd) || 0 },
formatDate (t) {
if (!t) return '—'
const d = new Date(t)
return `${d.getMonth() + 1}/${d.getDate()}`
},
goToProject (row) {
this.$router.push({
path: '/step/files',
query: { scheduleId: String(row.scheduleId || ''), trackId: String(row.trackId || '') }
})
}
}
}
</script>
<style lang="scss" scoped>
.my-progress { font-size: 12px; height: 100%; display: flex; flex-direction: column; }
.tab-row { display: flex; gap: 4px; padding-bottom: 6px; border-bottom: 1px solid #f0f0f0; margin-bottom: 4px; }
.tab {
padding: 2px 8px;
cursor: pointer;
color: #606266;
border-radius: 3px;
&.active { background: #ecf5ff; color: #409eff; font-weight: 600; }
.cnt {
display: inline-block;
background: #e4e7ed;
color: #606266;
padding: 0 5px;
margin-left: 3px;
border-radius: 8px;
font-size: 10px;
font-style: normal;
&.delayed { background: #fef0f0; color: #f56c6c; }
&.soon { background: #fdf6ec; color: #e6a23c; }
}
}
.step-list {
list-style: none;
padding: 0;
margin: 0;
flex: 1;
overflow-y: auto;
li {
padding: 6px 4px;
cursor: pointer;
border-bottom: 1px dashed #f0f0f0;
transition: background .15s;
&:hover { background: #f5f7fa; }
}
}
.step-line1 {
display: flex; align-items: center; justify-content: space-between;
gap: 4px;
.step-name { font-weight: 600; color: #303133; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
}
.step-line2 {
display: flex; justify-content: space-between; color: #909399;
margin-top: 2px;
.proj { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 70%; }
.end { font-size: 11px; }
}
.empty { text-align: center; color: #c0c4cc; padding: 32px 0; }
</style>