update admin UI

This commit is contained in:
Gloridust
2026-05-31 13:07:48 +08:00
parent d581a49a90
commit f0e3f978d0
3 changed files with 186 additions and 56 deletions
+157 -23
View File
@@ -1,8 +1,10 @@
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';
const BUSY_PHASES = ['downloading', 'extracting', 'installing'];
export default function Admin() {
const nav = useNavigate();
const { toast, confirm } = useUI();
@@ -16,8 +18,10 @@ 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 () => {
try {
@@ -31,8 +35,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));
@@ -76,28 +121,27 @@ export default function Admin() {
+
</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>
</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)}
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>
@@ -342,6 +386,96 @@ function DeleteInstance({ inst, onClose, onDone }: { inst: InstanceWithStatus; o
);
}
// 管理页的实例卡片:含微信版本管理(下载/更新)+ 重命名/分配/删除
function InstanceAdminCard({
inst,
userCount,
starting,
onTrigger,
onStart,
onRename,
onAssign,
onDelete,
}: {
inst: InstanceWithStatus;
userCount: number;
starting?: boolean;
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>
) : installed ? (
<button className="btn btn-primary inst-act-wide" onClick={() => onTrigger(inst, 'update')}>
</button>
) : (
<button className="btn btn-primary inst-act-wide" onClick={() => onTrigger(inst, 'install')}>
</button>
)}
</div>
)}
<div className="inst-admin-links">
<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,
-33
View File
@@ -39,25 +39,6 @@ export default function Dashboard() {
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));
@@ -133,7 +114,6 @@ export default function Dashboard() {
isAdmin={isAdmin}
starting={starting.has(inst.id)}
onEnter={() => nav(`/desktop/${inst.id}`)}
onTrigger={trigger}
onStart={() => start(inst)}
/>
))}
@@ -163,14 +143,12 @@ function InstanceCard({
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;
@@ -220,17 +198,6 @@ function InstanceCard({
</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>
);
+29
View File
@@ -326,6 +326,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;
@@ -803,6 +814,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 {