Files
2026-09-29 15:52:07 +08:00

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>