Files
zyt/admin/src/views/assistant_service/components/AlertInbox.vue
T
2026-11-12 09:07:07 +08:00

880 lines
28 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<section class="alert-inbox" aria-label="医助行为预警">
<el-alert
v-if="data?.scan.reason"
:title="data.scan.reason"
type="warning"
:closable="false"
show-icon
class="scan-note"
/>
<div v-if="data" class="metric-strip" aria-label="预警汇总">
<article class="metric" :class="{ 'metric--attention': data.summary.open > 0 }">
<span>待处理</span>
<strong>{{ data.summary.open }}</strong>
<small>还没有人复核</small>
</article>
<article class="metric" :class="{ 'metric--high': data.summary.high_open > 0 }">
<span>高风险</span>
<strong>{{ data.summary.high_open }}</strong>
<small>诊疗、承诺、脱离平台</small>
</article>
<article class="metric">
<span>聊天命中</span>
<strong>{{ data.summary.im_open }}</strong>
<small>员工侧已归档文字</small>
</article>
<article class="metric" :class="{ 'metric--attention': callCoverage?.status === 'available' && data.summary.call_open > 0 }">
<span>通话转写</span>
<strong v-if="callCoverage?.status === 'available'">{{ data.summary.call_open }}</strong>
<strong v-else class="metric-text">{{ callMetric }}</strong>
<small>{{ callCoverage?.status === 'available' ? '员工侧转写命中' : callMetricNote }}</small>
</article>
</div>
<div class="inbox-panel">
<div class="table-heading">
<div>
<h2>命中记录</h2>
<p class="muted">一条记录是一名员工、一条规则、一处原文。没有记录不等于今天没有沟通。</p>
</div>
<form class="search-form" @submit.prevent="search">
<el-input
v-model="keyword"
aria-label="搜索预警"
placeholder="搜索员工或患者"
clearable
@clear="search"
/>
<el-button native-type="submit">搜索</el-button>
</form>
</div>
<div class="role-switch" role="group" aria-label="按身份查看">
<button
type="button"
:class="{ active: role === 'assistant' }"
:aria-pressed="role === 'assistant'"
@click="setRole('assistant')"
>
<span>医助</span>
<strong>{{ data?.summary.assistant_open ?? 0 }}</strong>
<small>待处理</small>
</button>
<button
type="button"
:class="{ active: role === 'doctor' }"
:aria-pressed="role === 'doctor'"
@click="setRole('doctor')"
>
<span>医生</span>
<strong>{{ data?.summary.doctor_open ?? 0 }}</strong>
<small>待处理</small>
</button>
<button
type="button"
class="role-switch__all"
:class="{ active: role === 'all' }"
:aria-pressed="role === 'all'"
@click="setRole('all')"
>
全部身份
</button>
</div>
<section v-if="role === 'assistant'" class="chat-workspace" aria-label="企微聊天">
<aside class="people-pane">
<p class="pane-label">企微账号</p>
<button
v-for="account in directory?.accounts || []"
:key="account.external_id"
type="button"
:class="{ active: selected.account === account.external_id }"
@click="choosePerson({ id: 0, name: account.name || '未命名账号', account: account.external_id })"
>
<strong>{{ account.name || '未命名账号' }}</strong>
<small>{{ account.conversations }} 个会话</small>
</button>
<p class="pane-label">已关联医助</p>
<button
v-for="person in linkedStaff"
:key="person.id"
type="button"
:class="{ active: selected.id === person.id && !selected.account }"
@click="choosePerson({ id: person.id, name: person.name, account: '' })"
>
<strong>{{ person.name || '未命名医助' }}</strong>
<small>{{ person.conversations }} 个会话</small>
</button>
<p v-if="!linkedStaff.length" class="pane-note">还没有医助和企微账号同名或绑定了同一个 UserId。</p>
</aside>
<div class="chat-pane">
<h3>{{ selected.name || '选择左侧的人' }}</h3>
<WecomThread
v-if="selected.id || selected.account"
:assistant-id="selected.id"
:account="selected.account"
/>
</div>
</section>
<div v-if="role !== 'assistant'" class="table-toolbar">
<div class="filter-group" role="group" aria-label="筛选预警">
<button
v-for="item in filters"
:key="item.value"
type="button"
:aria-pressed="filter === item.value"
:class="{ active: filter === item.value }"
@click="setFilter(item.value)"
>
{{ item.label }}
</button>
</div>
<span class="muted">{{
data?.scan.last_scan_at
? `最近扫描 ${shanghaiTime(data.scan.last_scan_at)}`
: '扫描时间待确认'
}}</span>
</div>
<el-alert
v-if="error"
:title="error"
type="error"
:closable="false"
show-icon
class="table-error"
/>
<el-skeleton v-if="role !== 'assistant' && loading && !data" :rows="6" animated class="table-skeleton" />
<ul v-else-if="role !== 'assistant' && data?.lists.length" v-loading="loading" class="feed">
<li
v-for="row in data.lists"
:key="row.id"
:class="['feed-item', `feed-item--${roleKind(row)}`]"
>
<div class="feed-main">
<div class="feed-who">
<span :class="['role-pill', `role-pill--${roleKind(row)}`]">{{
row.staff.role_name || '员工'
}}</span>
<button
v-if="row.staff.assistant"
type="button"
class="staff-link"
@click="choosePerson({ id: row.staff.id, name: row.staff.name, account: '' })"
>
{{ row.staff.name || '未命名医助' }}
</button>
<strong v-else>{{ row.staff.name || '未命名员工' }}</strong>
<span class="patient">{{ row.patient_name || '未命名患者' }}</span>
<small v-if="row.diagnosis_id">诊单 #{{ row.diagnosis_id }}</small>
</div>
<p class="snippet">{{ row.snippet }}</p>
<p v-if="row.review_note" class="review-note">复核:{{ row.review_note }}</p>
<div class="feed-meta">
<time class="number">{{ shanghaiTime(row.occurred_at) }}</time>
<span :class="['severity', `severity--${row.severity}`]"
>{{ severityLabels[row.severity] }} · {{ row.rule_label }}</span
>
<span>{{ channelLabel(row.channel) }}</span>
<span>{{ statusLabels[row.status] }}</span>
</div>
</div>
<div v-if="canReview" class="feed-actions">
<div class="review-actions">
<button
type="button"
:disabled="row.status === 'confirmed'"
@click="review(row, 'confirmed')"
>
成立
</button>
<button
type="button"
:disabled="row.status === 'false_positive'"
@click="review(row, 'false_positive')"
>
误报
</button>
<button
type="button"
:disabled="row.status === 'followed'"
@click="review(row, 'followed')"
>
已跟进
</button>
</div>
<form
v-if="noteTarget === row.id"
class="note-form"
@submit.prevent="review(row, 'false_positive')"
>
<el-input
v-model="note"
aria-label="误报原因"
maxlength="200"
placeholder="填写误报原因"
/>
<el-button native-type="submit" link type="primary">提交</el-button>
</form>
</div>
</li>
</ul>
<el-empty v-else-if="role !== 'assistant'" :image-size="80" :description="emptyDescription" />
<footer v-if="role !== 'assistant'" class="table-footer">
<span class="muted">{{ coverageText }}</span>
<el-pagination
v-if="data?.count"
v-model:current-page="page"
:total="data.count"
:page-size="20"
layout="total, prev, pager, next"
@current-change="changePage"
/>
</footer>
</div>
</section>
</template>
<script setup lang="ts">
import { computed, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref, watch } from 'vue'
import {
assistantAlerts,
assistantReviewAlert,
assistantWecomDirectory,
type AlertPage,
type AlertSeverity,
type AlertStatus,
type ServiceAlert,
type Staff,
type WecomDirectory
} from '@/api/assistantService'
import WecomThread from './WecomThread.vue'
import { errorText, isCancelled, shanghaiTime } from '../shared'
const props = defineProps<{ refreshKey: number; canReview: boolean }>()
const emit = defineEmits<{ 'open-staff': [staff: Staff] }>()
const data = ref<AlertPage | null>(null)
const loading = ref(false)
const error = ref('')
const keyword = ref('')
const keywordApplied = ref('')
const filter = ref('open')
const role = ref<'all' | 'assistant' | 'doctor'>('all')
const page = ref(1)
const note = ref('')
const noteTarget = ref(0)
const directory = ref<WecomDirectory | null>(null)
const selected = ref({ id: 0, name: '', account: '' })
const linkedStaff = computed(() => (directory.value?.staff || []).filter((person) => person.conversations > 0))
const filters: Array<{
value: string
label: string
status: AlertStatus | 'all'
severity: AlertSeverity | 'all'
}> = [
{ value: 'open', label: '待处理', status: 'open', severity: 'all' },
{ value: 'high', label: '高风险', status: 'open', severity: 'high' },
{ value: 'all', label: '全部', status: 'all', severity: 'all' },
{ value: 'confirmed', label: '已成立', status: 'confirmed', severity: 'all' },
{ value: 'false_positive', label: '误报', status: 'false_positive', severity: 'all' },
{ value: 'followed', label: '已跟进', status: 'followed', severity: 'all' }
]
const statusLabels: Record<AlertStatus, string> = {
open: '待处理',
confirmed: '已成立',
false_positive: '误报',
followed: '已跟进'
}
const severityLabels: Record<AlertSeverity, string> = { high: '高', medium: '中', low: '低' }
const activeFilter = computed(() => filters.find((item) => item.value === filter.value) || filters[0])
const coverageText = computed(
() => data.value?.coverage.map((item) => item.note).join(' · ') || '通话转写和企微尚未接入'
)
const callCoverage = computed(() => data.value?.coverage.find((item) => item.channel === 'call'))
const callMetric = computed(() => {
if (callCoverage.value?.status === 'no_transcript') return '无正文'
if (callCoverage.value?.status === 'not_scanned') return '未检查'
return '未接入'
})
const callMetricNote = computed(() => {
if (callCoverage.value?.status === 'no_transcript') return '医助主叫没有可判定转写'
if (callCoverage.value?.status === 'not_scanned') return '已有转写,尚未写入预警'
return '有录音也不计为已检查'
})
const emptyDescription = computed(() => {
if (error.value) return '暂时无法读取预警,请重试'
if (data.value && !data.value.schema_ready) return '预警表尚未就绪'
if (data.value?.scan.state === 'disabled') return '预警扫描尚未开启'
if (data.value?.scan.state === 'waiting' && !data.value.scan.last_scan_at) return '尚未完成首次扫描'
if (filter.value === 'open' && role.value === 'assistant') return '当前没有医助的待处理预警'
if (filter.value === 'open' && role.value === 'doctor') return '当前没有医生的待处理预警'
if (filter.value === 'open' && data.value?.empty_reason) return data.value.empty_reason
if (filter.value === 'open') return '当前没有待处理预警'
return '当前筛选下没有预警'
})
let disposed = false,
activated = true,
mounted = false,
listening = false,
revision = 0
let request: AbortController | undefined
let timer: ReturnType<typeof setTimeout> | undefined
const visible = () =>
activated && (typeof document === 'undefined' || document.visibilityState !== 'hidden')
function roleKind(row: ServiceAlert) {
if (row.staff.assistant || row.staff.role_name === '医助') return 'assistant'
if (row.staff.role_name === '医生') return 'doctor'
return 'other'
}
function channelLabel(channel: string) {
if (channel === 'call') return '通话'
if (channel === 'wecom') return '企微'
if (channel === 'im') return '聊天'
return '其他'
}
function stopTimer() {
if (timer) clearTimeout(timer)
timer = undefined
}
function schedule() {
stopTimer()
if (!disposed && mounted && visible()) timer = setTimeout(() => void load(true), 30000)
}
async function load(silent = false) {
if (disposed || !visible()) return false
const token = ++revision
request?.abort()
request = new AbortController()
if (!silent) loading.value = true
error.value = ''
try {
const result = await assistantAlerts(
{
keyword: keywordApplied.value,
status: activeFilter.value.status,
severity: activeFilter.value.severity,
role: role.value,
page_no: page.value,
page_size: 20
},
request.signal
)
if (disposed || token !== revision) return false
if (!result.summary || !result.scan || !Array.isArray(result.lists)) {
throw new Error('预警数据不完整,请刷新后重试')
}
data.value = result
return true
} catch (cause) {
if (!disposed && token !== revision && !isCancelled(cause)) {
if (!silent) data.value = null
error.value = errorText(cause)
}
return false
} finally {
if (!disposed && token === revision) {
loading.value = false
schedule()
}
}
}
function search() {
keywordApplied.value = keyword.value.trim()
page.value = 1
void load()
}
function setFilter(value: string) {
if (filter.value === value) return
filter.value = value
page.value = 1
noteTarget.value = 0
void load()
}
let directoryTimer: ReturnType<typeof setTimeout> | undefined
function stopDirectoryTimer() {
if (directoryTimer) clearTimeout(directoryTimer)
directoryTimer = undefined
}
function scheduleDirectory() {
stopDirectoryTimer()
if (!disposed && role.value === 'assistant') directoryTimer = setTimeout(() => void loadDirectory(true), 15000)
}
async function loadDirectory(silent = false) {
try {
const result = await assistantWecomDirectory()
if (disposed) return
directory.value = result
const current = selected.value
const stillAccount = result.accounts.some((item) => item.external_id === current.account)
const stillStaff = result.staff.some((item) => item.id === current.id && !current.account)
if (!stillAccount && !stillStaff) {
const account = result.accounts.find((item) => item.conversations > 0) || result.accounts[0]
if (account) {
selected.value = { id: 0, name: account.name || '未命名账号', account: account.external_id }
}
}
} catch (cause) {
if (!disposed && !isCancelled(cause) && !silent) error.value = errorText(cause)
} finally {
scheduleDirectory()
}
}
function choosePerson(target: { id: number; name: string; account: string }) {
selected.value = target
if (role.value !== 'assistant') {
role.value = 'assistant'
page.value = 1
void load()
}
}
function setRole(value: 'all' | 'assistant' | 'doctor') {
if (role.value === value) return
role.value = value
page.value = 1
noteTarget.value = 0
if (value === 'assistant') void loadDirectory()
else stopDirectoryTimer()
void load()
}
function changePage() {
void load()
}
function openStaff(row: ServiceAlert) {
emit('open-staff', row.staff)
}
async function review(row: ServiceAlert, status: Exclude<AlertStatus, 'open'>) {
if (!props.canReview || row.status === status) return
if (status === 'false_positive' && note.value.trim() === '') {
noteTarget.value = row.id
note.value = ''
return
}
try {
await assistantReviewAlert({
id: row.id,
status,
note: status === 'false_positive' ? note.value.trim() : ''
})
noteTarget.value = 0
note.value = ''
await load(true)
} catch (cause) {
if (!isCancelled(cause)) error.value = errorText(cause)
}
}
function updateVisibility() {
if (!visible()) {
stopTimer()
request?.abort()
revision++
loading.value = false
return
}
void load(true)
}
watch(
() => props.refreshKey,
(_value, previous) => void load(previous !== undefined),
{ immediate: true, flush: 'sync' }
)
onMounted(() => {
mounted = true
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', updateVisibility)
listening = true
}
schedule()
})
onActivated(() => {
activated = true
})
onDeactivated(() => {
activated = false
updateVisibility()
})
onBeforeUnmount(() => {
disposed = true
stopTimer()
stopDirectoryTimer()
request?.abort()
revision++
if (listening && typeof document !== 'undefined') {
document.removeEventListener('visibilitychange', updateVisibility)
}
})
</script>
<style scoped>
.alert-inbox {
color: var(--el-text-color-primary);
}
.scan-note {
margin-bottom: 12px;
}
.metric-strip {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-light);
border-radius: 5px;
padding: 20px 0;
margin-bottom: 12px;
}
.metric {
padding: 0 22px;
border-right: 1px solid var(--el-border-color-lighter);
}
.metric:last-child {
border-right: 0;
}
.metric > span,
.metric small {
display: block;
color: var(--el-text-color-secondary);
font-size: 12px;
line-height: 1.6;
}
.metric strong {
display: block;
margin: 6px 0 3px;
font-size: 27px;
line-height: 1.15;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.metric-text {
font-size: 22px;
font-variant-numeric: normal;
}
.metric--attention strong {
color: var(--el-color-primary);
}
.metric--high strong {
color: var(--el-color-danger);
}
.inbox-panel {
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-light);
border-radius: 5px;
overflow: hidden;
}
.table-heading,
.table-toolbar,
.table-footer {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.table-heading {
padding: 22px 22px 14px;
}
.table-heading h2 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.table-heading p,
.muted,
small {
color: var(--el-text-color-secondary);
font-size: 12px;
line-height: 1.7;
}
.table-heading p {
margin: 5px 0 0;
}
.search-form {
display: flex;
gap: 8px;
width: 290px;
max-width: 100%;
}
.role-switch {
display: grid;
grid-template-columns: 1fr 1fr auto;
gap: 10px;
padding: 0 22px 16px;
}
.role-switch button {
display: flex;
align-items: baseline;
gap: 10px;
padding: 14px 16px;
border: 1px solid var(--el-border-color-light);
border-radius: 8px;
background: var(--el-fill-color-blank);
cursor: pointer;
text-align: left;
color: var(--el-text-color-primary);
}
.role-switch button span {
font-size: 14px;
font-weight: 650;
}
.role-switch button strong {
font-size: 26px;
line-height: 1;
font-variant-numeric: tabular-nums;
}
.role-switch button small {
color: var(--el-text-color-secondary);
}
.role-switch button:first-child strong {
color: #0f766e;
}
.role-switch button:nth-child(2) strong {
color: #1e4b8a;
}
.role-switch button.active:first-child {
border-color: #0f766e;
background: #f0fdfa;
}
.role-switch button.active:nth-child(2) {
border-color: #1e4b8a;
background: #eff6ff;
}
.role-switch__all {
align-items: center;
font-size: 13px;
font-weight: 600;
}
.role-switch__all.active {
border-color: var(--el-color-primary);
color: var(--el-color-primary);
}
.chat-workspace {
display: grid;
grid-template-columns: 240px 1fr;
gap: 16px;
min-height: 560px;
padding: 0 16px 16px;
}
.people-pane,
.chat-pane {
min-width: 0;
}
.people-pane {
padding: 8px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 10px;
background: var(--el-fill-color-blank);
}
.pane-label,
.pane-note {
margin: 8px 8px 6px;
color: var(--el-text-color-secondary);
font-size: 12px;
line-height: 1.5;
}
.people-pane button {
display: flex;
width: 100%;
justify-content: space-between;
align-items: baseline;
gap: 8px;
margin-bottom: 6px;
padding: 10px 12px;
border: 1px solid transparent;
border-radius: 8px;
background: transparent;
text-align: left;
cursor: pointer;
}
.people-pane button.active {
border-color: #1e4b8a;
background: #eff6ff;
}
.people-pane strong {
font-size: 14px;
}
.people-pane small {
color: var(--el-text-color-secondary);
}
.chat-pane {
padding: 8px 8px 8px 4px;
}
.chat-pane h3 {
margin: 0 0 12px;
font-size: 18px;
}
@media (max-width: 900px) {
.chat-workspace {
grid-template-columns: 1fr;
}
}
.feed {
list-style: none;
margin: 0;
padding: 0 12px 8px;
}
.feed-item {
display: flex;
justify-content: space-between;
gap: 18px;
margin-bottom: 10px;
padding: 14px 16px 14px 14px;
border: 1px solid var(--el-border-color-lighter);
border-left-width: 4px;
border-radius: 8px;
background: var(--el-bg-color);
}
.feed-item--assistant {
border-left-color: #0f766e;
}
.feed-item--doctor {
border-left-color: #1e4b8a;
}
.feed-item--other {
border-left-color: var(--el-border-color);
}
.feed-main {
min-width: 0;
flex: 1;
}
.feed-who {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.role-pill {
display: inline-flex;
align-items: center;
height: 22px;
padding: 0 8px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.04em;
}
.role-pill--assistant {
color: #0f766e;
background: #ccfbf1;
}
.role-pill--doctor {
color: #1e4b8a;
background: #dbeafe;
}
.role-pill--other {
color: var(--el-text-color-regular);
background: var(--el-fill-color);
}
.patient {
color: var(--el-text-color-regular);
}
.feed-who small {
margin: 0;
}
.snippet {
margin: 8px 0 0;
font-size: 14px;
line-height: 1.65;
}
.review-note {
margin: 4px 0 0;
color: var(--el-text-color-secondary);
font-size: 12px;
}
.feed-meta {
display: flex;
flex-wrap: wrap;
gap: 8px 14px;
margin-top: 8px;
color: var(--el-text-color-secondary);
font-size: 12px;
}
.feed-actions {
flex: 0 0 auto;
}
.table-toolbar {
padding: 0 22px 16px;
}
.filter-group {
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 3px;
border-radius: 4px;
background: var(--el-fill-color-light);
}
.filter-group button,
.review-actions button {
cursor: pointer;
border: 0;
padding: 7px 12px;
border-radius: 3px;
font-size: 12px;
background: transparent;
color: var(--el-text-color-regular);
}
.filter-group button.active {
background: var(--el-bg-color);
color: var(--el-color-primary);
font-weight: 600;
}
.review-actions {
display: flex;
gap: 4px;
}
.review-actions button:disabled {
cursor: default;
color: var(--el-text-color-placeholder);
}
.note-form {
display: flex;
gap: 6px;
margin-top: 8px;
}
.table-error {
margin: 0 22px 16px;
width: auto;
}
.table-skeleton {
padding: 22px;
}
.alert-table small {
display: block;
margin-top: 2px;
}
.staff-link {
border: 0;
background: transparent;
padding: 0;
cursor: pointer;
color: var(--el-color-primary);
font-size: 13px;
}
.severity {
display: inline-block;
margin-right: 6px;
font-size: 12px;
font-weight: 600;
}
.severity--high {
color: var(--el-color-danger);
}
.severity--medium {
color: var(--el-color-warning);
}
.snippet {
quotes: none;
font-style: normal;
}
.number {
font-variant-numeric: tabular-nums;
font-size: 13px;
}
.table-footer {
padding: 16px 22px;
}
@media (max-width: 720px) {
.metric-strip {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18px 0;
}
.metric:nth-child(2) {
border-right: 0;
}
.search-form {
width: 100%;
}
}
</style>