export interface PanelUser { id: string; username: string; role: 'admin' | 'sub'; disabled: boolean; createdAt: string; allowedInstances: string[]; // admin 为空数组(隐式全部) mustChangePassword?: boolean; // 仍在用默认密码时为 true } export type WechatPhase = 'idle' | 'downloading' | 'extracting' | 'installing' | 'done' | 'error'; export interface WechatStatus { phase: WechatPhase; percent: number; // -1 表示进度不确定 installed: boolean; version: string; message: string; updatedAt: number; } export type RuntimeState = 'running' | 'stopped' | 'missing'; export type AppType = 'wechat' | 'telegram' | 'chromium' | 'custom'; export const APP_LABELS: Record = { wechat: '微信', telegram: 'Telegram', chromium: 'Chromium', custom: '自定义应用', }; // 各应用的 UI 画像,供卡片/桌面页按类型显示正确文案(避免到处写死「微信」)。 // needsInstall: 是否需要运行时下载安装(微信/Telegram 是;Chromium 已烤进镜像、即创建即就绪)。 // enterHint: 首次进入实例的提示。 // updateLabel: 「管理」菜单里的更新按钮文案(needsInstall=false 时不显示)。 export interface AppProfile { label: string; needsInstall: boolean; enterHint: string; updateLabel: string; } export const APP_PROFILES: Record = { wechat: { label: '微信', needsInstall: true, enterHint: '首次进入请扫码登录微信', updateLabel: '更新微信' }, telegram: { label: 'Telegram', needsInstall: true, enterHint: '首次进入请登录 Telegram', updateLabel: '更新 Telegram' }, chromium: { label: 'Chromium', needsInstall: false, enterHint: '浏览器已就绪,直接使用即可', updateLabel: '' }, custom: { label: '自定义应用', needsInstall: true, enterHint: '', updateLabel: '更新' }, }; export const appProfile = (t?: AppType): AppProfile => APP_PROFILES[t ?? 'wechat'] ?? APP_PROFILES.wechat; export interface PanelInstance { id: string; name: string; appType?: AppType; // 缺省(老实例)= wechat icon?: string; // 自定义图标:data: 图片 / builtin:;缺省按 appType 取默认图标 createdAt: string; createdBy: string; memSoftLimitMB?: number; memHardLimitMB?: number; } export interface MemLimits { soft: number | null; hard: number | null; defaultSoft: number; defaultHard: number; currentMB: number; watchdogEnabled: boolean; intervalSec: number; } export interface InstanceWithStatus extends PanelInstance { runtime: RuntimeState; wechat: WechatStatus; } export interface VolEntry { name: string; type: 'dir' | 'file' | 'link' | 'other'; size: number; mtime: number; // epoch ms } export interface VersionInfo { current: string; // 当前构建版本(如 v1.2.0 / dev) latest: string | null; // 仓库上最新发布版(如 v1.2.1);查不到为 null hasUpdate: boolean; // 有更高的语义化版本可用 checkedAt: number; // 上次检查时间戳(ms);0=尚未检查 source: string | null; // 数据来源:dockerhub / ghcr / dockerhub+ghcr error: string | null; // 检查失败原因 } // 原始二进制上传(File 直传 application/octet-stream),用于数据卷上传/解压/恢复 async function rawUpload(url: string, file: File): Promise { const res = await fetch(url, { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/octet-stream' }, body: file, }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error((data as any).error || `请求失败 (${res.status})`); return data; } async function req(path: string, opts: RequestInit = {}): Promise { // 仅在有 body 时声明 JSON content-type:否则 Fastify 对「空 body + application/json」会报 400 const headers = opts.body ? { 'content-type': 'application/json', ...opts.headers } : opts.headers; const res = await fetch(path, { credentials: 'same-origin', ...opts, headers, }); const data = await res.json().catch(() => ({})); if (!res.ok) { // 会话过期:除登录/探测接口外,任意接口收到 401 都说明 cookie 失效,直接回登录页(避免页面卡在错误态) const isAuthProbe = path.includes('/api/auth/login') || path.includes('/api/auth/me'); if (res.status === 401 && !isAuthProbe && location.pathname !== '/login') { location.assign('/login'); } throw new Error((data as any).error || `请求失败 (${res.status})`); } return data as T; } export const api = { me: () => req<{ user: PanelUser }>('/api/auth/me'), login: (username: string, password: string) => req<{ user: PanelUser }>('/api/auth/login', { method: 'POST', body: JSON.stringify({ username, password }) }), logout: () => req('/api/auth/logout', { method: 'POST' }), changePassword: (oldPassword: string, newPassword: string) => req('/api/account/password', { method: 'POST', body: JSON.stringify({ oldPassword, newPassword }) }), // 版本与更新检测 getVersion: () => req('/api/version'), checkUpdate: () => req('/api/admin/version/check', { method: 'POST' }), // 子账号 listUsers: () => req<{ users: PanelUser[] }>('/api/admin/users'), createUser: (username: string, password: string, allowedInstances: string[] = []) => req<{ user: PanelUser }>('/api/admin/users', { method: 'POST', body: JSON.stringify({ username, password, allowedInstances }), }), setDisabled: (id: string, disabled: boolean) => req<{ user: PanelUser }>(`/api/admin/users/${id}/disable`, { method: 'POST', body: JSON.stringify({ disabled }) }), resetUser: (id: string, newPassword: string) => req<{ user: PanelUser }>(`/api/admin/users/${id}/reset`, { method: 'POST', body: JSON.stringify({ newPassword }) }), deleteUser: (id: string) => req(`/api/admin/users/${id}`, { method: 'DELETE' }), setUserInstances: (id: string, instanceIds: string[]) => req<{ user: PanelUser }>(`/api/admin/users/${id}/instances`, { method: 'POST', body: JSON.stringify({ instanceIds }) }), // 微信实例 listInstances: () => req<{ instances: InstanceWithStatus[] }>('/api/instances'), createInstance: (name: string, allowedUserIds: string[] = [], reuseVolume?: string, appType: AppType = 'wechat') => req<{ instance: PanelInstance }>('/api/admin/instances', { method: 'POST', body: JSON.stringify({ name, allowedUserIds, reuseVolume: reuseVolume || undefined, appType }), }), regenMachineId: (id: string) => req(`/api/admin/instances/${id}/regen-machine-id`, { method: 'POST' }), getInstanceMemLimits: (id: string) => req(`/api/admin/instances/${id}/mem-limits`), setInstanceMemLimits: (id: string, soft: number | null | undefined, hard: number | null | undefined) => req<{ instance: PanelInstance }>(`/api/admin/instances/${id}/mem-limits`, { method: 'PUT', body: JSON.stringify({ soft, hard }), }), listOrphanVolumes: () => req<{ volumes: { name: string; createdAt?: string; sizeBytes?: number }[] }>('/api/admin/orphan-volumes'), deleteOrphanVolume: (name: string) => req(`/api/admin/orphan-volumes/${encodeURIComponent(name)}`, { method: 'DELETE' }), listOrphanContainers: () => req<{ containers: { id: string; name: string; status: string; volumeName?: string }[] }>('/api/admin/orphan-containers'), deleteOrphanContainer: (idOrName: string) => req(`/api/admin/orphan-containers/${encodeURIComponent(idOrName)}`, { method: 'DELETE' }), setInstanceIcon: (id: string, icon: string | null) => req<{ instance: PanelInstance }>(`/api/admin/instances/${id}/icon`, { method: 'POST', body: JSON.stringify({ icon }) }), renameInstance: (id: string, name: string) => req<{ instance: PanelInstance }>(`/api/admin/instances/${id}/rename`, { method: 'POST', body: JSON.stringify({ name }) }), deleteInstance: (id: string, purge = false) => req(`/api/admin/instances/${id}${purge ? '?purge=1' : ''}`, { method: 'DELETE' }), setInstanceUsers: (id: string, userIds: string[]) => req(`/api/admin/instances/${id}/users`, { method: 'POST', body: JSON.stringify({ userIds }) }), instanceWechatStatus: (id: string) => req<{ status: WechatStatus }>(`/api/instances/${id}/wechat/status`), instanceWechatInstall: (id: string) => req(`/api/admin/instances/${id}/wechat/install`, { method: 'POST' }), instanceWechatUpdate: (id: string) => req(`/api/admin/instances/${id}/wechat/update`, { method: 'POST' }), instanceStart: (id: string) => req(`/api/admin/instances/${id}/start`, { method: 'POST' }), instanceStop: (id: string) => req(`/api/admin/instances/${id}/stop`, { method: 'POST' }), instanceRestart: (id: string) => req(`/api/admin/instances/${id}/restart`, { method: 'POST' }), instanceUpgrade: (id: string) => req(`/api/admin/instances/${id}/upgrade`, { method: 'POST' }), instanceLogsUrl: (id: string) => `/api/admin/instances/${id}/logs`, // 全局日志 / 诊断包(范围 24h/7d/30d/1y) diagnosticsUrl: (range: string) => `/api/admin/diagnostics?range=${encodeURIComponent(range)}`, panelLogUrl: (range: string) => `/api/admin/panel-log?range=${encodeURIComponent(range)}`, // 文件中转 listFiles: (id: string) => req<{ files: { name: string; size: number }[] }>(`/api/instances/${id}/files`), uploadFile: async (id: string, file: File) => { const res = await fetch(`/api/instances/${id}/upload?name=${encodeURIComponent(file.name)}`, { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/octet-stream' }, body: file, }); if (!res.ok) throw new Error(((await res.json().catch(() => ({}))) as any).error || '上传失败'); return res.json(); }, downloadFileUrl: (id: string, name: string) => `/api/instances/${id}/download?name=${encodeURIComponent(name)}`, deleteFile: (id: string, name: string) => req(`/api/instances/${id}/files?name=${encodeURIComponent(name)}`, { method: 'DELETE' }), // 数据卷管理(仅管理员) volumeList: (id: string, path = '') => req<{ path: string; entries: VolEntry[] }>(`/api/admin/instances/${id}/volume?path=${encodeURIComponent(path)}`), volumeMkdir: (id: string, path: string) => req(`/api/admin/instances/${id}/volume/mkdir`, { method: 'POST', body: JSON.stringify({ path }) }), volumeMove: (id: string, from: string, to: string) => req(`/api/admin/instances/${id}/volume/move`, { method: 'POST', body: JSON.stringify({ from, to }) }), volumeDelete: (id: string, path: string) => req(`/api/admin/instances/${id}/volume?path=${encodeURIComponent(path)}`, { method: 'DELETE' }), volumeDownloadUrl: (id: string, path: string) => `/api/admin/instances/${id}/volume/download?path=${encodeURIComponent(path)}`, volumeBackupUrl: (id: string) => `/api/admin/instances/${id}/volume/backup`, volumeUpload: (id: string, path: string, file: File) => rawUpload(`/api/admin/instances/${id}/volume/upload?path=${encodeURIComponent(path)}&name=${encodeURIComponent(file.name)}`, file), volumeExtract: (id: string, path: string, file: File) => rawUpload(`/api/admin/instances/${id}/volume/extract?path=${encodeURIComponent(path)}`, file), volumeRestore: (id: string, file: File) => rawUpload(`/api/admin/instances/${id}/volume/restore`, file), // 多端协作:操作控制权 controlStatus: (id: string) => req<{ free: boolean; mine: boolean; holder: string | null }>(`/api/instances/${id}/control`), controlBeat: (id: string) => req<{ mine: boolean; holder: string }>(`/api/instances/${id}/control/beat`, { method: 'POST' }), controlTake: (id: string) => req<{ mine: boolean; holder: string }>(`/api/instances/${id}/control/take`, { method: 'POST' }), typeInInstance: (id: string, text: string) => req(`/api/instances/${id}/type`, { method: 'POST', body: JSON.stringify({ text }) }), keyInInstance: (id: string, key: string) => req(`/api/instances/${id}/key`, { method: 'POST', body: JSON.stringify({ key }) }), };