|
|
@@ -0,0 +1,401 @@
|
|
|
+<!-- 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">
|
|
|
+ <el-tag
|
|
|
+ v-for="(f, idx) in picked"
|
|
|
+ :key="`${f.url}-${idx}`"
|
|
|
+ closable
|
|
|
+ size="small"
|
|
|
+ effect="plain"
|
|
|
+ @close="picked.splice(idx, 1)"
|
|
|
+ >
|
|
|
+ {{ f.fileName || f.url }}
|
|
|
+ </el-tag>
|
|
|
+ </div>
|
|
|
+ <div class="oa-comment-panel__actions">
|
|
|
+ <el-button class="btn-ghost" @click="openAttach">
|
|
|
+ <el-icon><Paperclip /></el-icon>
|
|
|
+ 引用附件
|
|
|
+ </el-button>
|
|
|
+ <el-button type="primary" class="btn-main" :loading="sending" @click="onSend">
|
|
|
+ <el-icon><Promotion /></el-icon>
|
|
|
+ 发送
|
|
|
+ </el-button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <el-dialog
|
|
|
+ v-model="attachVisible"
|
|
|
+ title="引用表单附件"
|
|
|
+ width="560px"
|
|
|
+ class="dialog-form-layout"
|
|
|
+ align-center
|
|
|
+ append-to-body
|
|
|
+ modal-class="protocol-dialog-mask"
|
|
|
+ destroy-on-close
|
|
|
+ :close-on-click-modal="false"
|
|
|
+ >
|
|
|
+ <div v-loading="attachLoading" class="oa-comment-attach">
|
|
|
+ <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-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
|
|
|
+ v-for="f in g.files"
|
|
|
+ :key="f.url"
|
|
|
+ :value="f.url"
|
|
|
+ class="oa-comment-attach__file"
|
|
|
+ >
|
|
|
+ {{ f.fileName || f.url }}
|
|
|
+ </el-checkbox>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </el-checkbox-group>
|
|
|
+ </div>
|
|
|
+ <template #footer>
|
|
|
+ <el-button class="btn-ghost" @click="attachVisible = false">
|
|
|
+ <el-icon><Close /></el-icon>
|
|
|
+ 取消
|
|
|
+ </el-button>
|
|
|
+ <el-button
|
|
|
+ type="primary"
|
|
|
+ class="btn-main"
|
|
|
+ :disabled="!attachGroups.length"
|
|
|
+ @click="confirmAttach"
|
|
|
+ >
|
|
|
+ <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 {
|
|
|
+ fetchCreateOaComment,
|
|
|
+ fetchOaFormAttachments,
|
|
|
+ fetchRevokeOaComment,
|
|
|
+ type OaCommentAttachmentRef,
|
|
|
+ type OaInstanceComment
|
|
|
+ } from '@/api/oaApproval'
|
|
|
+ import { attachmentFullUrl } from '@/composables/useAttachmentActions'
|
|
|
+
|
|
|
+ defineOptions({ name: 'OaCommentPanel' })
|
|
|
+
|
|
|
+ const props = withDefaults(
|
|
|
+ defineProps<{
|
|
|
+ instanceId: string
|
|
|
+ instanceStatus: string
|
|
|
+ comments?: OaInstanceComment[]
|
|
|
+ }>(),
|
|
|
+ {
|
|
|
+ comments: () => []
|
|
|
+ }
|
|
|
+ )
|
|
|
+
|
|
|
+ const emit = defineEmits<{ refresh: [] }>()
|
|
|
+
|
|
|
+ const draft = ref('')
|
|
|
+ const picked = ref<OaCommentAttachmentRef[]>([])
|
|
|
+ const sending = ref(false)
|
|
|
+ const revokingId = ref('')
|
|
|
+
|
|
|
+ const attachVisible = ref(false)
|
|
|
+ const attachLoading = ref(false)
|
|
|
+ const attachGroups = ref<
|
|
|
+ Array<{ fieldKey: string; fieldLabel: string; files: OaCommentAttachmentRef[] }>
|
|
|
+ >([])
|
|
|
+ const selectedUrls = ref<string[]>([])
|
|
|
+
|
|
|
+ function openUrl(url: string) {
|
|
|
+ const full = attachmentFullUrl(url) || url
|
|
|
+ if (full) window.open(full, '_blank')
|
|
|
+ }
|
|
|
+
|
|
|
+ async function openAttach() {
|
|
|
+ attachVisible.value = true
|
|
|
+ attachLoading.value = true
|
|
|
+ selectedUrls.value = picked.value.map((f) => f.url)
|
|
|
+ try {
|
|
|
+ const res = await fetchOaFormAttachments(props.instanceId)
|
|
|
+ const items = res?.items || []
|
|
|
+ attachGroups.value = items
|
|
|
+ if (!items.length) {
|
|
|
+ ElMessage.info('暂无可引用附件')
|
|
|
+ }
|
|
|
+ } catch (e: any) {
|
|
|
+ attachGroups.value = []
|
|
|
+ ElMessage.error(e?.message || '加载附件失败')
|
|
|
+ } finally {
|
|
|
+ attachLoading.value = false
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function confirmAttach() {
|
|
|
+ const map = new Map<string, OaCommentAttachmentRef>()
|
|
|
+ for (const g of attachGroups.value) {
|
|
|
+ for (const f of g.files || []) {
|
|
|
+ if (f?.url) map.set(f.url, f)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ const next: OaCommentAttachmentRef[] = []
|
|
|
+ for (const url of selectedUrls.value) {
|
|
|
+ const found = map.get(url) || picked.value.find((p) => p.url === url)
|
|
|
+ if (found) next.push({ ...found })
|
|
|
+ }
|
|
|
+ picked.value = next
|
|
|
+ attachVisible.value = false
|
|
|
+ }
|
|
|
+
|
|
|
+ async function onSend() {
|
|
|
+ const content = draft.value.trim()
|
|
|
+ if (!content) {
|
|
|
+ ElMessage.warning('请填写评论')
|
|
|
+ return
|
|
|
+ }
|
|
|
+ sending.value = true
|
|
|
+ try {
|
|
|
+ await fetchCreateOaComment(props.instanceId, {
|
|
|
+ content,
|
|
|
+ attachmentRefs: picked.value.length ? [...picked.value] : undefined
|
|
|
+ })
|
|
|
+ draft.value = ''
|
|
|
+ picked.value = []
|
|
|
+ ElMessage.success('已发送')
|
|
|
+ emit('refresh')
|
|
|
+ } catch (e: any) {
|
|
|
+ ElMessage.error(e?.message || '发送失败')
|
|
|
+ } finally {
|
|
|
+ sending.value = false
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ 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 = ''
|
|
|
+ }
|
|
|
+ }
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped lang="scss">
|
|
|
+ .oa-comment-panel {
|
|
|
+ max-width: 960px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .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-panel__title-icon {
|
|
|
+ color: var(--primary, #10b981);
|
|
|
+ font-size: 16px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .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;
|
|
|
+ gap: 6px;
|
|
|
+ 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 {
|
|
|
+ margin-top: 12px;
|
|
|
+ padding-top: 12px;
|
|
|
+ border-top: 1px solid var(--mint-border, #e2e8f0);
|
|
|
+ }
|
|
|
+
|
|
|
+ .oa-comment-panel__actions {
|
|
|
+ display: flex;
|
|
|
+ flex-wrap: wrap;
|
|
|
+ gap: 8px;
|
|
|
+ margin-top: 10px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .oa-comment-attach {
|
|
|
+ min-height: 80px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .oa-comment-attach__empty {
|
|
|
+ padding: 16px 0;
|
|
|
+ color: var(--mint-text-muted, #94a3b8);
|
|
|
+ font-size: 13px;
|
|
|
+ text-align: center;
|
|
|
+ }
|
|
|
+
|
|
|
+ .oa-comment-attach__groups {
|
|
|
+ display: grid;
|
|
|
+ gap: 14px;
|
|
|
+ width: 100%;
|
|
|
+ }
|
|
|
+
|
|
|
+ .oa-comment-attach__label {
|
|
|
+ margin-bottom: 6px;
|
|
|
+ color: var(--mint-text-strong, #0f172a);
|
|
|
+ font-size: 13px;
|
|
|
+ font-weight: 600;
|
|
|
+ }
|
|
|
+
|
|
|
+ .oa-comment-attach__files {
|
|
|
+ display: grid;
|
|
|
+ gap: 4px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .oa-comment-attach__file {
|
|
|
+ margin-right: 0;
|
|
|
+ height: auto;
|
|
|
+ white-space: normal;
|
|
|
+ align-items: flex-start;
|
|
|
+ }
|
|
|
+</style>
|