Files
WechatOnCloud/panel/web/src/api.ts
T
Gloridust 7293285d1a feat(panel): 显示构建版本号 + 自动检测新版(Docker Hub/GHCR 红点)
管理界面此前看不到面板版本,也无从知道有没有新版可升。现在:

- 构建时把版本号烤进面板镜像:Dockerfile 新增 ARG/ENV WOC_VERSION(放末尾不
  破坏依赖缓存);release.yml 用 git tag 注入(vX.Y.Z,手动触发为 dev-<sha>),
  仅面板镜像消费;build-local.sh 支持 --build-arg(默认 dev)。
- 后端 version.ts:best-effort 查询 Docker Hub 与 GHCR 上 woc-panel 的语义化
  标签取最大值,与当前版本比对;启动后 4s 首检 + 每 6h 复检 + 接口惰性触发,
  失败静默(离线/被墙/私有源不报错、不显红点)。命名空间从 WOC_WECHAT_IMAGE 推断。
- 接口:GET /api/version(任意登录用户读缓存)、POST /api/admin/version/check
  (管理员手动重查)。
- 前端:管理页「关于」卡显示当前版本/最新版/升级提示/检查更新/发布日志链接;
  侧栏「管理」入口在有新版时点红点(仅管理员)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 22:40:17 +08:00

229 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<AppType, string> = {
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<AppType, AppProfile> = {
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:<key>;缺省按 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<any> {
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<T = any>(path: string, opts: RequestInit = {}): Promise<T> {
// 仅在有 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<VersionInfo>('/api/version'),
checkUpdate: () => req<VersionInfo>('/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<MemLimits>(`/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`,
// 文件中转
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 }) }),
};