Prechádzať zdrojové kódy

feat(web-admin): add OaCommentPanel for instance discussion comments

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 1 mesiac pred
rodič
commit
3cc112c16c

+ 401 - 0
web-admin/src/components/oa-approval/OaCommentPanel.vue

@@ -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>