880 lines
28 KiB
Vue
880 lines
28 KiB
Vue
<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>
|