mirror of
https://github.com/Gloridust/WechatOnCloud.git
synced 2026-06-16 19:53:53 +08:00
338 lines
13 KiB
TypeScript
338 lines
13 KiB
TypeScript
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>
|
||
);
|
||
}
|