feat: 工作台 proxy 深度改造(Muximux 模式)——/proxy/{slug}/ 路径前缀 + HTML 改写 + 运行时 shim + WebSocket + cookie 鉴权 + 按面板信任模型 sandbox,支持嵌入几乎所有 SPA

This commit is contained in:
2026-08-22 00:17:39 +08:00
parent 77e8ab76ee
commit e505a170b7
11 changed files with 1519 additions and 191 deletions
+96 -4
View File
@@ -21,6 +21,7 @@ import DialogActions from '@mui/material/DialogActions';
import TextField from '@mui/material/TextField';
import FormControlLabel from '@mui/material/FormControlLabel';
import Switch from '@mui/material/Switch';
import Autocomplete from '@mui/material/Autocomplete';
import { listAdminLinks, createAdminLink, updateAdminLink, deleteAdminLink } from '../../api/adminLinks.js';
import { getSettings, saveSettings } from '../../api/settings.js';
import { showSnack } from '../snack.jsx';
@@ -29,9 +30,35 @@ import ConfirmDialog from '../ConfirmDialog.jsx';
const EMPTY = {
title: '', url: '', embed_url: '', use_proxy: false, description: '',
icon: '', category: '默认', version: '', sort_order: '0',
slug: '', trusted: true, permissions: [], scale: '1',
};
/** 面板链接管理:CRUD + 代理嵌入开关 + 面板代理白名单(迁移自 v1 面板链接卡片 */
/* 面板代理配置字段的展示与编辑选项(与工作台 PanelFrame 的委托权限一致 */
const PERMISSION_OPTIONS = [
{ value: 'camera', label: '摄像头' },
{ value: 'microphone', label: '麦克风' },
{ value: 'geolocation', label: '定位' },
{ value: 'clipboard-read', label: '剪贴板读取' },
{ value: 'clipboard-write', label: '剪贴板写入' },
{ value: 'payment', label: '支付' },
{ value: 'usb', label: 'USB' },
{ value: 'serial', label: '串口' },
{ value: 'notifications', label: '通知' },
];
/* permissions 字段:后端存 JSON 数组字符串,兼容已解析数组 */
function parsePermissions(raw) {
if (Array.isArray(raw)) return raw.map(String).filter(Boolean);
if (typeof raw === 'string' && raw.trim()) {
try {
const a = JSON.parse(raw);
return Array.isArray(a) ? a.map(String).filter(Boolean) : [];
} catch { return []; }
}
return [];
}
/** 面板链接管理:CRUD + 代理嵌入开关 + 信任模式/缩放/权限 + 面板代理白名单 */
export default function Links() {
const [links, setLinks] = useState(null);
const [dialog, setDialog] = useState(false);
@@ -60,6 +87,10 @@ export default function Links() {
url: l.url,
embed_url: l.embed_url || '',
use_proxy: !!l.use_proxy,
slug: l.slug || '',
trusted: !(l.trusted === 0 || l.trusted === '0'),
permissions: parsePermissions(l.permissions),
scale: String(parseFloat(l.scale) || 1),
description: l.description || '',
icon: l.icon || '',
category: l.category || '默认',
@@ -76,6 +107,10 @@ export default function Links() {
url: form.url.trim(),
embed_url: form.embed_url.trim(),
use_proxy: form.use_proxy ? 1 : 0,
slug: form.slug.trim(),
trusted: form.trusted ? 1 : 0,
permissions: JSON.stringify(form.permissions),
scale: parseFloat(form.scale) || 1,
description: form.description.trim(),
icon: form.icon.trim(),
category: form.category.trim() || '默认',
@@ -132,24 +167,43 @@ export default function Links() {
<TableCell>版本</TableCell>
<TableCell>URL</TableCell>
<TableCell>嵌入URL</TableCell>
<TableCell>代理配置</TableCell>
<TableCell>分类</TableCell>
<TableCell align="right">操作</TableCell>
</TableRow>
</TableHead>
<TableBody>
{links === null ? (
<TableRow><TableCell colSpan={7}>加载中...</TableCell></TableRow>
<TableRow><TableCell colSpan={8}>加载中...</TableCell></TableRow>
) : links.length === 0 ? (
<TableRow><TableCell colSpan={7}>暂无面板</TableCell></TableRow>
<TableRow><TableCell colSpan={8}>暂无面板</TableCell></TableRow>
) : links.map((l) => (
<TableRow key={l.id}>
<TableCell>{l.sort_order}</TableCell>
<TableCell><Box sx={{ fontWeight: 600 }}>{l.title}</Box></TableCell>
<TableCell>
<Box sx={{ fontWeight: 600 }}>{l.title}</Box>
{l.slug && (
<Box component="span" sx={{ color: 'text.secondary', fontSize: 12, fontFamily: 'monospace' }}>/{l.slug}/</Box>
)}
</TableCell>
<TableCell sx={{ color: 'text.secondary', fontSize: 13 }}>{l.version || '-'}</TableCell>
<TableCell sx={{ maxWidth: 180 }}>
<Box component="a" href={l.url} target="_blank" rel="noopener" sx={{ color: 'primary.main', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block' }}>{l.url}</Box>
</TableCell>
<TableCell sx={{ maxWidth: 140, color: 'text.secondary', fontSize: 13, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{l.embed_url || '-'}</TableCell>
<TableCell>
{l.use_proxy ? (
<Chip
size="small"
label={l.trusted === 0 || l.trusted === '0' ? '代理·安全' : '代理·可信'}
color={l.trusted === 0 || l.trusted === '0' ? 'default' : 'success'}
variant="outlined"
/>
) : <Chip size="small" label="直嵌" variant="outlined" />}
{parseFloat(l.scale) > 0 && parseFloat(l.scale) !== 1 && (
<Box component="span" sx={{ ml: 0.75, color: 'text.secondary', fontSize: 12 }}>缩放 {parseFloat(l.scale)}x</Box>
)}
</TableCell>
<TableCell><Chip size="small" label={l.category} variant="outlined" /></TableCell>
<TableCell align="right" sx={{ whiteSpace: 'nowrap' }}>
<IconButton size="small" onClick={() => openEdit(l)}><EditIcon fontSize="small" /></IconButton>
@@ -189,6 +243,44 @@ export default function Links() {
<TextField fullWidth label="URL *" type="url" value={form.url} onChange={(e) => setForm((p) => ({ ...p, url: e.target.value }))} margin="normal" placeholder="https://..." />
<TextField fullWidth label="嵌入 URLiframe嵌入用)" type="url" value={form.embed_url} onChange={(e) => setForm((p) => ({ ...p, embed_url: e.target.value }))} margin="normal" placeholder="留空则新标签页打开" />
<FormControlLabel control={<Switch checked={form.use_proxy} onChange={(e) => setForm((p) => ({ ...p, use_proxy: e.target.checked }))} />} label="通过代理嵌入(绕过 X-Frame-Options 限制)" />
{form.use_proxy && (
<Box sx={{ mt: 1, mb: 1 }}>
<TextField
fullWidth
label="代理路径标识 slug"
value={form.slug}
onChange={(e) => setForm((p) => ({ ...p, slug: e.target.value }))}
margin="dense"
placeholder="留空按标题自动生成"
helperText="仅小写字母/数字/连字符,≤32 字符;iframe 走 /proxy/{slug}/"
/>
<FormControlLabel
control={<Switch checked={form.trusted} onChange={(e) => setForm((p) => ({ ...p, trusted: e.target.checked }))} />}
label="可信模式(保留 allow-same-origin,目标站 cookie 登录态可用)"
/>
<TextField
label="缩放(0.13"
type="number"
value={form.scale}
onChange={(e) => setForm((p) => ({ ...p, scale: e.target.value }))}
margin="dense"
inputProps={{ min: 0.1, max: 3, step: 0.1 }}
sx={{ maxWidth: 160 }}
/>
<Autocomplete
multiple
fullWidth
size="small"
options={PERMISSION_OPTIONS}
getOptionLabel={(o) => o.label}
value={PERMISSION_OPTIONS.filter((o) => form.permissions.includes(o.value))}
onChange={(e, v) => setForm((p) => ({ ...p, permissions: v.map((o) => o.value) }))}
renderInput={(params) => (
<TextField {...params} label="委托权限(可选,iframe allow 属性)" margin="dense" placeholder="摄像头 / 麦克风 / 定位等" />
)}
/>
</Box>
)}
<TextField fullWidth label="描述" value={form.description} onChange={(e) => setForm((p) => ({ ...p, description: e.target.value }))} margin="normal" />
<TextField fullWidth label="图标 (Material图标名)" value={form.icon} onChange={(e) => setForm((p) => ({ ...p, icon: e.target.value }))} margin="normal" placeholder="settings, dashboard, ..." />
<TextField fullWidth label="分类" value={form.category} onChange={(e) => setForm((p) => ({ ...p, category: e.target.value }))} margin="normal" placeholder="默认" />
@@ -21,7 +21,8 @@ import { OPEN_MODE_EMBED, OPEN_MODE_TAB, OPEN_MODE_MODAL } from '../hooks/usePan
/* ============================================================
* AddPanelDialog:添加面板
* URL + 标题 + 分组 + favicon 实时预览 + 打开方式(内嵌/新标签/弹窗)
* + 嵌入 URL + 代理开关;保存到 admin_links(复用现有 API
* + 嵌入 URL + 代理开关 + 信任模式 + 缩放 + 权限委托;
* 保存到 admin_links(复用现有 APItrusted/scale/permissions 为代理新字段)
* ============================================================ */
const MODE_META = [
@@ -30,6 +31,19 @@ const MODE_META = [
{ mode: OPEN_MODE_MODAL, label: '弹窗', Icon: OpenInFullIcon },
];
/* iframe 委托权限候选(对应 Permissions Policy 的 allow 属性 token */
const PERMISSION_OPTIONS = [
{ value: 'camera', label: '摄像头' },
{ value: 'microphone', label: '麦克风' },
{ value: 'geolocation', label: '定位' },
{ value: 'clipboard-read', label: '剪贴板读取' },
{ value: 'clipboard-write', label: '剪贴板写入' },
{ value: 'payment', label: '支付' },
{ value: 'usb', label: 'USB' },
{ value: 'serial', label: '串口' },
{ value: 'notifications', label: '通知' },
];
function normalizeUrl(raw) {
const s = (raw || '').trim();
if (!s) return '';
@@ -40,9 +54,16 @@ export default function AddPanelDialog({ open, onClose, categories = [], onSaved
const [form, setForm] = useState({
title: '', url: '', category: '', openMode: OPEN_MODE_EMBED,
use_proxy: false, embed_url: '',
trusted: true, scale: '1', permissions: [],
});
const [err, setErr] = useState('');
const resetForm = () => setForm({
title: '', url: '', category: '', openMode: OPEN_MODE_EMBED,
use_proxy: false, embed_url: '',
trusted: true, scale: '1', permissions: [],
});
const host = useMemo(() => {
try { return new URL(normalizeUrl(form.url)).hostname; } catch { return ''; }
}, [form.url]);
@@ -61,6 +82,9 @@ export default function AddPanelDialog({ open, onClose, categories = [], onSaved
embed_url: form.embed_url.trim(),
category: form.category.trim() || '默认',
use_proxy: form.use_proxy ? 1 : 0,
trusted: form.trusted ? 1 : 0,
scale: parseFloat(form.scale) || 1,
permissions: JSON.stringify(form.permissions),
description: '',
icon: '',
sort_order: 0,
@@ -68,7 +92,7 @@ export default function AddPanelDialog({ open, onClose, categories = [], onSaved
});
if (form.openMode !== OPEN_MODE_EMBED) onSetMode(panel.id, form.openMode);
setErr('');
setForm({ title: '', url: '', category: '', openMode: OPEN_MODE_EMBED, use_proxy: false, embed_url: '' });
resetForm();
onSaved(panel);
} catch (e) {
setErr(e.message || '保存失败');
@@ -176,6 +200,53 @@ export default function AddPanelDialog({ open, onClose, categories = [], onSaved
margin="dense"
placeholder="留空则使用上面的链接"
/>
{/* 代理新字段:信任模式 / 缩放 / 权限委托 */}
<Box sx={{ mt: 2, pt: 1.5, borderTop: 1, borderColor: 'divider' }}>
<Typography variant="caption" sx={{ display: 'block', mb: 0.5, color: 'text.secondary' }}>
信任模式影响 iframe 沙箱
</Typography>
<ToggleButtonGroup
exclusive
fullWidth
size="small"
value={form.trusted ? 'trusted' : 'safe'}
onChange={(e, v) => { if (v) setForm((p) => ({ ...p, trusted: v === 'trusted' })); }}
aria-label="信任模式"
>
<ToggleButton value="trusted" sx={{ py: 0.75 }}>可信保留登录态</ToggleButton>
<ToggleButton value="safe" sx={{ py: 0.75 }}>安全隔离沙箱</ToggleButton>
</ToggleButtonGroup>
<Typography variant="caption" sx={{ display: 'block', mt: 0.5, color: 'text.secondary' }}>
{form.trusted
? '可信:iframe 保留 allow-same-origin,目标站 cookie 登录态可用'
: '安全:剥离 allow-same-originopaque origin),被代理页无法访问本站'}
</Typography>
<TextField
fullWidth
label="缩放(0.13,默认 1"
type="number"
value={form.scale}
onChange={set('scale')}
margin="dense"
inputProps={{ min: 0.1, max: 3, step: 0.1 }}
helperText="小于 1 缩小(看得更多),大于 1 放大(看得更清)"
/>
<Autocomplete
multiple
fullWidth
size="small"
options={PERMISSION_OPTIONS}
getOptionLabel={(o) => o.label}
value={PERMISSION_OPTIONS.filter((o) => form.permissions.includes(o.value))}
onChange={(e, v) => setForm((p) => ({ ...p, permissions: v.map((o) => o.value) }))}
renderInput={(params) => (
<TextField {...params} label="委托权限(可选)" margin="dense" placeholder="选择 iframe 可调用的设备权限" />
)}
/>
</Box>
</Box>
)}
@@ -1,4 +1,4 @@
import React, { useEffect, useRef, useState } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import Box from '@mui/material/Box';
import CircularProgress from '@mui/material/CircularProgress';
import Typography from '@mui/material/Typography';
@@ -17,21 +17,33 @@ import { getToken } from '../../../api/client.js';
* - 懒加载:首次激活才创建 iframe DOM,之后 display:none 保留状态
* - 超时 + load 双保险:加载中显示覆盖层;超时(X-Frame-Options
* 拒绝 / 目标无响应)显示失败提示 + 「在新标签页打开」回退
* - proxy 模式use_proxy=1 或 HTTPS 页内嵌 HTTP 目标
* /api/proxy/fetch?url=&token=proxy 剥 X-Frame-Options/CSP)。
* token 用 /api/proxy/token 签发的 5 分钟短 TTL JWT,避免把 7 天
* 主 token 暴露在 iframe URL 中(模块级缓存,4 分钟刷新一次)。
* - 站内 URL(/ 开头)与 proxy 模式均为本站同源伺服内容:沙箱剥离 allow-same-origin
* opaque origin),防其访问父页面 DOM / localStorage / 以本站身份发请求;
* 跨源面板(src 直指外部域)保留 allow-same-origin(多数站点依赖 cookie 登录态)。
* - 代理模式use_proxy=1 或 HTTPS 页内嵌 HTTP 目标):
* /proxy/{slug}/ 前缀代理(后端 HTML 改写 + shim 注入 + WebSocket
* + 全方法透传)。鉴权由后端处理:首次加载用 Authorization header
* 预热一次(触发下发 HttpOnly 的 rwp_{slug} 面板 cookiePath 限定
* /proxy/{slug}/12h 有效),iframe src 本身不带任何 token
* 被代理页脚本读不到凭据。旧面板无 slug 时回退 /api/proxy/fetch
* 单 URL 透传(保留兼容,短 TTL token 5 分钟)。
* - 信任模型(admin_links.trusted)→ sandbox
* trusted=1(默认):保留 allow-same-origin(保目标站 cookie 登录态),
* 追加 allow-modals / allow-downloads
* trusted=0:剥离 allow-same-originopaque origin 安全模式)。
* 站内 URL(/ 开头)直嵌一律剥离 allow-same-origin——本站同源伺服,
* 保留即等同 sandbox 逃逸,与信任开关无关。
* - permissions 委托:面板配置的权限数组 → iframe allow 属性
* - scale 缩放:外盒 1/scale + transform: scale 实现任意缩放(0.1-3
* - LRU 由父级 Workbench 控制挂载/卸载(此组件不自行卸载)
* ============================================================ */
const LOAD_TIMEOUT_MS = 15000;
const TOKEN_TTL_MS = 4 * 60 * 1000; // 缓存 4 分钟(短 token 5 分钟有效)
const HINT_TTL_MS = 5000; // 加载成功后的「空白?新标签打开」提示条 5 秒自动消失
const HINT_TTL_MS = 5000; // 加载成功后的「空白?新标签打开」提示条 5 秒自动消失
const WARM_TTL_MS = 10 * 60 * 60 * 1000; // 面板 cookie 12h 有效,预热缓存 10h 内复用
const TOKEN_TTL_MS = 4 * 60 * 1000; // 旧 /fetch 兼容:短 token 缓存 4 分钟
/* 短 TTL proxy token 的模块级缓存:多个 PanelFrame 共享,避免每帧都请求 */
/* 面板预热缓存:slug -> { p, at }(并发去重 + 定时过期) */
const warmCache = new Map();
/* 短 TTL proxy token 的模块级缓存(仅旧 /api/proxy/fetch 兼容回退使用) */
let proxyTokenCache = { promise: null, expiresAt: 0 };
function fetchProxyToken() {
@@ -54,6 +66,36 @@ function fetchProxyToken() {
return p;
}
/* 预热 /proxy/{slug}/:带主 token 的 fetch 触发后端下发 rwp_{slug} 面板 cookie
* (HttpOnly,面板脚本不可读),之后 iframe 同源加载自动携带该 cookie 鉴权。 */
function warmProxy(slug) {
const now = Date.now();
const hit = warmCache.get(slug);
if (hit && now - hit.at < WARM_TTL_MS) return hit.p;
const p = fetch('/proxy/' + slug + '/', {
headers: { Authorization: 'Bearer ' + (getToken() || '') },
})
.then((res) => {
if (!res.ok) throw new Error('面板代理鉴权失败');
return true;
})
.catch((e) => { warmCache.delete(slug); throw e; });
warmCache.set(slug, { p, at: now });
return p;
}
/* permissions 字段:后端存 JSON 数组字符串,兼容已解析数组 */
function parsePermissions(raw) {
if (Array.isArray(raw)) return raw.map(String).filter(Boolean);
if (typeof raw === 'string' && raw.trim()) {
try {
const a = JSON.parse(raw);
return Array.isArray(a) ? a.map(String).filter(Boolean) : [];
} catch { return []; }
}
return [];
}
export default function PanelFrame({ panel, active = false, refreshNonce = 0 }) {
const [mounted, setMounted] = useState(active);
const [status, setStatus] = useState('loading'); // loading | loaded | timeout
@@ -67,37 +109,65 @@ export default function PanelFrame({ panel, active = false, refreshNonce = 0 })
const targetUrl = panel.embed_url || panel.url || '';
const isHttpsPage = typeof window !== 'undefined' && window.location.protocol === 'https:';
const needsProxy = !!panel.use_proxy || (isHttpsPage && targetUrl.startsWith('http:'));
// 站内 URL(/ 开头)与 proxy 模式(外部 HTML 经 /api/proxy/fetch 在本站同源伺服):
// 沙箱均剥离 allow-same-origin → 内容变为 opaque origin,无法访问父页面 DOM / localStorage /
// 以本站身份发请求 / 开本站 WebSocket(H2 修复,阻断被代理页偷 JWT、冒充 admin、开 /ws/terminal)。
// 权衡:proxy 目标若依赖 cookie/登录态会受影响(部分面板如此),安全优先。
// 跨源直嵌(src 直接指向外部域)才保留 allow-same-origin:此时 iframe 与父页面本就不同源,
// 不构成同源逃逸,且多数外部面板依赖 cookie 登录态。
const isInternal = targetUrl.startsWith('/');
const stripSameOrigin = isInternal || needsProxy;
// slug:后端自动生成(小写 [a-z0-9-],≤32)。旧库行可能为空 → 回退旧 /fetch
const slug = String(panel.slug || '').toLowerCase();
const hasSlug = /^[a-z0-9-]{1,32}$/.test(slug);
const proxyPrefix = hasSlug ? '/proxy/' + slug + '/' : '';
// 信任模型:admin_links.trusted1=可信,0=安全)。缺失按可信处理
const trusted = !(panel.trusted === 0 || panel.trusted === '0');
// ── sandbox 按信任模型配置 ────────────────────────────────
// 代理模式:trusted=1 保 allow-same-origin(保目标站 cookie 登录态);
// trusted=0 剥离(opaque origin,被代理页无法以本站身份发请求/读 DOM)
// 站内直嵌(/ 开头,本站同源伺服):一律剥离(同源逃逸风险,与信任无关)
// 外部直嵌:跨源天然隔离,trusted=1 保 allow-same-origin(站点 cookie 依赖)
const stripSameOrigin = needsProxy
? !trusted
: (targetUrl.startsWith('/') || !trusted);
const sandbox = stripSameOrigin
? 'allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox'
: 'allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox';
: 'allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox allow-modals allow-downloads';
// ── permissions 委托:默认能力 + 面板配置的权限 → allow 属性 ──
const baseAllow = 'fullscreen; accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture';
const delegated = parsePermissions(panel.permissions);
const allowAttr = delegated.length ? [baseAllow, ...delegated].join('; ') : baseAllow;
// ── scale 缩放(0.1-3,默认 1):1/scale 外盒 + transform scale ──
const scale = (() => {
const s = parseFloat(panel.scale);
return Number.isFinite(s) ? Math.min(3, Math.max(0.1, s)) : 1;
})();
// 代理模式启动:预热(slug 模式)或取短 token(旧模式),成功后就绪 src。
// alive 守卫避免卸载后 setState;预热失败进入超时回退态。
const bootProxy = useCallback((alive = () => true) => {
if (!needsProxy) { setProxySrc(targetUrl); return; }
setProxySrc('');
if (hasSlug) {
warmProxy(slug)
.then(() => { if (alive()) setProxySrc(proxyPrefix); })
.catch(() => {
if (alive()) { statusRef.current = 'timeout'; setStatus('timeout'); }
});
} else {
fetchProxyToken()
.then((tok) => {
if (alive()) setProxySrc('/api/proxy/fetch?url=' + encodeURIComponent(targetUrl) + '&token=' + encodeURIComponent(tok));
})
.catch(() => {
if (alive()) { statusRef.current = 'timeout'; setStatus('timeout'); }
});
}
}, [needsProxy, hasSlug, slug, proxyPrefix, targetUrl]);
// proxy 模式:先取 5 分钟短 TTL token 再拼 iframe URL(失败进入超时回退态)
useEffect(() => {
let alive = true;
if (!needsProxy) { setProxySrc(targetUrl); return () => { alive = false; }; }
setProxySrc('');
fetchProxyToken()
.then((tok) => {
if (alive) {
setProxySrc('/api/proxy/fetch?url=' + encodeURIComponent(targetUrl) + '&token=' + encodeURIComponent(tok));
}
})
.catch(() => {
if (alive) {
statusRef.current = 'timeout';
setStatus('timeout');
}
});
bootProxy(() => alive);
return () => { alive = false; };
}, [needsProxy, targetUrl]);
}, [bootProxy]);
const src = needsProxy ? proxySrc : targetUrl;
@@ -126,18 +196,30 @@ export default function PanelFrame({ panel, active = false, refreshNonce = 0 })
return () => clearTimeout(timerRef.current);
}, [mounted, src]);
// 刷新信号(工具栏 ↻):仅对激活面板 reload,且重新进入加载态
// 统一刷新入口:从未就绪(预热/token 失败)时重新走启动路径;
// 已就绪则先重新预热(cookie 过期时刷新也能恢复),再 reload。
const performReload = () => {
statusRef.current = 'loading';
setStatus('loading');
setShowHint(false);
startTimer();
if (!src) { bootProxy(); return; }
const reloadNow = () => {
const iframe = iframeRef.current;
if (!iframe || !src) return;
try { iframe.contentWindow.location.reload(); }
catch { iframe.src = iframe.src; }
};
if (needsProxy && hasSlug) warmProxy(slug).then(reloadNow).catch(reloadNow);
else reloadNow();
};
// 刷新信号(工具栏 ↻):仅对激活面板执行统一刷新
const lastNonce = useRef(refreshNonce);
useEffect(() => {
if (!active || refreshNonce === lastNonce.current) return;
lastNonce.current = refreshNonce;
const iframe = iframeRef.current;
if (!iframe || !src) return;
statusRef.current = 'loading';
setStatus('loading');
startTimer();
try { iframe.contentWindow.location.reload(); }
catch { iframe.src = iframe.src; }
performReload();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [refreshNonce]);
@@ -155,13 +237,7 @@ export default function PanelFrame({ panel, active = false, refreshNonce = 0 })
};
const handleRetry = () => {
const iframe = iframeRef.current;
if (!iframe || !src) return;
statusRef.current = 'loading';
setStatus('loading');
startTimer();
try { iframe.contentWindow.location.reload(); }
catch { iframe.src = iframe.src; }
performReload();
};
useEffect(() => () => { clearTimeout(hintTimerRef.current); }, []);
@@ -174,15 +250,25 @@ export default function PanelFrame({ panel, active = false, refreshNonce = 0 })
}}
>
{mounted && (
<iframe
ref={iframeRef}
title={panel.title || '面板'}
src={src}
onLoad={handleLoad}
sandbox={sandbox}
allow="fullscreen; accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
style={{ width: '100%', height: '100%', border: 'none', display: 'block', background: 'transparent' }}
/>
/* scale 缩放:外盒宽高取 1/scale(相对容器),transform scale(scale) 后
* 恰好铺满容器;scale=1 时恒等,无副作用 */
<Box
sx={{
position: 'absolute', top: 0, left: 0,
width: `${100 / scale}%`, height: `${100 / scale}%`,
transform: `scale(${scale})`, transformOrigin: 'top left',
}}
>
<iframe
ref={iframeRef}
title={panel.title || '面板'}
src={src}
onLoad={handleLoad}
sandbox={sandbox}
allow={allowAttr}
style={{ width: '100%', height: '100%', border: 'none', display: 'block', background: 'transparent' }}
/>
</Box>
)}
{status === 'loading' && (
@@ -7,6 +7,11 @@ import { listAdminLinks } from '../../../api/adminLinks.js';
* 管理:面板列表(admin_links)、打开集合、当前面板、历史栈(◀▶)、
* 固定(pin)、分组折叠、打开方式、侧边栏折叠、搜索。
* 打开集合 / 当前面板 / 历史栈 / 折叠态等持久化到 localStoragekey: workbench.*)。
*
* 面板对象直接来自后端 admin_links 全行(SELECT *),已含代理新字段:
* slug(唯一标识)/ trusted(信任模型 0|1/ permissionsJSON 数组字符串)/
* scale(缩放 0.1-3)。加载与 panelOverride 场景统一经 normalizePanel 归一化,
* 兜底旧库行缺失字段,保证 PanelFrame 拿到的字段类型稳定。
* ============================================================ */
export const OPEN_MODE_EMBED = 'embed'; // 内嵌 iframe
@@ -33,6 +38,20 @@ function write(key, value) {
try { localStorage.setItem(key, JSON.stringify(value)); } catch { /* 隐私模式等场景忽略 */ }
}
/* 面板字段归一化:旧库行可能缺代理新字段,统一兜底默认值
* slug 空串、trusted 默认 1、permissions 原样保留、scale 默认 1 */
function normalizePanel(p) {
if (!p || typeof p !== 'object') return p;
const scale = parseFloat(p.scale);
return {
...p,
slug: String(p.slug || ''),
trusted: p.trusted === 0 || p.trusted === '0' ? 0 : 1,
permissions: p.permissions || '',
scale: Number.isFinite(scale) ? Math.min(3, Math.max(0.1, scale)) : 1,
};
}
export default function usePanels() {
const [links, setLinks] = useState(null); // null = 加载中
const [loadError, setLoadError] = useState('');
@@ -82,7 +101,7 @@ export default function usePanels() {
setLinks(null);
return listAdminLinks()
.then((ls) => {
const arr = Array.isArray(ls) ? ls : [];
const arr = Array.isArray(ls) ? ls.map(normalizePanel) : [];
setLinks(arr);
const valid = new Set(arr.map((p) => String(p.id)));
// String 归一化后再过滤,防止存量数字/字符串混杂 id 绕过校验
@@ -118,7 +137,7 @@ export default function usePanels() {
// panelOverride:新建面板保存后(links 尚未包含)时直接传入对象
const openPanel = useCallback((id, panelOverride) => {
id = String(id); // 统一字符串,避免数字/字符串双份加入 openIds
const panel = panelOverride || (links && links.find((p) => String(p.id) === id));
const panel = normalizePanel(panelOverride || (links && links.find((p) => String(p.id) === id)));
if (!panel) return false;
touch(id);
const mode = modes[id] || OPEN_MODE_EMBED;