feat(agent): approval queue inline card + exclusion panel
新增 ApprovalQueue 视图、ApprovalInlineCard/AgentExclusionPanel 组件与 useApprovalQueue; 对话/API 适配; public 静态资源。
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
*.log
|
||||
@@ -16,7 +16,7 @@ WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml* ./
|
||||
|
||||
# 安装依赖
|
||||
RUN pnpm install --frozen-lockfile
|
||||
RUN pnpm install --no-frozen-lockfile
|
||||
|
||||
# 复制源码(后续通过 volume mount 更新)
|
||||
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)
|
||||
=============================================================================
|
||||
说明:会话中的知识建议内联审批卡片,支持采纳/驳回/改写操作,
|
||||
展示拓扑预览(Issue→Action 小图)、confidence、audience 下拉编辑。
|
||||
|
||||
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>
|
||||
<!-- 重复标记(任务3:P2) -->
|
||||
<el-tag v-if="hasDuplicates" size="small" type="danger" effect="dark">
|
||||
⚠ 可能重复
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 重复警告(任务3:P2) -->
|
||||
<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">
|
||||
{{ senderLabel }}
|
||||
<!-- AI消息带AI标签 -->
|
||||
<span v-if="message.sender_type === 'ai'" class="ai-tag">AI</span>
|
||||
<!-- AI消息带达寇拉头像 -->
|
||||
<img v-if="message.sender_type === 'ai'" src="/duckula.webp" class="duckula-avatar" alt="Duckula" />
|
||||
</div>
|
||||
|
||||
<!-- 消息气泡 -->
|
||||
@@ -214,9 +214,9 @@ async function copyMessage(): Promise<void> {
|
||||
/** 发送者标签文字 */
|
||||
const senderLabel = computed(() => {
|
||||
const labelMap: Record<string, string> = {
|
||||
employee: props.message.sender_name || '员工',
|
||||
employee: props.message.sender_name || conversationStore.currentConversation?.employee_name || '未知',
|
||||
agent: props.message.sender_name || '我',
|
||||
ai: 'AI助手',
|
||||
ai: 'Duckula(达寇拉)',
|
||||
}
|
||||
return labelMap[props.message.sender_type] || '未知'
|
||||
})
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
<!-- 发送者名称 -->
|
||||
<div v-if="message.sender_type !== 'agent'" class="message-sender-name">
|
||||
{{ 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>
|
||||
|
||||
<!-- 消息气泡 -->
|
||||
@@ -196,9 +196,9 @@ const copySuccess = ref(false)
|
||||
/** 发送者标签 */
|
||||
const senderLabel = computed(() => {
|
||||
const labelMap: Record<string, string> = {
|
||||
employee: props.message.sender_name || '员工',
|
||||
employee: props.message.sender_name || conversationStore.currentConversation?.employee_name || '未知',
|
||||
agent: props.message.sender_name || '我',
|
||||
ai: 'AI助手',
|
||||
ai: 'Duckula(达寇拉)',
|
||||
}
|
||||
return labelMap[props.message.sender_type] || '未知'
|
||||
})
|
||||
|
||||
@@ -29,7 +29,8 @@
|
||||
|
||||
<!-- 姓名·部门岗位 + IT 等级 -->
|
||||
<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
|
||||
v-if="conversation?.department || conversation?.position"
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -436,7 +436,7 @@ export const mockMessages: Message[] = [
|
||||
...MSG('msg-05', min(45)),
|
||||
sender_type: 'ai',
|
||||
sender_id: 'wingman-ai',
|
||||
sender_name: 'AI助手',
|
||||
sender_name: 'Duckula(达寇拉)',
|
||||
content: '系统检测到 AnyConnect 4.10 版本存在已知证书兼容性问题。根据知识库记录,建议升级到 4.14 版本或使用 SSL VPN 网页版作为临时方案。',
|
||||
msg_type: 'text',
|
||||
ai_suggestion: true,
|
||||
|
||||
@@ -321,6 +321,19 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
try {
|
||||
loadingConversations.value = true
|
||||
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
|
||||
} catch (error) {
|
||||
console.error('获取会话列表失败:', error)
|
||||
|
||||
@@ -446,6 +446,15 @@ body {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* 达寇拉头像 */
|
||||
.duckula-avatar {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 会话项(v5.4: flex 布局含头像+内容+缩略头像) */
|
||||
.conversation-item {
|
||||
display: flex;
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user