This commit is contained in:
gr
2026-09-29 15:52:07 +08:00
parent 5dfc9303da
commit 993f4c42ab
363 changed files with 42810 additions and 15 deletions
+3
View File
@@ -0,0 +1,3 @@
node_modules
dist
*.local
+6
View File
@@ -0,0 +1,6 @@
# 独立项目:不加入 admin-web 的 pnpm workspace(见上级 pnpm-workspace.yaml 的排除项),
# 依赖和锁文件都在本目录。安装:pnpm install --ignore-workspace
registry=https://registry.npmmirror.com
fetch-timeout=600000
fetch-retries=6
network-concurrency=6
+54
View File
@@ -0,0 +1,54 @@
# 企微客服助手 · 手机远程控制台(mobile-h5)
给客服本人用的手机 / 平板 H5:用 ZYT 账号登录,查看自己电脑上客服软件的运行状态,远程启动 /
停止自动回复,管理会话黑名单。有 `wecom_rpa.remote/all` 权限的主管可以切换查看其他客服。
- 技术栈:Vue 3 + Vite + Vant 4,hash 路由,按需引入 Vant 样式。
- 布局:手机单栏 + 底部标签栏;宽度 ≥ 768px(平板、电脑浏览器)切成左列表 / 右详情两栏。
- 适配:iOS 安全区(`viewport-fit=cover`)、深色模式(跟随系统)、可"添加到主屏幕"(manifest + 不缓存的 Service Worker)。
- 托管:构建产物由管理端 `wechat_rpa/admin_api.py` 挂在 `/m/`,接口是同源的 `/api/v2/mobile/*`。
## 独立项目
这个目录**不属于** admin-web 的 pnpm workspace(`pnpm-workspace.yaml` 里有 `!apps/mobile-h5`),
依赖和锁文件都在本目录,不会影响 vben 管理端的安装和构建。
```bash
cd admin-web/apps/mobile-h5
pnpm install --ignore-workspace # 走本目录 .npmrc 里的 npmmirror
pnpm dev # http://127.0.0.1:5190/m/ ,/api 代理到 127.0.0.1:8766(MOBILE_API_TARGET 可改)
pnpm test # vitest:时间格式、设备 / 指令状态文案
pnpm build # vue-tsc 类型检查 + vite build → dist/
```
发布时把 `dist/` 放到服务器 `/opt/im-admin/admin-web/apps/mobile-h5/dist/`(见
`deploy/mobile-remote-20260928/`),`deploy/im-admin/Dockerfile` 会把它一起打进镜像。
## 页面
| 路由 | 内容 |
|---|---|
| `#/login` | ZYT 账号密码登录(后台代登 ZYT 终端 8,密码不落地);公网明文 HTTP 下提示改用 HTTPS |
| `#/desktops` | 电脑卡片:在线 / 离线、监听中 / 已停止、企业微信连接、回复通道、待处理数;启动 / 停止自动回复(需确认),下发后每 3 秒查询一次指令结果 |
| `#/conversations` | 会话列表(来自聊天归档,只有名称、类型、最近时间,不含聊天正文)+ 搜索 + 黑名单页签;详情里加入 / 移出黑名单 |
| `#/me` | 当前账号、五项手机端权限是否开通、登录信息、添加到主屏幕说明、退出登录 |
电脑离线(客服软件没开)时只显示"电脑离线",无法远程拉起;电脑上是旧版客服软件(没有命令通道)时显示
"版本过低",按钮不可用。
## 接口(`/api/v2/mobile`,Bearer 令牌)
| 方法 | 路径 | 说明 |
|---|---|---|
| POST | `/auth/login` | `{account, password, client_name}` → `{access_token, expires_at, me}` |
| POST | `/auth/logout` | 同时注销 ZYT 终端 8 会话 |
| GET | `/me` | 账号、权限(control / control_all / blacklist_add / blacklist_remove / root)、会话有效期 |
| GET | `/accounts?q=` | 全部客服电脑概况,仅 `wecom_rpa.remote/all` |
| GET | `/desktops?account_id=` | 名下电脑与实时状态;`account_id` 仅 remote/all 可指定别人 |
| POST | `/desktops/{device_id}/commands?account_id=` | `{type: start_listening \| stop_listening}`,离线 409 `device_offline`,旧版 409 `client_outdated` |
| GET | `/commands/{id}` | 指令状态:pending → delivered → succeeded / failed / expired / superseded |
| GET | `/conversations?q=&scope=all\|blocked&cursor=&limit=` | 会话与黑名单状态 |
| PUT | `/blacklist/{account}/{conversation}?account_id=` | `{name, account_name, reason}`,需 `wecom_rpa.blacklist/add` |
| DELETE | `/blacklist/{account}/{conversation}?account_id=` | 需 `wecom_rpa.blacklist/remove` |
错误体统一是 `{"detail": {"code": "...", "message": "给人看的中文"}}`,401 时前端清掉令牌回登录页。
+25
View File
@@ -0,0 +1,25 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="color-scheme" content="light dark" />
<meta name="theme-color" content="#f4f7fd" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#0b1222" media="(prefers-color-scheme: dark)" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="客服控制台" />
<meta name="format-detection" content="telephone=no" />
<meta name="referrer" content="no-referrer" />
<link rel="manifest" href="%BASE_URL%manifest.webmanifest" />
<link rel="icon" type="image/png" href="%BASE_URL%icons/favicon-64.png" />
<link rel="apple-touch-icon" href="%BASE_URL%icons/apple-touch-icon.png" />
<title>企微客服助手 · 手机控制台</title>
</head>
<body>
<div id="app"></div>
<noscript>请在浏览器设置中开启 JavaScript 后再打开手机控制台。</noscript>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+27
View File
@@ -0,0 +1,27 @@
{
"name": "@wecom/mobile-h5",
"version": "1.0.0",
"private": true,
"description": "企微客服助手 · 手机远程控制台(托管在管理端 /m/)",
"type": "module",
"packageManager": "pnpm@11.16.0",
"scripts": {
"dev": "vite --mode development",
"build": "vue-tsc --noEmit && vite build --mode production",
"preview": "vite preview",
"typecheck": "vue-tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"vant": "4.10.2",
"vue": "3.5.41",
"vue-router": "5.2.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "6.0.8",
"typescript": "6.0.3",
"vite": "8.2.1",
"vitest": "4.1.10",
"vue-tsc": "3.3.9"
}
}
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

@@ -0,0 +1,17 @@
{
"name": "企微客服助手 · 手机控制台",
"short_name": "客服控制台",
"description": "远程查看和启停客服电脑的自动回复,管理会话忽略。",
"lang": "zh-CN",
"start_url": "/m/",
"scope": "/m/",
"display": "standalone",
"orientation": "any",
"background_color": "#f4f7fd",
"theme_color": "#2f6fff",
"icons": [
{ "src": "/m/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "/m/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{ "src": "/m/icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}
+4
View File
@@ -0,0 +1,4 @@
// 手机控制台的 Service Worker 只为"添加到主屏幕"存在,不缓存任何内容:
// 远程控制看到的必须是实时状态,离线缓存只会展示过期的电脑状态。
self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', (event) => event.waitUntil(self.clients.claim()));
+47
View File
@@ -0,0 +1,47 @@
<script setup lang="ts">
import { ConfigProvider } from 'vant';
import { computed } from 'vue';
import { usePrefersDark } from './composables';
const dark = usePrefersDark();
const theme = computed(() => (dark.value ? 'dark' : 'light'));
// Vant 变量和桌面端控制台保持同一套主色;深色模式用藏青底,不用纯黑。
const themeVars = {
primaryColor: '#2f6fff',
successColor: '#13b77a',
warningColor: '#e8870c',
dangerColor: '#ff5358',
navBarBackground: 'var(--app-bg)',
navBarTitleTextColor: 'var(--app-text)',
cellGroupInsetRadius: '14px',
buttonRadius: '12px',
buttonLargeHeight: '48px',
};
const themeVarsDark = {
primaryColor: '#5b8cff',
successColor: '#2fd19a',
warningColor: '#ffae4a',
dangerColor: '#ff6b70',
background: '#0b1222',
background2: '#131c31',
background3: '#18223b',
textColor: '#e6ecfb',
textColor2: '#8b97b6',
textColor3: '#6c7898',
borderColor: 'rgba(140,160,210,.16)',
activeColor: '#1c2743',
};
</script>
<template>
<ConfigProvider
:theme="theme"
theme-vars-scope="global"
:theme-vars="themeVars"
:theme-vars-dark="themeVarsDark"
>
<RouterView />
</ConfigProvider>
</template>
+320
View File
@@ -0,0 +1,320 @@
// 手机控制台只调用 /api/v2/mobile/*。令牌放 localStorage,登出或失效即清除;
// 不保存密码。
const API_BASE = '/api/v2/mobile';
const TOKEN_KEY = 'wecom-mobile-token';
export interface Permissions {
control: boolean;
control_all: boolean;
blacklist_add: boolean;
blacklist_remove: boolean;
root: boolean;
known: boolean;
}
export interface Me {
account: { id: number; nickname: string; sn: string; avatar: string };
permissions: Permissions;
permission_codes: Record<keyof Omit<Permissions, 'root' | 'known'>, string>;
session: { created_at: string; expires_at: number };
}
export interface DeviceStatus {
listening: boolean;
state: string;
state_label: string;
transport: string;
transport_label: string;
wecom_connected: boolean;
pending_count: number;
reported_at: string;
reported_seconds: number | null;
fresh: boolean;
}
export type CommandType = 'start_listening' | 'stop_listening';
export type CommandStatus =
| 'pending'
| 'delivered'
| 'succeeded'
| 'failed'
| 'expired'
| 'superseded';
export interface Command {
id: string;
type: CommandType;
type_label: string;
device_id: string;
status: CommandStatus;
status_label: string;
final: boolean;
created_at: string;
created_by: string;
delivered_at: string;
acked_at: string;
expires_at: number;
result: { message?: string; listening?: boolean; [key: string]: unknown };
error: string;
}
export interface Device {
device_id: string;
device_name: string;
app_version: string;
online: boolean;
session_active: boolean;
last_seen_at: string;
last_seen_seconds: number | null;
remote_capable: boolean;
blacklist_sync: boolean;
status: DeviceStatus | null;
last_command: Command | null;
}
export interface DesktopsResponse {
account: { id: number; nickname: string; sn: string };
items: Device[];
server_time: number;
}
export interface AccountSummary {
id: number;
nickname: string;
sn: string;
device_count: number;
online: boolean;
listening: boolean;
last_online_at: string;
}
export interface BlacklistEntry {
account: string;
account_name: string;
conversation: string;
name: string;
reason: string;
source: string;
created_by: string;
created_at: string;
}
export interface Conversation {
account: string;
account_name: string;
conversation: string;
name: string;
type: string;
type_label: string;
last_message_at: string;
blocked: boolean;
blacklist: BlacklistEntry | null;
}
export interface ConversationPage {
items: Conversation[];
next_cursor: string;
has_more: boolean;
blocked_total: number;
}
export class ApiError extends Error {
readonly status: number;
readonly code: string;
readonly retryAfter: number;
constructor(status: number, code: string, message: string, retryAfter = 0) {
super(message);
this.status = status;
this.code = code;
this.retryAfter = retryAfter;
}
}
let unauthorizedHandler: (() => void) | null = null;
/** 任意接口返回 401(令牌失效 / 被管理端下线)时回调,由路由层跳回登录页。 */
export function onUnauthorized(handler: () => void): void {
unauthorizedHandler = handler;
}
export function readToken(): string {
try {
return window.localStorage.getItem(TOKEN_KEY) || '';
} catch {
return '';
}
}
export function writeToken(token: string): void {
try {
if (token) window.localStorage.setItem(TOKEN_KEY, token);
else window.localStorage.removeItem(TOKEN_KEY);
} catch {
// 隐私模式下 localStorage 可能不可写:本次打开期间仍可用,刷新后需要重新登录。
}
}
let memoryToken = '';
export function currentToken(): string {
return memoryToken || readToken();
}
export function setToken(token: string): void {
memoryToken = token;
writeToken(token);
}
/** 把后端各种形状的错误体(字符串、{code,message}、422 校验数组)归一成一句话。 */
export function errorFromBody(status: number, body: unknown): ApiError {
const detail = (body && typeof body === 'object' && 'detail' in body
? (body as { detail: unknown }).detail
: body) as unknown;
if (detail && typeof detail === 'object' && !Array.isArray(detail)) {
const value = detail as { code?: unknown; message?: unknown; retry_after?: unknown };
return new ApiError(
status,
String(value.code || `http_${status}`),
String(value.message || defaultMessage(status)),
Number(value.retry_after || 0),
);
}
if (Array.isArray(detail)) {
return new ApiError(status, 'validation', '填写的内容不符合要求,请检查后重试');
}
if (typeof detail === 'string' && detail.trim()) {
return new ApiError(status, `http_${status}`, detail.trim());
}
return new ApiError(status, `http_${status}`, defaultMessage(status));
}
function defaultMessage(status: number): string {
if (status === 401) return '登录已失效,请重新登录';
if (status === 403) return '没有权限执行这个操作';
if (status === 404) return '内容不存在或已被删除';
if (status === 429) return '操作太频繁,请稍后再试';
if (status >= 500) return '服务暂时不可用,请稍后重试';
return `请求失败(HTTP ${status})`;
}
interface RequestOptions {
body?: unknown;
query?: Record<string, string | number | undefined | null>;
auth?: boolean;
signal?: AbortSignal;
}
async function request<T>(method: string, path: string, options: RequestOptions = {}): Promise<T> {
const { body, query, auth = true, signal } = options;
const params = new URLSearchParams();
for (const [key, value] of Object.entries(query || {})) {
if (value !== undefined && value !== null && value !== '') params.set(key, String(value));
}
const url = `${API_BASE}${path}${params.toString() ? `?${params}` : ''}`;
const headers: Record<string, string> = { Accept: 'application/json' };
if (body !== undefined) headers['Content-Type'] = 'application/json';
const token = currentToken();
if (auth && token) headers.Authorization = `Bearer ${token}`;
let response: Response;
try {
response = await fetch(url, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
signal,
cache: 'no-store',
credentials: 'omit',
});
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') throw error;
throw new ApiError(0, 'network', '网络连接失败,请检查网络后重试');
}
let data: unknown = null;
const text = await response.text();
if (text) {
try {
data = JSON.parse(text);
} catch {
data = null;
}
}
if (!response.ok) {
const error = errorFromBody(response.status, data);
if (response.status === 401 && auth) unauthorizedHandler?.();
throw error;
}
return data as T;
}
const segment = (value: string) => encodeURIComponent(value);
export const api = {
login(account: string, password: string, clientName: string) {
return request<{ access_token: string; expires_at: number; me: Me }>('POST', '/auth/login', {
auth: false,
body: { account, password, client_name: clientName },
});
},
logout() {
return request<{ ok: boolean }>('POST', '/auth/logout');
},
me() {
return request<Me>('GET', '/me');
},
accounts(q = '') {
return request<{ items: AccountSummary[] }>('GET', '/accounts', { query: { q } });
},
desktops(accountId?: number) {
return request<DesktopsResponse>('GET', '/desktops', { query: { account_id: accountId } });
},
sendCommand(deviceId: string, type: CommandType, accountId?: number) {
return request<{ command: Command }>('POST', `/desktops/${segment(deviceId)}/commands`, {
body: { type },
query: { account_id: accountId },
});
},
command(commandId: string) {
return request<{ command: Command }>('GET', `/commands/${segment(commandId)}`);
},
conversations(options: {
accountId?: number;
q?: string;
scope?: 'all' | 'blocked';
cursor?: string;
limit?: number;
signal?: AbortSignal;
}) {
return request<ConversationPage>('GET', '/conversations', {
query: {
account_id: options.accountId,
q: options.q,
scope: options.scope,
cursor: options.cursor,
limit: options.limit,
},
signal: options.signal,
});
},
block(
target: { account: string; conversation: string; name?: string; account_name?: string },
reason: string,
accountId?: number,
) {
return request<{ entry: BlacklistEntry; version: number }>(
'PUT',
`/blacklist/${segment(target.account)}/${segment(target.conversation)}`,
{
body: { name: target.name || '', account_name: target.account_name || '', reason },
query: { account_id: accountId },
},
);
},
unblock(account: string, conversation: string, accountId?: number) {
return request<{ ok: boolean; version: number }>(
'DELETE',
`/blacklist/${segment(account)}/${segment(conversation)}`,
{ query: { account_id: accountId } },
);
},
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

@@ -0,0 +1,118 @@
<script setup lang="ts">
import { Cell, CellGroup, Empty, Loading, Popup, Search } from 'vant';
import { computed, ref, watch } from 'vue';
import { type AccountSummary, api, ApiError } from '../api';
import { session, viewAccount, viewedAccountId } from '../session';
const show = defineModel<boolean>('show', { default: false });
const query = ref('');
const items = ref<AccountSummary[]>([]);
const loading = ref(false);
const error = ref('');
let serial = 0;
let debounce: number | undefined;
const me = computed(() => session.me?.account);
async function load() {
const current = ++serial;
loading.value = true;
error.value = '';
try {
const { items: rows } = await api.accounts(query.value.trim());
if (current === serial) items.value = rows.filter((row) => row.id !== me.value?.id);
} catch (reason) {
if (current === serial) error.value = reason instanceof ApiError ? reason.message : '加载失败';
} finally {
if (current === serial) loading.value = false;
}
}
watch(show, (open) => {
if (open) void load();
});
watch(query, () => {
window.clearTimeout(debounce);
debounce = window.setTimeout(load, 300);
});
function pick(account: AccountSummary | null) {
viewAccount(account);
show.value = false;
}
function statusText(account: AccountSummary): string {
if (!account.device_count) return '没有电脑';
if (!account.online) return '离线';
return account.listening ? '监听中' : '已停止';
}
function statusTone(account: AccountSummary): string {
if (!account.online) return 'tone-muted';
return account.listening ? 'tone-success' : 'tone-warning';
}
</script>
<template>
<Popup v-model:show="show" position="bottom" round closeable safe-area-inset-bottom class="switcher">
<div class="switcher-body">
<h3>切换客服</h3>
<Search v-model="query" shape="round" placeholder="搜索客服姓名或账号" />
<CellGroup inset>
<Cell
title="我自己"
:label="me ? `${me.nickname || me.sn}` : ''"
icon="user-o"
clickable
:value="viewedAccountId === undefined ? '当前' : ''"
@click="pick(null)"
/>
</CellGroup>
<div v-if="loading && !items.length" class="switcher-state"><Loading size="20" /></div>
<Empty v-else-if="error" image="network" :description="error" />
<Empty v-else-if="!items.length" image="search" description="没有找到其他客服" />
<CellGroup v-else inset class="switcher-list">
<Cell
v-for="account in items"
:key="account.id"
:title="account.nickname || account.sn || `客服 #${account.id}`"
:label="account.sn"
clickable
@click="pick(account)"
>
<template #value>
<span :class="statusTone(account)">{{ statusText(account) }}</span>
<span v-if="viewedAccountId === account.id" class="current">当前</span>
</template>
</Cell>
</CellGroup>
</div>
</Popup>
</template>
<style scoped>
.switcher {
max-height: 80vh;
}
.switcher-body {
padding: 16px 0 12px;
}
.switcher-body h3 {
margin: 0 16px 8px;
font-size: 17px;
}
.switcher-state {
display: grid;
place-items: center;
padding: 32px 0;
}
.switcher-list {
margin-top: 12px;
}
.current {
margin-left: 6px;
color: var(--app-primary);
}
</style>
@@ -0,0 +1,260 @@
<script setup lang="ts">
import { Button, Cell, CellGroup, Field, Icon, showConfirmDialog, showToast } from 'vant';
import { computed, ref, watch } from 'vue';
import { api, ApiError, type Conversation } from '../api';
import { initial, relativeTime } from '../format';
const props = defineProps<{
conversation: Conversation;
accountId?: number;
canAdd: boolean;
canRemove: boolean;
addCode: string;
removeCode: string;
}>();
const emit = defineEmits<{ updated: [item: Conversation] }>();
const reason = ref('');
const saving = ref(false);
watch(
() => `${props.conversation.account}|${props.conversation.conversation}`,
() => {
reason.value = '';
},
);
const entry = computed(() => props.conversation.blacklist);
const sourceLabel = computed(() => {
const source = entry.value?.source;
if (source === 'mobile') return '手机控制台';
if (source === 'desktop') return '客服电脑';
return source || '—';
});
async function block() {
if (saving.value) return;
try {
await showConfirmDialog({
title: '加入忽略?',
message: `加入后,系统不再向「${props.conversation.name}」自动发送任何消息,只能由客服手动处理。`,
confirmButtonText: '加入忽略',
confirmButtonColor: 'var(--app-danger)',
});
} catch {
return;
}
saving.value = true;
try {
const { entry: saved } = await api.block(props.conversation, reason.value.trim(), props.accountId);
emit('updated', { ...props.conversation, blocked: true, blacklist: saved });
showToast({ message: '已加入忽略,电脑同步后立即生效', type: 'success' });
} catch (error) {
showToast({ message: error instanceof ApiError ? error.message : '操作失败,请重试', type: 'fail' });
} finally {
saving.value = false;
}
}
async function unblock() {
if (saving.value) return;
try {
await showConfirmDialog({
title: '移出忽略?',
message: `移出后,系统会恢复向「${props.conversation.name}」自动回复。`,
confirmButtonText: '移出',
});
} catch {
return;
}
saving.value = true;
try {
await api.unblock(props.conversation.account, props.conversation.conversation, props.accountId);
emit('updated', { ...props.conversation, blocked: false, blacklist: null });
showToast({ message: '已移出忽略', type: 'success' });
} catch (error) {
showToast({ message: error instanceof ApiError ? error.message : '操作失败,请重试', type: 'fail' });
} finally {
saving.value = false;
}
}
</script>
<template>
<section class="detail">
<header class="detail-head">
<span class="avatar" aria-hidden="true">{{ initial(conversation.name) }}</span>
<div class="detail-title">
<h2>{{ conversation.name }}</h2>
<p class="app-muted">{{ conversation.type_label }}</p>
</div>
<span v-if="conversation.blocked" class="badge"><Icon name="shield-o" /> 忽略</span>
</header>
<CellGroup inset class="group">
<Cell title="企业微信账号" :value="conversation.account_name || '—'" :label="conversation.account" />
<Cell title="最近消息" :value="conversation.last_message_at ? relativeTime(conversation.last_message_at) : '—'" />
<Cell title="会话标识">
<template #label><span class="app-mono">{{ conversation.conversation }}</span></template>
</Cell>
</CellGroup>
<div v-if="conversation.blocked && entry" class="state state--blocked">
<h3><Icon name="shield-o" /> 已加入忽略</h3>
<p>系统不会向这个会话自动发送消息;电脑离线时,下次联网自动同步。</p>
<dl>
<div><dt>原因</dt><dd>{{ entry.reason || '未填写' }}</dd></div>
<div><dt>操作人</dt><dd>{{ entry.created_by || '—' }}</dd></div>
<div><dt>时间</dt><dd>{{ relativeTime(entry.created_at) }}</dd></div>
<div><dt>来源</dt><dd>{{ sourceLabel }}</dd></div>
</dl>
<Button
v-if="canRemove"
block
round
plain
type="primary"
icon="revoke"
:loading="saving"
@click="unblock"
>
移出忽略
</Button>
<p v-else class="note">移出忽略需要主管权限({{ removeCode }}),请联系主管处理。</p>
</div>
<div v-else class="state">
<h3><Icon name="info-o" /> 正常接收自动回复</h3>
<p>客户投诉、需要人工跟进或不适合机器人回复的会话,可以加入忽略。</p>
<template v-if="canAdd">
<CellGroup inset class="reason">
<Field
v-model="reason"
label="原因"
placeholder="选填,例如:客户要求人工服务"
maxlength="200"
show-word-limit
rows="2"
autosize
type="textarea"
/>
</CellGroup>
<Button block round type="danger" icon="shield-o" :loading="saving" @click="block">
加入忽略
</Button>
</template>
<p v-else class="note">加入忽略需要 ZYT 权限({{ addCode }}),请联系管理员开通。</p>
</div>
</section>
</template>
<style scoped>
.detail {
padding-bottom: 16px;
}
.detail-head {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
}
.avatar {
flex: none;
width: 52px;
height: 52px;
border-radius: 14px;
display: grid;
place-items: center;
font-size: 22px;
font-weight: 600;
color: var(--app-primary);
background: var(--app-primary-soft);
}
.detail-title {
flex: 1;
min-width: 0;
}
.detail-title h2 {
margin: 0 0 4px;
font-size: 18px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.detail-title p {
margin: 0;
font-size: 13px;
}
.badge {
flex: none;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
color: var(--app-danger);
background: var(--app-danger-soft);
}
.group {
margin-bottom: 12px;
}
.state {
margin: 0 16px;
padding: 16px;
border-radius: 14px;
background: var(--app-surface);
border: 1px solid var(--app-border);
}
.state--blocked {
border-color: var(--app-danger-soft);
background: linear-gradient(0deg, var(--app-danger-soft), var(--app-danger-soft)), var(--app-surface);
}
.state h3 {
display: flex;
align-items: center;
gap: 6px;
margin: 0 0 6px;
font-size: 15px;
}
.state--blocked h3 {
color: var(--app-danger);
}
.state > p {
margin: 0 0 12px;
color: var(--app-text-2);
font-size: 13px;
line-height: 1.6;
}
.state dl {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px 12px;
margin: 0 0 14px;
}
.state dt {
font-size: 12px;
color: var(--app-muted);
}
.state dd {
margin: 2px 0 0;
font-size: 14px;
word-break: break-all;
}
.reason {
margin: 0 0 12px;
}
.reason :deep(.van-cell-group--inset) {
margin: 0;
}
.state :deep(.van-cell-group--inset) {
margin: 0 0 12px;
}
.note {
margin: 0;
color: var(--app-muted);
font-size: 13px;
line-height: 1.6;
}
</style>
@@ -0,0 +1,116 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { Conversation } from '../api';
import { initial, relativeTime } from '../format';
const props = defineProps<{ item: Conversation; active?: boolean }>();
defineEmits<{ select: [item: Conversation] }>();
const hue = computed(() => {
if (props.item.type === 'group') return 'group';
if (props.item.type === 'direct_wechat') return 'wechat';
return 'wecom';
});
</script>
<template>
<button type="button" class="row" :class="{ active }" @click="$emit('select', item)">
<span class="avatar" :class="`avatar--${hue}`" aria-hidden="true">{{ initial(item.name) }}</span>
<span class="body">
<span class="line">
<strong class="name">{{ item.name }}</strong>
<span class="time">{{ item.last_message_at ? relativeTime(item.last_message_at) : '' }}</span>
</span>
<span class="line sub">
<span class="meta">{{ item.type_label }}<template v-if="item.account_name"> · {{ item.account_name }}</template></span>
<span v-if="item.blocked" class="blocked">已拉黑</span>
</span>
</span>
</button>
</template>
<style scoped>
.row {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
min-height: 64px;
padding: 10px 16px;
border: 0;
border-bottom: 1px solid var(--app-border);
background: var(--app-surface);
color: inherit;
text-align: left;
cursor: pointer;
}
.row:active,
.row.active {
background: var(--app-primary-soft);
}
.avatar {
flex: none;
width: 42px;
height: 42px;
border-radius: 12px;
display: grid;
place-items: center;
font-size: 17px;
font-weight: 600;
}
.avatar--wecom {
color: #2f6fff;
background: rgba(47, 111, 255, 0.12);
}
.avatar--wechat {
color: #0f9e69;
background: rgba(19, 183, 122, 0.14);
}
.avatar--group {
color: #7a5cff;
background: rgba(138, 107, 255, 0.14);
}
.body {
flex: 1;
min-width: 0;
display: grid;
gap: 4px;
}
.line {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-width: 0;
}
.name {
font-size: 15px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.time {
flex: none;
font-size: 12px;
color: var(--app-muted);
}
.sub {
font-size: 12px;
color: var(--app-muted);
}
.meta {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.blocked {
flex: none;
padding: 1px 8px;
border-radius: 999px;
color: var(--app-danger);
background: var(--app-danger-soft);
font-weight: 600;
}
</style>
@@ -0,0 +1,336 @@
<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>
@@ -0,0 +1,74 @@
import { onActivated, onBeforeUnmount, onDeactivated, onMounted, ref } from 'vue';
/** ≥768px(平板横竖屏、桌面浏览器)切成左右两栏。 */
export function useWide(query = '(min-width: 768px)') {
const media = typeof window !== 'undefined' && window.matchMedia ? window.matchMedia(query) : null;
const wide = ref(media ? media.matches : false);
const update = (event: MediaQueryListEvent) => {
wide.value = event.matches;
};
onMounted(() => media?.addEventListener?.('change', update));
onBeforeUnmount(() => media?.removeEventListener?.('change', update));
return wide;
}
export function usePrefersDark() {
const media =
typeof window !== 'undefined' && window.matchMedia ? window.matchMedia('(prefers-color-scheme: dark)') : null;
const dark = ref(media ? media.matches : false);
const update = (event: MediaQueryListEvent) => {
dark.value = event.matches;
};
onMounted(() => media?.addEventListener?.('change', update));
onBeforeUnmount(() => media?.removeEventListener?.('change', update));
return dark;
}
/**
* 页面可见时按间隔刷新;切到后台就停,回到前台立刻补一次。
* 手机锁屏后还在轮询只会白白耗电和流量。被 KeepAlive 缓存的页面切走时也停。
*/
export function useVisibleInterval(task: () => unknown, intervalMs: number) {
let timer: number | undefined;
let active = false;
const stop = () => {
if (timer !== undefined) window.clearInterval(timer);
timer = undefined;
};
const start = () => {
stop();
timer = window.setInterval(() => {
if (document.visibilityState === 'visible') void task();
}, intervalMs);
};
const onVisibility = () => {
if (!active) return;
if (document.visibilityState === 'visible') {
void task();
start();
} else {
stop();
}
};
onMounted(() => {
active = true;
start();
document.addEventListener('visibilitychange', onVisibility);
});
onActivated(() => {
// 首次挂载也会触发 activated;那一次 onMounted 已经启动过了。
if (active) return;
active = true;
void task();
start();
});
onDeactivated(() => {
active = false;
stop();
});
onBeforeUnmount(() => {
active = false;
stop();
document.removeEventListener('visibilitychange', onVisibility);
});
}
+110
View File
@@ -0,0 +1,110 @@
import { describe, expect, it } from 'vitest';
import type { Command, Device } from './api';
import { describeCommand, describeDevice, durationText, initial, relativeTime } from './format';
const now = new Date(2026, 8, 28, 15, 30, 0);
function device(overrides: Partial<Device> = {}): Device {
return {
device_id: 'dev-1',
device_name: 'PC',
app_version: '1.5.0',
online: true,
session_active: true,
last_seen_at: '',
last_seen_seconds: 3,
remote_capable: true,
blacklist_sync: true,
status: {
listening: true,
state: 'running',
state_label: '监听中',
transport: 'visual',
transport_label: '视觉通道',
wecom_connected: true,
pending_count: 0,
reported_at: '',
reported_seconds: 4,
fresh: true,
},
last_command: null,
...overrides,
};
}
describe('relativeTime', () => {
it('uses friendly buckets', () => {
expect(relativeTime(new Date(2026, 8, 28, 15, 29, 40).toISOString(), now)).toBe('刚刚');
expect(relativeTime(new Date(2026, 8, 28, 15, 10, 0).toISOString(), now)).toBe('20 分钟前');
expect(relativeTime(new Date(2026, 8, 28, 9, 5, 0).toISOString(), now)).toBe('今天 09:05');
expect(relativeTime(new Date(2026, 8, 27, 21, 0, 0).toISOString(), now)).toBe('昨天 21:00');
expect(relativeTime(new Date(2026, 1, 3, 8, 0, 0).toISOString(), now)).toBe('02-03 08:00');
expect(relativeTime(new Date(2025, 11, 31, 8, 0, 0).toISOString(), now)).toBe('2025-12-31');
});
it('accepts backend offsets and tolerates junk', () => {
expect(relativeTime('2026-09-28T15:29:30+08:00', new Date('2026-09-28T15:30:00+08:00'))).toBe('刚刚');
expect(relativeTime('', now)).toBe('—');
expect(relativeTime('not a date', now)).toBe('—');
});
});
describe('durationText', () => {
it('rounds to the largest sensible unit', () => {
expect(durationText(12)).toBe('12 秒');
expect(durationText(150)).toBe('3 分钟');
expect(durationText(7200)).toBe('2 小时');
expect(durationText(3 * 86400)).toBe('3 天');
expect(durationText(null)).toBe('—');
});
});
describe('describeDevice', () => {
it('prioritises offline, then outdated, then status', () => {
expect(describeDevice(device({ online: false, last_seen_seconds: 600 })).label).toBe('离线');
expect(describeDevice(device({ remote_capable: false })).label).toBe('版本过低');
expect(describeDevice(device()).tone).toBe('success');
const stopped = device();
stopped.status = { ...stopped.status!, listening: false, state: 'stopped', state_label: '已停止' };
expect(describeDevice(stopped)).toMatchObject({ tone: 'muted', label: '已停止' });
const failed = device();
failed.status = { ...failed.status!, state: 'error', state_label: '连接失败' };
expect(describeDevice(failed).tone).toBe('danger');
});
});
describe('describeCommand', () => {
const base: Command = {
id: 'c1',
type: 'stop_listening',
type_label: '停止监听',
device_id: 'dev-1',
status: 'pending',
status_label: '',
final: false,
created_at: '',
created_by: '',
delivered_at: '',
acked_at: '',
expires_at: 0,
result: {},
error: '',
};
it('describes progress and outcomes', () => {
expect(describeCommand(base)).toMatchObject({ busy: true, tone: 'primary' });
expect(describeCommand({ ...base, status: 'succeeded', result: { message: '监听已停止' } }).text).toBe('监听已停止');
expect(describeCommand({ ...base, status: 'failed', error: '企业微信未登录' }).text).toContain('企业微信未登录');
expect(describeCommand({ ...base, status: 'expired' }).tone).toBe('warning');
});
});
describe('initial', () => {
it('takes the first visible character', () => {
expect(initial('张三')).toBe('张');
expect(initial('alice')).toBe('A');
expect(initial(' ')).toBe('?');
expect(initial('😀客户')).toBe('😀');
});
});
+107
View File
@@ -0,0 +1,107 @@
import type { Command, Device } from './api';
const pad = (value: number) => String(value).padStart(2, '0');
/** 解析后端的 ISO 时间(本地时区偏移或 UTC 都有),失败返回 null。 */
export function parseTime(value: string | null | undefined): Date | null {
const text = String(value || '').trim();
if (!text) return null;
const date = new Date(text);
return Number.isNaN(date.getTime()) ? null : date;
}
/** 刚刚 / N 分钟前 / 今天 09:30 / 昨天 21:05 / 09-18 14:00 / 2025-12-31 */
export function relativeTime(value: string | null | undefined, now: Date = new Date()): string {
const date = parseTime(value);
if (!date) return '—';
const seconds = Math.round((now.getTime() - date.getTime()) / 1000);
if (seconds < 45 && seconds > -60) return '刚刚';
if (seconds >= 45 && seconds < 3600) return `${Math.max(1, Math.round(seconds / 60))} 分钟前`;
const hm = `${pad(date.getHours())}:${pad(date.getMinutes())}`;
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
const day = 24 * 3600 * 1000;
if (date.getTime() >= startOfToday && date.getTime() < startOfToday + day) return `今天 ${hm}`;
if (date.getTime() >= startOfToday - day && date.getTime() < startOfToday) return `昨天 ${hm}`;
if (date.getFullYear() === now.getFullYear()) {
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${hm}`;
}
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
}
/** 秒数 → "3 秒 / 2 分钟 / 5 小时 / 3 天" */
export function durationText(seconds: number | null | undefined): string {
if (seconds === null || seconds === undefined || !Number.isFinite(seconds)) return '—';
const value = Math.max(0, Math.round(seconds));
if (value < 60) return `${value} 秒`;
if (value < 3600) return `${Math.round(value / 60)} 分钟`;
if (value < 86400) return `${Math.round(value / 3600)} 小时`;
return `${Math.round(value / 86400)} 天`;
}
export type Tone = 'success' | 'warning' | 'danger' | 'muted' | 'primary';
export interface DeviceSummary {
tone: Tone;
label: string;
hint: string;
}
/** 设备卡片最上面那一行:一眼看清在不在线、有没有在自动回复。 */
export function describeDevice(device: Device): DeviceSummary {
if (!device.online) {
const since = device.last_seen_seconds;
return {
tone: 'muted',
label: '离线',
hint: since === null ? '客服软件没有运行' : `客服软件没有运行,${durationText(since)}前在线`,
};
}
if (!device.remote_capable) {
return { tone: 'warning', label: '版本过低', hint: '电脑上的客服软件需要升级后才能远程控制' };
}
const status = device.status;
if (!status) return { tone: 'muted', label: '状态未知', hint: '等待电脑上报状态' };
if (status.state === 'error') return { tone: 'danger', label: status.state_label, hint: '请到电脑上查看运行日志' };
if (status.listening) {
if (status.state === 'running') {
return {
tone: 'success',
label: '监听中',
hint: status.wecom_connected ? '正在自动处理新消息' : '监听已开启,企业微信尚未连接',
};
}
return { tone: 'warning', label: status.state_label, hint: '监听已开启,正在准备' };
}
if (status.state === 'stopping') return { tone: 'warning', label: '正在停止', hint: '等待后台任务退出' };
return { tone: 'muted', label: '已停止', hint: '不会自动回复新消息' };
}
/** 下发指令后的进度文字。 */
export function describeCommand(command: Command): { tone: Tone; text: string; busy: boolean } {
const verb = command.type_label || '指令';
switch (command.status) {
case 'pending':
return { tone: 'primary', text: `「${verb}」已下发,等待电脑执行…`, busy: true };
case 'delivered':
return { tone: 'primary', text: `电脑已收到「${verb}」,正在执行…`, busy: true };
case 'succeeded': {
const message = typeof command.result?.message === 'string' ? command.result.message : '';
return { tone: 'success', text: message || `「${verb}」已执行`, busy: false };
}
case 'failed':
return { tone: 'danger', text: `「${verb}」执行失败:${command.error || '电脑没有给出原因'}`, busy: false };
case 'expired':
return { tone: 'warning', text: `「${verb}」没有在 10 分钟内得到电脑确认,已作废`, busy: false };
case 'superseded':
return { tone: 'muted', text: `「${verb}」已被新的指令取代`, busy: false };
default:
return { tone: 'muted', text: command.status_label || verb, busy: false };
}
}
/** 头像圆里显示的那个字:会话名的第一个字符。 */
export function initial(name: string): string {
const text = String(name || '').trim();
if (!text) return '?';
return Array.from(text)[0]!.toUpperCase();
}
+17
View File
@@ -0,0 +1,17 @@
import { createApp } from 'vue';
import App from './App.vue';
import { router } from './router';
import './vant';
import './styles.css';
createApp(App).use(router).mount('#app');
// 只为"添加到主屏幕"注册,不做离线缓存;明文 HTTP 下浏览器不允许注册,直接跳过。
if (import.meta.env.PROD && 'serviceWorker' in navigator && window.isSecureContext) {
window.addEventListener('load', () => {
navigator.serviceWorker
.register(`${import.meta.env.BASE_URL}sw.js`, { scope: import.meta.env.BASE_URL })
.catch(() => undefined);
});
}
+83
View File
@@ -0,0 +1,83 @@
import { createRouter, createWebHashHistory } from 'vue-router';
import { ApiError, onUnauthorized } from './api';
import { clearSession, loadMe, session } from './session';
// hash 路由:刷新任何页面都落在 /m/ 这一个入口上,后台不用为前端路由做重写。
export const router = createRouter({
history: createWebHashHistory(),
routes: [
{
path: '/login',
name: 'login',
component: () => import('./views/LoginView.vue'),
meta: { public: true },
},
{
path: '/',
component: () => import('./views/ShellView.vue'),
children: [
{ path: '', redirect: { name: 'desktops' } },
{ path: 'desktops', name: 'desktops', component: () => import('./views/DevicesView.vue') },
{
path: 'conversations',
name: 'conversations',
component: () => import('./views/ConversationsView.vue'),
},
{ path: 'me', name: 'me', component: () => import('./views/MeView.vue') },
],
},
{ path: '/:pathMatch(.*)*', redirect: { name: 'desktops' } },
],
});
router.beforeEach(async (to) => {
if (to.meta.public) return true;
if (!session.token) return { name: 'login', query: { redirect: to.fullPath } };
if (!session.me) {
try {
await loadMe();
} catch (error) {
if (error instanceof ApiError && error.status === 401) {
clearSession();
return { name: 'login', query: { redirect: to.fullPath } };
}
// 网络问题:放行,由页面自己展示错误并提供重试。
}
}
return true;
});
onUnauthorized(() => {
clearSession();
const current = router.currentRoute.value;
// 首次导航还没完成(路由守卫里 loadMe 得到 401):由守卫带着目标地址跳登录,
// 这里再跳一次会把 redirect 覆盖成起始的 "/"。
if (!current.matched.length) return;
if (current.name !== 'login') {
void router.replace({ name: 'login', query: { redirect: current.fullPath, expired: '1' } });
}
});
// 发版后,还开着的旧页面按旧文件名去拉页面代码会 404。整页刷新一次就能拿到新版本;
// 10 秒内只刷新一次,服务端真出问题时不会无限刷新。
const CHUNK_RELOAD_KEY = 'wecom-mobile-chunk-reload';
router.onError((error, to) => {
const message = String((error as Error)?.message || error);
const staleChunk =
/Failed to fetch dynamically imported module|Importing a module script failed|error loading dynamically imported module|Unable to preload CSS/i.test(
message,
);
if (!staleChunk) return;
let last = 0;
try {
last = Number(window.sessionStorage.getItem(CHUNK_RELOAD_KEY) || 0);
window.sessionStorage.setItem(CHUNK_RELOAD_KEY, String(Date.now()));
} catch {
// sessionStorage 不可用时照样刷新一次
}
if (Date.now() - last > 10_000) {
window.location.assign(`${import.meta.env.BASE_URL}#${to.fullPath}`);
window.location.reload();
}
});
+85
View File
@@ -0,0 +1,85 @@
import { computed, reactive } from 'vue';
import { type AccountSummary, api, currentToken, type Me, setToken } from './api';
const VIEW_ACCOUNT_KEY = 'wecom-mobile-view-account';
interface ViewedAccount {
id: number;
nickname: string;
sn: string;
}
function readViewedAccount(): ViewedAccount | null {
try {
const raw = window.localStorage.getItem(VIEW_ACCOUNT_KEY);
const value = raw ? (JSON.parse(raw) as ViewedAccount) : null;
return value && typeof value.id === 'number' ? value : null;
} catch {
return null;
}
}
export const session = reactive({
token: currentToken(),
me: null as Me | null,
// 有 remote/all 权限的人可以切到别的客服;其他人永远看自己。
viewed: readViewedAccount() as ViewedAccount | null,
});
export const permissions = computed(() => session.me?.permissions);
/** 当前查看的是不是别人的电脑。是的话,接口要带 account_id。 */
export const viewedAccountId = computed<number | undefined>(() => {
const me = session.me;
const viewed = session.viewed;
if (!me || !viewed || !me.permissions.control_all || viewed.id === me.account.id) return undefined;
return viewed.id;
});
export const viewedName = computed(() => {
if (viewedAccountId.value !== undefined && session.viewed) {
return session.viewed.nickname || session.viewed.sn || `客服 #${session.viewed.id}`;
}
return session.me?.account.nickname || session.me?.account.sn || '';
});
export function viewAccount(account: AccountSummary | ViewedAccount | null): void {
session.viewed = account ? { id: account.id, nickname: account.nickname, sn: account.sn } : null;
try {
if (session.viewed) window.localStorage.setItem(VIEW_ACCOUNT_KEY, JSON.stringify(session.viewed));
else window.localStorage.removeItem(VIEW_ACCOUNT_KEY);
} catch {
// 忽略:只是记住上次看的是谁
}
}
export function signIn(token: string, me: Me): void {
setToken(token);
session.token = token;
session.me = me;
if (!me.permissions.control_all) viewAccount(null);
}
export function clearSession(): void {
setToken('');
session.token = '';
session.me = null;
viewAccount(null);
}
export async function loadMe(): Promise<Me> {
const me = await api.me();
session.me = me;
if (!me.permissions.control_all) viewAccount(null);
return me;
}
export async function signOut(): Promise<void> {
try {
await api.logout();
} catch {
// 令牌已经失效也照样清掉本地状态
}
clearSession();
}
+123
View File
@@ -0,0 +1,123 @@
/* 配色沿用桌面端控制台(zhenyang-ai-console)的蓝白体系。 */
:root {
--app-bg: #f4f7fd;
--app-surface: #ffffff;
--app-surface-2: #f6f8fe;
--app-border: rgba(110, 137, 201, 0.18);
--app-text: #1d315f;
--app-text-2: #3f527d;
--app-muted: #7b89ab;
--app-primary: #2f6fff;
--app-primary-soft: rgba(47, 111, 255, 0.1);
--app-success: #0f9e69;
--app-success-soft: rgba(19, 183, 122, 0.12);
--app-warning: #c46f00;
--app-warning-soft: rgba(255, 152, 29, 0.14);
--app-danger: #e5383e;
--app-danger-soft: rgba(255, 83, 88, 0.12);
--app-muted-soft: rgba(123, 137, 171, 0.14);
--app-shadow: 0 10px 28px rgba(78, 99, 162, 0.08);
--app-radius: 16px;
--app-font: 'PingFang SC', 'HarmonyOS Sans SC', 'MiSans', 'Microsoft YaHei', system-ui,
-apple-system, 'Segoe UI', sans-serif;
--app-mono: ui-monospace, 'SF Mono', 'Cascadia Mono', Menlo, Consolas, monospace;
color-scheme: light;
}
@media (prefers-color-scheme: dark) {
:root {
--app-bg: #0b1222;
--app-surface: #131c31;
--app-surface-2: #18223b;
--app-border: rgba(140, 160, 210, 0.16);
--app-text: #e6ecfb;
--app-text-2: #bcc6e0;
--app-muted: #8b97b6;
--app-primary: #6f9bff;
--app-primary-soft: rgba(91, 140, 255, 0.16);
--app-success: #2fd19a;
--app-success-soft: rgba(47, 209, 154, 0.14);
--app-warning: #ffb45a;
--app-warning-soft: rgba(255, 174, 74, 0.14);
--app-danger: #ff7a7f;
--app-danger-soft: rgba(255, 107, 112, 0.15);
--app-muted-soft: rgba(131, 144, 176, 0.16);
--app-shadow: 0 10px 28px rgba(0, 0, 0, 0.28);
color-scheme: dark;
}
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
background: var(--app-bg);
color: var(--app-text);
font-family: var(--app-font);
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: transparent;
}
body,
#app {
min-height: 100vh;
min-height: 100dvh;
}
button,
input,
textarea {
font-family: inherit;
}
.app-card {
background: var(--app-surface);
border: 1px solid var(--app-border);
border-radius: var(--app-radius);
box-shadow: var(--app-shadow);
}
.app-muted {
color: var(--app-muted);
}
.app-mono {
font-family: var(--app-mono);
font-size: 12px;
word-break: break-all;
}
.tone-success {
color: var(--app-success);
}
.tone-warning {
color: var(--app-warning);
}
.tone-danger {
color: var(--app-danger);
}
.tone-muted {
color: var(--app-muted);
}
.tone-primary {
color: var(--app-primary);
}
.van-nav-bar .van-nav-bar__title {
font-weight: 600;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
+25
View File
@@ -0,0 +1,25 @@
// 按需引入 Vant 组件样式(组件本身在各 .vue 里具名导入),不打包用不到的 CSS。
import 'vant/es/style/base.css';
import 'vant/es/config-provider/style/index';
import 'vant/es/nav-bar/style/index';
import 'vant/es/tabbar/style/index';
import 'vant/es/tabbar-item/style/index';
import 'vant/es/form/style/index';
import 'vant/es/field/style/index';
import 'vant/es/cell/style/index';
import 'vant/es/cell-group/style/index';
import 'vant/es/button/style/index';
import 'vant/es/search/style/index';
import 'vant/es/list/style/index';
import 'vant/es/pull-refresh/style/index';
import 'vant/es/tag/style/index';
import 'vant/es/tabs/style/index';
import 'vant/es/tab/style/index';
import 'vant/es/popup/style/index';
import 'vant/es/empty/style/index';
import 'vant/es/loading/style/index';
import 'vant/es/notice-bar/style/index';
import 'vant/es/icon/style/index';
import 'vant/es/skeleton/style/index';
import 'vant/es/dialog/style/index';
import 'vant/es/toast/style/index';
@@ -0,0 +1,273 @@
<script setup lang="ts">
import { Button, Empty, Icon, List, NavBar, Popup, PullRefresh, Search, Tab, Tabs } from 'vant';
import { computed, ref, watch } from 'vue';
import { api, ApiError, type Conversation } from '../api';
import AccountSwitcher from '../components/AccountSwitcher.vue';
import ConversationDetail from '../components/ConversationDetail.vue';
import ConversationRow from '../components/ConversationRow.vue';
import { useWide } from '../composables';
import { permissions, session, viewedAccountId, viewedName } from '../session';
defineOptions({ name: 'ConversationsView' });
type Scope = 'all' | 'blocked';
const wide = useWide();
const query = ref('');
const scope = ref<Scope>('all');
const items = ref<Conversation[]>([]);
const cursor = ref('');
const finished = ref(false);
const loading = ref(false);
const failed = ref(false);
const errorText = ref('');
const refreshing = ref(false);
const blockedTotal = ref(0);
const selected = ref<Conversation | null>(null);
const sheet = ref(false);
const switching = ref(false);
let serial = 0;
let controller: AbortController | null = null;
let debounce: number | undefined;
const codes = computed(() => session.me?.permission_codes);
const title = computed(() => (viewedAccountId.value === undefined ? '会话' : `${viewedName.value} 的会话`));
const key = (item: Conversation) => `${item.account}|${item.conversation}`;
function reset() {
serial += 1;
controller?.abort();
controller = null;
items.value = [];
cursor.value = '';
finished.value = false;
failed.value = false;
errorText.value = '';
loading.value = false;
void loadMore();
}
async function loadMore() {
if (finished.value) return;
const current = serial;
controller = new AbortController();
loading.value = true;
try {
const page = await api.conversations({
accountId: viewedAccountId.value,
q: query.value.trim(),
scope: scope.value,
cursor: cursor.value,
limit: 30,
signal: controller.signal,
});
if (current !== serial) return;
const seen = new Set(items.value.map(key));
items.value = items.value.concat(page.items.filter((item) => !seen.has(key(item))));
cursor.value = page.next_cursor;
finished.value = !page.has_more;
blockedTotal.value = page.blocked_total;
failed.value = false;
if (wide.value && !selected.value && items.value.length) selected.value = items.value[0]!;
} catch (error) {
if (current !== serial || (error instanceof DOMException && error.name === 'AbortError')) return;
failed.value = true;
errorText.value = error instanceof ApiError ? error.message : '加载失败';
} finally {
if (current === serial) {
loading.value = false;
refreshing.value = false;
}
}
}
function onRefresh() {
refreshing.value = true;
reset();
}
function open(item: Conversation) {
selected.value = item;
if (!wide.value) sheet.value = true;
}
function onUpdated(updated: Conversation) {
const index = items.value.findIndex((item) => key(item) === key(updated));
const wasBlocked = index >= 0 ? items.value[index]!.blocked : selected.value?.blocked;
if (index >= 0) {
if (scope.value === 'blocked' && !updated.blocked) items.value.splice(index, 1);
else items.value.splice(index, 1, updated);
}
if (wasBlocked !== updated.blocked) blockedTotal.value += updated.blocked ? 1 : -1;
selected.value = updated;
}
watch(query, () => {
window.clearTimeout(debounce);
debounce = window.setTimeout(reset, 350);
});
watch(scope, reset);
watch(viewedAccountId, () => {
selected.value = null;
sheet.value = false;
reset();
});
reset();
</script>
<template>
<div class="page">
<NavBar :title="title" safe-area-inset-top fixed placeholder :border="false">
<template v-if="permissions?.control_all" #right>
<button type="button" class="nav-action" @click="switching = true">
<Icon name="friends-o" /> 切换客服
</button>
</template>
</NavBar>
<div :class="wide ? 'split' : 'single'">
<section class="list-pane">
<div class="toolbar">
<Search v-model="query" shape="round" placeholder="搜索会话名称" :clearable="true" />
<Tabs v-model:active="scope" shrink :border="false" class="tabs">
<Tab name="all" title="全部会话" />
<Tab name="blocked" :title="blockedTotal ? `忽略 ${blockedTotal}` : '忽略'" />
</Tabs>
</div>
<PullRefresh v-model="refreshing" class="list-body" @refresh="onRefresh">
<List
v-model:loading="loading"
v-model:error="failed"
:finished="finished"
:error-text="`${errorText},点击重试`"
:finished-text="items.length ? '没有更多了' : ''"
:immediate-check="false"
@load="loadMore"
>
<ConversationRow
v-for="item in items"
:key="key(item)"
:item="item"
:active="wide && selected !== null && key(selected) === key(item)"
@select="open"
/>
</List>
<Empty
v-if="finished && !items.length && !failed"
:image="query ? 'search' : 'default'"
:description="
query
? '没有找到匹配的会话'
: scope === 'blocked'
? '忽略是空的'
: '还没有归档的会话:电脑开启聊天备份后会自动出现'
"
>
<Button v-if="query" round size="small" @click="query = ''">清除搜索</Button>
</Empty>
</PullRefresh>
</section>
<section v-if="wide" class="detail-pane app-card">
<ConversationDetail
v-if="selected"
:conversation="selected"
:account-id="viewedAccountId"
:can-add="Boolean(permissions?.blacklist_add)"
:can-remove="Boolean(permissions?.blacklist_remove)"
:add-code="codes?.blacklist_add ?? ''"
:remove-code="codes?.blacklist_remove ?? ''"
@updated="onUpdated"
/>
<Empty v-else image="default" description="选择左侧的会话查看详情" />
</section>
</div>
<Popup
v-if="!wide"
v-model:show="sheet"
position="bottom"
round
closeable
safe-area-inset-bottom
class="sheet"
>
<ConversationDetail
v-if="selected"
:conversation="selected"
:account-id="viewedAccountId"
:can-add="Boolean(permissions?.blacklist_add)"
:can-remove="Boolean(permissions?.blacklist_remove)"
:add-code="codes?.blacklist_add ?? ''"
:remove-code="codes?.blacklist_remove ?? ''"
@updated="onUpdated"
/>
</Popup>
<AccountSwitcher v-if="permissions?.control_all" v-model:show="switching" />
</div>
</template>
<style scoped>
.page {
min-height: 100%;
}
.nav-action {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 6px 0;
border: 0;
background: none;
color: var(--app-primary);
font-size: 14px;
}
.toolbar {
position: sticky;
top: calc(46px + env(safe-area-inset-top));
z-index: 2;
background: var(--app-bg);
}
.toolbar :deep(.van-search) {
background: transparent;
padding-bottom: 4px;
}
.tabs {
--van-tabs-nav-background: transparent;
padding: 0 4px;
}
.list-body {
min-height: 60vh;
}
.single .list-pane {
max-width: 720px;
margin: 0 auto;
}
.split {
display: grid;
grid-template-columns: minmax(300px, 380px) minmax(0, 1fr);
gap: 16px;
padding: 0 24px 24px;
max-width: 1180px;
margin: 0 auto;
align-items: start;
}
/* 圆角只裁列表本身:给整个列表栏 overflow:hidden 会让它变成吸顶的参照容器,
搜索栏就会按 46px 偏移压到第一条会话上。overflow:clip 不产生滚动容器。 */
.split .list-body {
border-radius: 16px;
overflow: clip;
}
.detail-pane {
position: sticky;
top: calc(46px + env(safe-area-inset-top) + 12px);
margin-top: 12px;
min-height: 320px;
}
.sheet {
max-height: 88vh;
padding-top: 8px;
}
</style>
@@ -0,0 +1,233 @@
<script setup lang="ts">
import { Button, Empty, Icon, NavBar, PullRefresh, Skeleton } from 'vant';
import { computed, ref, watch } from 'vue';
import { api, ApiError, type DesktopsResponse, type Device } from '../api';
import AccountSwitcher from '../components/AccountSwitcher.vue';
import DevicePanel from '../components/DevicePanel.vue';
import { useVisibleInterval, useWide } from '../composables';
import { describeDevice } from '../format';
import { permissions, session, viewedAccountId, viewedName } from '../session';
defineOptions({ name: 'DevicesView' });
const wide = useWide();
const data = ref<DesktopsResponse | null>(null);
const loading = ref(false);
const refreshing = ref(false);
const error = ref('');
const selectedId = ref('');
const switching = ref(false);
let serial = 0;
const devices = computed<Device[]>(() => data.value?.items ?? []);
const selected = computed(
() => devices.value.find((item) => item.device_id === selectedId.value) ?? devices.value[0] ?? null,
);
const canControl = computed(() => Boolean(permissions.value?.control));
const controlCode = computed(() => session.me?.permission_codes.control ?? 'wecom_rpa.remote/control');
const title = computed(() => (viewedAccountId.value === undefined ? '我的电脑' : `${viewedName.value} 的电脑`));
async function load() {
const current = ++serial;
if (!data.value) loading.value = true;
try {
const response = await api.desktops(viewedAccountId.value);
if (current !== serial) return;
data.value = response;
error.value = '';
} catch (reason) {
if (current !== serial) return;
error.value = reason instanceof ApiError ? reason.message : '加载失败,请重试';
} finally {
if (current === serial) {
loading.value = false;
refreshing.value = false;
}
}
}
async function refresh() {
refreshing.value = true;
await load();
}
watch(
viewedAccountId,
() => {
data.value = null;
selectedId.value = '';
void load();
},
{ immediate: true },
);
useVisibleInterval(load, 5000);
</script>
<template>
<div class="page">
<NavBar :title="title" safe-area-inset-top fixed placeholder :border="false">
<template v-if="permissions?.control_all" #right>
<button type="button" class="nav-action" @click="switching = true">
<Icon name="friends-o" /> 切换客服
</button>
</template>
</NavBar>
<PullRefresh v-model="refreshing" class="page-body" @refresh="refresh">
<div v-if="loading && !data" class="content">
<div class="app-card skeleton-card"><Skeleton title :row="4" /></div>
</div>
<div v-else-if="error && !data" class="content">
<Empty image="network" :description="error">
<Button round type="primary" size="small" @click="load">重新加载</Button>
</Empty>
</div>
<div v-else-if="!devices.length" class="content">
<Empty image="default" description="还没有登录过企微客服助手的电脑">
<p class="empty-hint">
在电脑上打开企微客服助手,用{{ viewedAccountId === undefined ? '同一个' : '该客服的' }} ZYT
账号登录后,这里会显示电脑状态。
</p>
</Empty>
</div>
<div v-else-if="wide" class="split">
<aside class="split-list" aria-label="电脑列表">
<button
v-for="device in devices"
:key="device.device_id"
type="button"
class="device-row app-card"
:class="{ active: selected?.device_id === device.device_id }"
@click="selectedId = device.device_id"
>
<Icon name="desktop-o" class="device-row-icon" />
<span class="device-row-text">
<strong>{{ device.device_name || '未命名电脑' }}</strong>
<small :class="`tone-${describeDevice(device).tone}`">{{ describeDevice(device).label }}</small>
</span>
</button>
<p v-if="error" class="stale app-muted">{{ error }}(显示的是上次的状态)</p>
</aside>
<section class="split-detail">
<DevicePanel
v-if="selected"
:key="selected.device_id"
:device="selected"
:account-id="viewedAccountId"
:can-control="canControl"
:control-code="controlCode"
@changed="load"
/>
</section>
</div>
<div v-else class="content stack">
<p v-if="error" class="stale app-muted">{{ error }}(显示的是上次的状态)</p>
<DevicePanel
v-for="device in devices"
:key="device.device_id"
:device="device"
:account-id="viewedAccountId"
:can-control="canControl"
:control-code="controlCode"
@changed="load"
/>
</div>
</PullRefresh>
<AccountSwitcher v-if="permissions?.control_all" v-model:show="switching" />
</div>
</template>
<style scoped>
.page {
min-height: 100%;
}
.page-body {
min-height: calc(100vh - 46px - 50px);
min-height: calc(100dvh - 46px - 50px);
}
.content {
padding: 12px 16px 24px;
max-width: 720px;
margin: 0 auto;
}
.stack {
display: grid;
gap: 12px;
}
.skeleton-card {
padding: 16px 0;
}
.empty-hint {
max-width: 280px;
margin: 0 auto;
color: var(--app-muted);
font-size: 13px;
line-height: 1.6;
text-align: center;
}
.nav-action {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 6px 0;
border: 0;
background: none;
color: var(--app-primary);
font-size: 14px;
}
.stale {
margin: 0 4px;
font-size: 12px;
}
.split {
display: grid;
grid-template-columns: minmax(240px, 320px) minmax(0, 1fr);
gap: 16px;
padding: 16px 24px 24px;
max-width: 1080px;
margin: 0 auto;
align-items: start;
}
.split-list {
display: grid;
gap: 10px;
}
.device-row {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 12px 14px;
text-align: left;
color: inherit;
cursor: pointer;
}
.device-row.active {
border-color: var(--app-primary);
box-shadow: 0 0 0 2px var(--app-primary-soft);
}
.device-row-icon {
font-size: 20px;
color: var(--app-primary);
}
.device-row-text {
display: grid;
gap: 2px;
min-width: 0;
}
.device-row-text strong {
font-size: 15px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.device-row-text small {
font-size: 12px;
}
</style>
@@ -0,0 +1,190 @@
<script setup lang="ts">
import { Button, CellGroup, Field, Form, NoticeBar } from 'vant';
import { computed, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { api, ApiError } from '../api';
import brandMark from '../brand-mark.png';
import { signIn } from '../session';
const route = useRoute();
const router = useRouter();
const account = ref('');
const password = ref('');
const submitting = ref(false);
const error = ref('');
const expired = computed(() => route.query.expired === '1');
function clientName(): string {
const ua = navigator.userAgent;
const device = /iPad/.test(ua)
? 'iPad'
: /iPhone/.test(ua)
? 'iPhone'
: /HarmonyOS/i.test(ua)
? '鸿蒙'
: /Android/.test(ua)
? 'Android'
: /Windows/.test(ua)
? 'Windows'
: /Mac OS/.test(ua)
? 'Mac'
: '浏览器';
const shell = /wxwork/i.test(ua) ? '企业微信' : /MicroMessenger/i.test(ua) ? '微信' : '';
return shell ? `${device} · ${shell}` : device;
}
function safeRedirect(): string {
const value = route.query.redirect;
if (typeof value === 'string' && value.startsWith('/') && !value.startsWith('//') && !value.startsWith('/login')) {
return value;
}
return '/desktops';
}
async function submit() {
if (submitting.value) return;
error.value = '';
submitting.value = true;
try {
const result = await api.login(account.value.trim(), password.value, clientName());
password.value = '';
signIn(result.access_token, result.me);
await router.replace(safeRedirect());
} catch (reason) {
if (reason instanceof ApiError) {
error.value = reason.message;
if (reason.code === 'invalid_credentials') password.value = '';
} else {
error.value = '登录失败,请稍后重试';
}
} finally {
submitting.value = false;
}
}
</script>
<template>
<main class="login">
<section class="login-card">
<header class="login-head">
<img class="login-mark" :src="brandMark" alt="甄养堂" width="72" height="72" />
<h1>企微客服助手</h1>
<p>手机控制台 · 使用 ZYT 账号登录</p>
</header>
<NoticeBar
v-if="expired"
class="login-notice"
left-icon="info-o"
:scrollable="false"
wrapable
text="登录已失效或已在管理端被下线,请重新登录。"
/>
<Form class="login-form" @submit="submit">
<CellGroup inset>
<Field
v-model="account"
name="account"
label="账号"
placeholder="ZYT 登录账号"
autocomplete="username"
autocapitalize="off"
:spellcheck="false"
enterkeyhint="next"
clearable
:rules="[{ required: true, message: '请输入账号' }]"
/>
<Field
v-model="password"
type="password"
name="password"
label="密码"
placeholder="ZYT 登录密码"
autocomplete="current-password"
enterkeyhint="go"
:rules="[{ required: true, message: '请输入密码' }]"
/>
</CellGroup>
<p v-if="error" class="login-error" role="alert">{{ error }}</p>
<div class="login-actions">
<Button
round
block
type="primary"
native-type="submit"
:loading="submitting"
loading-text="正在登录…"
>
登录
</Button>
</div>
</Form>
<p class="login-foot">密码只用于向 ZYT 验证身份,不会保存在手机上。</p>
</section>
</main>
</template>
<style scoped>
.login {
min-height: 100vh;
min-height: 100dvh;
display: flex;
align-items: center;
justify-content: center;
padding: calc(env(safe-area-inset-top) + 24px) 16px calc(env(safe-area-inset-bottom) + 24px);
background:
radial-gradient(120% 60% at 50% 0%, var(--app-primary-soft), transparent 70%),
var(--app-bg);
}
.login-card {
width: 100%;
max-width: 420px;
}
.login-head {
text-align: center;
margin-bottom: 24px;
}
.login-mark {
border-radius: 20px;
box-shadow: var(--app-shadow);
}
.login-head h1 {
margin: 16px 0 6px;
font-size: 24px;
font-weight: 700;
letter-spacing: 0.5px;
}
.login-head p {
margin: 0;
color: var(--app-muted);
font-size: 14px;
}
.login-notice {
margin: 0 16px 12px;
border-radius: 12px;
}
.login-form :deep(.van-cell-group--inset) {
margin: 0;
}
.login-form {
margin: 0 0;
}
.login-error {
margin: 12px 16px 0;
color: var(--app-danger);
font-size: 14px;
line-height: 1.5;
}
.login-actions {
margin: 20px 16px 0;
}
.login-foot {
margin: 16px 16px 0;
text-align: center;
color: var(--app-muted);
font-size: 12px;
}
</style>
@@ -0,0 +1,149 @@
<script setup lang="ts">
import { Button, Cell, CellGroup, Icon, NavBar, showConfirmDialog } from 'vant';
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { initial, relativeTime } from '../format';
import { session, signOut } from '../session';
const router = useRouter();
const leaving = ref(false);
const me = computed(() => session.me);
const rows = computed(() => {
const value = me.value;
if (!value) return [];
const { permissions: p, permission_codes: codes } = value;
return [
{ title: '查看本人电脑与会话', code: '登录即可', granted: true },
{ title: '远程启停本人电脑', code: codes.control, granted: p.control },
{ title: '查看并控制所有客服电脑', code: codes.control_all, granted: p.control_all },
{ title: '会话加入忽略', code: codes.blacklist_add, granted: p.blacklist_add },
{ title: '会话移出忽略', code: codes.blacklist_remove, granted: p.blacklist_remove },
];
});
const expires = computed(() => {
const at = me.value?.session.expires_at;
return at ? new Date(at * 1000).toISOString() : '';
});
const isIos = /iPhone|iPad|iPod/.test(navigator.userAgent);
async function logout() {
try {
await showConfirmDialog({ title: '退出登录?', message: '退出后需要重新输入 ZYT 账号密码。', confirmButtonText: '退出' });
} catch {
return;
}
leaving.value = true;
await signOut();
await router.replace({ name: 'login' });
}
</script>
<template>
<div class="page">
<NavBar title="我的" safe-area-inset-top fixed placeholder :border="false" />
<div v-if="me" class="content">
<section class="profile app-card">
<span class="avatar" aria-hidden="true">{{ initial(me.account.nickname || me.account.sn) }}</span>
<div class="profile-text">
<h2>{{ me.account.nickname || me.account.sn }}</h2>
<p class="app-muted">ZYT 账号 {{ me.account.sn || '—' }}</p>
</div>
<span v-if="me.permissions.root" class="root">超级管理员</span>
</section>
<h3 class="section-title">手机端权限</h3>
<CellGroup inset>
<Cell v-for="row in rows" :key="row.title" :title="row.title" :label="row.code" center>
<template #value>
<span :class="row.granted ? 'tone-success' : 'tone-muted'">
<Icon :name="row.granted ? 'passed' : 'close'" /> {{ row.granted ? '已开通' : '未开通' }}
</span>
</template>
</Cell>
</CellGroup>
<p class="hint app-muted">
权限在 ZYT「权限管理 → 角色管理」里勾选「企微客服助手」下的项目,调整后 10 分钟内自动生效,或退出重新登录。
</p>
<h3 class="section-title">登录信息</h3>
<CellGroup inset>
<Cell title="登录时间" :value="relativeTime(me.session.created_at)" />
<Cell title="最长有效至" :value="expires ? relativeTime(expires) : '—'" label="ZYT 登录失效后会提前退出" />
</CellGroup>
<h3 class="section-title">添加到主屏幕</h3>
<p class="hint app-muted">
<template v-if="isIos">在 Safari 中点底部「分享」按钮,选择「添加到主屏幕」,之后像 App 一样打开。</template>
<template v-else>在浏览器菜单中选择「添加到主屏幕」或「安装应用」,之后像 App 一样打开。</template>
</p>
<div class="logout">
<Button block round type="danger" plain :loading="leaving" @click="logout">退出登录</Button>
</div>
</div>
</div>
</template>
<style scoped>
.content {
padding: 12px 0 32px;
max-width: 720px;
margin: 0 auto;
}
.profile {
display: flex;
align-items: center;
gap: 14px;
margin: 0 16px;
padding: 16px;
}
.avatar {
flex: none;
width: 54px;
height: 54px;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 22px;
font-weight: 700;
color: #fff;
background: linear-gradient(135deg, #2f6fff, #5a65ff);
}
.profile-text {
flex: 1;
min-width: 0;
}
.profile-text h2 {
margin: 0 0 4px;
font-size: 18px;
}
.profile-text p {
margin: 0;
font-size: 13px;
}
.root {
flex: none;
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
color: var(--app-warning);
background: var(--app-warning-soft);
}
.section-title {
margin: 24px 32px 8px;
font-size: 13px;
font-weight: 600;
color: var(--app-muted);
}
.hint {
margin: 8px 32px 0;
font-size: 12px;
line-height: 1.6;
}
.logout {
margin: 32px 16px 0;
}
</style>
@@ -0,0 +1,28 @@
<script setup lang="ts">
import { Tabbar, TabbarItem } from 'vant';
</script>
<template>
<div class="shell">
<RouterView v-slot="{ Component }">
<KeepAlive :include="['DevicesView', 'ConversationsView']">
<component :is="Component" />
</KeepAlive>
</RouterView>
<Tabbar route placeholder safe-area-inset-bottom class="shell-tabbar">
<TabbarItem replace :to="{ name: 'desktops' }" icon="desktop-o">电脑</TabbarItem>
<TabbarItem replace :to="{ name: 'conversations' }" icon="chat-o">会话</TabbarItem>
<TabbarItem replace :to="{ name: 'me' }" icon="user-o">我的</TabbarItem>
</Tabbar>
</div>
</template>
<style scoped>
.shell {
min-height: 100vh;
min-height: 100dvh;
}
.shell-tabbar {
border-top: 1px solid var(--app-border);
}
</style>
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"types": ["vite/client"],
"strict": true,
"jsx": "preserve",
"noEmit": true,
"isolatedModules": true,
"resolveJsonModule": true,
"useDefineForClassFields": true,
"skipLibCheck": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"paths": { "@/*": ["./src/*"] }
},
"include": ["src/**/*.ts", "src/**/*.vue"]
}
+46
View File
@@ -0,0 +1,46 @@
import { fileURLToPath, URL } from 'node:url';
import vue from '@vitejs/plugin-vue';
import { defineConfig, type Plugin } from 'vite';
// Vant 的图标字体同时写了内嵌的 woff2 和一个阿里 CDN 的 woff 兜底。内嵌那份总能用;
// 外链只会在内网环境里失败,并被页面的 CSP(font-src 'self' data:)拦下报错。
// 在打包前改写源 CSS,文件名里的内容哈希才会跟着变,浏览器缓存不会拿到旧内容。
function dropRemoteIconFont(): Plugin {
const remote = /,\s*url\(["']?\/\/at\.alicdn\.com[^)]*\)\s*format\(["']?woff["']?\)/g;
return {
name: 'drop-remote-icon-font',
enforce: 'pre',
transform(code, id) {
if (!id.split('?')[0].endsWith('.css') || !code.includes('at.alicdn.com')) return null;
return { code: code.replace(remote, ''), map: null };
},
};
}
// 构建产物由管理端 admin_api.py 托管在 /m/,与 /api/v2/mobile/* 同源。
// 开发时把 /api 代理到本机后台(默认 8766,可用 MOBILE_API_TARGET 覆盖)。
export default defineConfig({
base: '/m/',
plugins: [vue(), dropRemoteIconFont()],
resolve: {
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
},
build: {
outDir: 'dist',
assetsDir: 'assets',
// 企业微信 / 微信内置浏览器和老一点的 iOS Safari 也要能打开。
target: ['es2019', 'safari13', 'chrome80'],
sourcemap: false,
},
server: {
host: '127.0.0.1',
port: 5190,
proxy: {
'/api': {
target: process.env.MOBILE_API_TARGET || 'http://127.0.0.1:8766',
changeOrigin: false,
},
},
},
});
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
include: ['src/**/*.test.ts'],
},
});
+3
View File
@@ -9,6 +9,9 @@ packages:
- packages/effects/*
- packages/business/*
- apps/*
# 手机远程控制台是独立的轻量项目(Vue 3 + Vant),自带依赖和锁文件,
# 不跟 vben 管理端共用 workspace:cd apps/mobile-h5 && pnpm install --ignore-workspace
- '!apps/mobile-h5'
- scripts/*
publicHoistPattern: