提交
This commit is contained in:
1 parent
8248e5ef15
commit
b9b4cc2673
57 files changed
+42800
-30473
No files matched your search
@@ -0,0 +1,196 @@
|
||||
<!-- 评论附件缩略图:文字区分「引用 / 上传」;预览/下载/移除为图标 -->
|
||||
<template>
|
||||
<div class="oa-cmt-files">
|
||||
<div v-for="(f, idx) in files" :key="`${f.url}-${idx}`" class="oa-cmt-files__item">
|
||||
<div class="oa-cmt-files__thumb" :title="f.fileName || '附件'">
|
||||
<img v-if="isImage(f)" :src="fullUrl(f.url)" :alt="f.fileName || '附件'" />
|
||||
<el-icon v-else-if="isVideo(f)" :size="16"><VideoPlay /></el-icon>
|
||||
<el-icon v-else :size="16"><Document /></el-icon>
|
||||
<span class="oa-cmt-files__badge">{{ sourceLabel(f) }}</span>
|
||||
<span class="oa-cmt-files__actions">
|
||||
<button type="button" title="预览" aria-label="预览" @click.stop="preview(f)">
|
||||
<el-icon :size="14"><ZoomIn /></el-icon>
|
||||
</button>
|
||||
<button type="button" title="下载" aria-label="下载" @click.stop="download(f)">
|
||||
<el-icon :size="14"><Download /></el-icon>
|
||||
</button>
|
||||
<button
|
||||
v-if="removable"
|
||||
type="button"
|
||||
title="移除"
|
||||
aria-label="移除"
|
||||
@click.stop="emit('remove', idx)"
|
||||
>
|
||||
<el-icon :size="14"><Delete /></el-icon>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="oa-cmt-files__name" :title="f.fileName || '附件'">{{ f.fileName || '附件' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-dialog
|
||||
v-model="previewOpen"
|
||||
title="预览"
|
||||
width="720px"
|
||||
append-to-body
|
||||
align-center
|
||||
modal-class="protocol-dialog-mask"
|
||||
>
|
||||
<img v-if="previewImage" class="oa-cmt-files__preview" :src="previewImage" alt="" />
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Delete, Document, Download, VideoPlay, ZoomIn } from '@element-plus/icons-vue'
|
||||
import type { OaCommentAttachmentRef } from '@/api/oaApproval'
|
||||
import { attachmentFullUrl } from '@/composables/useAttachmentActions'
|
||||
import { downloadAttachmentFile } from '@/utils/download-attachment'
|
||||
import { isOaUploadImageFile, isOaUploadVideoFile } from '@/utils/oa-upload-file-type'
|
||||
|
||||
defineOptions({ name: 'OaCommentFileCards' })
|
||||
|
||||
defineProps<{
|
||||
files: OaCommentAttachmentRef[]
|
||||
removable?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ remove: [index: number] }>()
|
||||
|
||||
const previewOpen = ref(false)
|
||||
const previewImage = ref('')
|
||||
|
||||
function asFile(f: OaCommentAttachmentRef) {
|
||||
return { name: f.fileName, url: f.url }
|
||||
}
|
||||
|
||||
function isImage(f: OaCommentAttachmentRef) {
|
||||
return isOaUploadImageFile(asFile(f))
|
||||
}
|
||||
|
||||
function isVideo(f: OaCommentAttachmentRef) {
|
||||
return isOaUploadVideoFile(asFile(f))
|
||||
}
|
||||
|
||||
function fullUrl(url: string) {
|
||||
return attachmentFullUrl(url) || url
|
||||
}
|
||||
|
||||
function sourceLabel(f: OaCommentAttachmentRef) {
|
||||
return f.source === 'upload' ? '上传' : '引用'
|
||||
}
|
||||
|
||||
function preview(f: OaCommentAttachmentRef) {
|
||||
const url = fullUrl(f.url)
|
||||
if (!url) return
|
||||
if (isImage(f)) {
|
||||
previewImage.value = url
|
||||
previewOpen.value = true
|
||||
return
|
||||
}
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
|
||||
function download(f: OaCommentAttachmentRef) {
|
||||
void downloadAttachmentFile(
|
||||
{ fileUrl: f.url, fileName: f.fileName },
|
||||
{ fullFileUrl: (u) => attachmentFullUrl(u) }
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.oa-cmt-files {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.oa-cmt-files__item {
|
||||
width: 88px;
|
||||
}
|
||||
|
||||
.oa-cmt-files__thumb {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 88px;
|
||||
height: 64px;
|
||||
overflow: hidden;
|
||||
color: #64748b;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
|
||||
img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
&:hover .oa-cmt-files__actions {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.oa-cmt-files__badge {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
z-index: 1;
|
||||
padding: 0 4px;
|
||||
color: #334155;
|
||||
font-size: 10px;
|
||||
line-height: 16px;
|
||||
background: rgb(255 255 255 / 92%);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.oa-cmt-files__actions {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
background: rgb(15 23 42 / 45%);
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
|
||||
button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
background: rgb(255 255 255 / 18%);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
|
||||
&:hover {
|
||||
background: var(--primary, #10b981);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.oa-cmt-files__name {
|
||||
margin-top: 4px;
|
||||
color: var(--mint-text, #334155);
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
word-break: break-all;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.oa-cmt-files__preview {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -1,89 +1,81 @@
|
||||
<!-- OA 审批详情 · 实例讨论评论(≠ 审批意见) -->
|
||||
<!-- OA 审批详情 · 评论输入(列表已并入审批进度时间线) -->
|
||||
<template>
|
||||
<section class="oa-comment-panel">
|
||||
<h3 class="oa-comment-panel__title">
|
||||
<el-icon class="oa-comment-panel__title-icon"><ChatDotRound /></el-icon>
|
||||
评论
|
||||
</h3>
|
||||
|
||||
<div v-if="!comments.length" class="oa-comment-panel__empty">暂无评论</div>
|
||||
<ul v-else class="oa-comment-panel__list">
|
||||
<li v-for="c in comments" :key="c.id" class="oa-comment-panel__item">
|
||||
<template v-if="c.status === 'revoked'">
|
||||
<span class="oa-comment-panel__revoked">
|
||||
{{ c.createByName || c.createBy }} 撤回了一条评论
|
||||
<span v-if="c.revokedTime" class="oa-comment-panel__time">{{ c.revokedTime }}</span>
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="oa-comment-panel__meta">
|
||||
<strong>{{ c.createByName || c.createBy }}</strong>
|
||||
<span class="oa-comment-panel__time">{{ c.createTime }}</span>
|
||||
<el-button
|
||||
v-if="c.canRevoke"
|
||||
link
|
||||
type="danger"
|
||||
:loading="revokingId === c.id"
|
||||
@click="onRevoke(c)"
|
||||
>
|
||||
<el-icon><RefreshLeft /></el-icon>
|
||||
撤回
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="oa-comment-panel__content">{{ c.content }}</div>
|
||||
<div v-if="c.attachmentRefs?.length" class="oa-comment-panel__refs">
|
||||
<el-tag
|
||||
v-for="(f, idx) in c.attachmentRefs"
|
||||
:key="`${c.id}-${idx}-${f.url}`"
|
||||
class="oa-comment-panel__ref"
|
||||
size="small"
|
||||
effect="plain"
|
||||
@click="openUrl(f.url)"
|
||||
>
|
||||
{{ f.fileName || f.url }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div v-if="instanceStatus === 'running'" class="oa-comment-panel__composer">
|
||||
<el-input
|
||||
v-model="draft"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
maxlength="1024"
|
||||
show-word-limit
|
||||
placeholder="填写评论"
|
||||
/>
|
||||
<div v-if="picked.length" class="oa-comment-panel__picked">
|
||||
<section v-if="instanceStatus === 'running'" class="oa-comment-panel">
|
||||
<div class="oa-comment-panel__composer">
|
||||
<div class="oa-comment-panel__editor">
|
||||
<el-input
|
||||
ref="inputRef"
|
||||
v-model="draft"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
maxlength="1024"
|
||||
show-word-limit
|
||||
placeholder="填写评论,输入 @ 可提到人员"
|
||||
@focus="onDraftFocus"
|
||||
@input="onDraftInput"
|
||||
@keydown="onDraftKeydown"
|
||||
/>
|
||||
<div v-if="atOpen" class="oa-comment-at" v-loading="atLoading">
|
||||
<button
|
||||
v-for="(u, idx) in atOptions"
|
||||
:key="u.id"
|
||||
type="button"
|
||||
class="oa-comment-at__item"
|
||||
:class="{ 'is-active': idx === atActive }"
|
||||
@mousedown.prevent="pickAtUser(u)"
|
||||
>
|
||||
<span class="oa-comment-at__name">{{ u.name }}</span>
|
||||
</button>
|
||||
<div v-if="!atLoading && !atOptions.length" class="oa-comment-at__empty">未找到人员</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="mentionUsers.length" class="oa-comment-panel__picked">
|
||||
<el-tag
|
||||
v-for="(f, idx) in picked"
|
||||
:key="`${f.url}-${idx}`"
|
||||
v-for="u in mentionUsers"
|
||||
:key="u.id"
|
||||
closable
|
||||
size="small"
|
||||
effect="plain"
|
||||
@close="picked.splice(idx, 1)"
|
||||
@close="removeMention(u.id)"
|
||||
>
|
||||
{{ f.fileName || f.url }}
|
||||
@{{ u.name }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div v-if="picked.length" class="oa-comment-panel__files">
|
||||
<OaCommentFileCards :files="picked" removable @remove="removePicked" />
|
||||
</div>
|
||||
<div class="oa-comment-panel__actions">
|
||||
<el-button class="btn-ghost" @click="openMention">
|
||||
<el-icon><User /></el-icon>
|
||||
@人员
|
||||
</el-button>
|
||||
<el-button class="btn-ghost" @click="openAttach">
|
||||
<el-icon><Paperclip /></el-icon>
|
||||
引用附件
|
||||
</el-button>
|
||||
<el-button class="btn-ghost" :loading="uploading" @click="pickUpload">
|
||||
<el-icon><Upload /></el-icon>
|
||||
上传附件
|
||||
</el-button>
|
||||
<el-button type="primary" class="btn-main" :loading="sending" @click="onSend">
|
||||
<el-icon><Promotion /></el-icon>
|
||||
发送
|
||||
</el-button>
|
||||
</div>
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
class="oa-comment-file-input"
|
||||
type="file"
|
||||
multiple
|
||||
:accept="OA_UPLOAD_ACCEPT"
|
||||
@change="onUploadFiles"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="attachVisible"
|
||||
title="引用表单附件"
|
||||
width="560px"
|
||||
width="640px"
|
||||
class="dialog-form-layout"
|
||||
align-center
|
||||
append-to-body
|
||||
@@ -95,21 +87,43 @@
|
||||
<div v-if="!attachLoading && !attachGroups.length" class="oa-comment-attach__empty">
|
||||
暂无可引用附件
|
||||
</div>
|
||||
<el-checkbox-group v-else v-model="selectedUrls" class="oa-comment-attach__groups">
|
||||
<div v-else class="oa-comment-attach__groups">
|
||||
<div v-for="g in attachGroups" :key="g.fieldKey" class="oa-comment-attach__group">
|
||||
<div class="oa-comment-attach__label">{{ g.fieldLabel || g.fieldKey }}</div>
|
||||
<div class="oa-comment-attach__files">
|
||||
<el-checkbox
|
||||
<button
|
||||
v-for="f in g.files"
|
||||
:key="f.url"
|
||||
:value="f.url"
|
||||
class="oa-comment-attach__file"
|
||||
type="button"
|
||||
class="oa-comment-attach__card"
|
||||
:class="{ 'is-selected': isSelected(f.url) }"
|
||||
:title="f.fileName || f.url"
|
||||
@click="toggleUrl(f.url)"
|
||||
>
|
||||
{{ f.fileName || f.url }}
|
||||
</el-checkbox>
|
||||
<div class="oa-comment-attach__thumb">
|
||||
<img
|
||||
v-if="isImageRef(f)"
|
||||
:src="thumbSrc(f.url)"
|
||||
:alt="f.fileName || '附件'"
|
||||
/>
|
||||
<div
|
||||
v-else-if="isVideoRef(f)"
|
||||
class="oa-comment-attach__icon oa-comment-attach__icon--video"
|
||||
>
|
||||
<el-icon :size="16"><VideoPlay /></el-icon>
|
||||
</div>
|
||||
<div v-else class="oa-comment-attach__icon oa-comment-attach__icon--doc">
|
||||
<el-icon :size="16"><Document /></el-icon>
|
||||
</div>
|
||||
<span v-if="isSelected(f.url)" class="oa-comment-attach__check">
|
||||
<el-icon :size="12"><Check /></el-icon>
|
||||
</span>
|
||||
</div>
|
||||
<div class="oa-comment-attach__name">{{ f.fileName || f.url }}</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</el-checkbox-group>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button class="btn-ghost" @click="attachVisible = false">
|
||||
@@ -127,46 +141,98 @@
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog
|
||||
v-model="mentionVisible"
|
||||
title="提到人员"
|
||||
width="480px"
|
||||
class="dialog-form-layout"
|
||||
align-center
|
||||
append-to-body
|
||||
modal-class="protocol-dialog-mask"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<el-select
|
||||
v-model="mentionDraftIds"
|
||||
multiple
|
||||
filterable
|
||||
:loading="userLoading"
|
||||
placeholder="搜索姓名"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="u in dialogUserOptions"
|
||||
:key="u.id"
|
||||
:label="u.name"
|
||||
:value="u.id"
|
||||
/>
|
||||
</el-select>
|
||||
<template #footer>
|
||||
<el-button @click="mentionVisible = false">
|
||||
<el-icon><Close /></el-icon>
|
||||
取消
|
||||
</el-button>
|
||||
<el-button type="primary" @click="confirmMention">
|
||||
<el-icon><Check /></el-icon>
|
||||
确定
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ChatDotRound,
|
||||
Check,
|
||||
Close,
|
||||
Paperclip,
|
||||
Promotion,
|
||||
RefreshLeft
|
||||
} from '@element-plus/icons-vue'
|
||||
import { Check, Close, Document, Paperclip, Promotion, Upload, User, VideoPlay } from '@element-plus/icons-vue'
|
||||
import { fetchUserOptions } from '@/api/admin'
|
||||
import { fetchUploadAttachment } from '@/api/business'
|
||||
import {
|
||||
fetchCreateOaComment,
|
||||
fetchOaFormAttachments,
|
||||
fetchRevokeOaComment,
|
||||
type OaCommentAttachmentRef,
|
||||
type OaInstanceComment
|
||||
type OaCommentAttachmentRef
|
||||
} from '@/api/oaApproval'
|
||||
import OaCommentFileCards from '@/components/oa-approval/OaCommentFileCards.vue'
|
||||
import { attachmentFullUrl } from '@/composables/useAttachmentActions'
|
||||
import { isOaUploadImageFile, isOaUploadVideoFile } from '@/utils/oa-upload-file-type'
|
||||
|
||||
defineOptions({ name: 'OaCommentPanel' })
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
instanceId: string
|
||||
instanceStatus: string
|
||||
comments?: OaInstanceComment[]
|
||||
}>(),
|
||||
{
|
||||
comments: () => []
|
||||
}
|
||||
)
|
||||
type MentionUser = { id: string; name: string }
|
||||
|
||||
const props = defineProps<{
|
||||
instanceId: string
|
||||
instanceStatus: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ refresh: [] }>()
|
||||
|
||||
const inputRef = ref<any>(null)
|
||||
const draft = ref('')
|
||||
const picked = ref<OaCommentAttachmentRef[]>([])
|
||||
const sending = ref(false)
|
||||
const revokingId = ref('')
|
||||
const uploading = ref(false)
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||
const OA_UPLOAD_ACCEPT = 'image/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.zip,.rar'
|
||||
const mentionUsers = ref<MentionUser[]>([])
|
||||
const mentionVisible = ref(false)
|
||||
const mentionDraftIds = ref<string[]>([])
|
||||
const userLoading = ref(false)
|
||||
|
||||
/** 租户内启用用户缓存:@ 与弹窗共用,本地过滤,避免每次请求 */
|
||||
const allMentionUsers = ref<MentionUser[]>([])
|
||||
let userCachePromise: Promise<void> | null = null
|
||||
|
||||
const dialogUserOptions = computed(() => {
|
||||
const map = new Map(allMentionUsers.value.map((u) => [u.id, u]))
|
||||
for (const u of mentionUsers.value) map.set(u.id, u)
|
||||
return [...map.values()]
|
||||
})
|
||||
|
||||
const atOpen = ref(false)
|
||||
const atQuery = ref('')
|
||||
const atStart = ref(-1)
|
||||
const atActive = ref(0)
|
||||
const atOptions = ref<MentionUser[]>([])
|
||||
const atLoading = ref(false)
|
||||
|
||||
const attachVisible = ref(false)
|
||||
const attachLoading = ref(false)
|
||||
@@ -175,15 +241,238 @@
|
||||
>([])
|
||||
const selectedUrls = ref<string[]>([])
|
||||
|
||||
function openUrl(url: string) {
|
||||
const full = attachmentFullUrl(url) || url
|
||||
if (full) window.open(full, '_blank')
|
||||
function getTextareaEl(): HTMLTextAreaElement | null {
|
||||
const inst = inputRef.value
|
||||
if (!inst) return null
|
||||
if (inst.textarea instanceof HTMLTextAreaElement) return inst.textarea
|
||||
const el = inst.$el as HTMLElement | undefined
|
||||
return (el?.querySelector?.('textarea') as HTMLTextAreaElement | null) || null
|
||||
}
|
||||
|
||||
async function ensureUserCache() {
|
||||
if (allMentionUsers.value.length) return
|
||||
if (userCachePromise) return userCachePromise
|
||||
userCachePromise = (async () => {
|
||||
try {
|
||||
const res = await fetchUserOptions()
|
||||
const list = Array.isArray(res) ? res : []
|
||||
const next: MentionUser[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const u of list) {
|
||||
const id = String(u?.value || '').trim()
|
||||
if (!id || seen.has(id)) continue
|
||||
seen.add(id)
|
||||
const name = String(u?.label || id).trim() || id
|
||||
next.push({ id, name })
|
||||
}
|
||||
allMentionUsers.value = next
|
||||
} catch {
|
||||
allMentionUsers.value = []
|
||||
} finally {
|
||||
userCachePromise = null
|
||||
}
|
||||
})()
|
||||
return userCachePromise
|
||||
}
|
||||
|
||||
function filterMentionUsers(keyword: string, excludeIds?: Set<string>): MentionUser[] {
|
||||
const q = (keyword || '').trim().toLowerCase()
|
||||
const out: MentionUser[] = []
|
||||
for (const u of allMentionUsers.value) {
|
||||
if (excludeIds?.has(u.id)) continue
|
||||
if (q && !u.name.toLowerCase().includes(q) && !u.id.toLowerCase().includes(q)) {
|
||||
continue
|
||||
}
|
||||
out.push(u)
|
||||
if (out.length >= 30) break
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function closeAtSuggest() {
|
||||
atOpen.value = false
|
||||
atQuery.value = ''
|
||||
atStart.value = -1
|
||||
atActive.value = 0
|
||||
atOptions.value = []
|
||||
}
|
||||
|
||||
async function applyAtSuggest(query: string) {
|
||||
const needFetch = !allMentionUsers.value.length
|
||||
if (needFetch) atLoading.value = true
|
||||
try {
|
||||
await ensureUserCache()
|
||||
if (!atOpen.value) return
|
||||
const pickedIds = new Set(mentionUsers.value.map((u) => u.id))
|
||||
atOptions.value = filterMentionUsers(query, pickedIds)
|
||||
if (atActive.value >= atOptions.value.length) {
|
||||
atActive.value = Math.max(0, atOptions.value.length - 1)
|
||||
}
|
||||
} finally {
|
||||
atLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function detectAtSuggest() {
|
||||
const ta = getTextareaEl()
|
||||
if (!ta) {
|
||||
closeAtSuggest()
|
||||
return
|
||||
}
|
||||
const pos = ta.selectionStart ?? 0
|
||||
const before = draft.value.slice(0, pos)
|
||||
const matched = before.match(/(?:^|[\s\n])(@([^\s@]*))$/)
|
||||
if (!matched) {
|
||||
closeAtSuggest()
|
||||
return
|
||||
}
|
||||
const token = matched[1] || ''
|
||||
const query = matched[2] || ''
|
||||
atStart.value = before.length - token.length
|
||||
atQuery.value = query
|
||||
atOpen.value = true
|
||||
atActive.value = 0
|
||||
// 有缓存则同步过滤;无缓存才拉一次
|
||||
if (allMentionUsers.value.length) {
|
||||
const pickedIds = new Set(mentionUsers.value.map((u) => u.id))
|
||||
atOptions.value = filterMentionUsers(query, pickedIds)
|
||||
} else {
|
||||
void applyAtSuggest(query)
|
||||
}
|
||||
}
|
||||
|
||||
function onDraftInput() {
|
||||
nextTick(() => detectAtSuggest())
|
||||
}
|
||||
|
||||
function onDraftFocus() {
|
||||
void ensureUserCache()
|
||||
}
|
||||
|
||||
function onDraftKeydown(e: KeyboardEvent) {
|
||||
if (!atOpen.value) return
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
if (!atOptions.value.length) return
|
||||
atActive.value = (atActive.value + 1) % atOptions.value.length
|
||||
return
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
if (!atOptions.value.length) return
|
||||
atActive.value = (atActive.value - 1 + atOptions.value.length) % atOptions.value.length
|
||||
return
|
||||
}
|
||||
if (e.key === 'Enter' || e.key === 'Tab') {
|
||||
const u = atOptions.value[atActive.value]
|
||||
if (!u) return
|
||||
e.preventDefault()
|
||||
pickAtUser(u)
|
||||
return
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
closeAtSuggest()
|
||||
}
|
||||
}
|
||||
|
||||
function addMentionUser(u: MentionUser) {
|
||||
if (mentionUsers.value.some((x) => x.id === u.id)) return true
|
||||
if (mentionUsers.value.length >= 20) {
|
||||
ElMessage.warning('一次最多@20人')
|
||||
return false
|
||||
}
|
||||
mentionUsers.value.push(u)
|
||||
return true
|
||||
}
|
||||
|
||||
function pickAtUser(u: MentionUser) {
|
||||
if (!addMentionUser(u)) return
|
||||
const start = atStart.value
|
||||
if (start < 0) {
|
||||
closeAtSuggest()
|
||||
return
|
||||
}
|
||||
const ta = getTextareaEl()
|
||||
const end = ta?.selectionStart ?? draft.value.length
|
||||
const insert = `@${u.name} `
|
||||
draft.value = `${draft.value.slice(0, start)}${insert}${draft.value.slice(end)}`
|
||||
closeAtSuggest()
|
||||
nextTick(() => {
|
||||
const el = getTextareaEl()
|
||||
if (!el) return
|
||||
const pos = start + insert.length
|
||||
el.focus()
|
||||
el.setSelectionRange(pos, pos)
|
||||
})
|
||||
}
|
||||
|
||||
function fileLike(f: OaCommentAttachmentRef) {
|
||||
return { url: f.url, name: f.fileName }
|
||||
}
|
||||
|
||||
function isImageRef(f: OaCommentAttachmentRef) {
|
||||
return isOaUploadImageFile(fileLike(f))
|
||||
}
|
||||
|
||||
function isVideoRef(f: OaCommentAttachmentRef) {
|
||||
return isOaUploadVideoFile(fileLike(f))
|
||||
}
|
||||
|
||||
function thumbSrc(url: string) {
|
||||
return attachmentFullUrl(url) || url
|
||||
}
|
||||
|
||||
function isSelected(url: string) {
|
||||
return selectedUrls.value.includes(url)
|
||||
}
|
||||
|
||||
function toggleUrl(url: string) {
|
||||
const u = String(url || '').trim()
|
||||
if (!u) return
|
||||
const idx = selectedUrls.value.indexOf(u)
|
||||
if (idx >= 0) selectedUrls.value.splice(idx, 1)
|
||||
else selectedUrls.value.push(u)
|
||||
}
|
||||
|
||||
function removeMention(id: string) {
|
||||
mentionUsers.value = mentionUsers.value.filter((u) => u.id !== id)
|
||||
}
|
||||
|
||||
async function openMention() {
|
||||
closeAtSuggest()
|
||||
mentionDraftIds.value = mentionUsers.value.map((u) => u.id)
|
||||
mentionVisible.value = true
|
||||
userLoading.value = !allMentionUsers.value.length
|
||||
try {
|
||||
await ensureUserCache()
|
||||
} finally {
|
||||
userLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmMention() {
|
||||
const map = new Map(allMentionUsers.value.map((u) => [u.id, u.name]))
|
||||
for (const u of mentionUsers.value) map.set(u.id, u.name)
|
||||
const next = mentionDraftIds.value
|
||||
.map((id) => ({ id, name: map.get(id) || id }))
|
||||
.slice(0, 20)
|
||||
mentionUsers.value = next
|
||||
for (const u of next) {
|
||||
const token = `@${u.name}`
|
||||
if (!draft.value.includes(token)) {
|
||||
draft.value = `${draft.value.trimEnd()}${draft.value.trim() ? ' ' : ''}${token} `
|
||||
}
|
||||
}
|
||||
mentionVisible.value = false
|
||||
}
|
||||
|
||||
async function openAttach() {
|
||||
attachVisible.value = true
|
||||
attachLoading.value = true
|
||||
selectedUrls.value = picked.value.map((f) => f.url)
|
||||
selectedUrls.value = picked.value
|
||||
.filter((f) => f.source !== 'upload')
|
||||
.map((f) => f.url)
|
||||
try {
|
||||
const res = await fetchOaFormAttachments(props.instanceId)
|
||||
const items = res?.items || []
|
||||
@@ -206,29 +495,76 @@
|
||||
if (f?.url) map.set(f.url, f)
|
||||
}
|
||||
}
|
||||
const next: OaCommentAttachmentRef[] = []
|
||||
const cited: OaCommentAttachmentRef[] = []
|
||||
for (const url of selectedUrls.value) {
|
||||
const found = map.get(url) || picked.value.find((p) => p.url === url)
|
||||
if (found) next.push({ ...found })
|
||||
const found = map.get(url) || picked.value.find((p) => p.url === url && p.source !== 'upload')
|
||||
if (found) cited.push({ ...found, source: 'form' })
|
||||
}
|
||||
picked.value = next
|
||||
const uploads = picked.value.filter((f) => f.source === 'upload')
|
||||
picked.value = [...cited, ...uploads]
|
||||
attachVisible.value = false
|
||||
}
|
||||
|
||||
function removePicked(index: number) {
|
||||
picked.value.splice(index, 1)
|
||||
}
|
||||
|
||||
function pickUpload() {
|
||||
fileInputRef.value?.click()
|
||||
}
|
||||
|
||||
async function onUploadFiles(ev: Event) {
|
||||
const input = ev.target as HTMLInputElement
|
||||
const files = Array.from(input.files || [])
|
||||
input.value = ''
|
||||
if (!files.length) return
|
||||
uploading.value = true
|
||||
try {
|
||||
for (const file of files) {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
fd.append('folder', 'oa-approval')
|
||||
const res: any = await fetchUploadAttachment(fd)
|
||||
const url = String(res?.fileUrl || res?.data?.fileUrl || '').trim()
|
||||
if (!url) {
|
||||
ElMessage.error(`${file.name || '文件'} 上传失败`)
|
||||
continue
|
||||
}
|
||||
if (picked.value.some((p) => p.url === url)) continue
|
||||
picked.value.push({
|
||||
source: 'upload',
|
||||
fieldKey: '',
|
||||
fieldLabel: '',
|
||||
url,
|
||||
fileName: String(res?.fileName || res?.data?.fileName || file.name || '').trim() || file.name
|
||||
})
|
||||
}
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '上传失败')
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onSend() {
|
||||
const content = draft.value.trim()
|
||||
if (!content) {
|
||||
ElMessage.warning('请填写评论')
|
||||
if (!content && !mentionUsers.value.length && !picked.value.length) {
|
||||
ElMessage.warning('请填写评论、@人员或添加附件')
|
||||
return
|
||||
}
|
||||
sending.value = true
|
||||
try {
|
||||
await fetchCreateOaComment(props.instanceId, {
|
||||
content,
|
||||
attachmentRefs: picked.value.length ? [...picked.value] : undefined
|
||||
attachmentRefs: picked.value.length ? [...picked.value] : undefined,
|
||||
mentionUserIds: mentionUsers.value.length
|
||||
? mentionUsers.value.map((u) => u.id)
|
||||
: undefined
|
||||
})
|
||||
draft.value = ''
|
||||
picked.value = []
|
||||
mentionUsers.value = []
|
||||
closeAtSuggest()
|
||||
ElMessage.success('已发送')
|
||||
emit('refresh')
|
||||
} catch (e: any) {
|
||||
@@ -238,101 +574,29 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function onRevoke(c: OaInstanceComment) {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定撤回该评论吗?', '提示', { type: 'warning' })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
revokingId.value = c.id
|
||||
try {
|
||||
await fetchRevokeOaComment(props.instanceId, c.id)
|
||||
ElMessage.success('已撤回')
|
||||
emit('refresh')
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '撤回失败')
|
||||
} finally {
|
||||
revokingId.value = ''
|
||||
}
|
||||
}
|
||||
onMounted(() => {
|
||||
void ensureUserCache()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.oa-comment-panel {
|
||||
max-width: 960px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.oa-comment-panel__title {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
margin: 16px 0 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--mint-text, #0f172a);
|
||||
.oa-comment-file-input {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.oa-comment-panel__title-icon {
|
||||
color: var(--primary, #10b981);
|
||||
font-size: 16px;
|
||||
.oa-comment-panel__files {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.oa-comment-panel__empty {
|
||||
padding: 8px 0 12px;
|
||||
color: var(--mint-text-muted, #94a3b8);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.oa-comment-panel__list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.oa-comment-panel__item {
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--mint-border, #e2e8f0);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.oa-comment-panel__revoked {
|
||||
color: var(--mint-text-muted, #94a3b8);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.oa-comment-panel__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-bottom: 6px;
|
||||
|
||||
strong {
|
||||
color: var(--mint-text-strong, #0f172a);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.oa-comment-panel__time {
|
||||
margin-left: 4px;
|
||||
color: var(--mint-text-muted, #94a3b8);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.oa-comment-panel__content {
|
||||
color: var(--mint-text, #334155);
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.oa-comment-panel__refs,
|
||||
.oa-comment-panel__picked {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -340,29 +604,65 @@
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.oa-comment-panel__ref {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: var(--primary-dark, #059669);
|
||||
border-color: #a7f3d0;
|
||||
background: var(--primary-bg, #ecfdf5);
|
||||
}
|
||||
.oa-comment-panel__composer {
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.oa-comment-panel__composer {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--mint-border, #e2e8f0);
|
||||
.oa-comment-panel__editor {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.oa-comment-panel__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.oa-comment-at {
|
||||
position: absolute;
|
||||
top: calc(100% - 28px);
|
||||
left: 0;
|
||||
z-index: 20;
|
||||
width: min(320px, 100%);
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
.oa-comment-at__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-height: 36px;
|
||||
padding: 6px 12px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: none;
|
||||
|
||||
&:hover,
|
||||
&.is-active {
|
||||
background: var(--primary-bg, #ecfdf5);
|
||||
}
|
||||
}
|
||||
|
||||
.oa-comment-at__name {
|
||||
color: var(--mint-text-strong, #0f172a);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.oa-comment-at__empty {
|
||||
padding: 12px;
|
||||
color: var(--mint-text-muted, #94a3b8);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.oa-comment-attach {
|
||||
min-height: 80px;
|
||||
}
|
||||
@@ -376,26 +676,110 @@
|
||||
|
||||
.oa-comment-attach__groups {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.oa-comment-attach__label {
|
||||
margin-bottom: 6px;
|
||||
margin-bottom: 8px;
|
||||
color: var(--mint-text-strong, #0f172a);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.oa-comment-attach__files {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.oa-comment-attach__file {
|
||||
margin-right: 0;
|
||||
height: auto;
|
||||
white-space: normal;
|
||||
align-items: flex-start;
|
||||
.oa-comment-attach__card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 88px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
box-shadow 0.15s;
|
||||
|
||||
&:hover {
|
||||
border-color: #a7f3d0;
|
||||
box-shadow: 0 2px 8px rgba(16, 185, 129, 0.12);
|
||||
}
|
||||
|
||||
&.is-selected {
|
||||
border-color: var(--primary, #10b981);
|
||||
box-shadow: 0 0 0 1px var(--primary, #10b981);
|
||||
}
|
||||
}
|
||||
|
||||
.oa-comment-attach__thumb {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 64px;
|
||||
overflow: hidden;
|
||||
background: #f8fafc;
|
||||
|
||||
img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.oa-comment-attach__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
|
||||
&--video {
|
||||
color: #7c3aed;
|
||||
background: #ede9fe;
|
||||
}
|
||||
|
||||
&--doc {
|
||||
color: #0284c7;
|
||||
background: #e0f2fe;
|
||||
}
|
||||
}
|
||||
|
||||
.oa-comment-attach__check {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: #fff;
|
||||
background: var(--primary, #10b981);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.18);
|
||||
}
|
||||
|
||||
.oa-comment-attach__name {
|
||||
display: -webkit-box;
|
||||
margin: 0;
|
||||
padding: 4px 6px;
|
||||
overflow: hidden;
|
||||
color: var(--mint-text, #334155);
|
||||
font-size: 11px;
|
||||
line-height: 1.3;
|
||||
word-break: break-all;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
</style>
|
||||
@@ -39,8 +39,11 @@
|
||||
<img
|
||||
class="el-upload-list__item-thumbnail"
|
||||
:src="attachmentFullUrl(file.url || '') || file.url"
|
||||
alt=""
|
||||
:alt="getOaUploadDisplayName(file)"
|
||||
/>
|
||||
<div class="oa-fc-upload-card__name is-on-image" :title="getOaUploadDisplayName(file)">
|
||||
{{ getOaUploadDisplayName(file) }}
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="isOaUploadVideoFile(file)">
|
||||
<div class="oa-fc-upload-card__icon oa-fc-upload-card__icon--video">
|
||||
@@ -130,22 +133,64 @@
|
||||
return Array.isArray(value) ? value : [value]
|
||||
}
|
||||
|
||||
function getFileName(file: string): string {
|
||||
return String(file).split('/').pop() || String(file)
|
||||
function basenameOf(url: string): string {
|
||||
const path = String(url || '').split('?')[0]
|
||||
const last = path.split('/').pop() || ''
|
||||
try {
|
||||
return decodeURIComponent(last)
|
||||
} catch {
|
||||
return last
|
||||
}
|
||||
}
|
||||
|
||||
function parseFile(file: unknown, i: number): UploadUserFile & { is_string?: boolean } {
|
||||
/** 表单值里的地址:上传成功后的服务端 URL,不用本地 blob */
|
||||
function pickStoredUrl(file: any): string {
|
||||
const url = String(file?.url || file?.fileUrl || '').trim()
|
||||
if (!url || url.startsWith('blob:')) return ''
|
||||
return url
|
||||
}
|
||||
|
||||
/**
|
||||
* 回显名优先用户本地原始文件名。
|
||||
* 旧数据只有 URL 时,退回路径最后一段(通常是存储 UUID)。
|
||||
*/
|
||||
function pickStoredName(file: any, url: string): string {
|
||||
const raw = String(file?.name || file?.fileName || file?.raw?.name || '').trim()
|
||||
if (raw && raw !== url && !raw.startsWith('blob:') && !/^https?:\/\//i.test(raw)) {
|
||||
const base = raw.includes('/') ? raw.split('/').pop() || raw : raw
|
||||
if (base) return base
|
||||
}
|
||||
return basenameOf(url) || '未命名文件'
|
||||
}
|
||||
|
||||
function serializeUploadValue(file: any): { url: string; name: string } | undefined {
|
||||
const url = pickStoredUrl(file)
|
||||
if (!url) return undefined
|
||||
return { url, name: pickStoredName(file, url) }
|
||||
}
|
||||
|
||||
function parseFile(file: unknown, i: number): UploadUserFile {
|
||||
if (typeof file === 'string') {
|
||||
const url = file
|
||||
return {
|
||||
url,
|
||||
name: basenameOf(url) || '未命名文件',
|
||||
uid: i,
|
||||
status: 'success'
|
||||
}
|
||||
}
|
||||
if (file && typeof file === 'object') {
|
||||
return file as UploadUserFile
|
||||
}
|
||||
const url = String(file || '')
|
||||
return {
|
||||
url,
|
||||
name: getFileName(url),
|
||||
uid: i,
|
||||
status: 'success',
|
||||
is_string: true
|
||||
const url = pickStoredUrl(file)
|
||||
if (!url) return file as UploadUserFile
|
||||
const src = file as UploadUserFile
|
||||
return {
|
||||
url,
|
||||
name: pickStoredName(file, url),
|
||||
uid: src.uid ?? i,
|
||||
status: src.status || 'success'
|
||||
}
|
||||
}
|
||||
return { url: '', name: '未命名文件', uid: i, status: 'success' }
|
||||
}
|
||||
|
||||
function parseUpload(file: UploadUserFile): UploadUserFile & {
|
||||
@@ -273,10 +318,8 @@
|
||||
{ fullFileUrl: (u) => attachmentFullUrl(u) }
|
||||
)
|
||||
},
|
||||
update(list: Array<UploadUserFile & { is_string?: boolean; value?: unknown; url?: string }>) {
|
||||
const files = list
|
||||
.map((v) => (v.is_string ? v.url : (v.value as any) || v.url))
|
||||
.filter((url) => url !== undefined)
|
||||
update(list: Array<UploadUserFile & { url?: string; name?: string }>) {
|
||||
const files = list.map((v) => serializeUploadValue(v)).filter(Boolean)
|
||||
this.$emit('update:modelValue', files)
|
||||
},
|
||||
handleChange(file: UploadFile, list: UploadFile[]) {
|
||||
@@ -310,6 +353,7 @@
|
||||
|
||||
<style scoped lang="scss">
|
||||
.oa-fc-upload-card {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
@@ -365,5 +409,17 @@
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
word-break: break-all;
|
||||
|
||||
&.is-on-image {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
margin-top: 0;
|
||||
padding: 10px 4px 4px;
|
||||
color: #fff;
|
||||
background: linear-gradient(180deg, rgba(15, 23, 42, 0), rgba(15, 23, 42, 0.72));
|
||||
-webkit-line-clamp: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -33,18 +33,20 @@
|
||||
<pre v-else-if="!loading" class="oa-detail-dlg__json">{{ prettyForm }}</pre>
|
||||
</div>
|
||||
</section>
|
||||
<section class="oa-detail-dlg__section">
|
||||
<section class="oa-detail-dlg__section oa-detail-dlg__section--progress">
|
||||
<OaProgressChain
|
||||
v-if="detail?.id"
|
||||
:items="timelineItems"
|
||||
:instance-id="detail.id"
|
||||
@refresh="load"
|
||||
/>
|
||||
<OaCommentPanel
|
||||
v-if="detail?.id"
|
||||
:instance-id="detail.id"
|
||||
:instance-status="detail.status || ''"
|
||||
:comments="detail.comments || []"
|
||||
@refresh="load"
|
||||
/>
|
||||
</section>
|
||||
<section class="oa-detail-dlg__section oa-detail-dlg__section--progress">
|
||||
<OaProgressChain :items="timelineItems" />
|
||||
</section>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button class="btn-ghost" @click="emit('update:modelValue', false)">
|
||||
@@ -62,6 +64,7 @@
|
||||
import { fetchOaInstanceDetail } from '@/api/oaApproval'
|
||||
import OaCommentPanel from '@/components/oa-approval/OaCommentPanel.vue'
|
||||
import OaProgressChain from '@/components/oa-approval/OaProgressChain.vue'
|
||||
import { mergeProgressWithComments } from '@/utils/oa-progress-merge-comments'
|
||||
import {
|
||||
OA_FC_DEFAULT_GUTTER,
|
||||
ensureOaUploadRuleProps
|
||||
@@ -98,8 +101,10 @@
|
||||
|
||||
const timelineItems = computed(() => {
|
||||
const list = detail.value?.progress
|
||||
if (!Array.isArray(list) || !list.length) return []
|
||||
return list
|
||||
if (!Array.isArray(list) || !list.length) {
|
||||
return mergeProgressWithComments([], detail.value?.comments || [])
|
||||
}
|
||||
const progressMapped = list
|
||||
.filter((p: any) => {
|
||||
const kind = String(p?.kind || '')
|
||||
const status = String(p?.status || '')
|
||||
@@ -132,6 +137,7 @@
|
||||
}))
|
||||
: []
|
||||
}))
|
||||
return mergeProgressWithComments(progressMapped, detail.value?.comments || [])
|
||||
})
|
||||
|
||||
function statusType(s: string) {
|
||||
|
||||
@@ -11,70 +11,105 @@
|
||||
<div class="oa-flow-dot" :class="dotClass(item)" />
|
||||
<div v-if="idx < items.length - 1" class="oa-flow-line" />
|
||||
</div>
|
||||
<div class="oa-flow-card">
|
||||
<div class="oa-flow-card__row">
|
||||
<span class="oa-flow-card__name">{{ item.title }}</span>
|
||||
<span v-if="statusBadge(item)" class="oa-flow-badge" :class="statusBadge(item)">
|
||||
{{ nodeStatusText(item) }}
|
||||
</span>
|
||||
<span v-if="signBadge(item)" class="oa-flow-badge" :class="signBadge(item)">
|
||||
{{ signBadgeText(item) }}
|
||||
</span>
|
||||
<span v-else-if="item.taskType === 'cc'" class="oa-flow-badge cc">抄送</span>
|
||||
<span v-else-if="item.taskType === 'end'" class="oa-flow-badge mute">结束</span>
|
||||
<template v-if="showNodeTimestamp(item)">
|
||||
<span class="oa-flow-card__sep">·</span>
|
||||
<span class="oa-flow-card__meta" :title="item.timestamp">{{ item.timestamp }}</span>
|
||||
<div class="oa-flow-card" :class="{ 'is-comment': item.kind === 'comment' }">
|
||||
<!-- 讨论评论(钉钉式时间线条目) -->
|
||||
<template v-if="item.kind === 'comment'">
|
||||
<div v-if="item.status === 'revoked'" class="oa-flow-comment-revoked">
|
||||
{{ item.title }} 撤回了一条评论
|
||||
<span v-if="item.revokedTime" class="oa-flow-card__meta">{{ item.revokedTime }}</span>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="oa-flow-card__row">
|
||||
<span class="oa-flow-card__name">{{ item.title }}</span>
|
||||
<span class="oa-flow-badge cc">评论</span>
|
||||
<template v-if="showNodeTimestamp(item)">
|
||||
<span class="oa-flow-card__sep">·</span>
|
||||
<span class="oa-flow-card__meta" :title="item.timestamp">{{ item.timestamp }}</span>
|
||||
</template>
|
||||
<el-button
|
||||
v-if="item.canRevoke && item.commentId"
|
||||
class="oa-flow-comment-revoke"
|
||||
link
|
||||
type="danger"
|
||||
:loading="revokingId === item.commentId"
|
||||
@click="onRevokeComment(item)"
|
||||
>
|
||||
<el-icon><RefreshLeft /></el-icon>
|
||||
撤回
|
||||
</el-button>
|
||||
</div>
|
||||
<div v-if="item.commentText" class="oa-flow-card__comment oa-flow-card__comment--body">
|
||||
{{ item.commentText }}
|
||||
</div>
|
||||
<div v-if="mentionText(item)" class="oa-flow-comment-mentions">提到:{{ mentionText(item) }}</div>
|
||||
<OaCommentFileCards v-if="item.attachmentRefs?.length" :files="item.attachmentRefs" />
|
||||
</template>
|
||||
<template v-if="item.resolveSource && item.kind === 'task'">
|
||||
<span class="oa-flow-card__sep">·</span>
|
||||
<span class="oa-flow-card__meta" :title="resolveSourceText(item.resolveSource)">
|
||||
{{ resolveSourceText(item.resolveSource) }}
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="oa-flow-card__row">
|
||||
<span class="oa-flow-card__name">{{ item.title }}</span>
|
||||
<span v-if="statusBadge(item)" class="oa-flow-badge" :class="statusBadge(item)">
|
||||
{{ nodeStatusText(item) }}
|
||||
</span>
|
||||
</template>
|
||||
<span
|
||||
v-if="displayPeople(item).length && item.kind !== 'terminal'"
|
||||
class="oa-flow-card__people"
|
||||
>
|
||||
<span v-if="signBadge(item)" class="oa-flow-badge" :class="signBadge(item)">
|
||||
{{ signBadgeText(item) }}
|
||||
</span>
|
||||
<span v-else-if="item.taskType === 'cc'" class="oa-flow-badge cc">抄送</span>
|
||||
<span v-else-if="item.taskType === 'end'" class="oa-flow-badge mute">结束</span>
|
||||
<template v-if="showNodeTimestamp(item)">
|
||||
<span class="oa-flow-card__sep">·</span>
|
||||
<span class="oa-flow-card__meta" :title="item.timestamp">{{ item.timestamp }}</span>
|
||||
</template>
|
||||
<template v-if="item.resolveSource && item.kind === 'task'">
|
||||
<span class="oa-flow-card__sep">·</span>
|
||||
<span class="oa-flow-card__meta" :title="resolveSourceText(item.resolveSource)">
|
||||
{{ resolveSourceText(item.resolveSource) }}
|
||||
</span>
|
||||
</template>
|
||||
<span
|
||||
v-for="p in displayPeople(item)"
|
||||
:key="p.id || p.nickName"
|
||||
class="oa-person-chip"
|
||||
:class="personChipClass(p.status)"
|
||||
:title="personTitle(p)"
|
||||
v-if="displayPeople(item).length && item.kind !== 'terminal'"
|
||||
class="oa-flow-card__people"
|
||||
>
|
||||
<span class="oa-person-chip__dot">{{ avatarChar(p.nickName) }}</span>
|
||||
{{ p.nickName }}
|
||||
<span v-if="personStatusHint(p.status)" class="oa-person-chip__st">
|
||||
{{ personStatusHint(p.status) }}
|
||||
<span
|
||||
v-for="p in displayPeople(item)"
|
||||
:key="p.id || p.nickName"
|
||||
class="oa-person-chip"
|
||||
:class="personChipClass(p.status)"
|
||||
:title="personTitle(p)"
|
||||
>
|
||||
<span class="oa-person-chip__dot">{{ avatarChar(p.nickName) }}</span>
|
||||
{{ p.nickName }}
|
||||
<span v-if="personStatusHint(p.status)" class="oa-person-chip__st">
|
||||
{{ personStatusHint(p.status) }}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="personLines(item).length" class="oa-person-lines">
|
||||
<p
|
||||
v-for="(line, li) in personLines(item)"
|
||||
:key="`${item.key}-pl-${li}`"
|
||||
class="oa-person-line"
|
||||
:class="{
|
||||
'is-ok': line.tone === 'ok',
|
||||
'is-pending': line.tone === 'pending',
|
||||
'is-danger': line.tone === 'danger',
|
||||
'is-mute': line.tone === 'mute'
|
||||
}"
|
||||
</div>
|
||||
<div v-if="personLines(item).length" class="oa-person-lines">
|
||||
<p
|
||||
v-for="(line, li) in personLines(item)"
|
||||
:key="`${item.key}-pl-${li}`"
|
||||
class="oa-person-line"
|
||||
:class="{
|
||||
'is-ok': line.tone === 'ok',
|
||||
'is-pending': line.tone === 'pending',
|
||||
'is-danger': line.tone === 'danger',
|
||||
'is-mute': line.tone === 'mute'
|
||||
}"
|
||||
>
|
||||
{{ line.text }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="item.kind === 'terminal' && item.status === 'cancelled' && terminalWithdrawText(item)"
|
||||
class="oa-person-lines"
|
||||
>
|
||||
{{ line.text }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="item.kind === 'terminal' && item.status === 'cancelled' && terminalWithdrawText(item)"
|
||||
class="oa-person-lines"
|
||||
>
|
||||
<p class="oa-person-line is-danger">{{ terminalWithdrawText(item) }}</p>
|
||||
</div>
|
||||
<div v-else-if="showNodeComment(item)" class="oa-flow-card__comment">
|
||||
意见:{{ item.commentText }}
|
||||
</div>
|
||||
<p class="oa-person-line is-danger">{{ terminalWithdrawText(item) }}</p>
|
||||
</div>
|
||||
<div v-else-if="showNodeComment(item)" class="oa-flow-card__comment">
|
||||
意见:{{ item.commentText }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -82,6 +117,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { RefreshLeft } from '@element-plus/icons-vue'
|
||||
import { fetchRevokeOaComment, type OaCommentAttachmentRef } from '@/api/oaApproval'
|
||||
import OaCommentFileCards from '@/components/oa-approval/OaCommentFileCards.vue'
|
||||
import {
|
||||
formatProgressPersonLines,
|
||||
type ProgressPersonLine
|
||||
@@ -112,17 +150,61 @@
|
||||
kind?: string
|
||||
signMode?: string
|
||||
people?: OaProgressPerson[]
|
||||
/** kind=comment */
|
||||
attachmentRefs?: OaCommentAttachmentRef[]
|
||||
canRevoke?: boolean
|
||||
commentId?: string
|
||||
commentStatus?: string
|
||||
revokedTime?: string
|
||||
mentions?: Array<{ userId: string; userName: string }>
|
||||
}
|
||||
|
||||
withDefaults(
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
items?: OaProgressChainItem[]
|
||||
/** 用于进度内撤回评论 */
|
||||
instanceId?: string
|
||||
}>(),
|
||||
{
|
||||
items: () => []
|
||||
items: () => [],
|
||||
instanceId: ''
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
refresh: []
|
||||
}>()
|
||||
|
||||
const revokingId = ref('')
|
||||
|
||||
function mentionText(item: OaProgressChainItem) {
|
||||
const names = (item.mentions || [])
|
||||
.map((m) => String(m.userName || m.userId || '').trim())
|
||||
.filter(Boolean)
|
||||
return names.join('、')
|
||||
}
|
||||
|
||||
async function onRevokeComment(item: OaProgressChainItem) {
|
||||
const id = String(item.commentId || '').trim()
|
||||
const instanceId = String(props.instanceId || '').trim()
|
||||
if (!id || !instanceId) return
|
||||
try {
|
||||
await ElMessageBox.confirm('确定撤回该评论吗?', '提示', { type: 'warning' })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
revokingId.value = id
|
||||
try {
|
||||
await fetchRevokeOaComment(instanceId, id)
|
||||
ElMessage.success('已撤回')
|
||||
emit('refresh')
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '撤回失败')
|
||||
} finally {
|
||||
revokingId.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function statusText(s: string) {
|
||||
const map: Record<string, string> = {
|
||||
pending: '待处理',
|
||||
@@ -135,7 +217,9 @@
|
||||
submitted: '已提交',
|
||||
not_reached: '未到达',
|
||||
returned: '已打回',
|
||||
transferred: '已转交'
|
||||
transferred: '已转交',
|
||||
commented: '评论',
|
||||
revoked: '已撤回'
|
||||
}
|
||||
return map[s] || s
|
||||
}
|
||||
@@ -187,7 +271,7 @@
|
||||
}
|
||||
|
||||
function personLines(item: OaProgressChainItem): ProgressPersonLine[] {
|
||||
if (item.kind === 'terminal' || item.kind === 'submit') return []
|
||||
if (item.kind === 'terminal' || item.kind === 'submit' || item.kind === 'comment') return []
|
||||
if (item.taskType === 'cc' || item.signMode === 'cc') return []
|
||||
const list = displayPeople(item)
|
||||
if (!list.length) return []
|
||||
@@ -195,6 +279,7 @@
|
||||
}
|
||||
|
||||
function showNodeComment(item: OaProgressChainItem): boolean {
|
||||
if (item.kind === 'comment') return false
|
||||
// 有按人行时不再展示节点级拼接意见,避免双份
|
||||
if (personLines(item).length) return false
|
||||
if (item.kind === 'terminal' && item.status === 'cancelled') return false
|
||||
@@ -252,6 +337,10 @@
|
||||
|
||||
function dotClass(item: OaProgressChainItem) {
|
||||
const s = item.status || ''
|
||||
if (item.kind === 'comment') {
|
||||
if (s === 'revoked') return ''
|
||||
return 'ok'
|
||||
}
|
||||
if (item.kind !== 'terminal' && s === 'cancelled') return ''
|
||||
if (s === 'approved' || s === 'completed' || s === 'done' || s === 'submitted') return 'ok'
|
||||
if (s === 'pending' || s === 'running') return 'pending'
|
||||
@@ -414,6 +503,32 @@
|
||||
color: var(--mint-text, #334155);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
|
||||
&--body {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
.oa-flow-card.is-comment {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.oa-flow-comment-revoked {
|
||||
color: var(--mint-text-muted, #94a3b8);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.oa-flow-comment-revoke {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.oa-flow-comment-mentions {
|
||||
margin-top: 6px;
|
||||
color: var(--primary, #10b981);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.oa-person-lines {
|
||||
|
||||
Reference in new issue
Block a user