337 lines
9.3 KiB
Vue
337 lines
9.3 KiB
Vue
<script setup lang="ts">
|
|
import { Button, Icon, Loading, showConfirmDialog, showToast } from 'vant';
|
|
import { computed, onBeforeUnmount, ref, watch } from 'vue';
|
|
|
|
import { api, ApiError, type Command, type CommandType, type Device } from '../api';
|
|
import { describeCommand, describeDevice, durationText } from '../format';
|
|
|
|
const props = defineProps<{
|
|
device: Device;
|
|
accountId?: number;
|
|
canControl: boolean;
|
|
controlCode: string;
|
|
}>();
|
|
const emit = defineEmits<{ changed: [] }>();
|
|
|
|
const sending = ref(false);
|
|
const command = ref<Command | null>(null);
|
|
let pollTimer: number | undefined;
|
|
let pollDeadline = 0;
|
|
|
|
const summary = computed(() => describeDevice(props.device));
|
|
const status = computed(() => props.device.status);
|
|
const listening = computed(() => Boolean(status.value?.listening));
|
|
const nextAction = computed<CommandType>(() => (listening.value ? 'stop_listening' : 'start_listening'));
|
|
const progress = computed(() => (command.value ? describeCommand(command.value) : null));
|
|
const busy = computed(() => sending.value || Boolean(progress.value?.busy));
|
|
|
|
// 按钮不能点的时候,要告诉人为什么,而不是一个灰按钮。
|
|
const blockedReason = computed(() => {
|
|
if (!props.device.online) return '电脑离线:客服软件没有运行,无法远程拉起。请到电脑上打开软件。';
|
|
if (!props.device.remote_capable) return '电脑上的客服软件版本过低,升级到最新版后才能远程控制。';
|
|
if (!props.canControl) return `你的 ZYT 账号没有远程启停权限(${props.controlCode}),请联系管理员开通。`;
|
|
return '';
|
|
});
|
|
|
|
function stopPolling() {
|
|
if (pollTimer !== undefined) window.clearTimeout(pollTimer);
|
|
pollTimer = undefined;
|
|
}
|
|
|
|
async function pollCommand() {
|
|
const current = command.value;
|
|
if (!current || current.final) return;
|
|
try {
|
|
const { command: fresh } = await api.command(current.id);
|
|
command.value = fresh;
|
|
if (fresh.final) {
|
|
emit('changed');
|
|
const text = describeCommand(fresh).text;
|
|
showToast({ message: text, type: fresh.status === 'succeeded' ? 'success' : 'fail' });
|
|
return;
|
|
}
|
|
} catch (error) {
|
|
if (error instanceof ApiError && error.status === 404) return;
|
|
}
|
|
if (Date.now() < pollDeadline) {
|
|
pollTimer = window.setTimeout(pollCommand, 3000);
|
|
} else {
|
|
// 电脑迟迟不回执时不再傻等;指令本身 10 分钟后在服务端作废。
|
|
emit('changed');
|
|
}
|
|
}
|
|
|
|
function track(next: Command) {
|
|
stopPolling();
|
|
command.value = next;
|
|
if (!next.final) {
|
|
pollDeadline = Date.now() + 150_000;
|
|
pollTimer = window.setTimeout(pollCommand, 1500);
|
|
}
|
|
}
|
|
|
|
// 页面刷新或重新打开时,接着追踪还没执行完的指令。
|
|
watch(
|
|
() => props.device.last_command,
|
|
(latest) => {
|
|
if (!latest) return;
|
|
if (command.value && command.value.id === latest.id) {
|
|
if (!command.value.final && latest.final) command.value = latest;
|
|
return;
|
|
}
|
|
const recent = latest.expires_at * 1000 > Date.now();
|
|
if (!latest.final && recent) track(latest);
|
|
else if (!command.value && latest.final && recent) command.value = latest;
|
|
},
|
|
{ immediate: true },
|
|
);
|
|
|
|
onBeforeUnmount(stopPolling);
|
|
|
|
async function toggle() {
|
|
if (busy.value || blockedReason.value) return;
|
|
const stopping = nextAction.value === 'stop_listening';
|
|
try {
|
|
await showConfirmDialog({
|
|
title: stopping ? '停止自动回复?' : '启动自动回复?',
|
|
message: stopping
|
|
? `「${props.device.device_name || '这台电脑'}」将停止监听,新消息不再自动回复。`
|
|
: `「${props.device.device_name || '这台电脑'}」将开始监听,并按电脑上的设置自动回复新消息。`,
|
|
confirmButtonText: stopping ? '停止' : '启动',
|
|
confirmButtonColor: stopping ? 'var(--app-danger)' : 'var(--app-primary)',
|
|
});
|
|
} catch {
|
|
return;
|
|
}
|
|
sending.value = true;
|
|
try {
|
|
const { command: created } = await api.sendCommand(props.device.device_id, nextAction.value, props.accountId);
|
|
track(created);
|
|
} catch (error) {
|
|
showToast({ message: error instanceof ApiError ? error.message : '指令发送失败,请重试', type: 'fail' });
|
|
emit('changed');
|
|
} finally {
|
|
sending.value = false;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<article class="panel app-card" :class="`panel--${summary.tone}`">
|
|
<header class="panel-head">
|
|
<div class="panel-icon" aria-hidden="true"><Icon name="desktop-o" /></div>
|
|
<div class="panel-title">
|
|
<h2>{{ device.device_name || '未命名电脑' }}</h2>
|
|
<p class="app-muted">{{ summary.hint }}</p>
|
|
</div>
|
|
<span class="pill" :class="`pill--${summary.tone}`">
|
|
<span class="dot" aria-hidden="true" />
|
|
{{ summary.label }}
|
|
</span>
|
|
</header>
|
|
|
|
<dl class="stats">
|
|
<div class="stat">
|
|
<dt>自动回复</dt>
|
|
<dd :class="listening ? 'tone-success' : 'tone-muted'">
|
|
{{ status ? (listening ? '监听中' : '已停止') : '—' }}
|
|
</dd>
|
|
</div>
|
|
<div class="stat">
|
|
<dt>企业微信</dt>
|
|
<dd :class="status?.wecom_connected ? 'tone-success' : 'tone-muted'">
|
|
{{ status ? (status.wecom_connected ? '已连接' : '未连接') : '—' }}
|
|
</dd>
|
|
</div>
|
|
<div class="stat">
|
|
<dt>回复通道</dt>
|
|
<dd>{{ status?.transport_label || '—' }}</dd>
|
|
</div>
|
|
<div class="stat">
|
|
<dt>待处理</dt>
|
|
<dd :class="(status?.pending_count || 0) > 0 ? 'tone-warning' : ''">
|
|
{{ status ? `${status.pending_count} 条` : '—' }}
|
|
</dd>
|
|
</div>
|
|
</dl>
|
|
|
|
<p class="meta app-muted">
|
|
<span>版本 {{ device.app_version || '未知' }}</span>
|
|
<span v-if="device.online && status?.reported_seconds !== null && status?.reported_seconds !== undefined">
|
|
· 状态 {{ durationText(status.reported_seconds) }}前上报
|
|
</span>
|
|
<span v-else-if="!device.online && device.last_seen_seconds !== null">
|
|
· {{ durationText(device.last_seen_seconds) }}前在线
|
|
</span>
|
|
</p>
|
|
|
|
<div v-if="progress" class="progress" :class="`progress--${progress.tone}`" role="status" aria-live="polite">
|
|
<Loading v-if="progress.busy" size="16" />
|
|
<Icon v-else :name="progress.tone === 'success' ? 'passed' : 'info-o'" />
|
|
<span>{{ progress.text }}</span>
|
|
</div>
|
|
|
|
<div class="actions">
|
|
<Button
|
|
block
|
|
round
|
|
:type="listening ? 'danger' : 'primary'"
|
|
:plain="listening"
|
|
:icon="listening ? 'pause-circle-o' : 'play-circle-o'"
|
|
:disabled="Boolean(blockedReason)"
|
|
:loading="busy"
|
|
:loading-text="sending ? '正在下发…' : '等待电脑执行…'"
|
|
@click="toggle"
|
|
>
|
|
{{ listening ? '停止自动回复' : '启动自动回复' }}
|
|
</Button>
|
|
<p v-if="blockedReason" class="blocked app-muted">{{ blockedReason }}</p>
|
|
</div>
|
|
</article>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.panel {
|
|
padding: 16px;
|
|
}
|
|
.panel-head {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 12px;
|
|
}
|
|
.panel-icon {
|
|
flex: none;
|
|
width: 44px;
|
|
height: 44px;
|
|
border-radius: 12px;
|
|
display: grid;
|
|
place-items: center;
|
|
font-size: 22px;
|
|
color: var(--app-primary);
|
|
background: var(--app-primary-soft);
|
|
}
|
|
.panel-title {
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
.panel-title h2 {
|
|
margin: 2px 0 4px;
|
|
font-size: 17px;
|
|
font-weight: 600;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
.panel-title p {
|
|
margin: 0;
|
|
font-size: 13px;
|
|
line-height: 1.45;
|
|
}
|
|
.pill {
|
|
flex: none;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
padding: 4px 10px;
|
|
border-radius: 999px;
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
white-space: nowrap;
|
|
}
|
|
.pill .dot {
|
|
width: 7px;
|
|
height: 7px;
|
|
border-radius: 50%;
|
|
background: currentColor;
|
|
}
|
|
.pill--success {
|
|
color: var(--app-success);
|
|
background: var(--app-success-soft);
|
|
}
|
|
.pill--success .dot {
|
|
box-shadow: 0 0 0 3px var(--app-success-soft);
|
|
animation: breathe 2.2s ease-in-out infinite;
|
|
}
|
|
.pill--warning {
|
|
color: var(--app-warning);
|
|
background: var(--app-warning-soft);
|
|
}
|
|
.pill--danger {
|
|
color: var(--app-danger);
|
|
background: var(--app-danger-soft);
|
|
}
|
|
.pill--muted,
|
|
.pill--primary {
|
|
color: var(--app-muted);
|
|
background: var(--app-muted-soft);
|
|
}
|
|
@keyframes breathe {
|
|
50% {
|
|
opacity: 0.45;
|
|
}
|
|
}
|
|
.stats {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
gap: 8px;
|
|
margin: 16px 0 0;
|
|
}
|
|
.stat {
|
|
padding: 10px 12px;
|
|
border-radius: 12px;
|
|
background: var(--app-surface-2);
|
|
border: 1px solid var(--app-border);
|
|
}
|
|
.stat dt {
|
|
font-size: 12px;
|
|
color: var(--app-muted);
|
|
}
|
|
.stat dd {
|
|
margin: 4px 0 0;
|
|
font-size: 15px;
|
|
font-weight: 600;
|
|
}
|
|
.meta {
|
|
margin: 12px 0 0;
|
|
font-size: 12px;
|
|
}
|
|
.progress {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
margin-top: 12px;
|
|
padding: 10px 12px;
|
|
border-radius: 12px;
|
|
font-size: 13px;
|
|
line-height: 1.45;
|
|
}
|
|
.progress--primary {
|
|
color: var(--app-primary);
|
|
background: var(--app-primary-soft);
|
|
}
|
|
.progress--success {
|
|
color: var(--app-success);
|
|
background: var(--app-success-soft);
|
|
}
|
|
.progress--danger {
|
|
color: var(--app-danger);
|
|
background: var(--app-danger-soft);
|
|
}
|
|
.progress--warning {
|
|
color: var(--app-warning);
|
|
background: var(--app-warning-soft);
|
|
}
|
|
.progress--muted {
|
|
color: var(--app-muted);
|
|
background: var(--app-muted-soft);
|
|
}
|
|
.actions {
|
|
margin-top: 16px;
|
|
}
|
|
.blocked {
|
|
margin: 10px 4px 0;
|
|
font-size: 13px;
|
|
line-height: 1.5;
|
|
}
|
|
</style>
|