Compare commits
5 Commits
@@ -118,7 +118,7 @@ docker volume ls | grep woc # 看所有微信实例的数据卷
|
||||
**方式 A · 本地自构建(官方尚未发布镜像时用这个)**
|
||||
|
||||
```bash
|
||||
git clone <this-repo> WechatOnCloud
|
||||
git clone https://github.com/Gloridust/WechatOnCloud.git WechatOnCloud
|
||||
cd WechatOnCloud
|
||||
cp .env.example .env # 至少改掉默认密码 WOC_PASSWORD
|
||||
./scripts/build-local.sh # 构建面板 + 微信实例镜像,打成 compose 用的同名标签
|
||||
@@ -128,7 +128,7 @@ docker compose up -d # compose 默认优先用本地镜像,不会
|
||||
**方式 B · 拉取官方镜像(已发布到 GHCR 后)**
|
||||
|
||||
```bash
|
||||
git clone <this-repo> WechatOnCloud
|
||||
git clone https://github.com/Gloridust/WechatOnCloud.git WechatOnCloud
|
||||
cd WechatOnCloud
|
||||
cp .env.example .env # 至少改掉默认密码 WOC_PASSWORD
|
||||
docker compose up -d # 直接从 GHCR 拉取
|
||||
@@ -182,13 +182,47 @@ docker compose up -d # 直接从 GHCR 拉取
|
||||
|
||||
## 发布到 GHCR
|
||||
|
||||
两种方式任选其一。
|
||||
|
||||
### 方式 A · GitHub Actions(推荐)
|
||||
|
||||
仓库自带 GitHub Actions([.github/workflows/release.yml](.github/workflows/release.yml)),在你**推送 `vX.Y.Z` 标签或发布 Release** 时,自动构建多架构(amd64+arm64)镜像并推到 GHCR:
|
||||
|
||||
```bash
|
||||
git tag v1.0.0
|
||||
git push origin v1.0.0 # 触发 Actions,产出 ghcr.io/<owner>/woc-panel:1.0.0 等标签
|
||||
# 或在 GitHub 上 Publish 一个 Release(会额外打 latest):
|
||||
gh release create v1.0.0 --title v1.0.0 --notes "..."
|
||||
```
|
||||
|
||||
> 注意:单纯 push tag 只产出 `X.Y.Z / X.Y / X`,**不会更新 `latest`**;要更新 `latest` 请改用 **发布 Release** 或在 Actions 里手动 `workflow_dispatch`。
|
||||
|
||||
### 方式 B · 本机 buildx 手动构建并推送(不走 Actions)
|
||||
|
||||
适合想立刻出包、或不依赖 CI 的场景。需要 Docker Buildx(Docker Desktop 自带;纯 Linux 跨架构需先装 QEMU:`docker run --privileged --rm tonistiigi/binfmt --install all`)。
|
||||
|
||||
```bash
|
||||
# 1) 登录 GHCR(PAT 需 write:packages 权限)
|
||||
echo <YOUR_GITHUB_PAT> | docker login ghcr.io -u <github 用户名> --password-stdin
|
||||
|
||||
# 2) 首次创建并启用多架构构建器(已建过改用 docker buildx use woc)
|
||||
docker buildx create --name woc --use
|
||||
|
||||
# 3) 构建并推送两个镜像(amd64 + arm64)。VER 与 git tag 保持一致(不带 v)
|
||||
VER=1.0.1
|
||||
docker buildx build --platform linux/amd64,linux/arm64 \
|
||||
-t ghcr.io/gloridust/woc-panel:$VER -t ghcr.io/gloridust/woc-panel:latest \
|
||||
--push ./panel
|
||||
docker buildx build --platform linux/amd64,linux/arm64 \
|
||||
-t ghcr.io/gloridust/wechat-on-cloud:$VER -t ghcr.io/gloridust/wechat-on-cloud:latest \
|
||||
--push ./docker
|
||||
```
|
||||
|
||||
> 把 `gloridust` 换成你的 GHCR 命名空间(与 `docker-compose.yml` / `WOC_IMAGE_PREFIX` 一致)。
|
||||
> 只想本机自用、不推 GHCR,用 [`./scripts/build-local.sh`](scripts/build-local.sh) 构建本机架构单架构镜像即可。
|
||||
|
||||
### 发布后:把包设为公开
|
||||
|
||||
首次发布后还需把 GHCR 包设为公开,否则别人 `docker compose up -d` 会报 `denied`:
|
||||
|
||||
1. 打开 GitHub → 你的头像 → **Packages** → 分别进入 `woc-panel`、`wechat-on-cloud`;
|
||||
@@ -244,13 +278,40 @@ git push origin v1.0.0 # 触发 Actions,产出 ghcr.io/<owner>/woc-panel:1
|
||||
| 界面/消息显示成方块 | 中文字体没装好,确认实例镜像含 `fonts-noto-cjk` |
|
||||
| 微信起不来 / 黑屏 | 看实例日志 `docker logs woc-wx-<id>`;确认 `seccomp=unconfined` 与 `shm_size` 生效。微信 deb 漏声明的运行时依赖已在 Dockerfile 内置 |
|
||||
| 排查缺哪个库 | `docker exec woc-wx-<id> ldd /config/wechat/opt/wechat/wechat`,看 `not found` 项补进 Dockerfile 依赖层 |
|
||||
| 多人同时操作很乱 | 单会话多端共享、键鼠会打架。未做并发控制,建议同一时刻一人操作(见技术方案 6.1) |
|
||||
| 多人同时操作很乱 | 已内置「操作控制权」软锁:当前操作者每数秒心跳续约,其余端自动转为**只读遮罩**(仍可看画面),空闲超时(约 10s)自动释放,他人可点「申请控制」接管。仍建议同一时刻一人操作 |
|
||||
| 过段时间掉登录 | 微信桌面会话会定期失效,需手机重新扫码(见技术方案 6.2) |
|
||||
| 下载 / 更新微信失败 | 腾讯 CDN 偶发波动,重新点「下载并安装 / 更新」即可;脚本已内置主/备 CDN 自动回退 |
|
||||
| 架构不支持报错 | 微信仅提供 x86_64 / arm64;其他架构下载时会在面板状态里报错 |
|
||||
| 忘记超管密码 | 见下方「重置超管密码」离线找回 |
|
||||
|
||||
查看面板日志:`docker logs -f woc-panel`;查看某实例日志:`docker logs -f woc-wx-<id>`(实例 ID 可在面板看到,或 `docker ps | grep woc-wx`)。
|
||||
|
||||
### 重置超管密码(离线找回)
|
||||
|
||||
管理员密码无法被他人重置,忘记时按以下步骤离线找回:
|
||||
|
||||
```bash
|
||||
docker compose stop panel # 1) 先停面板,避免覆盖你的手动修改
|
||||
```
|
||||
|
||||
2) 编辑 `./data-panel/accounts.json`,给对应用户对象加一行 `"resetPassword": true`:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "...", "username": "admin", "role": "admin",
|
||||
"passwordHash": "...", "disabled": false,
|
||||
"resetPassword": true
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
docker compose up -d # 3) 启动,面板初始化时会重置该账号
|
||||
```
|
||||
|
||||
> ⚠️ 重置逻辑只在面板**进程启动**时执行。若你没先 `stop` 而面板仍在运行,直接 `docker compose up -d` 会因「容器无变化」而空操作(输出 `Running` 而非 `Started`),重置不会发生。此时执行 **`docker compose restart panel`**(或 `docker restart woc-panel`)强制重启即可生效。
|
||||
|
||||
重启后该账号密码被重置为 `PANEL_ADMIN_PASSWORD`(即 `.env` 的 `WOC_PASSWORD`,默认 `wechat`),并自动**解禁**、清除该标记;用此密码登录后请立即在「修改密码」改掉。日志会打印 `[store] 已重置用户 '<用户名>' 的密码`。
|
||||
|
||||
---
|
||||
|
||||
## 目录结构
|
||||
@@ -288,6 +349,6 @@ WechatOnCloud/
|
||||
- [x] 多实例管理 + 按账号的实例访问权限(RBAC)
|
||||
- [x] 预构建多架构镜像发布到 GHCR + GitHub Actions 自动化
|
||||
- [ ] 面板外层 TLS / 陌生设备验证码 / 审计日志
|
||||
- [ ] 多端并发控制(控制权令牌)
|
||||
- [x] 多端并发控制(操作控制权心跳软锁 + 只读遮罩 + 申请接管)
|
||||
- [ ] 掉登录时 web 端二维码重扫入口
|
||||
- [~] 打包成飞牛原生 fpk 分发(工程已就绪见 [fnos/](fnos/),待真实设备验证 docker.sock 权限)
|
||||
|
||||
@@ -269,6 +269,12 @@ export async function listInstanceFiles(inst: Instance): Promise<TransferFile[]>
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteInstanceFile(inst: Instance, name: string): Promise<void> {
|
||||
if (!safeName(name)) throw new Error('文件名不合法');
|
||||
// argv 数组直传,不经 shell;safeName 已排除路径穿越
|
||||
await execCapture(inst, ['rm', '-f', `${TRANSFER_DIR}/${name}`]);
|
||||
}
|
||||
|
||||
export async function downloadFromInstance(inst: Instance, name: string): Promise<Buffer> {
|
||||
if (!safeName(name)) throw new Error('文件名不合法');
|
||||
const c = docker.getContainer(inst.containerName);
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
uploadToInstance,
|
||||
listInstanceFiles,
|
||||
downloadFromInstance,
|
||||
deleteInstanceFile,
|
||||
} from './docker.js';
|
||||
import { createSession, getSession, destroySession, destroyUserSessions } from './sessions.js';
|
||||
|
||||
@@ -248,6 +249,7 @@ app.delete('/api/admin/instances/:id', async (req, reply) => {
|
||||
if (!inst) return reply.code(404).send({ error: '实例不存在' });
|
||||
await removeInstanceContainer(inst, purge);
|
||||
removeInstanceRecord(id);
|
||||
controlHolders.delete(id);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
@@ -319,6 +321,21 @@ app.get('/api/instances/:id/files', async (req, reply) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 删除某个中转文件(有访问权限即可)
|
||||
app.delete('/api/instances/:id/files', async (req, reply) => {
|
||||
const u = requireAuth(req, reply);
|
||||
if (!u) return;
|
||||
const id = (req.params as any).id;
|
||||
if (!userCanAccess(u, id)) return reply.code(403).send({ error: '无权访问该实例' });
|
||||
const name = String((req.query as any)?.name || '').trim();
|
||||
try {
|
||||
await deleteInstanceFile(findInstance(id)!, name);
|
||||
return { ok: true };
|
||||
} catch (e: any) {
|
||||
return reply.code(400).send({ error: e?.message || '删除失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 下载某个中转文件
|
||||
app.get('/api/instances/:id/download', async (req, reply) => {
|
||||
const u = requireAuth(req, reply);
|
||||
@@ -336,6 +353,48 @@ app.get('/api/instances/:id/download', async (req, reply) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- 多端协作:操作控制权(心跳软锁,避免多人同时操作打架) ----------
|
||||
// 同一实例被多个浏览器连的是同一会话,键鼠会互相打架。这里用"心跳持锁":
|
||||
// 当前操作者每隔几秒 beat 续约;TTL 内他人只读(前端盖只读遮罩)。空闲超 TTL 自动释放。
|
||||
const CONTROL_TTL = 10_000; // ms:超过则视为已空闲,可被接管
|
||||
const controlHolders = new Map<string, { userId: string; username: string; at: number }>();
|
||||
|
||||
// 续约/认领:无人持有、已超时、或本来就是我 → 我成为操作者;否则返回当前操作者。
|
||||
app.post('/api/instances/:id/control/beat', async (req, reply) => {
|
||||
const u = requireAuth(req, reply);
|
||||
if (!u) return;
|
||||
const id = (req.params as any).id;
|
||||
if (!userCanAccess(u, id)) return reply.code(403).send({ error: '无权访问该实例' });
|
||||
const now = Date.now();
|
||||
const h = controlHolders.get(id);
|
||||
if (!h || now - h.at > CONTROL_TTL || h.userId === u.id) {
|
||||
controlHolders.set(id, { userId: u.id, username: u.username, at: now });
|
||||
return { mine: true, holder: u.username };
|
||||
}
|
||||
return { mine: false, holder: h.username };
|
||||
});
|
||||
|
||||
// 只读查询当前操作者(前端轮询;不认领)。超 TTL 视为空闲。
|
||||
app.get('/api/instances/:id/control', async (req, reply) => {
|
||||
const u = requireAuth(req, reply);
|
||||
if (!u) return;
|
||||
const id = (req.params as any).id;
|
||||
if (!userCanAccess(u, id)) return reply.code(403).send({ error: '无权访问该实例' });
|
||||
const h = controlHolders.get(id);
|
||||
if (!h || Date.now() - h.at > CONTROL_TTL) return { free: true, mine: false, holder: null };
|
||||
return { free: false, mine: h.userId === u.id, holder: h.username };
|
||||
});
|
||||
|
||||
// 主动接管("申请控制"):强制把操作权抢过来。
|
||||
app.post('/api/instances/:id/control/take', async (req, reply) => {
|
||||
const u = requireAuth(req, reply);
|
||||
if (!u) return;
|
||||
const id = (req.params as any).id;
|
||||
if (!userCanAccess(u, id)) return reply.code(403).send({ error: '无权访问该实例' });
|
||||
controlHolders.set(id, { userId: u.id, username: u.username, at: Date.now() });
|
||||
return { mine: true, holder: u.username };
|
||||
});
|
||||
|
||||
// 该实例的微信安装状态(有访问权限即可看)
|
||||
app.get('/api/instances/:id/wechat/status', async (req, reply) => {
|
||||
const u = requireAuth(req, reply);
|
||||
|
||||
@@ -16,6 +16,10 @@ export interface User {
|
||||
allowedInstances: string[];
|
||||
// 仍在使用初始默认密码时为 true,前端据此提示尽快改密;任意一次改密/重置后清除。
|
||||
mustChangePassword?: boolean;
|
||||
// 离线密码找回:在 accounts.json 手动把某用户置为 true,重启面板即重置其密码并清除此标记。
|
||||
// 兼容下划线写法 reset_password。
|
||||
resetPassword?: boolean;
|
||||
reset_password?: boolean;
|
||||
}
|
||||
|
||||
// 初始默认管理员密码;管理员仍在用它时强烈提示改密。
|
||||
@@ -87,6 +91,19 @@ export function initStore() {
|
||||
}
|
||||
}
|
||||
}
|
||||
// 离线密码找回:忘记超管密码时,停掉面板 → 在 accounts.json 给该用户加 "resetPassword": true
|
||||
// → 重启面板。这里把其密码重置为 PANEL_ADMIN_PASSWORD(默认 wechat)、解禁,并清除标记。
|
||||
for (const u of data.users) {
|
||||
if ((u as any).resetPassword === true || (u as any).reset_password === true) {
|
||||
const pw = process.env.PANEL_ADMIN_PASSWORD || DEFAULT_ADMIN_PASSWORD;
|
||||
u.passwordHash = bcrypt.hashSync(pw, 10);
|
||||
u.mustChangePassword = pw === DEFAULT_ADMIN_PASSWORD; // 重置成默认密码则提示尽快改密
|
||||
u.disabled = false;
|
||||
delete (u as any).resetPassword;
|
||||
delete (u as any).reset_password;
|
||||
console.log(`[store] 已重置用户 '${u.username}' 的密码(resetPassword 标记,密码=PANEL_ADMIN_PASSWORD 或默认 wechat)`);
|
||||
}
|
||||
}
|
||||
persist();
|
||||
}
|
||||
|
||||
|
||||
@@ -7,9 +7,8 @@
|
||||
</defs>
|
||||
<rect width="100" height="100" rx="23" fill="url(#bg)"/>
|
||||
<rect x="3.5" y="3.5" width="93" height="93" rx="20" fill="none" stroke="#ffffff" stroke-opacity="0.18" stroke-width="2"/>
|
||||
<circle cx="22" cy="36" r="3.4" fill="#ffffff" fill-opacity="0.92"/>
|
||||
<g fill="none" stroke="#ffffff" stroke-width="7.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M34 30 L48 44 L34 58"/>
|
||||
<path d="M56 60 L72 60"/>
|
||||
<path d="M28 24 L42 38 L28 52"/>
|
||||
<path d="M50 54 L66 54"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 715 B After Width: | Height: | Size: 644 B |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 8.5 KiB After Width: | Height: | Size: 7.9 KiB |
@@ -56,15 +56,14 @@ function makePng(size) {
|
||||
const stroke = (7.5 / 2) * s; // 笔画半宽
|
||||
// 提示符坐标(基于 100 视图)
|
||||
const chevron = [
|
||||
[34, 30],
|
||||
[48, 44],
|
||||
[34, 58],
|
||||
[28, 24],
|
||||
[42, 38],
|
||||
[28, 52],
|
||||
].map(([x, y]) => [x * s, y * s]);
|
||||
const underline = [
|
||||
[56, 60],
|
||||
[72, 60],
|
||||
[50, 54],
|
||||
[66, 54],
|
||||
].map(([x, y]) => [x * s, y * s]);
|
||||
const dot = [22 * s, 36 * s, 3.4 * s]; // cx, cy, r
|
||||
|
||||
const inRounded = (x, y) => {
|
||||
const r = radius;
|
||||
@@ -93,7 +92,6 @@ function makePng(size) {
|
||||
cov = Math.max(cov, clamp01(stroke - distToSeg(gx, gy, chevron[0][0], chevron[0][1], chevron[1][0], chevron[1][1]) + 0.5));
|
||||
cov = Math.max(cov, clamp01(stroke - distToSeg(gx, gy, chevron[1][0], chevron[1][1], chevron[2][0], chevron[2][1]) + 0.5));
|
||||
cov = Math.max(cov, clamp01(stroke - distToSeg(gx, gy, underline[0][0], underline[0][1], underline[1][0], underline[1][1]) + 0.5));
|
||||
cov = Math.max(cov, clamp01(dot[2] - Math.hypot(gx - dot[0], gy - dot[1]) + 0.5));
|
||||
|
||||
const o = y * rowLen + 1 + x * 4;
|
||||
raw[o] = Math.round(bg[0] + (WHITE[0] - bg[0]) * cov);
|
||||
|
||||
@@ -2,9 +2,7 @@ import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
import { AuthProvider, useAuth } from './auth';
|
||||
import { UIProvider } from './ui';
|
||||
import Login from './pages/Login';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import Admin from './pages/Admin';
|
||||
import Desktop from './pages/Desktop';
|
||||
import AppShell from './AppShell';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
function Splash() {
|
||||
@@ -15,11 +13,10 @@ function Splash() {
|
||||
);
|
||||
}
|
||||
|
||||
function RequireAuth({ children, admin }: { children: ReactNode; admin?: boolean }) {
|
||||
function RequireAuth({ children }: { children: ReactNode }) {
|
||||
const { user, loading } = useAuth();
|
||||
if (loading) return <Splash />;
|
||||
if (!user) return <Navigate to="/login" replace />;
|
||||
if (admin && user.role !== 'admin') return <Navigate to="/" replace />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
@@ -28,30 +25,13 @@ function Shell() {
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route
|
||||
path="/"
|
||||
path="/*"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<Dashboard />
|
||||
<AppShell />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin"
|
||||
element={
|
||||
<RequireAuth admin>
|
||||
<Admin />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/desktop/:id"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<Desktop />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
import { createContext, useContext, useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from './auth';
|
||||
import { useUI, PasswordInput } from './ui';
|
||||
import { api, type InstanceWithStatus } from './api';
|
||||
import InstanceView from './pages/Desktop';
|
||||
import Admin from './pages/Admin';
|
||||
|
||||
const BUSY = ['downloading', 'extracting', 'installing'];
|
||||
|
||||
// ---- 实例数据:侧栏 / 主页 / 实例视图共享,安装中轮询 ----
|
||||
interface InstancesState {
|
||||
instances: InstanceWithStatus[];
|
||||
loaded: boolean;
|
||||
reload: () => Promise<void>;
|
||||
}
|
||||
const InstancesCtx = createContext<InstancesState>({ instances: [], loaded: false, reload: async () => {} });
|
||||
export const useInstances = () => useContext(InstancesCtx);
|
||||
|
||||
function useInstancesLoader(): InstancesState {
|
||||
const [instances, setInstances] = useState<InstanceWithStatus[]>([]);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const timer = useRef<number | undefined>(undefined);
|
||||
const reload = async () => {
|
||||
try {
|
||||
const { instances } = await api.listInstances();
|
||||
setInstances(instances);
|
||||
} catch {
|
||||
/* 401 会被 api 层重定向到登录 */
|
||||
} finally {
|
||||
setLoaded(true);
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
reload();
|
||||
return () => window.clearTimeout(timer.current);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
window.clearTimeout(timer.current);
|
||||
if (instances.some((i) => BUSY.includes(i.wechat.phase))) timer.current = window.setTimeout(reload, 1500);
|
||||
return () => window.clearTimeout(timer.current);
|
||||
}, [instances]);
|
||||
return { instances, loaded, reload };
|
||||
}
|
||||
|
||||
// 实例状态点(颜色 + 文案)
|
||||
export function statusOf(inst: InstanceWithStatus): { cls: string; text: string } {
|
||||
const offline = inst.runtime !== 'running';
|
||||
if (offline) return { cls: 'st-off', text: inst.runtime === 'missing' ? '未创建' : '已停止' };
|
||||
if (BUSY.includes(inst.wechat.phase)) return { cls: 'st-busy', text: '处理中' };
|
||||
if (inst.wechat.installed) return { cls: 'st-on', text: '在线' };
|
||||
return { cls: 'st-warn', text: '待安装' };
|
||||
}
|
||||
|
||||
// ---- 图标 ----
|
||||
const Icon = {
|
||||
home: (
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 10.5 12 3l9 7.5" /><path d="M5 9.5V20h14V9.5" /><path d="M9.5 20v-6h5v6" />
|
||||
</svg>
|
||||
),
|
||||
gear: (
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="3.2" /><path d="M19.4 13a1.6 1.6 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.6 1.6 0 0 0-2.7 1.1V21a2 2 0 1 1-4 0v-.1a1.6 1.6 0 0 0-2.7-1.1l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.6 1.6 0 0 0-1.1-2.7H3a2 2 0 1 1 0-4h.1a1.6 1.6 0 0 0 1.1-2.7l-.1-.1A2 2 0 1 1 6.9 4.5l.1.1a1.6 1.6 0 0 0 1.8.3H9a1.6 1.6 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.6 1.6 0 0 0 2.7 1.1l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.6 1.6 0 0 0-.3 1.8V9a1.6 1.6 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.6 1.6 0 0 0-1.5 1z" />
|
||||
</svg>
|
||||
),
|
||||
logout: (
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" /><path d="M16 17l5-5-5-5" /><path d="M21 12H9" />
|
||||
</svg>
|
||||
),
|
||||
collapse: (
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="4" width="18" height="16" rx="2.5" /><path d="M9 4v16" />
|
||||
</svg>
|
||||
),
|
||||
menu: (
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<path d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
export default function AppShell() {
|
||||
const state = useInstancesLoader();
|
||||
const { refresh } = useAuth();
|
||||
const [collapsed, setCollapsed] = useState(() => localStorage.getItem('woc_sb_collapsed') === '1');
|
||||
const [drawer, setDrawer] = useState(false);
|
||||
const [showPw, setShowPw] = useState(false);
|
||||
const [isDesktop, setIsDesktop] = useState(() => window.matchMedia('(min-width: 768px)').matches);
|
||||
const loc = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
const m = window.matchMedia('(min-width: 768px)');
|
||||
const h = () => setIsDesktop(m.matches);
|
||||
m.addEventListener('change', h);
|
||||
return () => m.removeEventListener('change', h);
|
||||
}, []);
|
||||
|
||||
useEffect(() => setDrawer(false), [loc.pathname]); // 路由变化关抽屉
|
||||
|
||||
// 移动端不收成窄栏(改用抽屉);折叠仅桌面生效
|
||||
const railed = collapsed && isDesktop;
|
||||
|
||||
const toggleCollapsed = () =>
|
||||
setCollapsed((c) => {
|
||||
localStorage.setItem('woc_sb_collapsed', c ? '0' : '1');
|
||||
return !c;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'b') {
|
||||
e.preventDefault();
|
||||
toggleCollapsed();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, []);
|
||||
|
||||
const openMenu = () => setDrawer(true);
|
||||
const openChangePassword = () => setShowPw(true);
|
||||
|
||||
return (
|
||||
<InstancesCtx.Provider value={state}>
|
||||
<div className={'shell' + (railed ? ' collapsed' : '') + (drawer ? ' drawer-open' : '')}>
|
||||
<Sidebar collapsed={railed} onToggleCollapsed={toggleCollapsed} />
|
||||
<div className="shell-backdrop" onClick={() => setDrawer(false)} />
|
||||
<main className="workspace">
|
||||
<Routes>
|
||||
<Route path="/" element={<HomeView onOpenMenu={openMenu} onChangePassword={openChangePassword} />} />
|
||||
<Route path="/admin" element={<Admin onOpenMenu={openMenu} onChangePassword={openChangePassword} />} />
|
||||
<Route path="/i/:id" element={<InstanceView onOpenMenu={openMenu} />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
{showPw && <ChangePassword onClose={() => setShowPw(false)} onSaved={() => refresh()} />}
|
||||
</InstancesCtx.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function Sidebar({ collapsed, onToggleCollapsed }: { collapsed: boolean; onToggleCollapsed: () => void }) {
|
||||
const { user, logout } = useAuth();
|
||||
const { confirm } = useUI();
|
||||
const { instances } = useInstances();
|
||||
const nav = useNavigate();
|
||||
const loc = useLocation();
|
||||
const isAdmin = user?.role === 'admin';
|
||||
const go = (p: string) => nav(p);
|
||||
|
||||
return (
|
||||
<aside className="sidebar">
|
||||
<div className="sb-top">
|
||||
<div className="sb-brand">
|
||||
<img src="/favicon.svg" className="sb-logo" alt="" />
|
||||
{!collapsed && <span className="sb-name">云微</span>}
|
||||
</div>
|
||||
<button className="sb-collapse" title="折叠侧栏 (⌘B)" onClick={onToggleCollapsed}>
|
||||
{Icon.collapse}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav className="sb-nav">
|
||||
<button className={'sb-item' + (loc.pathname === '/' ? ' on' : '')} onClick={() => go('/')} title="主页">
|
||||
<span className="sb-ic">{Icon.home}</span>
|
||||
{!collapsed && <span className="sb-label">主页</span>}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{!collapsed && <div className="sb-section">微信实例</div>}
|
||||
<div className="sb-list">
|
||||
{instances.length === 0 && !collapsed && <div className="sb-empty">暂无可用实例</div>}
|
||||
{instances.map((inst) => {
|
||||
const on = loc.pathname === `/i/${inst.id}`;
|
||||
const st = statusOf(inst);
|
||||
return (
|
||||
<button key={inst.id} className={'sb-item sb-inst' + (on ? ' on' : '')} onClick={() => go(`/i/${inst.id}`)} title={inst.name}>
|
||||
<span className="sb-avatar">
|
||||
{inst.name.slice(0, 1)}
|
||||
<span className={'sb-dot ' + st.cls} />
|
||||
</span>
|
||||
{!collapsed && <span className="sb-label">{inst.name}</span>}
|
||||
{!collapsed && <span className="sb-stxt">{st.text}</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="sb-footer">
|
||||
<button className={'sb-item' + (loc.pathname === '/admin' ? ' on' : '')} onClick={() => go('/admin')} title={isAdmin ? '管理' : '设置'}>
|
||||
<span className="sb-ic">{Icon.gear}</span>
|
||||
{!collapsed && <span className="sb-label">{isAdmin ? '管理' : '设置'}</span>}
|
||||
</button>
|
||||
<button
|
||||
className="sb-item"
|
||||
title="退出"
|
||||
onClick={async () => {
|
||||
if (await confirm({ title: '退出登录?', confirmText: '退出' })) logout();
|
||||
}}
|
||||
>
|
||||
<span className="sb-ic">{Icon.logout}</span>
|
||||
{!collapsed && <span className="sb-label">退出</span>}
|
||||
</button>
|
||||
{!collapsed && (
|
||||
<div className="sb-user">
|
||||
{user?.username}
|
||||
{isAdmin && ' · 管理员'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function HomeView({ onOpenMenu, onChangePassword }: { onOpenMenu: () => void; onChangePassword: () => void }) {
|
||||
const { user } = useAuth();
|
||||
const { instances, loaded } = useInstances();
|
||||
const nav = useNavigate();
|
||||
const isAdmin = user?.role === 'admin';
|
||||
|
||||
return (
|
||||
<div className="ws-page">
|
||||
<header className="ws-head">
|
||||
<button className="ws-menu" onClick={onOpenMenu} aria-label="菜单">
|
||||
{Icon.menu}
|
||||
</button>
|
||||
<span className="ws-title">主页</span>
|
||||
</header>
|
||||
|
||||
<div className="content">
|
||||
<div className="hello">
|
||||
你好,<b>{user?.username}</b>
|
||||
{isAdmin && <span className="tag">管理员</span>}
|
||||
</div>
|
||||
|
||||
{user?.mustChangePassword && (
|
||||
<button className="warn-banner" onClick={onChangePassword}>
|
||||
<span className="warn-icon">!</span>
|
||||
<span className="warn-text">
|
||||
<b>你还在使用默认密码</b>
|
||||
<span>该系统登录着你的微信,请立即修改密码 ›</span>
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="section-row">
|
||||
<span className="section-title">我的微信实例</span>
|
||||
{isAdmin && (
|
||||
<button className="btn-text" onClick={() => nav('/admin')}>
|
||||
管理 ›
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loaded && instances.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-blob">
|
||||
<img src="/favicon.svg" alt="" />
|
||||
</div>
|
||||
<div className="empty-title">还没有微信实例</div>
|
||||
<div className="empty-sub">{isAdmin ? '去「管理」新建一个微信实例' : '请联系管理员为你分配实例'}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="inst-grid">
|
||||
{instances.map((inst) => {
|
||||
const st = statusOf(inst);
|
||||
return (
|
||||
<button key={inst.id} className="home-card" onClick={() => nav(`/i/${inst.id}`)}>
|
||||
<span className="home-card-av">{inst.name.slice(0, 1)}</span>
|
||||
<span className="home-card-main">
|
||||
<span className="home-card-name">{inst.name}</span>
|
||||
<span className={'home-card-st ' + st.cls}>● {st.text}</span>
|
||||
</span>
|
||||
<span className="enter-arrow">›</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChangePassword({ onClose, onSaved }: { onClose: () => void; onSaved?: () => void }) {
|
||||
const [oldPassword, setOld] = useState('');
|
||||
const [newPassword, setNew] = useState('');
|
||||
const [confirm, setConfirm] = useState('');
|
||||
const [msg, setMsg] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const mismatch = confirm.length > 0 && newPassword !== confirm;
|
||||
const canSubmit = !busy && !!oldPassword && newPassword.length >= 6 && newPassword === confirm;
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setMsg('');
|
||||
if (newPassword !== confirm) {
|
||||
setMsg('两次输入的新密码不一致');
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.changePassword(oldPassword, newPassword);
|
||||
setMsg('修改成功');
|
||||
onSaved?.();
|
||||
setTimeout(onClose, 800);
|
||||
} catch (e: any) {
|
||||
setMsg(e.message || '修改失败');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-mask" onClick={onClose}>
|
||||
<form className="card modal" onClick={(e) => e.stopPropagation()} onSubmit={submit}>
|
||||
<h2>修改密码</h2>
|
||||
<PasswordInput placeholder="原密码" autoComplete="current-password" value={oldPassword} onChange={setOld} />
|
||||
<PasswordInput placeholder="新密码(至少 6 位)" autoComplete="new-password" value={newPassword} onChange={setNew} />
|
||||
<PasswordInput placeholder="再次输入新密码" autoComplete="new-password" value={confirm} onChange={setConfirm} />
|
||||
{mismatch && <div className="error">两次输入的新密码不一致</div>}
|
||||
{msg && <div className={msg === '修改成功' ? 'ok' : 'error'}>{msg}</div>}
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="btn" onClick={onClose}>
|
||||
取消
|
||||
</button>
|
||||
<button className="btn btn-primary" disabled={!canSubmit}>
|
||||
确定
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -104,4 +104,10 @@ export const api = {
|
||||
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' }),
|
||||
|
||||
// 多端协作:操作控制权
|
||||
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' }),
|
||||
};
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { api, type PanelUser, type InstanceWithStatus } from '../api';
|
||||
import { useUI, PasswordInput } from '../ui';
|
||||
import { useAuth } from '../auth';
|
||||
|
||||
export default function Admin() {
|
||||
const BUSY_PHASES = ['downloading', 'extracting', 'installing'];
|
||||
|
||||
const MenuIcon = (
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<path d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: () => void; onChangePassword: () => void }) {
|
||||
const nav = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const isAdmin = user?.role === 'admin';
|
||||
const { toast, confirm } = useUI();
|
||||
const [users, setUsers] = useState<PanelUser[]>([]);
|
||||
const [instances, setInstances] = useState<InstanceWithStatus[]>([]);
|
||||
@@ -16,10 +27,13 @@ export default function Admin() {
|
||||
const [resetTarget, setResetTarget] = useState<PanelUser | null>(null); // 重置密码弹窗
|
||||
const [deleteInst, setDeleteInst] = useState<InstanceWithStatus | null>(null); // 删除实例弹窗
|
||||
const [renameInst, setRenameInst] = useState<InstanceWithStatus | null>(null); // 重命名实例弹窗
|
||||
const [starting, setStarting] = useState<Set<string>>(new Set());
|
||||
|
||||
const subs = users.filter((u) => u.role !== 'admin');
|
||||
const timer = useRef<number | undefined>(undefined);
|
||||
|
||||
const load = async () => {
|
||||
if (!isAdmin) return; // 子账号无管理数据权限,管理页只给改密
|
||||
try {
|
||||
const [{ users }, { instances }] = await Promise.all([api.listUsers(), api.listInstances()]);
|
||||
setUsers(users);
|
||||
@@ -31,8 +45,49 @@ export default function Admin() {
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
return () => window.clearTimeout(timer.current);
|
||||
}, []);
|
||||
|
||||
// 安装/更新进行中时轮询进度
|
||||
useEffect(() => {
|
||||
window.clearTimeout(timer.current);
|
||||
if (instances.some((i) => BUSY_PHASES.includes(i.wechat.phase))) timer.current = window.setTimeout(load, 1500);
|
||||
return () => window.clearTimeout(timer.current);
|
||||
}, [instances]);
|
||||
|
||||
const trigger = async (inst: InstanceWithStatus, kind: 'install' | 'update') => {
|
||||
try {
|
||||
await (kind === 'install' ? api.instanceWechatInstall(inst.id) : api.instanceWechatUpdate(inst.id));
|
||||
setInstances((list) =>
|
||||
list.map((i) =>
|
||||
i.id === inst.id ? { ...i, wechat: { ...i.wechat, phase: 'downloading', percent: -1, message: '正在准备…' } } : i,
|
||||
),
|
||||
);
|
||||
window.clearTimeout(timer.current);
|
||||
timer.current = window.setTimeout(load, 1000);
|
||||
toast(kind === 'install' ? '已开始下载微信' : '已开始更新', 'ok');
|
||||
} catch (e: any) {
|
||||
toast(e.message || '操作失败', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const start = async (inst: InstanceWithStatus) => {
|
||||
setStarting((s) => new Set(s).add(inst.id));
|
||||
try {
|
||||
await api.instanceStart(inst.id);
|
||||
toast('实例已启动', 'ok');
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
toast(e.message || '启动失败', 'error');
|
||||
} finally {
|
||||
setStarting((s) => {
|
||||
const n = new Set(s);
|
||||
n.delete(inst.id);
|
||||
return n;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const instName = (id: string) => instances.find((i) => i.id === id)?.name || id;
|
||||
const usersForInstance = (id: string) => subs.filter((u) => u.allowedInstances.includes(id));
|
||||
|
||||
@@ -58,94 +113,114 @@ export default function Admin() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<header className="topbar">
|
||||
<button className="btn-text" onClick={() => nav('/')}>
|
||||
‹ 返回
|
||||
<div className="ws-page">
|
||||
<header className="ws-head">
|
||||
<button className="ws-menu" onClick={onOpenMenu} aria-label="菜单">
|
||||
{MenuIcon}
|
||||
</button>
|
||||
<span className="topbar-title">管理</span>
|
||||
<span style={{ width: 48 }} />
|
||||
<span className="ws-title">{isAdmin ? '管理' : '设置'}</span>
|
||||
</header>
|
||||
|
||||
<main className="content">
|
||||
{err && <div className="error">{err}</div>}
|
||||
|
||||
<div className="section-row">
|
||||
<span className="section-title">微信实例</span>
|
||||
<button className="btn-text" onClick={() => setCreatingInst(true)}>
|
||||
+ 新建实例
|
||||
</button>
|
||||
</div>
|
||||
<div className="list">
|
||||
{instances.length === 0 && <div className="muted small" style={{ padding: '14px 16px' }}>暂无实例</div>}
|
||||
{instances.map((inst) => (
|
||||
<div key={inst.id} className="user-row">
|
||||
<div className="user-main">
|
||||
<span className="user-name">{inst.name}</span>
|
||||
<span className="muted small">可访问账户 {usersForInstance(inst.id).length} 人</span>
|
||||
</div>
|
||||
<div className="user-actions">
|
||||
<button className="btn-text" onClick={() => setRenameInst(inst)}>
|
||||
重命名
|
||||
</button>
|
||||
<button className="btn-text" onClick={() => setAssignInst(inst)}>
|
||||
分配账户
|
||||
</button>
|
||||
<button className="btn-text danger" onClick={() => setDeleteInst(inst)}>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<>
|
||||
<div className="section-row">
|
||||
<span className="section-title">微信实例</span>
|
||||
<button className="btn-text" onClick={() => setCreatingInst(true)}>
|
||||
+ 新建实例
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{instances.length === 0 ? (
|
||||
<div className="list">
|
||||
<div className="muted small" style={{ padding: '14px 16px' }}>暂无实例</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="inst-grid">
|
||||
{instances.map((inst) => (
|
||||
<InstanceAdminCard
|
||||
key={inst.id}
|
||||
inst={inst}
|
||||
userCount={usersForInstance(inst.id).length}
|
||||
starting={starting.has(inst.id)}
|
||||
onEnter={() => nav(`/i/${inst.id}`)}
|
||||
onTrigger={trigger}
|
||||
onStart={() => start(inst)}
|
||||
onRename={() => setRenameInst(inst)}
|
||||
onAssign={() => setAssignInst(inst)}
|
||||
onDelete={() => setDeleteInst(inst)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="section-row" style={{ marginTop: 22 }}>
|
||||
<span className="section-title">子账号</span>
|
||||
<button className="btn-text" onClick={() => setCreatingUser(true)}>
|
||||
+ 新建子账号
|
||||
</button>
|
||||
</div>
|
||||
<div className="list">
|
||||
{users.map((u) => (
|
||||
<div key={u.id} className="user-row">
|
||||
<div className="user-main">
|
||||
<span className="user-name">
|
||||
{u.username}
|
||||
{u.role === 'admin' && <span className="tag">管理员</span>}
|
||||
{u.disabled && <span className="tag tag-off">已禁用</span>}
|
||||
</span>
|
||||
{u.role === 'admin' ? (
|
||||
<span className="muted small">可访问全部实例</span>
|
||||
) : u.allowedInstances.length > 0 ? (
|
||||
<span className="chip-row">
|
||||
{u.allowedInstances.map((id) => (
|
||||
<span key={id} className="chip chip-static">
|
||||
{instName(id)}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
) : (
|
||||
<span className="muted small">未分配实例</span>
|
||||
)}
|
||||
</div>
|
||||
{u.role !== 'admin' && (
|
||||
<div className="user-actions">
|
||||
<button className="btn-text" onClick={() => setAssignUser(u)}>
|
||||
可访问实例
|
||||
</button>
|
||||
<button className="btn-text" onClick={() => toggle(u)}>
|
||||
{u.disabled ? '启用' : '禁用'}
|
||||
</button>
|
||||
<button className="btn-text" onClick={() => setResetTarget(u)}>
|
||||
重置密码
|
||||
</button>
|
||||
<button className="btn-text danger" onClick={() => removeUser(u)}>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="section-row" style={{ marginTop: 22 }}>
|
||||
<span className="section-title">子账号</span>
|
||||
<button className="btn-text" onClick={() => setCreatingUser(true)}>
|
||||
+ 新建子账号
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{subs.length === 0 ? (
|
||||
<div className="list">
|
||||
<div className="muted small" style={{ padding: '14px 16px' }}>暂无子账号</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="inst-grid">
|
||||
{subs.map((u) => (
|
||||
<div key={u.id} className="inst-card">
|
||||
<div className="inst-head">
|
||||
<span className="inst-name">{u.username}</span>
|
||||
{u.disabled ? <span className="tag tag-off">已禁用</span> : <span className="tag tag-on">正常</span>}
|
||||
</div>
|
||||
<div className="inst-sub">{u.allowedInstances.length > 0 ? `可访问 ${u.allowedInstances.length} 个实例` : '未分配实例'}</div>
|
||||
{u.allowedInstances.length > 0 && (
|
||||
<div className="chip-row" style={{ marginTop: 8 }}>
|
||||
{u.allowedInstances.map((id) => (
|
||||
<span key={id} className="chip chip-static">
|
||||
{instName(id)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="inst-admin-links">
|
||||
<button className="btn-text" onClick={() => setAssignUser(u)}>
|
||||
可访问实例
|
||||
</button>
|
||||
<button className="btn-text" onClick={() => toggle(u)}>
|
||||
{u.disabled ? '启用' : '禁用'}
|
||||
</button>
|
||||
<button className="btn-text" onClick={() => setResetTarget(u)}>
|
||||
重置密码
|
||||
</button>
|
||||
<button className="btn-text danger" onClick={() => removeUser(u)}>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 账号:所有人(含子账号)都能在此改密 */}
|
||||
<div className="section-row" style={{ marginTop: isAdmin ? 22 : 0 }}>
|
||||
<span className="section-title">账号</span>
|
||||
</div>
|
||||
<div className="inst-grid">
|
||||
<div className="inst-card">
|
||||
<div className="inst-head">
|
||||
<span className="inst-name">{user?.username}</span>
|
||||
{isAdmin ? <span className="tag">管理员</span> : <span className="tag tag-on">子账号</span>}
|
||||
</div>
|
||||
<div className="inst-sub">{isAdmin ? '可访问全部实例' : `可访问 ${user?.allowedInstances.length ?? 0} 个实例`}</div>
|
||||
<div className="inst-actions">
|
||||
<button className="btn btn-primary inst-act-wide" onClick={onChangePassword}>
|
||||
修改密码
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -265,11 +340,17 @@ function RenameInstance({ inst, onClose, onDone }: { inst: InstanceWithStatus; o
|
||||
|
||||
function ResetPassword({ user, onClose, onDone }: { user: PanelUser; onClose: () => void; onDone: () => void }) {
|
||||
const [pw, setPw] = useState('');
|
||||
const [confirm, setConfirm] = useState('');
|
||||
const [err, setErr] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const mismatch = confirm.length > 0 && pw !== confirm;
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setErr('');
|
||||
if (pw !== confirm) {
|
||||
setErr('两次输入的新密码不一致');
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.resetUser(user.id, pw);
|
||||
@@ -285,12 +366,13 @@ function ResetPassword({ user, onClose, onDone }: { user: PanelUser; onClose: ()
|
||||
<form className="card modal" onClick={(e) => e.stopPropagation()} onSubmit={submit}>
|
||||
<h2>重置「{user.username}」的密码</h2>
|
||||
<PasswordInput placeholder="新密码(至少 6 位)" autoComplete="new-password" value={pw} onChange={setPw} />
|
||||
{err && <div className="error">{err}</div>}
|
||||
<PasswordInput placeholder="再次输入新密码" autoComplete="new-password" value={confirm} onChange={setConfirm} />
|
||||
{(mismatch || err) && <div className="error">{mismatch ? '两次输入的新密码不一致' : err}</div>}
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="btn" onClick={onClose}>
|
||||
取消
|
||||
</button>
|
||||
<button className="btn btn-primary" disabled={busy || pw.length < 6}>
|
||||
<button className="btn btn-primary" disabled={busy || pw.length < 6 || pw !== confirm}>
|
||||
重置
|
||||
</button>
|
||||
</div>
|
||||
@@ -342,6 +424,99 @@ function DeleteInstance({ inst, onClose, onDone }: { inst: InstanceWithStatus; o
|
||||
);
|
||||
}
|
||||
|
||||
// 管理页的实例卡片:含微信版本管理(下载/更新)+ 重命名/分配/删除
|
||||
function InstanceAdminCard({
|
||||
inst,
|
||||
userCount,
|
||||
starting,
|
||||
onEnter,
|
||||
onTrigger,
|
||||
onStart,
|
||||
onRename,
|
||||
onAssign,
|
||||
onDelete,
|
||||
}: {
|
||||
inst: InstanceWithStatus;
|
||||
userCount: number;
|
||||
starting?: boolean;
|
||||
onEnter: () => void;
|
||||
onTrigger: (inst: InstanceWithStatus, kind: 'install' | 'update') => void;
|
||||
onStart: () => void;
|
||||
onRename: () => void;
|
||||
onAssign: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const wx = inst.wechat;
|
||||
const busy = BUSY_PHASES.includes(wx.phase);
|
||||
const installed = wx.installed && wx.phase !== 'downloading';
|
||||
const offline = inst.runtime !== 'running';
|
||||
|
||||
let badge: { text: string; cls: string };
|
||||
if (offline) badge = { text: inst.runtime === 'missing' ? '未创建' : '已停止', cls: 'tag-off' };
|
||||
else if (busy) badge = { text: '处理中', cls: 'tag-busy' };
|
||||
else if (installed) badge = { text: '在线', cls: 'tag-on' };
|
||||
else badge = { text: '待安装', cls: 'tag-warn' };
|
||||
|
||||
let sub: string;
|
||||
if (busy) sub = wx.percent >= 0 ? `${wx.message || '处理中'} ${wx.percent}%` : wx.message || '请稍候…';
|
||||
else if (wx.phase === 'error') sub = wx.message || '操作失败,可重试';
|
||||
else if (offline) sub = inst.runtime === 'missing' ? '容器尚未创建' : '容器已停止';
|
||||
else if (installed) sub = wx.version ? `微信 ${wx.version}` : '微信已安装';
|
||||
else sub = '微信尚未安装';
|
||||
|
||||
return (
|
||||
<div className="inst-card">
|
||||
<div className="inst-head">
|
||||
<span className="inst-name">{inst.name}</span>
|
||||
<span className={'tag ' + badge.cls}>{badge.text}</span>
|
||||
</div>
|
||||
<div className="inst-sub">
|
||||
{sub} · 可访问 {userCount} 人
|
||||
</div>
|
||||
|
||||
{busy && (
|
||||
<div className="wx-progress">
|
||||
<div
|
||||
className={'wx-progress-bar' + (wx.percent < 0 ? ' indeterminate' : '')}
|
||||
style={wx.percent >= 0 ? { width: `${wx.percent}%` } : undefined}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!busy && (
|
||||
<div className="inst-actions">
|
||||
{offline ? (
|
||||
<button className="btn btn-primary inst-act-wide" disabled={starting} onClick={onStart}>
|
||||
{starting ? '启动中…' : inst.runtime === 'missing' ? '创建并启动' : '启动实例'}
|
||||
</button>
|
||||
) : (
|
||||
<button className="btn btn-primary inst-act-wide" disabled={!installed} onClick={onEnter} title={installed ? '' : '需先下载安装微信'}>
|
||||
进入实例
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="inst-admin-links">
|
||||
{!busy && !offline && (
|
||||
<button className="btn-text" onClick={() => onTrigger(inst, installed ? 'update' : 'install')}>
|
||||
{installed ? '更新微信' : '下载安装'}
|
||||
</button>
|
||||
)}
|
||||
<button className="btn-text" onClick={onRename}>
|
||||
重命名
|
||||
</button>
|
||||
<button className="btn-text" onClick={onAssign}>
|
||||
分配账户
|
||||
</button>
|
||||
<button className="btn-text danger" onClick={onDelete}>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 通用 chip 多选
|
||||
function ChipMultiSelect({
|
||||
options,
|
||||
|
||||
@@ -1,279 +1,2 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../auth';
|
||||
import { useUI, PasswordInput } from '../ui';
|
||||
import { api, type InstanceWithStatus } from '../api';
|
||||
|
||||
const BUSY_PHASES = ['downloading', 'extracting', 'installing'];
|
||||
|
||||
export default function Dashboard() {
|
||||
const { user, logout, refresh } = useAuth();
|
||||
const { toast, confirm } = useUI();
|
||||
const nav = useNavigate();
|
||||
const [showPw, setShowPw] = useState(false);
|
||||
const [instances, setInstances] = useState<InstanceWithStatus[] | null>(null);
|
||||
const [err, setErr] = useState('');
|
||||
const [starting, setStarting] = useState<Set<string>>(new Set());
|
||||
const timer = useRef<number | undefined>(undefined);
|
||||
const isAdmin = user?.role === 'admin';
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
const { instances } = await api.listInstances();
|
||||
setInstances(instances);
|
||||
} catch (e: any) {
|
||||
setErr(e.message || '加载失败');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
return () => window.clearTimeout(timer.current);
|
||||
}, []);
|
||||
|
||||
// 任一实例安装/更新进行中时轮询
|
||||
useEffect(() => {
|
||||
window.clearTimeout(timer.current);
|
||||
const busy = instances?.some((i) => BUSY_PHASES.includes(i.wechat.phase));
|
||||
if (busy) timer.current = window.setTimeout(load, 1500);
|
||||
return () => window.clearTimeout(timer.current);
|
||||
}, [instances]);
|
||||
|
||||
const trigger = async (inst: InstanceWithStatus, kind: 'install' | 'update') => {
|
||||
setErr('');
|
||||
try {
|
||||
await (kind === 'install' ? api.instanceWechatInstall(inst.id) : api.instanceWechatUpdate(inst.id));
|
||||
setInstances(
|
||||
(list) =>
|
||||
list?.map((i) =>
|
||||
i.id === inst.id ? { ...i, wechat: { ...i.wechat, phase: 'downloading', percent: -1, message: '正在准备…' } } : i,
|
||||
) ?? list,
|
||||
);
|
||||
window.clearTimeout(timer.current);
|
||||
timer.current = window.setTimeout(load, 1000);
|
||||
toast(kind === 'install' ? '已开始下载微信' : '已开始更新', 'ok');
|
||||
} catch (e: any) {
|
||||
setErr(e.message || '操作失败');
|
||||
toast(e.message || '操作失败', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const start = async (inst: InstanceWithStatus) => {
|
||||
setErr('');
|
||||
setStarting((s) => new Set(s).add(inst.id));
|
||||
try {
|
||||
await api.instanceStart(inst.id);
|
||||
toast('实例已启动', 'ok');
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
toast(e.message || '启动失败', 'error');
|
||||
} finally {
|
||||
setStarting((s) => {
|
||||
const n = new Set(s);
|
||||
n.delete(inst.id);
|
||||
return n;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<header className="topbar">
|
||||
<span className="topbar-title">云微</span>
|
||||
<button
|
||||
className="btn-text"
|
||||
onClick={async () => {
|
||||
if (await confirm({ title: '退出登录?', confirmText: '退出' })) logout();
|
||||
}}
|
||||
>
|
||||
退出
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<main className="content">
|
||||
<div className="hello">
|
||||
你好,<b>{user?.username}</b>
|
||||
{isAdmin && <span className="tag">管理员</span>}
|
||||
</div>
|
||||
|
||||
{user?.mustChangePassword && (
|
||||
<button className="warn-banner" onClick={() => setShowPw(true)}>
|
||||
<span className="warn-icon">!</span>
|
||||
<span className="warn-text">
|
||||
<b>你还在使用默认密码</b>
|
||||
<span>该系统登录着你的微信,请立即修改密码 ›</span>
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{err && <div className="error">{err}</div>}
|
||||
|
||||
<div className="section-row">
|
||||
<span className="section-title">微信实例</span>
|
||||
{isAdmin && (
|
||||
<button className="btn-text" onClick={() => nav('/admin')}>
|
||||
管理 ›
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{instances && instances.length === 0 && (
|
||||
<div className="empty-state">
|
||||
<div className="empty-blob"><img src="/favicon.svg" alt="" /></div>
|
||||
<div className="empty-title">还没有微信实例</div>
|
||||
<div className="empty-sub">{isAdmin ? '去「管理」新建一个微信实例' : '请联系管理员为你分配实例'}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="inst-grid">
|
||||
{instances?.map((inst) => (
|
||||
<InstanceCard
|
||||
key={inst.id}
|
||||
inst={inst}
|
||||
isAdmin={isAdmin}
|
||||
starting={starting.has(inst.id)}
|
||||
onEnter={() => nav(`/desktop/${inst.id}`)}
|
||||
onTrigger={trigger}
|
||||
onStart={() => start(inst)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="list">
|
||||
<button className="list-item" onClick={() => setShowPw(true)}>
|
||||
<span>修改密码</span>
|
||||
<span className="enter-arrow">›</span>
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<button className="list-item" onClick={() => nav('/admin')}>
|
||||
<span>实例与子账号管理</span>
|
||||
<span className="enter-arrow">›</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{showPw && <ChangePassword onClose={() => setShowPw(false)} onSaved={() => refresh()} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InstanceCard({
|
||||
inst,
|
||||
isAdmin,
|
||||
starting,
|
||||
onEnter,
|
||||
onTrigger,
|
||||
onStart,
|
||||
}: {
|
||||
inst: InstanceWithStatus;
|
||||
isAdmin?: boolean;
|
||||
starting?: boolean;
|
||||
onEnter: () => void;
|
||||
onTrigger: (inst: InstanceWithStatus, kind: 'install' | 'update') => void;
|
||||
onStart: () => void;
|
||||
}) {
|
||||
const wx = inst.wechat;
|
||||
const busy = BUSY_PHASES.includes(wx.phase);
|
||||
const installed = wx.installed && wx.phase !== 'downloading';
|
||||
const offline = inst.runtime !== 'running';
|
||||
|
||||
let badge: { text: string; cls: string };
|
||||
if (offline) badge = { text: inst.runtime === 'missing' ? '未创建' : '已停止', cls: 'tag-off' };
|
||||
else if (busy) badge = { text: '处理中', cls: 'tag-busy' };
|
||||
else if (installed) badge = { text: '在线', cls: 'tag-on' };
|
||||
else badge = { text: '待安装', cls: 'tag-warn' };
|
||||
|
||||
let sub: string;
|
||||
if (offline) sub = inst.runtime === 'missing' ? '容器尚未创建' : '容器已停止,需先启动';
|
||||
else if (busy) sub = wx.percent >= 0 ? `${wx.message || '处理中'} ${wx.percent}%` : wx.message || '请稍候…';
|
||||
else if (wx.phase === 'error') sub = wx.message || '操作失败,可重试';
|
||||
else if (installed) sub = wx.version ? `微信 ${wx.version}` : '微信已安装';
|
||||
else sub = '微信尚未安装';
|
||||
|
||||
const canEnter = !offline && installed && !busy;
|
||||
|
||||
return (
|
||||
<div className="inst-card">
|
||||
<div className="inst-head">
|
||||
<span className="inst-name">{inst.name}</span>
|
||||
<span className={'tag ' + badge.cls}>{badge.text}</span>
|
||||
</div>
|
||||
<div className="inst-sub">{sub}</div>
|
||||
|
||||
{busy && (
|
||||
<div className="wx-progress">
|
||||
<div
|
||||
className={'wx-progress-bar' + (wx.percent < 0 ? ' indeterminate' : '')}
|
||||
style={wx.percent >= 0 ? { width: `${wx.percent}%` } : undefined}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="inst-actions">
|
||||
{offline && isAdmin ? (
|
||||
<button className="btn btn-primary inst-enter" disabled={starting} onClick={onStart}>
|
||||
{starting ? '启动中…' : inst.runtime === 'missing' ? '创建并启动' : '启动实例'}
|
||||
</button>
|
||||
) : (
|
||||
<button className="btn btn-primary inst-enter" disabled={!canEnter} onClick={onEnter}>
|
||||
进入微信
|
||||
</button>
|
||||
)}
|
||||
{isAdmin && !busy && !offline && (
|
||||
installed ? (
|
||||
<button className="btn inst-act" onClick={() => onTrigger(inst, 'update')}>
|
||||
更新
|
||||
</button>
|
||||
) : (
|
||||
<button className="btn inst-act" onClick={() => onTrigger(inst, 'install')}>
|
||||
下载安装
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChangePassword({ onClose, onSaved }: { onClose: () => void; onSaved?: () => void }) {
|
||||
const [oldPassword, setOld] = useState('');
|
||||
const [newPassword, setNew] = useState('');
|
||||
const [msg, setMsg] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setMsg('');
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.changePassword(oldPassword, newPassword);
|
||||
setMsg('修改成功');
|
||||
onSaved?.(); // 刷新当前用户,清除「默认密码」提示
|
||||
setTimeout(onClose, 800);
|
||||
} catch (e: any) {
|
||||
setMsg(e.message || '修改失败');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-mask" onClick={onClose}>
|
||||
<form className="card modal" onClick={(e) => e.stopPropagation()} onSubmit={submit}>
|
||||
<h2>修改密码</h2>
|
||||
<PasswordInput placeholder="原密码" autoComplete="current-password" value={oldPassword} onChange={setOld} />
|
||||
<PasswordInput placeholder="新密码(至少 6 位)" autoComplete="new-password" value={newPassword} onChange={setNew} />
|
||||
{msg && <div className={msg === '修改成功' ? 'ok' : 'error'}>{msg}</div>}
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="btn" onClick={onClose}>
|
||||
取消
|
||||
</button>
|
||||
<button className="btn btn-primary" disabled={busy || !oldPassword || !newPassword}>
|
||||
确定
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// 已废弃:主页与改密已迁移到 ../AppShell.tsx(HomeView / ChangePassword)。
|
||||
export {};
|
||||
|
||||
@@ -2,10 +2,10 @@ import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { api } from '../api';
|
||||
import { useUI } from '../ui';
|
||||
import { useAuth } from '../auth';
|
||||
import { useInstances } from '../AppShell';
|
||||
|
||||
// 直接加载 KasmVNC 的 noVNC 页面(由 kclient 静态托管)。
|
||||
// 反代按实例隔离:所有桌面流量走 /desktop/<id>/*,网关据 <id> 选目标容器并注入该实例凭据。
|
||||
// path=desktop/<id>/websockify:让 noVNC 把 ws 连到该实例路径,网关剥前缀反代回 KasmVNC 根 /websockify。
|
||||
// KasmVNC noVNC 页面;反代按实例隔离:/desktop/<id>/* → 对应容器,注入凭据。
|
||||
function desktopUrl(id: string) {
|
||||
return (
|
||||
`/desktop/${id}/vnc/index.html?autoconnect=1&path=desktop/${id}/websockify&resize=remote` +
|
||||
@@ -24,20 +24,47 @@ function humanSize(n: number) {
|
||||
return (n / 1024 / 1024 / 1024).toFixed(2) + ' GB';
|
||||
}
|
||||
|
||||
export default function Desktop() {
|
||||
const nav = useNavigate();
|
||||
const { toast } = useUI();
|
||||
const MenuIcon = (
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<path d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default function InstanceView({ onOpenMenu }: { onOpenMenu: () => void }) {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const nav = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const { toast, confirm } = useUI();
|
||||
const { instances, loaded, reload } = useInstances();
|
||||
const isAdmin = user?.role === 'admin';
|
||||
|
||||
const [frameLoaded, setFrameLoaded] = useState(false);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [showFiles, setShowFiles] = useState(false);
|
||||
const [files, setFiles] = useState<TFile[]>([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [starting, setStarting] = useState(false);
|
||||
const [control, setControl] = useState<{ free: boolean; mine: boolean; holder: string | null } | null>(null);
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const frameRef = useRef<HTMLIFrameElement>(null);
|
||||
const dragDepth = useRef(0);
|
||||
const lastBeat = useRef(0);
|
||||
|
||||
// 文件拖到窗口任意位置时,弹出落区(覆盖 iframe,否则 drop 会被 iframe 吞掉)
|
||||
const inst = instances.find((i) => i.id === id);
|
||||
const offline = inst ? inst.runtime !== 'running' : false;
|
||||
const installed = !!inst && inst.wechat.installed && inst.wechat.phase !== 'downloading';
|
||||
const showVnc = !!inst && !offline && installed;
|
||||
|
||||
// 切换实例时重置内嵌态
|
||||
useEffect(() => {
|
||||
setFrameLoaded(false);
|
||||
setShowFiles(false);
|
||||
setFiles([]);
|
||||
}, [id]);
|
||||
|
||||
// 文件拖到窗口 → 弹出落区(覆盖 iframe 接住 drop)
|
||||
useEffect(() => {
|
||||
if (!showVnc) return;
|
||||
const hasFiles = (e: DragEvent) => Array.from(e.dataTransfer?.types || []).includes('Files');
|
||||
const onEnter = (e: DragEvent) => {
|
||||
if (!hasFiles(e)) return;
|
||||
@@ -45,9 +72,7 @@ export default function Desktop() {
|
||||
dragDepth.current++;
|
||||
setDragging(true);
|
||||
};
|
||||
const onOver = (e: DragEvent) => {
|
||||
if (hasFiles(e)) e.preventDefault();
|
||||
};
|
||||
const onOver = (e: DragEvent) => hasFiles(e) && e.preventDefault();
|
||||
const onLeave = (e: DragEvent) => {
|
||||
if (!hasFiles(e)) return;
|
||||
dragDepth.current = Math.max(0, dragDepth.current - 1);
|
||||
@@ -68,7 +93,65 @@ export default function Desktop() {
|
||||
window.removeEventListener('dragleave', onLeave);
|
||||
window.removeEventListener('drop', onDropWin);
|
||||
};
|
||||
}, []);
|
||||
}, [showVnc]);
|
||||
|
||||
// 控制权(交互驱动的心跳软锁):每 3s 只读轮询当前操作者;超 TTL 自动释放。
|
||||
useEffect(() => {
|
||||
if (!showVnc || !id) {
|
||||
setControl(null);
|
||||
return;
|
||||
}
|
||||
let alive = true;
|
||||
const poll = async () => {
|
||||
if (document.hidden) return;
|
||||
try {
|
||||
const r = await api.controlStatus(id);
|
||||
if (!alive) return;
|
||||
setControl(r);
|
||||
if (!r.free && !r.mine) frameRef.current?.blur(); // 只读:移开键盘焦点
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
poll();
|
||||
const t = window.setInterval(poll, 3000);
|
||||
return () => {
|
||||
alive = false;
|
||||
window.clearInterval(t);
|
||||
};
|
||||
}, [showVnc, id]);
|
||||
|
||||
// 用户在 VNC 内真实操作(鼠标/键盘/滚轮)时续约控制权(同源 iframe 可监听)。节流 2.5s。
|
||||
// 只读用户的操作已被遮罩拦截/失焦,不会误续约;空闲不操作则超时自动释放。
|
||||
useEffect(() => {
|
||||
if (!showVnc || !id || !frameLoaded) return;
|
||||
const win = frameRef.current?.contentWindow;
|
||||
if (!win) return;
|
||||
const onInteract = async () => {
|
||||
const now = Date.now();
|
||||
if (now - lastBeat.current < 2500) return;
|
||||
lastBeat.current = now;
|
||||
try {
|
||||
const r = await api.controlBeat(id);
|
||||
setControl({ free: false, mine: r.mine, holder: r.holder });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
const evs = ['mousedown', 'keydown', 'wheel'] as const;
|
||||
try {
|
||||
evs.forEach((e) => win.addEventListener(e, onInteract, { capture: true, passive: true }));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
return () => {
|
||||
try {
|
||||
evs.forEach((e) => win.removeEventListener(e, onInteract, { capture: true } as any));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
}, [showVnc, id, frameLoaded]);
|
||||
|
||||
if (!id) {
|
||||
nav('/', { replace: true });
|
||||
@@ -111,86 +194,230 @@ export default function Desktop() {
|
||||
if (e.dataTransfer.files?.length) uploadFiles(e.dataTransfer.files);
|
||||
};
|
||||
|
||||
const delFile = async (name: string) => {
|
||||
if (!(await confirm({ title: `删除「${name}」?`, body: '将从微信桌面(~/Desktop)移除该文件。', danger: true, confirmText: '删除' }))) return;
|
||||
try {
|
||||
await api.deleteFile(id, name);
|
||||
toast('已删除', 'ok');
|
||||
refreshFiles();
|
||||
} catch (e: any) {
|
||||
toast(e.message || '删除失败', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// 同源 iframe:把键盘焦点交给 VNC,帮助宿主机输入法把合成的字送进去
|
||||
const focusFrame = () => {
|
||||
try {
|
||||
frameRef.current?.focus();
|
||||
frameRef.current?.contentWindow?.focus();
|
||||
const ki = frameRef.current?.contentDocument?.getElementById('noVNC_keyboardinput') as HTMLElement | null;
|
||||
ki?.focus();
|
||||
} catch {
|
||||
/* 跨域兜底(正常同源不会到这) */
|
||||
}
|
||||
};
|
||||
|
||||
// 打开/收起 KasmVNC 原生控制条(音频/麦克风/摄像头/剪贴板/全屏/键盘设置都在里面)。
|
||||
// noVNC 的 openControlbar 是给 #noVNC_control_bar 加 .noVNC_open(CSS 据此展开;手柄绑 mousedown,
|
||||
// 合成 click 触发不了),所以直接切换 bar 上的该 class 最可靠。iframe 与面板同源,可直接访问其 DOM。
|
||||
const openVncControls = () => {
|
||||
try {
|
||||
const doc = frameRef.current?.contentDocument;
|
||||
const bar = doc?.getElementById('noVNC_control_bar');
|
||||
if (bar) {
|
||||
bar.classList.toggle('noVNC_open');
|
||||
focusFrame();
|
||||
} else {
|
||||
toast('控制条尚未就绪,请等画面出现后重试', 'error');
|
||||
}
|
||||
} catch {
|
||||
toast('无法打开 VNC 控制条', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const takeControl = async () => {
|
||||
try {
|
||||
const r = await api.controlTake(id);
|
||||
setControl({ free: false, mine: r.mine, holder: r.holder });
|
||||
lastBeat.current = Date.now();
|
||||
focusFrame();
|
||||
} catch (e: any) {
|
||||
toast(e.message || '接管失败', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const start = async () => {
|
||||
setStarting(true);
|
||||
try {
|
||||
await api.instanceStart(id);
|
||||
toast('实例已启动', 'ok');
|
||||
await reload();
|
||||
} catch (e: any) {
|
||||
toast(e.message || '启动失败', 'error');
|
||||
} finally {
|
||||
setStarting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const title = inst?.name || '微信实例';
|
||||
|
||||
return (
|
||||
<div className="desktop-wrap">
|
||||
<iframe
|
||||
className="desktop-frame"
|
||||
src={desktopUrl(id)}
|
||||
title="电脑版微信"
|
||||
allow="clipboard-read; clipboard-write; microphone; camera; autoplay"
|
||||
onLoad={() => setLoaded(true)}
|
||||
/>
|
||||
<div className="ws-page">
|
||||
<header className="ws-head">
|
||||
<button className="ws-menu" onClick={onOpenMenu} aria-label="菜单">
|
||||
{MenuIcon}
|
||||
</button>
|
||||
<span className="ws-title">{title}</span>
|
||||
{showVnc && (
|
||||
<>
|
||||
<button className="ws-action" title="VNC 控制:音频 / 麦克风 / 摄像头 / 剪贴板 / 全屏 / 键盘" onClick={openVncControls}>
|
||||
控制
|
||||
</button>
|
||||
<button
|
||||
className="ws-action"
|
||||
title="文件传输"
|
||||
onClick={() => {
|
||||
setShowFiles((v) => !v);
|
||||
if (!showFiles) refreshFiles();
|
||||
}}
|
||||
>
|
||||
文件
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{!loaded && (
|
||||
<div className="desktop-loading">
|
||||
{/* —— 各种态 —— */}
|
||||
{!loaded ? (
|
||||
<div className="iv-stage iv-center">
|
||||
<div className="spinner" />
|
||||
<div className="desktop-loading-text">正在连接桌面…</div>
|
||||
<div className="desktop-loading-sub">首次进入请扫码登录微信</div>
|
||||
<div className="desktop-loading-sub">拖文件到窗口即可上传;语音/视频在左侧工具条开启</div>
|
||||
{!window.isSecureContext && (
|
||||
<div className="desktop-loading-warn">当前非 HTTPS 访问,浏览器将禁用麦克风与摄像头(音频播放不受影响)</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 拖拽落区:仅拖入文件时出现,覆盖 iframe 接住 drop */}
|
||||
{dragging && (
|
||||
<div className="drop-zone" onDrop={onDrop} onDragOver={(e) => e.preventDefault()}>
|
||||
<div className="drop-card">
|
||||
<div className="drop-icon">⬇</div>
|
||||
<div className="drop-title">松开上传到微信桌面</div>
|
||||
<div className="drop-sub">上传后在微信里「+ / 文件」选择即可</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button className="desktop-back" onClick={() => nav('/')} title="返回">
|
||||
‹
|
||||
</button>
|
||||
|
||||
{/* 文件按钮 */}
|
||||
<button
|
||||
className="desktop-files-btn"
|
||||
title="文件传输"
|
||||
onClick={() => {
|
||||
setShowFiles((v) => !v);
|
||||
if (!showFiles) refreshFiles();
|
||||
}}
|
||||
>
|
||||
⇅
|
||||
</button>
|
||||
|
||||
{showFiles && (
|
||||
<div className="files-panel">
|
||||
<div className="files-head">
|
||||
<span>文件传输</span>
|
||||
<button className="btn-text" onClick={() => setShowFiles(false)}>
|
||||
关闭
|
||||
) : !inst ? (
|
||||
<div className="iv-stage iv-center">
|
||||
<div className="iv-notice">
|
||||
<div className="iv-notice-title">无权访问或实例不存在</div>
|
||||
<button className="btn btn-primary iv-notice-btn" onClick={() => nav('/')}>
|
||||
返回主页
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
multiple
|
||||
style={{ display: 'none' }}
|
||||
onChange={(e) => {
|
||||
if (e.target.files) uploadFiles(e.target.files);
|
||||
e.target.value = '';
|
||||
</div>
|
||||
) : offline ? (
|
||||
<div className="iv-stage iv-center">
|
||||
<div className="iv-notice">
|
||||
<div className="iv-notice-title">{inst.runtime === 'missing' ? '容器尚未创建' : '实例已停止'}</div>
|
||||
{isAdmin ? (
|
||||
<button className="btn btn-primary iv-notice-btn" disabled={starting} onClick={start}>
|
||||
{starting ? '启动中…' : inst.runtime === 'missing' ? '创建并启动' : '启动实例'}
|
||||
</button>
|
||||
) : (
|
||||
<div className="iv-notice-sub">请联系管理员启动该实例</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : !installed ? (
|
||||
<div className="iv-stage iv-center">
|
||||
<div className="iv-notice">
|
||||
<div className="iv-notice-title">微信尚未安装</div>
|
||||
{isAdmin ? (
|
||||
<button className="btn btn-primary iv-notice-btn" onClick={() => nav('/admin')}>
|
||||
去「管理」下载安装
|
||||
</button>
|
||||
) : (
|
||||
<div className="iv-notice-sub">请联系管理员在「管理」中下载安装微信</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="iv-stage">
|
||||
<iframe
|
||||
key={id}
|
||||
ref={frameRef}
|
||||
className="iv-frame"
|
||||
src={desktopUrl(id)}
|
||||
title="电脑版微信"
|
||||
allow="clipboard-read; clipboard-write; microphone; camera; autoplay"
|
||||
onLoad={() => {
|
||||
setFrameLoaded(true);
|
||||
setTimeout(focusFrame, 400); // 加载完把键盘焦点交给 VNC(宿主机输入法)
|
||||
}}
|
||||
/>
|
||||
<button className="btn btn-primary files-upload" disabled={uploading} onClick={() => fileInput.current?.click()}>
|
||||
{uploading ? '上传中…' : '+ 选择文件上传'}
|
||||
</button>
|
||||
<div className="files-hint">也可直接把文件拖到窗口。下方为桌面(~/Desktop)里的文件,微信收到的文件另存到桌面即可在此下载。</div>
|
||||
<div className="files-list">
|
||||
{files.length === 0 && <div className="muted small" style={{ padding: '10px 2px' }}>暂无文件</div>}
|
||||
{files.map((f) => (
|
||||
<a key={f.name} className="files-item" href={api.downloadFileUrl(id, f.name)} download={f.name}>
|
||||
<span className="files-name">{f.name}</span>
|
||||
<span className="files-size">{humanSize(f.size)} ↓</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!frameLoaded && (
|
||||
<div className="iv-loading">
|
||||
<div className="spinner" />
|
||||
<div className="iv-loading-text">正在连接桌面…</div>
|
||||
<div className="iv-loading-sub">首次进入请扫码登录微信</div>
|
||||
<div className="iv-loading-sub">拖文件到此处即可上传;语音/视频/剪贴板点右上角「控制」开启</div>
|
||||
{!window.isSecureContext && (
|
||||
<div className="iv-loading-warn">当前非 HTTPS 访问,浏览器将禁用麦克风与摄像头(音频播放不受影响)</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dragging && (
|
||||
<div className="iv-drop" onDrop={onDrop} onDragOver={(e) => e.preventDefault()}>
|
||||
<div className="drop-card">
|
||||
<div className="drop-icon">⬇</div>
|
||||
<div className="drop-title">松开上传到微信桌面</div>
|
||||
<div className="drop-sub">上传后在微信里「+ / 文件」选择即可</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{control && !control.free && !control.mine && (
|
||||
<div className="iv-lock">
|
||||
<div className="iv-lock-card">
|
||||
<div className="iv-lock-title">「{control.holder}」正在操作</div>
|
||||
<div className="iv-lock-sub">为避免多端互相干扰,你当前为只读模式。</div>
|
||||
<button className="btn btn-primary iv-notice-btn" onClick={takeControl}>
|
||||
申请控制
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showFiles && (
|
||||
<div className="iv-files">
|
||||
<div className="files-head">
|
||||
<span>文件传输</span>
|
||||
<button className="btn-text" onClick={() => setShowFiles(false)}>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
multiple
|
||||
style={{ display: 'none' }}
|
||||
onChange={(e) => {
|
||||
if (e.target.files) uploadFiles(e.target.files);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<button className="btn btn-primary files-upload" disabled={uploading} onClick={() => fileInput.current?.click()}>
|
||||
{uploading ? '上传中…' : '+ 选择文件上传'}
|
||||
</button>
|
||||
<div className="files-hint">也可直接把文件拖进来。下方为桌面(~/Desktop)里的文件,微信收到的文件另存到桌面即可在此下载。</div>
|
||||
<div className="files-list">
|
||||
{files.length === 0 && (
|
||||
<div className="muted small" style={{ padding: '10px 2px' }}>
|
||||
暂无文件
|
||||
</div>
|
||||
)}
|
||||
{files.map((f) => (
|
||||
<div key={f.name} className="files-item">
|
||||
<a className="files-dl" href={api.downloadFileUrl(id, f.name)} download={f.name} title="下载">
|
||||
<span className="files-name">{f.name}</span>
|
||||
<span className="files-size">{humanSize(f.size)} ↓</span>
|
||||
</a>
|
||||
<button className="files-del" title="删除" onClick={() => delFile(f.name)}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -26,27 +26,33 @@ export default function Login() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="center-screen">
|
||||
<form className="card login-card" onSubmit={submit}>
|
||||
<div className="brand">
|
||||
<div className="brand-logo"><img src="/favicon.svg" alt="" /></div>
|
||||
<h1>云微</h1>
|
||||
<p className="muted">登录以访问 NAS 上的微信</p>
|
||||
</div>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="用户名"
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
<PasswordInput placeholder="密码" autoComplete="current-password" value={password} onChange={setPassword} />
|
||||
{err && <div className="error">{err}</div>}
|
||||
<button className="btn btn-primary" disabled={busy || !username || !password}>
|
||||
{busy ? '登录中…' : '登录'}
|
||||
</button>
|
||||
</form>
|
||||
<div className="center-screen login-screen">
|
||||
<div className="login-wrap">
|
||||
<form className="card login-card" onSubmit={submit}>
|
||||
<div className="brand">
|
||||
<div className="brand-logo">
|
||||
<img src="/favicon.svg" alt="" />
|
||||
</div>
|
||||
<h1>云微</h1>
|
||||
<p className="muted">登录以访问 NAS 上的微信</p>
|
||||
</div>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="用户名"
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
<PasswordInput placeholder="密码" autoComplete="current-password" value={password} onChange={setPassword} />
|
||||
{err && <div className="error">{err}</div>}
|
||||
<button className="btn btn-primary" disabled={busy || !username || !password}>
|
||||
{busy ? '登录中…' : '登录'}
|
||||
</button>
|
||||
</form>
|
||||
<div className="login-foot">服务端微信 · 多端共享 · 建议仅在内网 / 可信网络访问</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -107,10 +107,30 @@ button {
|
||||
}
|
||||
|
||||
/* 登录 */
|
||||
.login-screen {
|
||||
background:
|
||||
radial-gradient(60% 50% at 50% 0%, rgba(var(--green-rgb) / 0.1), transparent 70%),
|
||||
var(--base);
|
||||
}
|
||||
.login-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
}
|
||||
.login-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
padding: 28px 24px;
|
||||
}
|
||||
.login-foot {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
padding: 0 12px;
|
||||
}
|
||||
.brand {
|
||||
text-align: center;
|
||||
@@ -326,6 +346,17 @@ button {
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
/* PC 大屏:加宽容器,实例网格自动多列卡片 */
|
||||
@media (min-width: 880px) {
|
||||
.content {
|
||||
max-width: 940px;
|
||||
padding: 20px 28px 40px;
|
||||
}
|
||||
.topbar {
|
||||
padding-left: 16px;
|
||||
padding-right: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.hello {
|
||||
font-size: 22px;
|
||||
@@ -693,6 +724,17 @@ button {
|
||||
flex-direction: column;
|
||||
}
|
||||
.files-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
box-shadow: inset 0 -1px 0 rgba(var(--shadow) / 0.08);
|
||||
}
|
||||
.files-item:last-child {
|
||||
box-shadow: none;
|
||||
}
|
||||
.files-dl {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
@@ -701,10 +743,6 @@ button {
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
box-shadow: inset 0 -1px 0 rgba(var(--shadow) / 0.08);
|
||||
}
|
||||
.files-item:last-child {
|
||||
box-shadow: none;
|
||||
}
|
||||
.files-name {
|
||||
overflow: hidden;
|
||||
@@ -717,6 +755,21 @@ button {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.files-del {
|
||||
flex: none;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.files-del:active {
|
||||
background: rgba(var(--danger-rgb) / 0.12);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* ── loading ───────────────────────────────────────────── */
|
||||
.spinner {
|
||||
@@ -803,6 +856,24 @@ button {
|
||||
.inst-act {
|
||||
flex: none;
|
||||
}
|
||||
.inst-act-wide {
|
||||
flex: 1;
|
||||
height: 42px;
|
||||
font-size: 15px;
|
||||
}
|
||||
/* 管理卡片底部的文字操作(重命名/分配/删除) */
|
||||
.inst-admin-links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px;
|
||||
margin-top: 10px;
|
||||
padding-top: 8px;
|
||||
box-shadow: inset 0 1px 0 rgba(var(--shadow) / 0.08);
|
||||
}
|
||||
.inst-admin-links .btn-text {
|
||||
padding: 6px 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 状态徽章配色 */
|
||||
.tag-on {
|
||||
@@ -1057,3 +1128,505 @@ button {
|
||||
text-align: center;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 微信 PC 式布局:左侧 tab 栏 + 右侧工作区
|
||||
* ============================================================ */
|
||||
.shell {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
background: var(--base);
|
||||
}
|
||||
|
||||
/* —— 左侧栏 —— */
|
||||
.sidebar {
|
||||
flex: none;
|
||||
width: 264px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--surface);
|
||||
box-shadow: 1px 0 0 rgba(var(--shadow) / 0.08), 4px 0 16px rgba(var(--shadow) / 0.05);
|
||||
z-index: 20;
|
||||
transition: width 0.2s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
padding-top: env(safe-area-inset-top);
|
||||
}
|
||||
.shell.collapsed .sidebar {
|
||||
width: 64px;
|
||||
}
|
||||
.sb-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 14px 14px 10px;
|
||||
}
|
||||
.sb-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
.sb-logo {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
flex: none;
|
||||
border-radius: 9px;
|
||||
}
|
||||
.sb-name {
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sb-collapse {
|
||||
flex: none;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
border-radius: 9px;
|
||||
}
|
||||
.sb-collapse:active {
|
||||
background: rgba(var(--shadow) / 0.06);
|
||||
}
|
||||
.collapsed .sb-top {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sb-nav {
|
||||
padding: 4px 10px;
|
||||
}
|
||||
.sb-section {
|
||||
padding: 12px 16px 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--muted);
|
||||
}
|
||||
.sb-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 0 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.sb-empty {
|
||||
padding: 10px 16px;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.sb-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
padding: 9px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 15px;
|
||||
color: var(--text);
|
||||
text-align: left;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.sb-item:hover {
|
||||
background: rgba(var(--shadow) / 0.05);
|
||||
}
|
||||
.sb-item.on {
|
||||
background: rgba(var(--green-rgb) / 0.12);
|
||||
color: var(--wx-green-dark);
|
||||
}
|
||||
.sb-item.on .sb-ic {
|
||||
color: var(--wx-green-dark);
|
||||
}
|
||||
.sb-ic {
|
||||
flex: none;
|
||||
width: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--muted);
|
||||
}
|
||||
.sb-label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
}
|
||||
.collapsed .sb-item {
|
||||
justify-content: center;
|
||||
padding: 9px 0;
|
||||
}
|
||||
|
||||
/* 实例头像 + 状态点 */
|
||||
.sb-avatar {
|
||||
position: relative;
|
||||
flex: none;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(150deg, var(--wx-green), var(--wx-green-dark));
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.sb-dot {
|
||||
position: absolute;
|
||||
right: -2px;
|
||||
bottom: -2px;
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--surface);
|
||||
}
|
||||
.sb-stxt {
|
||||
flex: none;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
}
|
||||
.st-on {
|
||||
background: #16c060;
|
||||
color: var(--wx-green-dark);
|
||||
}
|
||||
.st-off {
|
||||
background: #c2c7d0;
|
||||
color: var(--muted);
|
||||
}
|
||||
.st-busy {
|
||||
background: #2f7ad0;
|
||||
color: #2f5fd0;
|
||||
}
|
||||
.st-warn {
|
||||
background: #e6a23c;
|
||||
color: #b9770a;
|
||||
}
|
||||
|
||||
.sb-footer {
|
||||
padding: 8px 10px calc(10px + env(safe-area-inset-bottom));
|
||||
box-shadow: inset 0 1px 0 rgba(var(--shadow) / 0.08);
|
||||
}
|
||||
.sb-user {
|
||||
padding: 8px 12px 2px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* —— 右侧工作区 —— */
|
||||
.workspace {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
.ws-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
.ws-head {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
height: calc(52px + env(safe-area-inset-top));
|
||||
padding: env(safe-area-inset-top) 16px 0;
|
||||
background: var(--base);
|
||||
box-shadow: inset 0 -1px 0 rgba(var(--shadow) / 0.07);
|
||||
}
|
||||
.ws-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ws-menu {
|
||||
display: none;
|
||||
flex: none;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
border-radius: 9px;
|
||||
margin-left: -6px;
|
||||
}
|
||||
.ws-action {
|
||||
flex: none;
|
||||
border: none;
|
||||
background: var(--surface);
|
||||
color: var(--wx-green-dark);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
padding: 7px 14px;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
box-shadow: var(--crease);
|
||||
}
|
||||
.ws-action:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
.ws-page .content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 主页实例快捷卡 */
|
||||
.home-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
background: var(--surface);
|
||||
border-radius: var(--r-card);
|
||||
padding: 16px;
|
||||
box-shadow: var(--crease);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: transform 0.16s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
.home-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
background: var(--sheen);
|
||||
pointer-events: none;
|
||||
}
|
||||
.home-card:active {
|
||||
transform: scale(0.985);
|
||||
}
|
||||
.home-card-av {
|
||||
position: relative;
|
||||
flex: none;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(150deg, var(--wx-green), var(--wx-green-dark));
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-size: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.home-card-main {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
.home-card-name {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.home-card-st {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
background: none !important;
|
||||
}
|
||||
|
||||
/* —— 内嵌实例视图 —— */
|
||||
.iv-stage {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
background: #000;
|
||||
}
|
||||
.iv-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--base);
|
||||
}
|
||||
.iv-frame {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
display: block;
|
||||
}
|
||||
.iv-loading,
|
||||
.iv-drop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
z-index: 5;
|
||||
}
|
||||
.iv-loading {
|
||||
background: var(--base);
|
||||
}
|
||||
.iv-loading-text {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.iv-loading-sub {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
padding: 0 24px;
|
||||
}
|
||||
.iv-loading-warn {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--danger);
|
||||
text-align: center;
|
||||
padding: 0 24px;
|
||||
}
|
||||
.iv-drop {
|
||||
z-index: 30;
|
||||
background: rgba(7, 193, 96, 0.16);
|
||||
-webkit-backdrop-filter: blur(3px);
|
||||
backdrop-filter: blur(3px);
|
||||
}
|
||||
.iv-files {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
right: 14px;
|
||||
width: min(330px, calc(100% - 28px));
|
||||
max-height: calc(100% - 28px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
background: var(--surface);
|
||||
border-radius: var(--r-card);
|
||||
box-shadow: var(--crease), 0 8px 30px rgba(var(--shadow) / 0.18);
|
||||
z-index: 12;
|
||||
padding: 16px;
|
||||
}
|
||||
/* 只读遮罩:他人正在操作时盖住 VNC,拦截键鼠(半透,仍能看到画面) */
|
||||
.iv-lock {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 8;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(26, 29, 36, 0.28);
|
||||
-webkit-backdrop-filter: blur(1.5px);
|
||||
backdrop-filter: blur(1.5px);
|
||||
}
|
||||
.iv-lock-card {
|
||||
background: var(--surface);
|
||||
border-radius: var(--r-card);
|
||||
box-shadow: var(--crease), 0 8px 30px rgba(var(--shadow) / 0.25);
|
||||
padding: 22px 26px;
|
||||
text-align: center;
|
||||
max-width: 320px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.iv-lock-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.iv-lock-sub {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.iv-notice {
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 24px;
|
||||
}
|
||||
.iv-notice-title {
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.iv-notice-sub {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.iv-notice-btn {
|
||||
padding: 0 22px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
/* —— 移动端:侧栏变抽屉 —— */
|
||||
.shell-backdrop {
|
||||
display: none;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 82vw;
|
||||
max-width: 320px;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.25s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
box-shadow: 0 0 40px rgba(var(--shadow) / 0.35);
|
||||
}
|
||||
.shell.drawer-open .sidebar {
|
||||
transform: none;
|
||||
}
|
||||
.shell.drawer-open .shell-backdrop {
|
||||
display: block;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(26, 29, 36, 0.4);
|
||||
z-index: 15;
|
||||
}
|
||||
.ws-menu {
|
||||
display: flex;
|
||||
}
|
||||
.ws-action {
|
||||
padding: 7px 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 宽屏:工作区内容容器更宽(实例网格多列) */
|
||||
@media (min-width: 880px) {
|
||||
.ws-page .content {
|
||||
max-width: 940px;
|
||||
}
|
||||
}
|
||||
|
||||