This commit is contained in:
zlwu committed 2026-09-24 09:59:26 +08:00
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 {