geng
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
dist
|
||||
*.local
|
||||
@@ -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
|
||||
@@ -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 时前端清掉令牌回登录页。
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 141 KiB |
|
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" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// 手机控制台的 Service Worker 只为"添加到主屏幕"存在,不缓存任何内容:
|
||||
// 远程控制看到的必须是实时状态,离线缓存只会展示过期的电脑状态。
|
||||
self.addEventListener('install', () => self.skipWaiting());
|
||||
self.addEventListener('activate', (event) => event.waitUntil(self.clients.claim()));
|
||||
@@ -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>
|
||||
@@ -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 } },
|
||||
);
|
||||
},
|
||||
};
|
||||
|
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);
|
||||
});
|
||||
}
|
||||
@@ -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('😀');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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'],
|
||||
},
|
||||
});
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
dist
|
||||
*.local
|
||||
@@ -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
|
||||
@@ -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 时前端清掉令牌回登录页。
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 141 KiB |
|
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" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// 手机控制台的 Service Worker 只为"添加到主屏幕"存在,不缓存任何内容:
|
||||
// 远程控制看到的必须是实时状态,离线缓存只会展示过期的电脑状态。
|
||||
self.addEventListener('install', () => self.skipWaiting());
|
||||
self.addEventListener('activate', (event) => event.waitUntil(self.clients.claim()));
|
||||
@@ -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>
|
||||
@@ -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 } },
|
||||
);
|
||||
},
|
||||
};
|
||||
|
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);
|
||||
});
|
||||
}
|
||||
@@ -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('😀');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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'],
|
||||
},
|
||||
});
|
||||
@@ -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:
|
||||
|
||||
@@ -12,6 +12,8 @@ RUN pip install --disable-pip-version-check -r /tmp/requirements.txt \
|
||||
|
||||
COPY --chown=app:app wechat_rpa/*.py /app/wechat_rpa/
|
||||
COPY --chown=app:app admin-web/apps/web-antd/dist/ /app/admin-web/apps/web-antd/dist/
|
||||
# 手机远程控制台(admin-web/apps/mobile-h5,独立项目),由 admin_api.py 托管在 /m/
|
||||
COPY --chown=app:app admin-web/apps/mobile-h5/dist/ /app/admin-web/apps/mobile-h5/dist/
|
||||
|
||||
USER app
|
||||
WORKDIR /app/wechat_rpa
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
# 手机远程控制台(/m/)的 HTTPS 入口。
|
||||
#
|
||||
# 自 2026-09-28(mobile-http-20260928)起按用户要求,内外网都允许明文 HTTP 登录手机端
|
||||
# (MOBILE_REQUIRE_HTTPS 默认 never)。HTTP 下 ZYT 密码和手机会话令牌在公网上可被截获;
|
||||
# 配好本 HTTPS 入口后,建议给 api 服务加环境变量 MOBILE_REQUIRE_HTTPS=auto(公网必须 HTTPS,内网仍可 HTTP)。
|
||||
#
|
||||
# 启用步骤(宝塔面板或手工均可):
|
||||
# 1. 证书:宝塔「网站 → im.zhenyangtang.cn → SSL」申请 Let's Encrypt,或 acme.sh。
|
||||
# 公网 443 没有转发到本机时,HTTP-01 验证会失败,改用 DNS-01(DNS API)签发。
|
||||
# 2. 端口:路由器把公网 TCP 443(或选定的 HTTPS 端口,例如 8443)转发到 192.168.110.111。
|
||||
# 用非 443 端口时把下面的 listen 改成对应端口,手机访问 https://im.zhenyangtang.cn:8443/m/。
|
||||
# 3. 把本文件复制成 im.zhenyangtang.cn.https.conf 放到与现有 conf 相同的目录,修改证书路径,
|
||||
# `nginx -t` 通过后 `nginx -s reload`。现有的 80 端口配置保持不动,桌面端继续可用。
|
||||
# 4. 验证:curl -I https://im.zhenyangtang.cn/m/ 返回 200;手机浏览器打开后登录页不再提示明文连接。
|
||||
#
|
||||
# 注意 X-Real-IP 必须由 Nginx 用 $remote_addr 覆盖写入:后台用它做登录限流和"是否内网"的判断。
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name im.zhenyangtang.cn;
|
||||
|
||||
ssl_certificate /www/server/panel/vhost/cert/im.zhenyangtang.cn/fullchain.pem;
|
||||
ssl_certificate_key /www/server/panel/vhost/cert/im.zhenyangtang.cn/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_prefer_server_ciphers off;
|
||||
ssl_session_cache shared:SSL_IM:10m;
|
||||
ssl_session_timeout 1d;
|
||||
add_header Strict-Transport-Security "max-age=31536000" always;
|
||||
|
||||
client_max_body_size 100m;
|
||||
|
||||
location ^~ /api/v2/ {
|
||||
proxy_pass http://127.0.0.1:18766;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_connect_timeout 15s;
|
||||
proxy_send_timeout 300s;
|
||||
# 桌面端命令通道长轮询最长挂起 25 秒,远小于这里的读超时。
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
|
||||
location ^~ /api/ {
|
||||
proxy_pass http://127.0.0.1:18766/api/v2/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_connect_timeout 15s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
|
||||
location ^~ /gateway/ {
|
||||
proxy_pass http://127.0.0.1:18770/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_connect_timeout 15s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:18766;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_connect_timeout 15s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
|
||||
access_log /www/wwwlogs/im.zhenyangtang.cn.https.log;
|
||||
error_log /www/wwwlogs/im.zhenyangtang.cn.https.error.log;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env bash
|
||||
# 基线核对、备份将被替换的源文件和 /m/ 目录、给当前镜像打回滚标签。只读线上 + 写本目录。
|
||||
set -euo pipefail
|
||||
test "${REMOTE_RELEASE_APPROVED:-}" = mobile-http-20260928
|
||||
release=/home/ps/mobile-http-20260928
|
||||
cd "$release"
|
||||
test ! -e baseline-check.json
|
||||
python3 remote_server_guard.py baseline > baseline-check.json.tmp
|
||||
mv baseline-check.json.tmp baseline-check.json
|
||||
cat baseline-check.json
|
||||
test ! -e source-before
|
||||
test ! -e preflight-complete
|
||||
if docker image inspect zyt/wecom-admin:before-mobile-http-20260928 >/dev/null 2>&1; then
|
||||
echo 'Refuse to replace existing rollback tag' >&2; exit 1
|
||||
fi
|
||||
mkdir -m 700 source-before
|
||||
python3 - <<'PY'
|
||||
from pathlib import Path
|
||||
import json, shutil
|
||||
r = Path('/home/ps/mobile-http-20260928'); project = Path('/opt/im-admin')
|
||||
plan = json.loads((r / 'release-plan.json').read_text())
|
||||
for name in plan['backendFiles'] + plan['deployFiles']:
|
||||
target = r / 'source-before' / name
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(project / name, target)
|
||||
shutil.copytree(project / plan['frontendDir'], r / 'source-before' / plan['frontendDir'])
|
||||
PY
|
||||
docker tag zyt/wecom-admin:current zyt/wecom-admin:before-mobile-http-20260928
|
||||
docker image inspect zyt/wecom-admin:current --format '{{.Id}}' > image-before.txt
|
||||
printf '%s\n' ready > preflight-complete
|
||||
echo "preflight complete; rollback image tagged before-mobile-http-20260928 ($(cat image-before.txt))"
|
||||
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env bash
|
||||
# 构建候选镜像 + 离线跑测试 + 核对候选镜像文件。线上服务不受影响。
|
||||
# 本次不改数据库(只改登录的 HTTPS 默认策略和 /m/ 静态文件),不做整库备份;回滚靠旧镜像和 source-before。
|
||||
set -euo pipefail
|
||||
test "${REMOTE_RELEASE_APPROVED:-}" = mobile-http-20260928
|
||||
release=/home/ps/mobile-http-20260928
|
||||
cd "$release"
|
||||
test -f preflight-complete
|
||||
test ! -e candidate-passed
|
||||
python3 remote_server_guard.py baseline
|
||||
test "$(docker image inspect zyt/wecom-admin:before-mobile-http-20260928 --format '{{.Id}}')" = "$(cat image-before.txt)"
|
||||
docker build --network=none --pull=false -f Dockerfile.overlay -t zyt/wecom-admin:mobile-http-20260928 . > candidate-build.log 2>&1
|
||||
# serverTestModules 随包放在 tests/;imageTestModules 用镜像自带的测试文件(PYTHONPATH 后一项)。
|
||||
# -P:不把工作目录 /app/wechat_rpa 放到 sys.path 最前面,否则镜像里同名的旧测试会盖住随包的新测试。
|
||||
mapfile -t modules < <(python3 -c 'import json;p=json.load(open("release-plan.json"));print("\n".join(p["serverTestModules"]+p.get("imageTestModules",[])))')
|
||||
test "${#modules[@]}" -gt 0
|
||||
docker run --rm --network=none --read-only --tmpfs /tmp:size=512m,mode=1777 --tmpfs /data:size=256m,mode=1777 \
|
||||
-v "$release/tests:/release-tests:ro" -e PYTHONPATH=/release-tests:/app/wechat_rpa \
|
||||
zyt/wecom-admin:mobile-http-20260928 python -X utf8 -B -P -m unittest "${modules[@]}" -q > candidate-tests.log 2>&1
|
||||
tail -n 3 candidate-tests.log
|
||||
python3 remote_server_guard.py candidate
|
||||
printf '%s\n' passed > candidate-passed
|
||||
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env bash
|
||||
# 替换 remote_api.py 和整个 /m/ 目录;切换镜像,只重建 api 容器。任何一步失败自动调用 rollback.sh。
|
||||
set -euo pipefail
|
||||
test "${REMOTE_RELEASE_APPROVED:-}" = mobile-http-20260928
|
||||
release=/home/ps/mobile-http-20260928
|
||||
cd "$release"
|
||||
test -f candidate-passed
|
||||
python3 remote_server_guard.py baseline
|
||||
cd /opt/im-admin/deploy/im-admin
|
||||
docker compose config --quiet
|
||||
rollback_on_error() { rc=$?; trap - ERR; bash "$release/rollback.sh"; exit "$rc"; }
|
||||
trap rollback_on_error ERR
|
||||
python3 - <<'PY'
|
||||
from pathlib import Path
|
||||
import json, hashlib, os, shutil
|
||||
r = Path('/home/ps/mobile-http-20260928'); target = Path('/opt/im-admin')
|
||||
plan = json.loads((r / 'release-plan.json').read_text())
|
||||
rows = [row for row in json.loads((r / 'manifest.json').read_text()) if row['path'].startswith('payload/')]
|
||||
frontend = plan['frontendDir']
|
||||
# 容器从镜像里读 /m/,宿主上的目录只给以后整包重建用;仍然整体换名,避免留下半套文件。
|
||||
staging = target / (frontend + '.mobile-http.tmp'); assert not staging.exists()
|
||||
old = target / (frontend + '.mobile-http.old'); assert not old.exists()
|
||||
for row in rows:
|
||||
rel = row['path'][len('payload/'):]
|
||||
data = (r / row['path']).read_bytes(); assert hashlib.sha256(data).hexdigest() == row['sha256'], rel
|
||||
if rel.startswith(frontend + '/'):
|
||||
dest = staging / rel[len(frontend) + 1:]
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest.write_bytes(data); os.chmod(dest, 0o644)
|
||||
continue
|
||||
dest = (target / rel).resolve(); assert dest.is_relative_to(target) and dest.exists()
|
||||
tmp = dest.with_name(dest.name + '.mobile-http.tmp'); assert not tmp.exists()
|
||||
with tmp.open('xb') as f:
|
||||
f.write(data)
|
||||
os.chmod(tmp, dest.stat().st_mode & 0o777); os.replace(tmp, dest)
|
||||
for directory in [staging] + [p for p in staging.rglob('*') if p.is_dir()]:
|
||||
os.chmod(directory, 0o755)
|
||||
final = target / frontend
|
||||
os.replace(final, old)
|
||||
os.replace(staging, final)
|
||||
shutil.rmtree(old) # 原目录已在 source-before 里
|
||||
PY
|
||||
docker tag zyt/wecom-admin:mobile-http-20260928 zyt/wecom-admin:current
|
||||
docker compose up -d --no-deps --no-build --wait --wait-timeout 180 api
|
||||
bash "$release/04-verify.sh"
|
||||
trap - ERR
|
||||
printf '%s\n' 'Plain-HTTP phone login enabled, deployed and verified.'
|
||||
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
test "${REMOTE_RELEASE_APPROVED:-}" = mobile-http-20260928
|
||||
release=/home/ps/mobile-http-20260928
|
||||
cd "$release"
|
||||
python3 remote_server_guard.py deployed | tee deployed-verification.json
|
||||
@@ -0,0 +1,9 @@
|
||||
ARG BASE_IMAGE=zyt/wecom-admin:before-mobile-http-20260928
|
||||
FROM ${BASE_IMAGE}
|
||||
# 先删掉旧版 /m/ 目录再放新版,镜像里不残留旧的(带内网横幅的)页面文件。
|
||||
USER root
|
||||
RUN rm -rf /app/admin-web/apps/mobile-h5/dist
|
||||
USER app
|
||||
COPY --chown=app:app payload/wechat_rpa/remote_api.py /app/wechat_rpa/remote_api.py
|
||||
COPY --chown=app:app payload/admin-web/apps/mobile-h5/dist/ /app/admin-web/apps/mobile-h5/dist/
|
||||
WORKDIR /app/wechat_rpa
|
||||
@@ -0,0 +1,162 @@
|
||||
[
|
||||
{
|
||||
"path": "payload/admin-web/apps/mobile-h5/dist/assets/_plugin-vue_export-helper-4o9rbdkf.js",
|
||||
"sha256": "8d6186020c3d8562f581ea579742dd497649203a880b9c13cc904462b134bc60",
|
||||
"bytes": 2308
|
||||
},
|
||||
{
|
||||
"path": "payload/admin-web/apps/mobile-h5/dist/assets/AccountSwitcher-BZXg15MH.css",
|
||||
"sha256": "b61f1f1af65c2c3438066ffe41683af7e38062eb9389cda3f8fdad8ee77498ff",
|
||||
"bytes": 359
|
||||
},
|
||||
{
|
||||
"path": "payload/admin-web/apps/mobile-h5/dist/assets/AccountSwitcher-DHX0QDeT.js",
|
||||
"sha256": "226d4cde03c5434f20aa96c84cfb33538af084d79805348a089e343c5056281f",
|
||||
"bytes": 14528
|
||||
},
|
||||
{
|
||||
"path": "payload/admin-web/apps/mobile-h5/dist/assets/brand-mark-CPUQS9Am.png",
|
||||
"sha256": "1caa8c492da279e8d01f676fe9232ccad016321699d519b3834692052cc5f5ee",
|
||||
"bytes": 16743
|
||||
},
|
||||
{
|
||||
"path": "payload/admin-web/apps/mobile-h5/dist/assets/cell-group-CEKvg8Wf.js",
|
||||
"sha256": "b0d4b420bc5197a5df6d918e740e2b90b108c6e06ba930c3fd8e97fabe010556",
|
||||
"bytes": 4490
|
||||
},
|
||||
{
|
||||
"path": "payload/admin-web/apps/mobile-h5/dist/assets/ConversationsView-BMjeHKhk.js",
|
||||
"sha256": "75cd3809eb0d7db6c994302dd296674eadd366a53a4feb97a759173d70546e80",
|
||||
"bytes": 28526
|
||||
},
|
||||
{
|
||||
"path": "payload/admin-web/apps/mobile-h5/dist/assets/ConversationsView-CytDMSuM.css",
|
||||
"sha256": "1f646a0178eb9e3f837ac62b5521ec60c0e7d5b4b086b9076c4bf2a7448f98ae",
|
||||
"bytes": 4220
|
||||
},
|
||||
{
|
||||
"path": "payload/admin-web/apps/mobile-h5/dist/assets/DevicesView-CHTvxQxK.css",
|
||||
"sha256": "40fd5adaea4d4aed376a57c75ebec4544d50f424863704b00ce80b98f7881ddd",
|
||||
"bytes": 3983
|
||||
},
|
||||
{
|
||||
"path": "payload/admin-web/apps/mobile-h5/dist/assets/DevicesView-DVXAQfpT.js",
|
||||
"sha256": "237de4f76004cbf16e5928060cf009fbbb3beec826006a876ddbede0ee9512f7",
|
||||
"bytes": 10460
|
||||
},
|
||||
{
|
||||
"path": "payload/admin-web/apps/mobile-h5/dist/assets/field-Bs9V8Rb1.js",
|
||||
"sha256": "da55fbacb606cdb51f6eb09fb8bf4963fe249e869d653f0fb71d3c0da9ed7b4c",
|
||||
"bytes": 8422
|
||||
},
|
||||
{
|
||||
"path": "payload/admin-web/apps/mobile-h5/dist/assets/format-2xch3ZbY.js",
|
||||
"sha256": "a98c816d31f9a937c5418dd48d25bc5b7b96fb3084d58571565d0810f420ced9",
|
||||
"bytes": 14363
|
||||
},
|
||||
{
|
||||
"path": "payload/admin-web/apps/mobile-h5/dist/assets/index-C_mEe-tZ.js",
|
||||
"sha256": "6c6620136850dbdfa6ea22a9fa6c320f3f5d33150b83f1d1b9597e0894f5d0de",
|
||||
"bytes": 117757
|
||||
},
|
||||
{
|
||||
"path": "payload/admin-web/apps/mobile-h5/dist/assets/index-D80qzV0-.css",
|
||||
"sha256": "c0c7a7706d3d4a4ca2baa88f31aa2fd5c6080b4f2e539b4812a99d51f2de9fd0",
|
||||
"bytes": 97051
|
||||
},
|
||||
{
|
||||
"path": "payload/admin-web/apps/mobile-h5/dist/assets/LoginView-Br8nsdQM.js",
|
||||
"sha256": "29bdcbe8030ef2e169b2ea453cef160f2b3c86b9a83718971023a9862e10bd45",
|
||||
"bytes": 6336
|
||||
},
|
||||
{
|
||||
"path": "payload/admin-web/apps/mobile-h5/dist/assets/LoginView-J4b4q6ai.css",
|
||||
"sha256": "15ecdd5cb294bb51eef05e4843ac3a65f6e1718cbf16206547a876c52c5e8a2d",
|
||||
"bytes": 1107
|
||||
},
|
||||
{
|
||||
"path": "payload/admin-web/apps/mobile-h5/dist/assets/MeView-B-MK2tgf.css",
|
||||
"sha256": "a3bd246d2658ee50d4cac9afa1d051c33e8676be3155260256e7106a9c5dd136",
|
||||
"bytes": 927
|
||||
},
|
||||
{
|
||||
"path": "payload/admin-web/apps/mobile-h5/dist/assets/MeView-BOWGunAt.js",
|
||||
"sha256": "9e5ff0c332dfae54d9f8e1c70b0a896e48aef80e8b45101092c08a59dfddf521",
|
||||
"bytes": 3465
|
||||
},
|
||||
{
|
||||
"path": "payload/admin-web/apps/mobile-h5/dist/assets/ShellView-D354hIv5.css",
|
||||
"sha256": "513a381d7ecfe8c24ca22166d658c8604df9d9478a8765ffcef7beb031917dcd",
|
||||
"bytes": 130
|
||||
},
|
||||
{
|
||||
"path": "payload/admin-web/apps/mobile-h5/dist/assets/ShellView-DcQ5QK1V.js",
|
||||
"sha256": "13f70e99d3eccfa014f14dc313a195b4f24200929bf60142f6fd190ef33f2f3d",
|
||||
"bytes": 2939
|
||||
},
|
||||
{
|
||||
"path": "payload/admin-web/apps/mobile-h5/dist/assets/use-placeholder-D9ZemjXL.js",
|
||||
"sha256": "530e97b69f045bf3c2798b79c1f49bd3ee69f53a657bad39825cb69d68552c9c",
|
||||
"bytes": 607
|
||||
},
|
||||
{
|
||||
"path": "payload/admin-web/apps/mobile-h5/dist/icons/apple-touch-icon.png",
|
||||
"sha256": "3167d1d07f22a44ea393b377e5181918ae4890c137de37fe91e2f3780b6ce70e",
|
||||
"bytes": 24681
|
||||
},
|
||||
{
|
||||
"path": "payload/admin-web/apps/mobile-h5/dist/icons/favicon-64.png",
|
||||
"sha256": "7c9d031dcd296a45a79d960481c34eb150dd33e4a2bace6f28385be01c1a675e",
|
||||
"bytes": 5439
|
||||
},
|
||||
{
|
||||
"path": "payload/admin-web/apps/mobile-h5/dist/icons/icon-192.png",
|
||||
"sha256": "9637a4d3e4cbb385ed0b35069d6bb3522b90d1b8aaec439cfdf5ec1264040b5a",
|
||||
"bytes": 26433
|
||||
},
|
||||
{
|
||||
"path": "payload/admin-web/apps/mobile-h5/dist/icons/icon-512.png",
|
||||
"sha256": "803ed98fb41fc724cb5319f45409d5e9725465f6f37cb6312e6b79170f852974",
|
||||
"bytes": 144310
|
||||
},
|
||||
{
|
||||
"path": "payload/admin-web/apps/mobile-h5/dist/icons/icon-maskable-512.png",
|
||||
"sha256": "88b6cecdb691f9e2f4179fd00eae937a5f6863cd61d34aadda1e6ac45e6217a2",
|
||||
"bytes": 95961
|
||||
},
|
||||
{
|
||||
"path": "payload/admin-web/apps/mobile-h5/dist/index.html",
|
||||
"sha256": "6f335e7804fe9a1ce7bb5df388d2af81c7d919ddebb3beaee9533553a36adf04",
|
||||
"bytes": 1357
|
||||
},
|
||||
{
|
||||
"path": "payload/admin-web/apps/mobile-h5/dist/manifest.webmanifest",
|
||||
"sha256": "ac64783b6865e3f68fdc735d5d66cad0c13ffc8b6be6ae5fbebc0e2eac5a61fd",
|
||||
"bytes": 684
|
||||
},
|
||||
{
|
||||
"path": "payload/admin-web/apps/mobile-h5/dist/sw.js",
|
||||
"sha256": "d908e9a6ec5c75ffcca8f9f6d97ec683b005fa211975f89beee372849df44a69",
|
||||
"bytes": 339
|
||||
},
|
||||
{
|
||||
"path": "payload/wechat_rpa/remote_api.py",
|
||||
"sha256": "e8a51c1701a05d54713f45325e03481ad22609bc721455aa5ddef0d3ed5ae29a",
|
||||
"bytes": 37707
|
||||
},
|
||||
{
|
||||
"path": "tests/test_backend.py",
|
||||
"sha256": "7473ec5583c97941e132ade7a1f66abcf54fa6108ec6bb2a182f0152964fff61",
|
||||
"bytes": 66279
|
||||
},
|
||||
{
|
||||
"path": "tests/test_remote_api.py",
|
||||
"sha256": "479cb9b042dc14707af718734c33688013203d4154f36199b7b0ced656653c39",
|
||||
"bytes": 38523
|
||||
},
|
||||
{
|
||||
"path": "tests/test_zyt_auth.py",
|
||||
"sha256": "40ab2d37f51780af26497e22948ef665c92b7f3707f6ba30a6d711bf5b15dfa6",
|
||||
"bytes": 4907
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,27 @@
|
||||
--- production/wechat_rpa/remote_api.py
|
||||
+++ release/wechat_rpa/remote_api.py
|
||||
@@ -79,15 +79,17 @@
|
||||
|
||||
|
||||
def https_required(request: Request) -> bool:
|
||||
- """手机端要在公网上提交 ZYT 密码,明文 HTTP 只允许在内网试用。
|
||||
-
|
||||
- MOBILE_REQUIRE_HTTPS:auto(默认,公网地址必须 HTTPS)/ always / never。
|
||||
-
|
||||
- 没有 X-Real-IP 时按"来源不明"处理:生产环境里直连 API 的对端是 Docker 网桥
|
||||
- (172.x,看起来像内网),代理漏配头就会把公网请求误当内网放行。只有本机回环
|
||||
+ """手机端登录是否必须走 HTTPS。
|
||||
+
|
||||
+ MOBILE_REQUIRE_HTTPS:never(默认,内外网都允许明文 HTTP 登录,按用户要求)/
|
||||
+ auto(公网地址必须 HTTPS,内网可用 HTTP)/ always。配好 HTTPS 入口后建议改成
|
||||
+ auto:明文 HTTP 下 ZYT 密码和手机会话令牌在公网上可被截获。
|
||||
+
|
||||
+ auto 模式下没有 X-Real-IP 时按"来源不明"处理:生产环境里直连 API 的对端是 Docker
|
||||
+ 网桥(172.x,看起来像内网),代理漏配头就会把公网请求误当内网放行。只有本机回环
|
||||
(开发时直接访问 127.0.0.1)才在没有这个头时被信任。
|
||||
"""
|
||||
- policy = os.environ.get("MOBILE_REQUIRE_HTTPS", "auto").strip().lower()
|
||||
+ policy = os.environ.get("MOBILE_REQUIRE_HTTPS", "never").strip().lower()
|
||||
if policy == "never" or request_scheme(request) == "https":
|
||||
return False
|
||||
if policy == "always":
|
||||
@@ -0,0 +1 @@
|
||||
.switcher[data-v-91cf283c]{max-height:80vh}.switcher-body[data-v-91cf283c]{padding:16px 0 12px}.switcher-body h3[data-v-91cf283c]{margin:0 16px 8px;font-size:17px}.switcher-state[data-v-91cf283c]{place-items:center;padding:32px 0;display:grid}.switcher-list[data-v-91cf283c]{margin-top:12px}.current[data-v-91cf283c]{color:var(--app-primary);margin-left:6px}
|
||||
@@ -0,0 +1 @@
|
||||
.detail[data-v-70465908]{padding-bottom:16px}.detail-head[data-v-70465908]{align-items:center;gap:12px;padding:16px;display:flex}.avatar[data-v-70465908]{width:52px;height:52px;color:var(--app-primary);background:var(--app-primary-soft);border-radius:14px;flex:none;place-items:center;font-size:22px;font-weight:600;display:grid}.detail-title[data-v-70465908]{flex:1;min-width:0}.detail-title h2[data-v-70465908]{text-overflow:ellipsis;white-space:nowrap;margin:0 0 4px;font-size:18px;overflow:hidden}.detail-title p[data-v-70465908]{margin:0;font-size:13px}.badge[data-v-70465908]{color:var(--app-danger);background:var(--app-danger-soft);border-radius:999px;flex:none;align-items:center;gap:4px;padding:4px 10px;font-size:12px;font-weight:600;display:inline-flex}.group[data-v-70465908]{margin-bottom:12px}.state[data-v-70465908]{background:var(--app-surface);border:1px solid var(--app-border);border-radius:14px;margin:0 16px;padding:16px}.state--blocked[data-v-70465908]{border-color:var(--app-danger-soft);background:linear-gradient(0deg, var(--app-danger-soft), var(--app-danger-soft)), var(--app-surface)}.state h3[data-v-70465908]{align-items:center;gap:6px;margin:0 0 6px;font-size:15px;display:flex}.state--blocked h3[data-v-70465908]{color:var(--app-danger)}.state>p[data-v-70465908]{color:var(--app-text-2);margin:0 0 12px;font-size:13px;line-height:1.6}.state dl[data-v-70465908]{grid-template-columns:repeat(2,minmax(0,1fr));gap:8px 12px;margin:0 0 14px;display:grid}.state dt[data-v-70465908]{color:var(--app-muted);font-size:12px}.state dd[data-v-70465908]{word-break:break-all;margin:2px 0 0;font-size:14px}.reason[data-v-70465908]{margin:0 0 12px}.reason[data-v-70465908] .van-cell-group--inset{margin:0}.state[data-v-70465908] .van-cell-group--inset{margin:0 0 12px}.note[data-v-70465908]{color:var(--app-muted);margin:0;font-size:13px;line-height:1.6}.row[data-v-645c253b]{border:0;border-bottom:1px solid var(--app-border);background:var(--app-surface);width:100%;min-height:64px;color:inherit;text-align:left;cursor:pointer;align-items:center;gap:12px;padding:10px 16px;display:flex}.row[data-v-645c253b]:active,.row.active[data-v-645c253b]{background:var(--app-primary-soft)}.avatar[data-v-645c253b]{border-radius:12px;flex:none;place-items:center;width:42px;height:42px;font-size:17px;font-weight:600;display:grid}.avatar--wecom[data-v-645c253b]{color:#2f6fff;background:#2f6fff1f}.avatar--wechat[data-v-645c253b]{color:#0f9e69;background:#13b77a24}.avatar--group[data-v-645c253b]{color:#7a5cff;background:#8a6bff24}.body[data-v-645c253b]{flex:1;gap:4px;min-width:0;display:grid}.line[data-v-645c253b]{justify-content:space-between;align-items:center;gap:8px;min-width:0;display:flex}.name[data-v-645c253b]{text-overflow:ellipsis;white-space:nowrap;font-size:15px;font-weight:600;overflow:hidden}.time[data-v-645c253b]{color:var(--app-muted);flex:none;font-size:12px}.sub[data-v-645c253b]{color:var(--app-muted);font-size:12px}.meta[data-v-645c253b]{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.blocked[data-v-645c253b]{color:var(--app-danger);background:var(--app-danger-soft);border-radius:999px;flex:none;padding:1px 8px;font-weight:600}.page[data-v-cc7f80e6]{min-height:100%}.nav-action[data-v-cc7f80e6]{color:var(--app-primary);background:0 0;border:0;align-items:center;gap:4px;padding:6px 0;font-size:14px;display:inline-flex}.toolbar[data-v-cc7f80e6]{top:calc(46px + env(safe-area-inset-top));z-index:2;background:var(--app-bg);position:-webkit-sticky;position:sticky}.toolbar[data-v-cc7f80e6] .van-search{background:0 0;padding-bottom:4px}.tabs[data-v-cc7f80e6]{--van-tabs-nav-background:transparent;padding:0 4px}.list-body[data-v-cc7f80e6]{min-height:60vh}.single .list-pane[data-v-cc7f80e6]{max-width:720px;margin:0 auto}.split[data-v-cc7f80e6]{grid-template-columns:minmax(300px,380px) minmax(0,1fr);align-items:start;gap:16px;max-width:1180px;margin:0 auto;padding:0 24px 24px;display:grid}.split .list-body[data-v-cc7f80e6]{border-radius:16px;overflow:clip}.detail-pane[data-v-cc7f80e6]{top:calc(46px + env(safe-area-inset-top) + 12px);min-height:320px;margin-top:12px;position:-webkit-sticky;position:sticky}.sheet[data-v-cc7f80e6]{max-height:88vh;padding-top:8px}
|
||||
@@ -0,0 +1 @@
|
||||
.panel[data-v-bcd7f731]{padding:16px}.panel-head[data-v-bcd7f731]{align-items:flex-start;gap:12px;display:flex}.panel-icon[data-v-bcd7f731]{width:44px;height:44px;color:var(--app-primary);background:var(--app-primary-soft);border-radius:12px;flex:none;place-items:center;font-size:22px;display:grid}.panel-title[data-v-bcd7f731]{flex:1;min-width:0}.panel-title h2[data-v-bcd7f731]{text-overflow:ellipsis;white-space:nowrap;margin:2px 0 4px;font-size:17px;font-weight:600;overflow:hidden}.panel-title p[data-v-bcd7f731]{margin:0;font-size:13px;line-height:1.45}.pill[data-v-bcd7f731]{white-space:nowrap;border-radius:999px;flex:none;align-items:center;gap:6px;padding:4px 10px;font-size:12px;font-weight:600;display:inline-flex}.pill .dot[data-v-bcd7f731]{background:currentColor;border-radius:50%;width:7px;height:7px}.pill--success[data-v-bcd7f731]{color:var(--app-success);background:var(--app-success-soft)}.pill--success .dot[data-v-bcd7f731]{box-shadow:0 0 0 3px var(--app-success-soft);animation:2.2s ease-in-out infinite breathe-bcd7f731}.pill--warning[data-v-bcd7f731]{color:var(--app-warning);background:var(--app-warning-soft)}.pill--danger[data-v-bcd7f731]{color:var(--app-danger);background:var(--app-danger-soft)}.pill--muted[data-v-bcd7f731],.pill--primary[data-v-bcd7f731]{color:var(--app-muted);background:var(--app-muted-soft)}@keyframes breathe-bcd7f731{50%{opacity:.45}}.stats[data-v-bcd7f731]{grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;margin:16px 0 0;display:grid}.stat[data-v-bcd7f731]{background:var(--app-surface-2);border:1px solid var(--app-border);border-radius:12px;padding:10px 12px}.stat dt[data-v-bcd7f731]{color:var(--app-muted);font-size:12px}.stat dd[data-v-bcd7f731]{margin:4px 0 0;font-size:15px;font-weight:600}.meta[data-v-bcd7f731]{margin:12px 0 0;font-size:12px}.progress[data-v-bcd7f731]{border-radius:12px;align-items:center;gap:8px;margin-top:12px;padding:10px 12px;font-size:13px;line-height:1.45;display:flex}.progress--primary[data-v-bcd7f731]{color:var(--app-primary);background:var(--app-primary-soft)}.progress--success[data-v-bcd7f731]{color:var(--app-success);background:var(--app-success-soft)}.progress--danger[data-v-bcd7f731]{color:var(--app-danger);background:var(--app-danger-soft)}.progress--warning[data-v-bcd7f731]{color:var(--app-warning);background:var(--app-warning-soft)}.progress--muted[data-v-bcd7f731]{color:var(--app-muted);background:var(--app-muted-soft)}.actions[data-v-bcd7f731]{margin-top:16px}.blocked[data-v-bcd7f731]{margin:10px 4px 0;font-size:13px;line-height:1.5}.page[data-v-8ead1f63]{min-height:100%}.page-body[data-v-8ead1f63]{min-height:calc(100vh - 96px);min-height:calc(100dvh - 96px)}.content[data-v-8ead1f63]{max-width:720px;margin:0 auto;padding:12px 16px 24px}.stack[data-v-8ead1f63]{gap:12px;display:grid}.skeleton-card[data-v-8ead1f63]{padding:16px 0}.empty-hint[data-v-8ead1f63]{max-width:280px;color:var(--app-muted);text-align:center;margin:0 auto;font-size:13px;line-height:1.6}.nav-action[data-v-8ead1f63]{color:var(--app-primary);background:0 0;border:0;align-items:center;gap:4px;padding:6px 0;font-size:14px;display:inline-flex}.stale[data-v-8ead1f63]{margin:0 4px;font-size:12px}.split[data-v-8ead1f63]{grid-template-columns:minmax(240px,320px) minmax(0,1fr);align-items:start;gap:16px;max-width:1080px;margin:0 auto;padding:16px 24px 24px;display:grid}.split-list[data-v-8ead1f63]{gap:10px;display:grid}.device-row[data-v-8ead1f63]{text-align:left;width:100%;color:inherit;cursor:pointer;align-items:center;gap:12px;padding:12px 14px;display:flex}.device-row.active[data-v-8ead1f63]{border-color:var(--app-primary);box-shadow:0 0 0 2px var(--app-primary-soft)}.device-row-icon[data-v-8ead1f63]{color:var(--app-primary);font-size:20px}.device-row-text[data-v-8ead1f63]{gap:2px;min-width:0;display:grid}.device-row-text strong[data-v-8ead1f63]{text-overflow:ellipsis;white-space:nowrap;font-size:15px;overflow:hidden}.device-row-text small[data-v-8ead1f63]{font-size:12px}
|
||||
@@ -0,0 +1 @@
|
||||
.login[data-v-004201f6]{min-height:100vh;min-height:100dvh;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);justify-content:center;align-items:center;display:flex}.login-card[data-v-004201f6]{width:100%;max-width:420px}.login-head[data-v-004201f6]{text-align:center;margin-bottom:24px}.login-mark[data-v-004201f6]{box-shadow:var(--app-shadow);border-radius:20px}.login-head h1[data-v-004201f6]{letter-spacing:.5px;margin:16px 0 6px;font-size:24px;font-weight:700}.login-head p[data-v-004201f6]{color:var(--app-muted);margin:0;font-size:14px}.login-notice[data-v-004201f6]{border-radius:12px;margin:0 16px 12px}.login-form[data-v-004201f6] .van-cell-group--inset,.login-form[data-v-004201f6]{margin:0}.login-error[data-v-004201f6]{color:var(--app-danger);margin:12px 16px 0;font-size:14px;line-height:1.5}.login-actions[data-v-004201f6]{margin:20px 16px 0}.login-foot[data-v-004201f6]{text-align:center;color:var(--app-muted);margin:16px 16px 0;font-size:12px}
|
||||
@@ -0,0 +1 @@
|
||||
.content[data-v-1710faf3]{max-width:720px;margin:0 auto;padding:12px 0 32px}.profile[data-v-1710faf3]{align-items:center;gap:14px;margin:0 16px;padding:16px;display:flex}.avatar[data-v-1710faf3]{color:#fff;background:linear-gradient(135deg,#2f6fff,#5a65ff);border-radius:50%;flex:none;place-items:center;width:54px;height:54px;font-size:22px;font-weight:700;display:grid}.profile-text[data-v-1710faf3]{flex:1;min-width:0}.profile-text h2[data-v-1710faf3]{margin:0 0 4px;font-size:18px}.profile-text p[data-v-1710faf3]{margin:0;font-size:13px}.root[data-v-1710faf3]{color:var(--app-warning);background:var(--app-warning-soft);border-radius:999px;flex:none;padding:4px 10px;font-size:12px;font-weight:600}.section-title[data-v-1710faf3]{color:var(--app-muted);margin:24px 32px 8px;font-size:13px;font-weight:600}.hint[data-v-1710faf3]{margin:8px 32px 0;font-size:12px;line-height:1.6}.logout[data-v-1710faf3]{margin:32px 16px 0}
|
||||
@@ -0,0 +1 @@
|
||||
import{n as e,t}from"./_plugin-vue_export-helper-4o9rbdkf.js";import{a as n,i as r,o as i,s as a}from"./format-2xch3ZbY.js";import{a as o,n as s,t as c}from"./cell-group-CEKvg8Wf.js";import{Ct as l,Ft as u,Jt as d,Kt as f,Lt as p,St as m,Ut as h,Yt as g,_t as _,d as v,ft as y,gt as b,ht as x,i as S,n as C,qt as w,vt as T,xt as E,yt as D}from"./index-C_mEe-tZ.js";var O={class:`page`},k={key:0,class:`content`},A={class:`profile app-card`},j={class:`avatar`,"aria-hidden":`true`},M={class:`profile-text`},N={class:`app-muted`},P={key:0,class:`root`},F={class:`hint app-muted`},I={class:`logout`},L=t(l({__name:`MeView`,setup(t){let l=v(),L=f(!1),R=x(()=>C.me),z=x(()=>{let e=R.value;if(!e)return[];let{permissions:t,permission_codes:n}=e;return[{title:`查看本人电脑与会话`,code:`登录即可`,granted:!0},{title:`远程启停本人电脑`,code:n.control,granted:t.control},{title:`查看并控制所有客服电脑`,code:n.control_all,granted:t.control_all},{title:`会话加入黑名单`,code:n.blacklist_add,granted:t.blacklist_add},{title:`会话移出黑名单`,code:n.blacklist_remove,granted:t.blacklist_remove}]}),B=x(()=>{var e;let t=(e=R.value)==null?void 0:e.session.expires_at;return t?new Date(t*1e3).toISOString():``}),V=/iPhone|iPad|iPod/.test(navigator.userAgent);async function H(){try{await a({title:`退出登录?`,message:`退出后需要重新输入 ZYT 账号密码。`,confirmButtonText:`退出`})}catch{return}L.value=!0,await S(),await l.replace({name:`login`})}return(t,a)=>(u(),D(`div`,O,[m(w(i),{title:`我的`,"safe-area-inset-top":``,fixed:``,placeholder:``,border:!1}),R.value?(u(),D(`div`,k,[b(`section`,A,[b(`span`,j,g(w(r)(R.value.account.nickname||R.value.account.sn)),1),b(`div`,M,[b(`h2`,null,g(R.value.account.nickname||R.value.account.sn),1),b(`p`,N,`ZYT 账号 `+g(R.value.account.sn||`—`),1)]),R.value.permissions.root?(u(),D(`span`,P,`超级管理员`)):T(``,!0)]),a[1]||(a[1]=b(`h3`,{class:`section-title`},`手机端权限`,-1)),m(w(c),{inset:``},{default:h(()=>[(u(!0),D(y,null,p(z.value,t=>(u(),_(w(s),{key:t.title,title:t.title,label:t.code,center:``},{value:h(()=>[b(`span`,{class:d(t.granted?`tone-success`:`tone-muted`)},[m(w(e),{name:t.granted?`passed`:`close`},null,8,[`name`]),E(` `+g(t.granted?`已开通`:`未开通`),1)],2)]),_:2},1032,[`title`,`label`]))),128))]),_:1}),a[2]||(a[2]=b(`p`,{class:`hint app-muted`},` 权限在 ZYT「权限管理 → 角色管理」里勾选「企微客服助手」下的项目,调整后 10 分钟内自动生效,或退出重新登录。 `,-1)),a[3]||(a[3]=b(`h3`,{class:`section-title`},`登录信息`,-1)),m(w(c),{inset:``},{default:h(()=>[m(w(s),{title:`登录时间`,value:w(n)(R.value.session.created_at)},null,8,[`value`]),m(w(s),{title:`最长有效至`,value:B.value?w(n)(B.value):`—`,label:`ZYT 登录失效后会提前退出`},null,8,[`value`])]),_:1}),a[4]||(a[4]=b(`h3`,{class:`section-title`},`添加到主屏幕`,-1)),b(`p`,F,[w(V)?(u(),D(y,{key:0},[E(`在 Safari 中点底部「分享」按钮,选择「添加到主屏幕」,之后像 App 一样打开。`)],64)):(u(),D(y,{key:1},[E(`在浏览器菜单中选择「添加到主屏幕」或「安装应用」,之后像 App 一样打开。`)],64))]),b(`div`,I,[m(w(o),{block:``,round:``,type:`danger`,plain:``,loading:L.value,onClick:H},{default:h(()=>[...a[0]||(a[0]=[E(`退出登录`,-1)])]),_:1},8,[`loading`])])])):T(``,!0)]))}}),[[`__scopeId`,`data-v-1710faf3`]]);export{L as default};
|
||||
@@ -0,0 +1 @@
|
||||
.shell[data-v-5972669c]{min-height:100vh;min-height:100dvh}.shell-tabbar[data-v-5972669c]{border-top:1px solid var(--app-border)}
|
||||
@@ -0,0 +1 @@
|
||||
import{a as e,n as t,o as n,p as r,r as i,t as a}from"./_plugin-vue_export-helper-4o9rbdkf.js";import{n as o,t as s}from"./use-placeholder-D9ZemjXL.js";import{B as c,Ct as l,Et as u,Ft as d,Kt as f,Q as p,Rt as m,S as h,St as g,U as _,Ut as v,X as y,Z as b,_ as x,_t as S,g as C,ht as w,pt as T,q as E,qt as D,rt as O,wt as k,xt as A,yt as j,zt as M}from"./index-C_mEe-tZ.js";var[N,P]=x(`tabbar`),F={route:Boolean,fixed:b,border:b,zIndex:y,placeholder:Boolean,activeColor:String,beforeChange:Function,inactiveColor:String,modelValue:E(0),safeAreaInsetBottom:{type:Boolean,default:null}},I=Symbol(N),L=l({name:N,props:F,emits:[`change`,`update:modelValue`],setup(e,{emit:t,slots:n}){let i=f(),{linkChildren:a}=c(I),l=s(i,P),u=()=>{var t;return(t=e.safeAreaInsetBottom)==null?e.fixed:t},d=()=>{var t;let{fixed:a,zIndex:o,border:s}=e;return g(`div`,{ref:i,role:`tablist`,style:h(o),class:[P({fixed:a}),{[r]:s,"van-safe-area-bottom":u()}]},[(t=n.default)==null?void 0:t.call(n)])};return a({props:e,setActive:(n,r)=>{o(e.beforeChange,{args:[n],done(){t(`update:modelValue`,n),t(`change`,n),r()}})}}),()=>e.fixed&&e.placeholder?l(d):d()}}),R=C(L),[z,B]=x(`tabbar-item`),V=p({},e,{dot:Boolean,icon:String,name:y,badge:y,badgeProps:Object,iconPrefix:String}),H=l({name:z,props:V,emits:[`click`],setup(e,{emit:r,slots:a}){let o=n(),s=k().proxy,{parent:c,index:l}=_(I);if(!c)return;let d=w(()=>{var t;let{route:n,modelValue:r}=c.props;if(n&&`$route`in s){let{$route:t}=s,{to:n}=e,r=O(n)?n:{path:n};return t.matched.some(e=>{let t=`path`in r&&r.path===e.path,n=`name`in r&&r.name===e.name;return t||n})}return((t=e.name)==null?l.value:t)===r}),f=t=>{var n;d.value||c.setActive((n=e.name)==null?l.value:n,o),r(`click`,t)},p=()=>{if(a.icon)return a.icon({active:d.value});if(e.icon)return g(t,{name:e.icon,classPrefix:e.iconPrefix},null)};return()=>{var t;let{dot:n,badge:r}=e,{activeColor:o,inactiveColor:s}=c.props,l=d.value?o:s;return g(`div`,{role:`tab`,class:B({active:d.value}),style:{color:l},tabindex:0,"aria-selected":d.value,onClick:f},[g(i,u({dot:n,class:B(`icon`),content:r},e.badgeProps),{default:p}),g(`div`,{class:B(`text`)},[(t=a.default)==null?void 0:t.call(a,{active:d.value})])])}}}),U=C(H),W={class:`shell`},G=a(l({__name:`ShellView`,setup(e){return(e,t)=>{let n=m(`RouterView`);return d(),j(`div`,W,[g(n,null,{default:v(({Component:e})=>[(d(),S(T,{include:[`DevicesView`,`ConversationsView`]},[(d(),S(M(e)))],1024))]),_:1}),g(D(R),{route:``,placeholder:``,"safe-area-inset-bottom":``,class:`shell-tabbar`},{default:v(()=>[g(D(U),{replace:``,to:{name:`desktops`},icon:`desktop-o`},{default:v(()=>[...t[0]||(t[0]=[A(`电脑`,-1)])]),_:1}),g(D(U),{replace:``,to:{name:`conversations`},icon:`chat-o`},{default:v(()=>[...t[1]||(t[1]=[A(`会话`,-1)])]),_:1}),g(D(U),{replace:``,to:{name:`me`},icon:`user-o`},{default:v(()=>[...t[2]||(t[2]=[A(`我的`,-1)])]),_:1})]),_:1})])}}}),[[`__scopeId`,`data-v-5972669c`]]);export{G as default};
|
||||
@@ -0,0 +1 @@
|
||||
import{Ct as e,Et as t,St as n,Tt as r,Vt as i,X as a,Y as o,Z as s,_ as c,et as l,g as u,ht as d,m as f,nt as p,v as m,wt as h}from"./index-C_mEe-tZ.js";var g=`van-hairline`,_=`${g}--top`,v=`${g}--left`;`${g}`;var y=`${g}--bottom`,b=`${g}--surround`,x=`${g}--top-bottom`;`${g}`;var S=`van-haptics-feedback`,C=Symbol(`van-form`),w=Symbol();function T(e){let t=r(w,null);t&&i(t,t=>{t&&e()})}var E={to:[String,Object],url:String,replace:Boolean};function D({to:e,url:t,replace:n,$router:r}){e&&r?r[n?`replace`:`push`](e):t&&(n?location.replace(t):location.href=t)}function O(){let e=h().proxy;return()=>D(e)}var[k,A]=c(`badge`),j={dot:Boolean,max:a,tag:o(`div`),color:String,offset:Array,content:a,showZero:s,position:o(`top-right`)},M=e({name:k,props:j,setup(e,{slots:t}){let r=()=>{if(t.content)return!0;let{content:n,showZero:r}=e;return l(n)&&n!==``&&(r||n!==0&&n!==`0`)},i=()=>{let{dot:n,max:i,content:a}=e;if(!n&&r())return t.content?t.content():l(i)&&p(a)&&+a>+i?`${i}+`:a},a=e=>e.startsWith(`-`)?e.replace(`-`,``):`-${e}`,o=d(()=>{let n={background:e.color};if(e.offset){let[r,i]=e.offset,{position:o}=e,[s,c]=o.split(`-`);t.default?(n[s]=typeof i==`number`?m(s===`top`?i:-i):s===`top`?m(i):a(i),n[c]=typeof r==`number`?m(c===`left`?r:-r):c===`left`?m(r):a(r)):(n.marginTop=m(i),n.marginLeft=m(r))}return n}),s=()=>{if(r()||e.dot)return n(`div`,{class:A([e.position,{dot:e.dot,fixed:!!t.default}]),style:o.value},[i()])};return()=>{if(t.default){let{tag:r}=e;return n(r,{class:A(`wrapper`)},{default:()=>[t.default(),s()]})}return s()}}}),N=u(M),[P,F]=c(`icon`),I=e=>e==null?void 0:e.includes(`/`),L={dot:Boolean,tag:o(`i`),name:String,size:a,badge:a,color:String,badgeProps:Object,classPrefix:String},R=e({name:P,props:L,setup(e,{slots:i}){let a=r(f,null),o=d(()=>e.classPrefix||(a==null?void 0:a.iconPrefix)||F());return()=>{let{tag:r,dot:a,name:s,size:c,badge:l,color:u}=e,d=I(s);return n(N,t({dot:a,tag:r,class:[o.value,d?``:`${o.value}-${s}`],style:{color:u,fontSize:m(c)},content:l},e.badgeProps),{default:()=>{var e;return[(e=i.default)==null?void 0:e.call(i),d&&n(`img`,{class:F(`image`),src:s},null)]}})}}}),z=u(R),B=(e,t)=>{let n=e.__vccOpts||e;for(let[e,r]of t)n[e]=r;return n};export{E as a,T as c,b as d,_ as f,S as h,D as i,y as l,C as m,z as n,O as o,x as p,N as r,w as s,B as t,v as u};
|
||||
|
After Width: | Height: | Size: 16 KiB |
@@ -0,0 +1 @@
|
||||
import{a as e,d as t,n,o as r,p as i}from"./_plugin-vue_export-helper-4o9rbdkf.js";import{Ct as a,Et as o,O as s,Q as c,St as l,X as u,Y as d,Z as f,_ as p,et as m,ft as h,g,ht as _,v,wt as y,x as b}from"./index-C_mEe-tZ.js";function x(e){let t=y();t&&c(t.proxy,e)}var[S,C]=p(`loading`),w=Array(12).fill(null).map((e,t)=>l(`i`,{class:C(`line`,String(t+1))},null)),T=l(`svg`,{class:C(`circular`),viewBox:`25 25 50 50`},[l(`circle`,{cx:`50`,cy:`50`,r:`20`,fill:`none`},null)]),E={size:u,type:d(`circular`),color:String,vertical:Boolean,textSize:u,textColor:String},D=a({name:S,props:E,setup(e,{slots:t}){let n=_(()=>c({color:e.color},b(e.size))),r=()=>{let r=e.type===`spinner`?w:T;return l(`span`,{class:C(`spinner`,e.type),style:n.value},[t.icon?t.icon():r])},i=()=>{var n;if(t.default)return l(`span`,{class:C(`text`),style:{fontSize:v(e.textSize),color:(n=e.textColor)==null?e.color:n}},[t.default()])};return()=>{let{type:t,vertical:n}=e;return l(`div`,{class:C([t,{vertical:n}]),"aria-live":`polite`,"aria-busy":!0},[r(),i()])}}}),O=g(D),[k,A]=p(`button`),j=c({},e,{tag:d(`button`),text:String,icon:String,type:d(`default`),size:d(`normal`),color:String,block:Boolean,plain:Boolean,round:Boolean,square:Boolean,loading:Boolean,hairline:Boolean,disabled:Boolean,iconPrefix:String,nativeType:d(`button`),loadingSize:u,loadingText:String,loadingType:String,iconPosition:d(`left`)}),M=a({name:k,props:j,emits:[`click`],setup(e,{emit:i,slots:a}){let o=r(),c=()=>a.loading?a.loading():l(O,{size:e.loadingSize,type:e.loadingType,class:A(`loading`)},null),u=()=>{if(e.loading)return c();if(a.icon)return l(`div`,{class:A(`icon`)},[a.icon()]);if(e.icon)return l(n,{name:e.icon,class:A(`icon`),classPrefix:e.iconPrefix},null)},d=()=>{let t;if(t=e.loading?e.loadingText:a.default?a.default():e.text,t)return l(`span`,{class:A(`text`)},[t])},f=()=>{let{color:t,plain:n}=e;if(t){let e={color:n?t:`white`};return n||(e.background=t),t.includes(`gradient`)?e.border=0:e.borderColor=t,e}},p=t=>{e.loading?s(t):e.disabled||(i(`click`,t),o())};return()=>{let{tag:n,type:r,size:i,block:a,round:o,plain:s,square:c,loading:m,disabled:h,hairline:g,nativeType:_,iconPosition:v}=e,y=[A([r,i,{plain:s,block:a,round:o,square:c,loading:m,disabled:h,hairline:g}]),{[t]:g}];return l(n,{type:_,class:y,style:f(),disabled:h,onClick:p},{default:()=>[l(`div`,{class:A(`content`)},[v===`left`&&u(),d(),v===`right`&&u()])]})}}}),N=g(M),P=()=>{var e;let{scopeId:t}=((e=y())==null?void 0:e.vnode)||{};return t?{[t]:``}:null},[F,I]=p(`cell`),L={tag:d(`div`),icon:String,size:String,title:u,value:u,label:u,center:Boolean,isLink:Boolean,border:f,iconPrefix:String,valueClass:null,labelClass:null,titleClass:null,titleStyle:null,arrowDirection:String,required:{type:[Boolean,String],default:null},clickable:{type:Boolean,default:null}},R=c({},L,e),z=a({name:F,props:R,setup(e,{slots:t}){let i=r(),a=()=>{if(t.label||m(e.label))return l(`div`,{class:[I(`label`),e.labelClass]},[t.label?t.label():e.label])},o=()=>{var n;if(t.title||m(e.title)){let r=(n=t.title)==null?void 0:n.call(t);return Array.isArray(r)&&r.length===0?void 0:l(`div`,{class:[I(`title`),e.titleClass],style:e.titleStyle},[r||l(`span`,null,[e.title]),a()])}},s=()=>{let n=t.value||t.default;if(n||m(e.value))return l(`div`,{class:[I(`value`),e.valueClass]},[n?n():l(`span`,null,[e.value])])},c=()=>{if(t.icon)return t.icon();if(e.icon)return l(n,{name:e.icon,class:I(`left-icon`),classPrefix:e.iconPrefix},null)},u=()=>{if(t[`right-icon`])return t[`right-icon`]();if(e.isLink){let t=e.arrowDirection&&e.arrowDirection!==`right`?`arrow-${e.arrowDirection}`:`arrow`;return l(n,{name:t,class:I(`right-icon`)},null)}};return()=>{var n,r,a;let{tag:d,size:f,center:p,border:m,isLink:h,required:g}=e,_=!!((a=(r=(n=e.clickable)==null?e.to:n)==null?e.url:r)==null?h:a),v={center:p,required:!!g,clickable:_,borderless:!m};return f&&(v[f]=!!f),l(d,{class:I(v),role:_?`button`:void 0,tabindex:_?0:void 0,onClick:_?i:void 0},{default:()=>{var e;return[c(),o(),s(),u(),(e=t.extra)==null?void 0:e.call(t)]}})}}}),B=g(z),[V,H]=p(`cell-group`),U=a({name:V,inheritAttrs:!1,props:{title:String,inset:Boolean,border:f},setup(e,{slots:t,attrs:n}){let r=()=>{var r;return l(`div`,o({class:[H({inset:e.inset}),{[i]:e.border&&!e.inset}]},n,P()),[(r=t.default)==null?void 0:r.call(t)])},a=()=>l(`div`,{class:H(`title`,{inset:e.inset})},[t.title?t.title():e.title]);return()=>e.title||t.title?l(h,null,[a(),r()]):r()}}),W=g(U);export{N as a,P as i,B as n,O as o,L as r,x as s,W as t};
|
||||