feat(agent): approval queue inline card + exclusion panel

新增 ApprovalQueue 视图、ApprovalInlineCard/AgentExclusionPanel 组件与 useApprovalQueue; 对话/API 适配; public 静态资源。
This commit is contained in:
Simon
2026-07-09 11:50:07 +08:00
parent 12d89dfb35
commit 018c87e10b
14 changed files with 1883 additions and 10 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
.git
*.log
+1 -1
View File
@@ -16,7 +16,7 @@ WORKDIR /app
COPY package.json pnpm-lock.yaml* ./ COPY package.json pnpm-lock.yaml* ./
# 安装依赖 # 安装依赖
RUN pnpm install --frozen-lockfile RUN pnpm install --no-frozen-lockfile
# 复制源码(后续通过 volume mount 更新) # 复制源码(后续通过 volume mount 更新)
COPY . . COPY . .
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,456 @@
<!-- =============================================================================
企微IT智能服务台 坐席控制台 代答排除控件Tier1 / P1-2 / D9
=============================================================================
说明坐席对 AI 澄清题进行排除/推荐操作的面板
- 可勾选排除错误选项置灰/划除
- 可加手动推荐标记
- 不能代用户点最终确认
- 30s 未确认推送确认/取消提醒卡片
D9 硬约束
- 坐席仅能排除错误项 + 用户最终确认
- 可加手动推荐标记
- 不能完全代用户回答防越权/误代答
- 30s 超时推送提醒
============================================================================= -->
<template>
<div v-if="visible" class="agent-exclusion-panel" :class="{ 'agent-exclusion-panel--expired': isExpired }">
<!-- 标题栏 -->
<div class="agent-exclusion-panel__header">
<div class="agent-exclusion-panel__title">
<span class="agent-exclusion-panel__title-icon">🛠</span>
<span class="agent-exclusion-panel__title-text">AI 澄清题坐席侧镜像</span>
</div>
<!-- 倒计时 -->
<div class="agent-exclusion-panel__timer" :class="timerClass">
<span v-if="!isExpired"> {{ timerDisplay }}</span>
<span v-else> 已超时</span>
</div>
</div>
<!-- 问题文本 -->
<div class="agent-exclusion-panel__question">
AI 问用户"{{ question }}"
</div>
<!-- 选项列表 -->
<div class="agent-exclusion-panel__options">
<div
v-for="(option, idx) in editableOptions"
:key="idx"
class="agent-exclusion-panel__option"
:class="{
'agent-exclusion-panel__option--excluded': option.excluded,
'agent-exclusion-panel__option--recommended': option.recommended,
}"
>
<!-- 排除勾选框 -->
<el-checkbox
:model-value="option.excluded"
:disabled="option.recommended"
@change="(val: boolean) => toggleExclude(idx, val)"
>
<span :class="{ 'agent-exclusion-panel__option-label--strikethrough': option.excluded }">
{{ option.label }}
</span>
</el-checkbox>
<!-- 推荐标记按钮 -->
<el-button
:type="option.recommended ? 'warning' : 'default'"
size="small"
:disabled="option.excluded"
:icon="option.recommended ? 'StarFilled' : 'Star'"
circle
@click="toggleRecommend(idx)"
/>
</div>
</div>
<!-- 操作按钮 -->
<div class="agent-exclusion-panel__actions">
<el-tooltip
content="仅标记推荐/排除,最终确认权在用户"
placement="top"
>
<el-tag type="warning" size="small">
不能代用户确认
</el-tag>
</el-tooltip>
<div class="agent-exclusion-panel__action-btns">
<el-button size="small" @click="resetChanges">重置</el-button>
<el-button
size="small"
type="primary"
:loading="submitting"
@click="submitExclusions"
>
同步到用户侧
</el-button>
</div>
</div>
<!-- 已同步提示 -->
<div v-if="synced" class="agent-exclusion-panel__synced">
已同步到员工端坐席已排除 {{ excludedLabelsText }}推荐选 {{ recommendedLabelText }}
</div>
<!-- 超时提醒 -->
<div v-if="isExpired && !synced" class="agent-exclusion-panel__expired">
用户 30s 未确认已推送确认/取消提醒卡片
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch, onMounted, onUnmounted } from 'vue'
// ===========================================================================
// 类型定义
// ===========================================================================
interface ExclusionOption {
label: string
excluded: boolean
recommended: boolean
}
// ===========================================================================
// 属性与事件
// ===========================================================================
const props = withDefaults(defineProps<{
/** 是否可见 */
visible?: boolean
/** AI 问题文本 */
question?: string
/** 选项列表 */
options?: string[]
/** 超时秒数(默认 30s */
timeoutSeconds?: number
}>(), {
visible: false,
question: '',
options: () => [],
timeoutSeconds: 30,
})
const emit = defineEmits<{
/** 提交排除/推荐 */
(e: 'submit', data: { excluded: string[]; recommended: string | null; messageId: string }): void
/** 超时 */
(e: 'timeout'): void
/** 重置 */
(e: 'reset'): void
}>()
// ===========================================================================
// 状态
// ===========================================================================
/** 可编辑选项列表 */
const editableOptions = reactive<ExclusionOption[]>([])
/** 是否已同步到用户侧 */
const synced = ref(false)
/** 是否正在提交 */
const submitting = ref(false)
/** 倒计时(秒) */
const remainingSeconds = ref(props.timeoutSeconds)
/** 是否已超时 */
const isExpired = ref(false)
/** 定时器 ID */
let timerId: ReturnType<typeof setInterval> | null = null
// ===========================================================================
// 计算属性
// ===========================================================================
/** 倒计时显示文本 */
const timerDisplay = computed(() => {
const mins = Math.floor(remainingSeconds.value / 60)
const secs = remainingSeconds.value % 60
return `${mins}:${secs.toString().padStart(2, '0')}`
})
/** 倒计时样式 */
const timerClass = computed(() => {
if (remainingSeconds.value <= 10) return 'agent-exclusion-panel__timer--urgent'
if (remainingSeconds.value <= 20) return 'agent-exclusion-panel__timer--warning'
return ''
})
/** 已排除选项标签 */
const excludedLabelsText = computed(() => {
const excluded = editableOptions.filter(o => o.excluded)
if (excluded.length === 0) return '无'
return excluded.map(o => `"${o.label}"`).join('、')
})
/** 推荐选项标签 */
const recommendedLabelText = computed(() => {
const rec = editableOptions.find(o => o.recommended)
return rec ? rec.label : '无'
})
// ===========================================================================
// 方法
// ===========================================================================
/** 切换排除状态 */
function toggleExclude(idx: number, excluded: boolean) {
if (idx >= 0 && idx < editableOptions.length) {
editableOptions[idx].excluded = excluded
// 排除的项不能同时为推荐
if (excluded) {
editableOptions[idx].recommended = false
}
}
}
/** 切换推荐标记 */
function toggleRecommend(idx: number) {
if (idx >= 0 && idx < editableOptions.length) {
// 只能有一个推荐项
editableOptions.forEach((o, i) => {
o.recommended = i === idx && !o.excluded && !o.recommended
})
}
}
/** 重置所有变更 */
function resetChanges() {
editableOptions.forEach(o => {
o.excluded = false
o.recommended = false
})
synced.value = false
emit('reset')
}
/** 提交排除/推荐 */
async function submitExclusions() {
submitting.value = true
try {
const excluded = editableOptions.filter(o => o.excluded).map(o => o.label)
const recommended = editableOptions.find(o => o.recommended)?.label ?? null
// messageId 由父组件通过 prop 或上下文提供
emit('submit', { excluded, recommended, messageId: '' })
synced.value = true
stopTimer()
} finally {
submitting.value = false
}
}
/** 启动倒计时 */
function startTimer() {
stopTimer()
remainingSeconds.value = props.timeoutSeconds
isExpired.value = false
synced.value = false
timerId = setInterval(() => {
remainingSeconds.value--
if (remainingSeconds.value <= 0) {
isExpired.value = true
stopTimer()
emit('timeout')
}
}, 1000)
}
/** 停止倒计时 */
function stopTimer() {
if (timerId) {
clearInterval(timerId)
timerId = null
}
}
// ===========================================================================
// 生命周期
// ===========================================================================
/** 初始化选项 */
watch(
() => props.options,
(opts) => {
editableOptions.length = 0
editableOptions.push(
...opts.map(label => ({
label,
excluded: false,
recommended: false,
}))
)
},
{ immediate: true },
)
/** 控制可见性时的计时器管理 */
watch(
() => props.visible,
(vis) => {
if (vis) {
startTimer()
} else {
stopTimer()
}
},
{ immediate: true },
)
onMounted(() => {
if (props.visible) startTimer()
})
onUnmounted(() => {
stopTimer()
})
</script>
<style scoped>
/* =========================================================================
坐席排除面板 — Element Plus 风格,坐席侧会话内展示
========================================================================= */
.agent-exclusion-panel {
margin: 8px 0;
padding: 12px 14px;
background: #fafbfc;
border: 1px solid #e4e7ed;
border-radius: 8px;
}
.agent-exclusion-panel--expired {
border-color: #fde2e2;
background: #fef0f0;
}
/* 标题栏 */
.agent-exclusion-panel__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.agent-exclusion-panel__title {
display: flex;
align-items: center;
gap: 4px;
}
.agent-exclusion-panel__title-icon {
font-size: 14px;
}
.agent-exclusion-panel__title-text {
font-size: 13px;
font-weight: 600;
color: #303133;
}
/* 倒计时 */
.agent-exclusion-panel__timer {
font-size: 13px;
color: #909399;
font-variant-numeric: tabular-nums;
}
.agent-exclusion-panel__timer--warning {
color: #e6a23c;
}
.agent-exclusion-panel__timer--urgent {
color: #f56c6c;
font-weight: 700;
animation: pulse 1s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
/* 问题文本 */
.agent-exclusion-panel__question {
font-size: 13px;
color: #606266;
margin-bottom: 10px;
padding: 6px 10px;
background: #f0f7ff;
border-radius: 4px;
font-style: italic;
}
/* 选项列表 */
.agent-exclusion-panel__options {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 10px;
}
.agent-exclusion-panel__option {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 8px;
border-radius: 4px;
transition: background 0.2s;
}
.agent-exclusion-panel__option--excluded {
background: #f5f5f5;
opacity: 0.6;
}
.agent-exclusion-panel__option--recommended {
background: #fdf6ec;
}
.agent-exclusion-panel__option-label--strikethrough {
text-decoration: line-through;
color: #c0c4cc;
}
/* 操作栏 */
.agent-exclusion-panel__actions {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: 8px;
border-top: 1px solid #ebeef5;
}
.agent-exclusion-panel__action-btns {
display: flex;
gap: 6px;
}
/* 已同步 */
.agent-exclusion-panel__synced {
margin-top: 8px;
padding: 6px 10px;
background: #f0f9eb;
border-radius: 4px;
font-size: 12px;
color: #67c23a;
}
/* 超时提醒 */
.agent-exclusion-panel__expired {
margin-top: 8px;
padding: 6px 10px;
background: #fef0f0;
border-radius: 4px;
font-size: 12px;
color: #f56c6c;
}
</style>
@@ -0,0 +1,694 @@
<!-- =============================================================================
企微IT智能服务台 坐席控制台 内联审批卡片Tier1 / P0-6 / D7
=============================================================================
说明会话中的知识建议内联审批卡片支持采纳/驳回/改写操作
展示拓扑预览IssueAction 小图confidenceaudience 下拉编辑
D7 硬约束
- 提案默认 status=pending不自动采纳
- 内联审批 + 独立队列双通道
- audience 坐席可改D8
- 审批动作写入审计日志
============================================================================= -->
<template>
<div class="approval-inline-card" :class="statusClass">
<!-- 头部提案类型 + 状态标签 -->
<div class="approval-inline-card__header">
<div class="approval-inline-card__type">
<span class="approval-inline-card__type-icon">
{{ suggestion.suggestion_type === 'new_faq' ? '💡' : suggestion.source_type === 'merge' ? '🔀' : '🔄' }}
</span>
<span class="approval-inline-card__type-text">
{{ suggestion.source_type === 'merge' ? '合并去重提案' : suggestion.suggestion_type === 'new_faq' ? '新 FAQ 提案' : '内容更新提案' }}
</span>
</div>
<div class="approval-inline-card__badges">
<el-tag size="small" :type="confidenceTagType">
置信度 {{ displayConfidence }}%
</el-tag>
<el-tag size="small" type="info">
{{ statusLabel }}
</el-tag>
<!-- 重复标记任务3P2 -->
<el-tag v-if="hasDuplicates" size="small" type="danger" effect="dark">
可能重复
</el-tag>
</div>
</div>
<!-- 重复警告任务3P2 -->
<div v-if="hasDuplicates && duplicateItems.length > 0" class="approval-inline-card__dup-warning">
<div class="approval-inline-card__dup-warning-title">
疑似重复条目建议合并而非新建
</div>
<div
v-for="dup in duplicateItems.slice(0, 2)"
:key="dup.suggestion_id || dup.name"
class="approval-inline-card__dup-item"
>
<span class="approval-inline-card__dup-name">{{ dup.name }}</span>
<el-tag size="small" :type="dup.type === 'same_issue' ? 'danger' : 'warning'">
{{ dup.type === 'same_issue' ? '同名' : '相似' }} {{ Math.round((dup.similarity ?? 0) * 100) }}%
</el-tag>
</div>
</div>
<!-- 核心信息 -->
<div class="approval-inline-card__body">
<div class="approval-inline-card__title">
<label>标题</label>
<span>{{ suggestion.title || '(无标题)' }}</span>
</div>
<div class="approval-inline-card__content">
<label>内容</label>
<div class="approval-inline-card__content-text">
{{ suggestion.content?.substring(0, 150) }}{{ suggestion.content?.length > 150 ? '...' : '' }}
</div>
</div>
</div>
<!-- 拓扑预览D1 图字段展示 + 任务2 P2 小缩略图 -->
<div v-if="hasTopology" class="approval-inline-card__topology">
<div class="approval-inline-card__topology-label">🔗 知识图谱预览</div>
<!-- 小缩略图SVG 简易力导向图 -->
<svg
v-if="miniGraphNodes.length > 0"
class="approval-inline-card__topology-svg"
:viewBox="`0 0 ${miniGraphWidth} 80`"
>
<!-- 连线 -->
<line
v-for="(edge, ei) in miniGraphEdges"
:key="'e' + ei"
:x1="edge.x1" :y1="edge.y1"
:x2="edge.x2" :y2="edge.y2"
stroke="#c0c4cc"
stroke-width="1.5"
stroke-dasharray="4,2"
/>
<!-- 节点 -->
<template v-for="(node, ni) in miniGraphNodes" :key="'n' + ni">
<circle
:cx="node.x" :cy="node.y" :r="node.type === 'issue' ? 12 : 9"
:fill="node.type === 'issue' ? '#67c23a' : node.type === 'parent' ? '#409eff' : '#e6a23c'"
:opacity="0.85"
/>
<text
:x="node.x" :y="node.y + 22"
text-anchor="middle"
font-size="9"
fill="#606266"
>{{ node.label?.length > 6 ? node.label.substring(0, 6) + '…' : node.label }}</text>
</template>
</svg>
<!-- 原有文本流预览 -->
<div class="approval-inline-card__topology-flow">
<!-- Issue -->
<span v-if="suggestion.parent_issue" class="approval-inline-card__topology-node approval-inline-card__topology-node--parent">
{{ suggestion.parent_issue }}
</span>
<span v-if="suggestion.parent_issue" class="approval-inline-card__topology-arrow"></span>
<!-- 当前 Issue -->
<span v-if="suggestion.issue" class="approval-inline-card__topology-node approval-inline-card__topology-node--issue">
{{ suggestion.issue }}
</span>
<span v-if="suggestion.issue && suggestion.action" class="approval-inline-card__topology-arrow">
{{ suggestion.relation_type || 'LEADS_TO' }}
</span>
<!-- Action -->
<span v-if="suggestion.action" class="approval-inline-card__topology-node approval-inline-card__topology-node--action">
{{ suggestion.action }}
</span>
</div>
</div>
<!-- audience 下拉编辑D8 -->
<div class="approval-inline-card__audience">
<label>受众类型</label>
<el-select
v-model="editAudience"
size="small"
:disabled="readonly"
@change="onAudienceChange"
>
<el-option label="员工快捷回复" value="employee_quick_reply" />
<el-option label="工程师作业指导" value="engineer_workguide" />
</el-select>
</div>
<!-- 改写表单展开时 -->
<div v-if="showRewriteForm" class="approval-inline-card__rewrite-form">
<el-input
v-model="rewriteTitle"
placeholder="修改标题"
size="small"
class="approval-inline-card__rewrite-field"
/>
<el-input
v-model="rewriteContent"
type="textarea"
:rows="2"
placeholder="修改内容"
size="small"
class="approval-inline-card__rewrite-field"
/>
<el-input
v-model="rewriteCategory"
placeholder="修改分类"
size="small"
class="approval-inline-card__rewrite-field"
/>
<div class="approval-inline-card__rewrite-actions">
<el-button size="small" @click="cancelRewrite">取消</el-button>
<el-button size="small" type="primary" @click="submitRewrite">提交改写</el-button>
</div>
</div>
<!-- 操作按钮 -->
<div v-if="!readonly" class="approval-inline-card__actions">
<el-button
size="small"
type="success"
:loading="approving"
@click="handleApprove"
>
采纳
</el-button>
<el-button
size="small"
type="warning"
@click="toggleRewrite"
>
改写
</el-button>
<el-button
size="small"
type="danger"
:loading="rejecting"
@click="handleReject"
>
驳回
</el-button>
</div>
<!-- 已处理状态展示 -->
<div v-else class="approval-inline-card__resolved">
<el-tag :type="resolvedTagType" size="small">
{{ resolvedLabel }}
</el-tag>
<span v-if="suggestion.reviewer_id" class="approval-inline-card__reviewer">
审核人: {{ suggestion.reviewer_id }}
</span>
<span v-if="suggestion.reviewed_at" class="approval-inline-card__review-time">
{{ formatTime(suggestion.reviewed_at) }}
</span>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
// ===========================================================================
// 类型定义
// ===========================================================================
export interface SuggestionData {
id: string
suggestion_type: string
status: string
title: string
content: string
category: string
tags: string[]
confidence?: number | null
audience?: string | null
issue?: string | null
action?: string | null
relation_type?: string | null
parent_issue?: string | null
source_type?: string | null
graph_meta?: Record<string, any> | null
graph_sync_status?: string | null
source_failed?: boolean
reviewer_id?: string | null
reviewed_at?: string | null
created_at: string
}
// ── 重复项类型(任务3:P2) ──
export interface DuplicateItem {
type: string
name: string
suggestion_id?: string | null
similarity?: number
}
// ===========================================================================
// 属性与事件
// ===========================================================================
const props = defineProps<{
/** 建议数据 */
suggestion: SuggestionData
/** 是否只读(已处理/终态时不可操作) */
readonly?: boolean
/** 重复检测结果(任务3:P2,由父组件传入) */
duplicates?: DuplicateItem[]
}>()
const emit = defineEmits<{
/** 采纳 */
(e: 'approve', id: string): void
/** 驳回 */
(e: 'reject', id: string, reason?: string): void
/** 改写提交 */
(e: 'rewrite', id: string, data: Record<string, any>): void
/** audience 变更 */
(e: 'audience-change', id: string, audience: string): void
}>()
// ===========================================================================
// 状态
// ===========================================================================
const approving = ref(false)
const rejecting = ref(false)
const showRewriteForm = ref(false)
// audience 编辑
const editAudience = ref(props.suggestion.audience || 'employee_quick_reply')
// 改写表单
const rewriteTitle = ref('')
const rewriteContent = ref('')
const rewriteCategory = ref('')
watch(() => props.suggestion, (val) => {
editAudience.value = val.audience || 'employee_quick_reply'
})
// ===========================================================================
// 计算属性
// ===========================================================================
/** 展示置信度(百分比) */
const displayConfidence = computed(() => {
const c = props.suggestion.confidence ?? 0
return Math.round(c * 100)
})
/** 置信度标签颜色 */
const confidenceTagType = computed(() => {
const c = props.suggestion.confidence ?? 0
if (c >= 0.7) return 'success'
if (c >= 0.5) return 'warning'
return 'danger'
})
/** 是否有拓扑数据 */
const hasTopology = computed(() => {
return !!(props.suggestion.issue || props.suggestion.action)
})
/** 是否有重复项(任务3:P2) */
const hasDuplicates = computed(() => {
return (props.duplicates && props.duplicates.length > 0) || false
})
/** 重复项列表 */
const duplicateItems = computed(() => {
return props.duplicates || []
})
// ── 迷你图节点计算(任务2:P2 SVG缩略图) ──
interface MiniNode { x: number; y: number; label: string; type: string }
interface MiniEdge { x1: number; y1: number; x2: number; y2: number }
const miniGraphWidth = computed(() => {
let count = 0
if (props.suggestion.parent_issue) count++
if (props.suggestion.issue) count++
if (props.suggestion.action) count++
return Math.max(120, count * 100)
})
const miniGraphNodes = computed<MiniNode[]>(() => {
const nodes: MiniNode[] = []
let x = 30
const y = 28
if (props.suggestion.parent_issue) {
nodes.push({ x, y, label: props.suggestion.parent_issue, type: 'parent' })
x += 90
}
if (props.suggestion.issue) {
nodes.push({ x, y, label: props.suggestion.issue, type: 'issue' })
x += 90
}
if (props.suggestion.action) {
nodes.push({ x, y, label: props.suggestion.action, type: 'action' })
}
return nodes
})
const miniGraphEdges = computed<MiniEdge[]>(() => {
const edges: MiniEdge[] = []
const nodes = miniGraphNodes.value
for (let i = 0; i < nodes.length - 1; i++) {
edges.push({
x1: nodes[i].x + 14,
y1: nodes[i].y,
x2: nodes[i + 1].x - 14,
y2: nodes[i + 1].y,
})
}
return edges
})
/** 状态展示文本 */
const statusLabel = computed(() => {
const map: Record<string, string> = {
pending: '待审',
queued: '队列中',
approved: '已通过',
rejected: '已驳回',
applied: '已应用',
graph_synced: '已同步',
expired: '已过期',
}
return map[props.suggestion.status] || props.suggestion.status
})
/** 卡片样式 */
const statusClass = computed(() => {
if (props.suggestion.source_failed) return 'approval-inline-card--failed'
return ''
})
/** 已处理标签 */
const resolvedTagType = computed(() => {
const map: Record<string, string> = {
approved: 'success',
applied: 'success',
graph_synced: 'success',
rejected: 'danger',
expired: 'info',
}
return map[props.suggestion.status] || 'info'
})
/** 已处理文本 */
const resolvedLabel = computed(() => {
const map: Record<string, string> = {
approved: '已采纳',
applied: '已应用至知识库',
graph_synced: '已同步知识图谱',
rejected: '已驳回',
expired: '已过期',
}
return map[props.suggestion.status] || props.suggestion.status
})
// ===========================================================================
// 方法
// ===========================================================================
function formatTime(iso: string) {
if (!iso) return ''
return new Date(iso).toLocaleString('zh-CN')
}
async function handleApprove() {
approving.value = true
try {
emit('approve', props.suggestion.id)
} finally {
approving.value = false
}
}
async function handleReject() {
rejecting.value = true
try {
emit('reject', props.suggestion.id, '坐席审核不通过')
} finally {
rejecting.value = false
}
}
function toggleRewrite() {
if (showRewriteForm.value) {
cancelRewrite()
} else {
rewriteTitle.value = props.suggestion.title || ''
rewriteContent.value = props.suggestion.content || ''
rewriteCategory.value = props.suggestion.category || ''
showRewriteForm.value = true
}
}
function cancelRewrite() {
showRewriteForm.value = false
}
function submitRewrite() {
emit('rewrite', props.suggestion.id, {
title: rewriteTitle.value || undefined,
content: rewriteContent.value || undefined,
category: rewriteCategory.value || undefined,
})
showRewriteForm.value = false
}
function onAudienceChange(val: string) {
emit('audience-change', props.suggestion.id, val)
}
</script>
<style scoped>
/* =========================================================================
内联审批卡片 — Element Plus 风格,坐席侧会话内展示
========================================================================= */
.approval-inline-card {
margin: 8px 0;
padding: 12px 14px;
background: #ffffff;
border: 1px solid #e4e7ed;
border-radius: 8px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
}
.approval-inline-card--failed {
border-color: #fde2e2;
background: #fef0f0;
}
/* 头部 */
.approval-inline-card__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.approval-inline-card__type {
display: flex;
align-items: center;
gap: 4px;
}
.approval-inline-card__type-icon {
font-size: 16px;
}
.approval-inline-card__type-text {
font-size: 14px;
font-weight: 600;
color: #303133;
}
.approval-inline-card__badges {
display: flex;
gap: 6px;
}
/* 内容 */
.approval-inline-card__body {
margin-bottom: 8px;
}
.approval-inline-card__title {
display: flex;
align-items: baseline;
gap: 4px;
margin-bottom: 4px;
font-size: 13px;
}
.approval-inline-card__title label {
color: #909399;
white-space: nowrap;
}
.approval-inline-card__content {
display: flex;
align-items: flex-start;
gap: 4px;
font-size: 13px;
}
.approval-inline-card__content label {
color: #909399;
white-space: nowrap;
}
.approval-inline-card__content-text {
color: #606266;
line-height: 1.5;
}
/* 拓扑预览 */
.approval-inline-card__topology {
margin-bottom: 8px;
padding: 8px 10px;
background: #f5f7fa;
border-radius: 6px;
}
.approval-inline-card__topology-label {
font-size: 12px;
color: #909399;
margin-bottom: 4px;
}
/* 迷你SVG图谱缩略图(任务2:P2) */
.approval-inline-card__topology-svg {
width: 100%;
height: 76px;
margin-bottom: 4px;
background: #ffffff;
border-radius: 4px;
border: 1px solid #ebeef5;
}
.approval-inline-card__topology-flow {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.approval-inline-card__topology-node {
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
.approval-inline-card__topology-node--parent {
background: #ecf5ff;
color: #409eff;
}
.approval-inline-card__topology-node--issue {
background: #f0f9eb;
color: #67c23a;
}
.approval-inline-card__topology-node--action {
background: #fdf6ec;
color: #e6a23c;
}
.approval-inline-card__topology-arrow {
font-size: 11px;
color: #c0c4cc;
}
/* audience 下拉 */
.approval-inline-card__audience {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 10px;
font-size: 13px;
}
.approval-inline-card__audience label {
color: #909399;
}
/* 改写表单 */
.approval-inline-card__rewrite-form {
margin-bottom: 10px;
padding: 10px;
background: #f5f7fa;
border-radius: 6px;
}
.approval-inline-card__rewrite-field {
margin-bottom: 6px;
}
.approval-inline-card__rewrite-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
/* 操作按钮 */
.approval-inline-card__actions {
display: flex;
gap: 8px;
}
/* ── 重复警告(任务3:P2) ── */
.approval-inline-card__dup-warning {
margin-bottom: 8px;
padding: 8px 10px;
background: #fef0f0;
border: 1px solid #fde2e2;
border-radius: 6px;
}
.approval-inline-card__dup-warning-title {
font-size: 12px;
font-weight: 600;
color: #f56c6c;
margin-bottom: 4px;
}
.approval-inline-card__dup-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 2px 0;
font-size: 12px;
}
.approval-inline-card__dup-name {
color: #606266;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 200px;
}
/* 已处理状态 */
.approval-inline-card__resolved {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: #909399;
}
.approval-inline-card__reviewer {
color: #909399;
}
.approval-inline-card__review-time {
color: #c0c4cc;
}
</style>
@@ -26,8 +26,8 @@
<!-- 发送者名称 --> <!-- 发送者名称 -->
<div class="message-sender-name"> <div class="message-sender-name">
{{ senderLabel }} {{ senderLabel }}
<!-- AI消息带AI标签 --> <!-- AI消息带达寇拉头像 -->
<span v-if="message.sender_type === 'ai'" class="ai-tag">AI</span> <img v-if="message.sender_type === 'ai'" src="/duckula.webp" class="duckula-avatar" alt="Duckula" />
</div> </div>
<!-- 消息气泡 --> <!-- 消息气泡 -->
@@ -214,9 +214,9 @@ async function copyMessage(): Promise<void> {
/** 发送者标签文字 */ /** 发送者标签文字 */
const senderLabel = computed(() => { const senderLabel = computed(() => {
const labelMap: Record<string, string> = { const labelMap: Record<string, string> = {
employee: props.message.sender_name || '员工', employee: props.message.sender_name || conversationStore.currentConversation?.employee_name || '未知',
agent: props.message.sender_name || '我', agent: props.message.sender_name || '我',
ai: 'AI助手', ai: 'Duckula(达寇拉)',
} }
return labelMap[props.message.sender_type] || '未知' return labelMap[props.message.sender_type] || '未知'
}) })
@@ -25,7 +25,7 @@
<!-- 发送者名称 --> <!-- 发送者名称 -->
<div v-if="message.sender_type !== 'agent'" class="message-sender-name"> <div v-if="message.sender_type !== 'agent'" class="message-sender-name">
{{ senderLabel }} {{ senderLabel }}
<span v-if="message.sender_type === 'ai'" class="ai-tag">AI</span> <img v-if="message.sender_type === 'ai'" src="/duckula.webp" class="duckula-avatar" alt="Duckula" />
</div> </div>
<!-- 消息气泡 --> <!-- 消息气泡 -->
@@ -196,9 +196,9 @@ const copySuccess = ref(false)
/** 发送者标签 */ /** 发送者标签 */
const senderLabel = computed(() => { const senderLabel = computed(() => {
const labelMap: Record<string, string> = { const labelMap: Record<string, string> = {
employee: props.message.sender_name || '员工', employee: props.message.sender_name || conversationStore.currentConversation?.employee_name || '未知',
agent: props.message.sender_name || '我', agent: props.message.sender_name || '我',
ai: 'AI助手', ai: 'Duckula(达寇拉)',
} }
return labelMap[props.message.sender_type] || '未知' return labelMap[props.message.sender_type] || '未知'
}) })
@@ -29,7 +29,8 @@
<!-- 姓名·部门岗位 + IT 等级 --> <!-- 姓名·部门岗位 + IT 等级 -->
<div class="user-info-bar__name-group"> <div class="user-info-bar__name-group">
<span class="user-info-bar__name">{{ conversation?.employee_name || '未知' }}</span> <!-- BUGFIX: 当姓名缺失时至少显示工号避免显示"未知" -->
<span class="user-info-bar__name">{{ conversation?.employee_name || conversation?.employee_id || '未知' }}</span>
<span <span
v-if="conversation?.department || conversation?.position" v-if="conversation?.department || conversation?.position"
class="user-info-bar__dept" class="user-info-bar__dept"
@@ -0,0 +1,254 @@
// =============================================================================
// 企微IT智能服务台 — 坐席控制台 审批队列状态管理(Tier1 / P0-6 / D7
// =============================================================================
// 说明:独立审批队列的 Vue3 Composable,封装队列数据获取、
// 筛选状态管理、审批动作调用。
//
// D7 硬约束:
// - 提案默认 pending,不自动 applied
// - 72 小时超时 → expired
// =============================================================================
import { ref, reactive, computed } from 'vue'
// ===========================================================================
// 类型定义
// ===========================================================================
/** 队列中建议条目 */
export interface QueueSuggestion {
id: string
suggestion_type: string
status: string
title: string
content: string
category: string
tags: string[]
source_type: string
confidence?: number | null
audience?: string | null
issue?: string | null
action?: string | null
relation_type?: string | null
parent_issue?: string | null
graph_sync_status?: string | null
source_failed?: boolean
queued_at?: string | null
created_at: string
updated_at: string
}
/** 队列筛选参数 */
export interface QueueFilter {
status?: string
audience?: string
page: number
page_size: number
}
/** 队列统计 */
export interface QueueStats {
queued_total: number
pending_total: number
by_audience: Record<string, number>
by_source_type: Record<string, number>
}
// ===========================================================================
// Composable
// ===========================================================================
export function useApprovalQueue() {
// =========================================================================
// 状态
// =========================================================================
/** 队列列表 */
const items = ref<QueueSuggestion[]>([])
/** 总数 */
const total = ref(0)
/** 加载状态 */
const loading = ref(false)
/** 当前筛选 */
const filter = reactive<QueueFilter>({
status: '',
audience: '',
page: 1,
page_size: 20,
})
/** 队列统计 */
const stats = ref<QueueStats>({
queued_total: 0,
pending_total: 0,
by_audience: {},
by_source_type: {},
})
/** 当前操作的建议 ID */
const operatingId = ref<string | null>(null)
// =========================================================================
// 计算属性
// =========================================================================
/** 总页数 */
const totalPages = computed(() => Math.ceil(total.value / filter.page_size))
/** API base URL */
const apiBase = computed(() => '/api/admin/approval-queue')
// =========================================================================
// 方法
// =========================================================================
/**
* 获取队列列表
*/
async function fetchQueue() {
loading.value = true
try {
const params = new URLSearchParams()
if (filter.status) params.set('status', filter.status)
if (filter.audience) params.set('audience', filter.audience)
params.set('page', String(filter.page))
params.set('page_size', String(filter.page_size))
const res = await fetch(`${apiBase.value}/queued?${params}`)
const json = await res.json()
if (json.code === 0 && json.data) {
items.value = json.data.items || []
total.value = json.data.total || 0
} else {
console.warn('获取审批队列失败:', json.message)
items.value = []
total.value = 0
}
} catch (err) {
console.error('获取审批队列异常:', err)
items.value = []
total.value = 0
} finally {
loading.value = false
}
}
/**
* 获取队列统计
*/
async function fetchStats() {
try {
const res = await fetch(`${apiBase.value}/queued/stats`)
const json = await res.json()
if (json.code === 0 && json.data) {
stats.value = json.data
}
} catch (err) {
console.error('获取队列统计失败:', err)
}
}
/**
* 审批通过队列中的建议
*/
async function approveQueued(id: string): Promise<boolean> {
operatingId.value = id
try {
const res = await fetch(`${apiBase.value}/queued/${id}/dequeue-approve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
})
const json = await res.json()
if (json.code === 0) {
// 从列表中移除
items.value = items.value.filter(item => item.id !== id)
total.value--
return true
}
return false
} catch (err) {
console.error('队列审批失败:', err)
return false
} finally {
operatingId.value = null
}
}
/**
* 审批拒绝(通过 knowledge_iteration API
*/
async function rejectQueued(id: string, reason: string = '审核不通过'): Promise<boolean> {
operatingId.value = id
try {
const res = await fetch(`/api/admin/knowledge-iteration/suggestions/${id}/reject`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reject_reason: reason }),
})
const json = await res.json()
if (json.code === 0) {
items.value = items.value.filter(item => item.id !== id)
total.value--
return true
}
return false
} catch (err) {
console.error('队列拒绝失败:', err)
return false
} finally {
operatingId.value = null
}
}
/**
* 更新筛选并重新获取
*/
async function updateFilter(newFilter: Partial<QueueFilter>) {
Object.assign(filter, newFilter)
filter.page = newFilter.page ?? 1 // 筛选变更时重置页码
await fetchQueue()
}
/**
* 切换到指定页
*/
async function goToPage(page: number) {
filter.page = page
await fetchQueue()
}
/**
* 刷新数据
*/
async function refresh() {
await Promise.all([fetchQueue(), fetchStats()])
}
// =========================================================================
// 导出
// =========================================================================
return {
// 状态
items,
total,
totalPages,
loading,
filter,
stats,
operatingId,
// 方法
fetchQueue,
fetchStats,
approveQueued,
rejectQueued,
updateFilter,
goToPage,
refresh,
}
}
+1 -1
View File
@@ -436,7 +436,7 @@ export const mockMessages: Message[] = [
...MSG('msg-05', min(45)), ...MSG('msg-05', min(45)),
sender_type: 'ai', sender_type: 'ai',
sender_id: 'wingman-ai', sender_id: 'wingman-ai',
sender_name: 'AI助手', sender_name: 'Duckula(达寇拉)',
content: '系统检测到 AnyConnect 4.10 版本存在已知证书兼容性问题。根据知识库记录,建议升级到 4.14 版本或使用 SSL VPN 网页版作为临时方案。', content: '系统检测到 AnyConnect 4.10 版本存在已知证书兼容性问题。根据知识库记录,建议升级到 4.14 版本或使用 SSL VPN 网页版作为临时方案。',
msg_type: 'text', msg_type: 'text',
ai_suggestion: true, ai_suggestion: true,
+13
View File
@@ -321,6 +321,19 @@ export const useConversationStore = defineStore('conversation', () => {
try { try {
loadingConversations.value = true loadingConversations.value = true
const data = await getConversations({ page: 1, page_size: 100 }) const data = await getConversations({ page: 1, page_size: 100 })
// ── BUGFIX: 前端兜底 — 确保 UserInfoBar 有可显示的用户标识 ──
// 为什么:即使后端已增加 employees 表回退逻辑,仍有边缘情况可能出现
// employee_name 为空(如 employees 表中也无记录、网络分区等)。
// 这里做前端兜底:当 employee_name 缺失但 employee_id 存在时,
// 用 employee_id 作为显示名,避免 UserInfoBar 显示"未知"。
// 何时触发:仅当后端返回的 employee_name 为空字符串且 employee_id 非空时。
for (const conv of data.items) {
if (!conv.employee_name && conv.employee_id) {
conv.employee_name = conv.employee_id
}
}
conversations.value = data.items conversations.value = data.items
} catch (error) { } catch (error) {
console.error('获取会话列表失败:', error) console.error('获取会话列表失败:', error)
+9
View File
@@ -446,6 +446,15 @@ body {
vertical-align: middle; vertical-align: middle;
} }
/* 达寇拉头像 */
.duckula-avatar {
width: 20px;
height: 20px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
}
/* 会话项(v5.4: flex 布局含头像+内容+缩略头像) */ /* 会话项(v5.4: flex 布局含头像+内容+缩略头像) */
.conversation-item { .conversation-item {
display: flex; display: flex;
+442
View File
@@ -0,0 +1,442 @@
<!-- =============================================================================
企微IT智能服务台 坐席控制台 独立审批队列页Tier1 / P0-6 / D7
=============================================================================
说明独立的审批队列页面展示 pending/queued 状态的提案列表
支持筛选批量操作和超时工单展示
D7 硬约束
- 提案默认待审不自动采纳
- 72 小时超时 expired
- 支持按来源/audience/confidence 筛选
============================================================================= -->
<template>
<div class="approval-queue-page">
<!-- 页面标题 -->
<div class="approval-queue-page__header">
<h2>📋 独立审批队列</h2>
<div class="approval-queue-page__stats">
<el-tag type="warning" size="default">
待审核 {{ stats.pending_total }}
</el-tag>
<el-tag type="info" size="default">
队列中 {{ stats.queued_total }}
</el-tag>
</div>
</div>
<!-- 筛选栏 -->
<div class="approval-queue-page__filters">
<el-select
v-model="filter.audience"
placeholder="受众类型"
clearable
size="default"
style="width: 160px"
@change="onFilterChange"
>
<el-option label="全部" value="" />
<el-option label="员工快捷回复" value="employee_quick_reply" />
<el-option label="工程师作业指导" value="engineer_workguide" />
</el-select>
<el-select
v-model="filter.status"
placeholder="状态"
clearable
size="default"
style="width: 120px"
@change="onFilterChange"
>
<el-option label="全部" value="" />
<el-option label="待审核" value="pending" />
<el-option label="队列中" value="queued" />
</el-select>
<el-button type="primary" :icon="'Refresh'" @click="refresh" :loading="loading">
刷新
</el-button>
</div>
<!-- 统计卡片 -->
<div class="approval-queue-page__stat-cards">
<div
v-for="(count, label) in stats.by_source_type"
:key="label"
class="approval-queue-page__stat-card"
>
<span class="approval-queue-page__stat-label">{{ sourceTypeLabel(label) }}</span>
<span class="approval-queue-page__stat-count">{{ count }}</span>
</div>
</div>
<!-- 提案列表 -->
<div class="approval-queue-page__list" v-loading="loading">
<div v-if="items.length === 0 && !loading" class="approval-queue-page__empty">
<el-empty description="暂无待审批提案" />
</div>
<div
v-for="item in items"
:key="item.id"
class="approval-queue-page__item"
:class="{ 'approval-queue-page__item--overdue': isOverdue(item) }"
>
<div class="approval-queue-page__item-header">
<div class="approval-queue-page__item-type">
<span>{{ item.suggestion_type === 'new_faq' ? '💡' : '🔄' }}</span>
<span>{{ item.title || '(无标题)' }}</span>
</div>
<div class="approval-queue-page__item-badges">
<el-tag
size="small"
:type="getConfidenceType(item.confidence)"
>
置信 {{ confidencePercent(item.confidence) }}%
</el-tag>
<el-tag size="small" type="info">
{{ sourceTypeLabel(item.source_type) }}
</el-tag>
<el-tag v-if="isOverdue(item)" size="small" type="danger">
即将超时
</el-tag>
</div>
</div>
<div class="approval-queue-page__item-content">
{{ item.content?.substring(0, 120) }}{{ item.content?.length > 120 ? '...' : '' }}
</div>
<div class="approval-queue-page__item-meta">
<span>受众: {{ audienceLabel(item.audience) }}</span>
<span>分类: {{ item.category }}</span>
<span>入队: {{ formatTime(item.queued_at || item.created_at) }}</span>
</div>
<!-- 操作按钮 -->
<div class="approval-queue-page__item-actions">
<el-button
size="small"
type="success"
:loading="operatingId === item.id"
@click="handleApprove(item.id)"
>
采纳
</el-button>
<el-button
size="small"
type="danger"
@click="handleReject(item.id)"
>
驳回
</el-button>
<el-button
size="small"
@click="viewDetail(item)"
>
详情
</el-button>
</div>
</div>
</div>
<!-- 分页 -->
<div v-if="totalPages > 1" class="approval-queue-page__pagination">
<el-pagination
v-model:current-page="filter.page"
:total="total"
:page-size="filter.page_size"
layout="prev, pager, next"
@current-change="goToPage"
/>
</div>
<!-- 驳回确认弹窗 -->
<el-dialog
v-model="showRejectDialog"
title="驳回提案"
width="400px"
>
<el-input
v-model="rejectReason"
type="textarea"
:rows="2"
placeholder="请输入驳回理由..."
/>
<template #footer>
<el-button @click="showRejectDialog = false">取消</el-button>
<el-button type="danger" @click="confirmReject">确认驳回</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useApprovalQueue, type QueueSuggestion } from '@/composables/useApprovalQueue'
// ===========================================================================
// 状态
// ===========================================================================
const router = useRouter()
const {
items, total, totalPages, loading, filter, stats, operatingId,
fetchQueue, fetchStats, approveQueued, rejectQueued,
updateFilter, goToPage, refresh,
} = useApprovalQueue()
/** 驳回弹窗 */
const showRejectDialog = ref(false)
const rejectTargetId = ref('')
const rejectReason = ref('')
// ===========================================================================
// 方法
// ===========================================================================
/** 筛选变更 */
async function onFilterChange() {
await updateFilter({ audience: filter.audience, status: filter.status })
}
/** 采纳 */
async function handleApprove(id: string) {
const ok = await approveQueued(id)
if (ok) {
// 刷新统计
await fetchStats()
}
}
/** 打开驳回弹窗 */
function handleReject(id: string) {
rejectTargetId.value = id
rejectReason.value = '审核不通过'
showRejectDialog.value = true
}
/** 确认驳回 */
async function confirmReject() {
const ok = await rejectQueued(rejectTargetId.value, rejectReason.value)
if (ok) {
showRejectDialog.value = false
await fetchStats()
}
}
/** 查看详情(跳转到内联审批卡片或详情页) */
function viewDetail(item: QueueSuggestion) {
// 可扩展:打开详情弹窗或导航到详情页
console.log('查看提案详情:', item.id)
}
/** 置信度百分比 */
function confidencePercent(confidence: number | null | undefined): number {
return Math.round((confidence ?? 0) * 100)
}
/** 置信度标签类型 */
function getConfidenceType(confidence: number | null | undefined): string {
const c = confidence ?? 0
if (c >= 0.7) return 'success'
if (c >= 0.5) return 'warning'
return 'danger'
}
/** 来源类型标签 */
function sourceTypeLabel(type: string): string {
const map: Record<string, string> = {
annotation: '标注分析',
conversation: '会话分析',
ai_uncertain: 'AI不确定',
manual: '手动录入',
document_ragflow: 'RAGFlow文档',
}
return map[type] || type
}
/** 受众标签 */
function audienceLabel(audience: string | null | undefined): string {
const map: Record<string, string> = {
employee_quick_reply: '员工回复',
engineer_workguide: '工程师指导',
}
return map[audience || ''] || audience || '未知'
}
/** 是否超时(72小时) */
function isOverdue(item: QueueSuggestion): boolean {
if (!item.queued_at) return false
const queuedTime = new Date(item.queued_at).getTime()
const now = Date.now()
const hoursSinceQueued = (now - queuedTime) / (1000 * 60 * 60)
return hoursSinceQueued >= 66 // 到期前 6 小时预警
}
/** 时间格式化 */
function formatTime(iso: string): string {
if (!iso) return '—'
const d = new Date(iso)
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getMonth() + 1}/${d.getDate()} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
// ===========================================================================
// 生命周期
// ===========================================================================
onMounted(() => {
refresh()
})
</script>
<style scoped>
/* =========================================================================
独立审批队列页 — Element Plus 风格
========================================================================= */
.approval-queue-page {
padding: 20px;
max-width: 1000px;
margin: 0 auto;
}
/* 页面标题 */
.approval-queue-page__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.approval-queue-page__header h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
color: #303133;
}
.approval-queue-page__stats {
display: flex;
gap: 8px;
}
/* 筛选栏 */
.approval-queue-page__filters {
display: flex;
gap: 10px;
margin-bottom: 12px;
align-items: center;
}
/* 统计卡片 */
.approval-queue-page__stat-cards {
display: flex;
gap: 10px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.approval-queue-page__stat-card {
display: flex;
flex-direction: column;
align-items: center;
padding: 8px 16px;
background: #f5f7fa;
border-radius: 6px;
min-width: 100px;
}
.approval-queue-page__stat-label {
font-size: 11px;
color: #909399;
}
.approval-queue-page__stat-count {
font-size: 20px;
font-weight: 700;
color: #303133;
}
/* 列表 */
.approval-queue-page__list {
min-height: 200px;
}
.approval-queue-page__empty {
padding: 40px 0;
}
/* 单条提案 */
.approval-queue-page__item {
padding: 14px;
margin-bottom: 10px;
background: #ffffff;
border: 1px solid #e4e7ed;
border-radius: 8px;
transition: box-shadow 0.2s;
}
.approval-queue-page__item:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.approval-queue-page__item--overdue {
border-color: #fde2e2;
background: #fef0f0;
}
/* 提案头部 */
.approval-queue-page__item-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.approval-queue-page__item-type {
display: flex;
align-items: center;
gap: 4px;
font-size: 14px;
font-weight: 600;
color: #303133;
}
.approval-queue-page__item-badges {
display: flex;
gap: 6px;
}
/* 内容预览 */
.approval-queue-page__item-content {
font-size: 13px;
color: #606266;
margin-bottom: 8px;
line-height: 1.5;
}
/* 元信息 */
.approval-queue-page__item-meta {
display: flex;
gap: 16px;
font-size: 12px;
color: #909399;
margin-bottom: 10px;
}
/* 操作按钮 */
.approval-queue-page__item-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
/* 分页 */
.approval-queue-page__pagination {
display: flex;
justify-content: center;
margin-top: 20px;
}
</style>