mirror of
https://github.com/Gloridust/WechatOnCloud.git
synced 2026-06-16 19:53:53 +08:00
7293285d1a
管理界面此前看不到面板版本,也无从知道有没有新版可升。现在: - 构建时把版本号烤进面板镜像: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>
229 lines
12 KiB
TypeScript
229 lines
12 KiB
TypeScript
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 }) }),
|
||
};
|