Files
console-dev/app/components/instance/RemoteConsole.vue
T

942 lines
36 KiB
Vue

<script>
/**
* 同源远程控制台:父级提供实例、模块及操作密码,通过 update:visible 关闭面板。
* 浏览器只向 /remote-console/sessions 申请短期会话,再以服务端返回的同源路径连接 noVNC。
* 这里不直连供应商 VNC 地址;部署时 HTTP 与 WebSocket 路径都需要网关反向代理。
*/
import "~/assets/styles/RemoteConsole.DLmX5UZu.css";
import { toDisplayString } from "vue";
import { onMounted } from "vue";
import { useId } from "vue";
import { normalizeClass } from "vue";
import { onBeforeUnmount } from "vue";
import { watch } from "vue";
import { createBlock } from "vue";
import { ref } from "vue";
import { createCommentVNode } from "vue";
import { computed } from "vue";
import { isRef } from "vue";
import { createElementBlock } from "vue";
import { Fragment } from "vue";
import { createElementVNode } from "vue";
import { createTextVNode } from "vue";
import { unref } from "vue";
import { defineComponent } from "vue";
import { createVNode } from "vue";
import { openBlock } from "vue";
import { withModifiers } from "vue";
import { loadModule } from "~/utils/load-module.js";
import { useAuth } from "~/lib/auth.js";
import button from "primevue/button";
import { t as _plugin_vue_export_helper_t } from "~/lib/_plugin-vue_export-helper.js";
import inputtext from "primevue/inputtext";
var T = /^[a-f0-9]{64}$/;
function E(e, n) {
// 独立连接状态机;d 是连接代次,h 保存需要删除的网关会话,g 是当前 RFB 客户端。
let r = useAuth(),
o = ref(`idle`),
c = ref(``),
l = ref(!0),
u = ref([]),
d = 0,
f = !1,
p = null,
m = null,
h = null,
g = null,
_ = [];
function v() {
(m !== null && clearTimeout(m), (m = null));
}
async function y(e, t) {
// 使用创建会话时的 token 做尽力释放;keepalive 允许页面离开时仍发送清理请求。
if (T.test(e))
try {
await fetch(`/remote-console/sessions/${e}`, {
method: `DELETE`,
headers: {
Authorization: `Bearer ${t}`,
},
credentials: `same-origin`,
cache: `no-store`,
keepalive: !0,
});
} catch {}
}
function S() {
// 统一释放入口先推进代次,再中止请求、断开 RFB、移除事件并删除远端会话。
if ((d++, p?.abort(), (p = null), v(), g)) {
for (let [e, t] of _) g.removeEventListener(e, t);
try {
g.disconnect();
} catch {}
g = null;
}
if (((_ = []), (u.value = []), h)) {
let e = h;
((h = null), y(e.id, e.token));
}
}
function C() {
(S(), (c.value = ``), (o.value = `disconnected`));
}
function w(e) {
(S(), (c.value = e), (o.value = `error`));
}
function E(e) {
// 建立连接或提交凭据后均有 45 秒上限,旧连接定时器不能终止新的连接。
(v(),
(m = setTimeout(() => {
e === d && !f && w(`连接超时,请重新连接。`);
}, 45e3)));
}
function D(e, t) {
// 仅展示有限长度的中文网关错误,避免把供应商地址、凭据或内部异常原样暴露。
if (t === 401) return `登录已失效,请重新登录后打开控制台。`;
if (t === 403) return `暂时无法访问此实例的控制台,请核对账号后重试。`;
let n = e && typeof e == `object` && `error` in e ? e.error : null;
return typeof n == `string` &&
/^[\u3400-\u9fff\s,。!?、:()…0-9-]{1,100}$/u.test(n)
? n
: `控制台暂时无法连接,请稍后重试。`;
}
async function O() {
// 登录状态、实例、模块和挂载节点共同构成连接身份;异步阶段每次回写前都重新核对。
if ((S(), (c.value = ``), f || !n.visible() || !e.value)) {
o.value = `idle`;
return;
}
let t = localStorage.getItem(`jwt`);
if (!t || r.token !== t) {
((o.value = `error`), (c.value = `请重新登录后打开控制台。`));
return;
}
let i = String(n.hostId()),
a = n.module(),
s = e.value;
if (!/^\d+$/.test(i) || Number(i) < 1 || !a) {
((o.value = `error`), (c.value = `实例信息尚未就绪,请重新打开控制台。`));
return;
}
let m = d,
x = () =>
m === d &&
!f &&
n.visible() &&
e.value === s &&
String(n.hostId()) === i &&
n.module() === a &&
r.token === t &&
localStorage.getItem(`jwt`) === t;
((o.value = `creating`), (p = new AbortController()));
let C = p.signal;
E(m);
try {
let e = await fetch(`/remote-console/sessions`, {
method: `POST`,
credentials: `same-origin`,
cache: `no-store`,
signal: C,
headers: {
Authorization: `Bearer ${t}`,
"Content-Type": `application/json`,
},
body: JSON.stringify({
hostId: i,
module: a,
...(n.operatePassword()
? {
client_operate_password: n.operatePassword(),
}
: {}),
}),
}),
r = await e.json().catch(() => null),
d =
typeof r?.sessionId == `string` && T.test(r.sessionId)
? r.sessionId
: ``;
if (!x()) {
// 创建成功但用户已关闭或切换实例时,仍要回收刚生成的服务端会话。
d && y(d, t);
return;
}
if (!e.ok) {
(d && y(d, t), w(D(r, e.status)));
return;
}
if (
(d &&
(h = {
id: d,
token: t,
}),
!d || r?.websocketPath !== `/remote-console/sessions/${d}/socket`)
) {
w(`控制台连接信息无效,请重新连接。`);
return;
}
let { default: f } = await loadModule(
// 只在实际连接时加载 noVNC,避免把远控依赖放进所有页面的首屏执行路径。
async () => {
let { default: e } = await import("@novnc/novnc");
return {
default: e,
};
},
[],
import.meta.url,
);
if (!x()) return;
o.value = `connecting`;
let p = new f(
s,
`${window.location.protocol === `https:` ? `wss:` : `ws:`}//${window.location.host}${r.websocketPath}`,
{
...(typeof r.credentials?.password == `string`
? {
credentials: {
password: r.credentials.password,
},
}
: {}),
},
);
((g = p), (p.scaleViewport = l.value), (p.resizeSession = !1));
let m = (e, t) => {
(p.addEventListener(e, t), _.push([e, t]));
};
(m(`connect`, () => {
x() && (v(), (u.value = []), (o.value = `connected`));
}),
m(`disconnect`, () => {
if (!x()) return;
let e = o.value === `connected`;
(S(),
(o.value = e ? `disconnected` : `error`),
(c.value = e ? `` : `连接未能建立,请重新连接。`));
}),
m(`credentialsrequired`, (e) => {
if (!x()) return;
v();
let t = e.detail?.types;
if (
((u.value = Array.isArray(t)
? t.filter((e) => [`username`, `password`, `target`].includes(e))
: [`password`]),
!u.value.length)
) {
w(`此控制台暂不支持当前验证方式,请联系支持。`);
return;
}
o.value = `credentials`;
}),
m(`securityfailure`, () => {
x() && w(`控制台验证失败,请重新连接。`);
}));
} catch {
x() && w(`控制台暂时无法连接,请稍后重试。`);
}
}
function k(e) {
// 只提交服务器明确要求的完整凭据;密码仅保留当前面板内存,不写入持久化存储。
if (!g || o.value !== `credentials` || u.value.some((t) => !e[t]))
return !1;
try {
return (
g.sendCredentials(e),
(o.value = `connecting`),
(u.value = []),
E(d),
!0
);
} catch {
return (w(`控制台验证失败,请重新连接。`), !1);
}
}
function A() {
if (o.value === `connected` && g)
try {
(g.sendCtrlAltDel(), g.focus());
} catch {
w(`连接已中断,请重新连接。`);
}
}
(watch(l, (e) => {
g && (g.scaleViewport = e);
}),
watch(
[n.visible, n.hostId, n.module, e],
() => {
n.visible() && e.value
? O()
: (S(), (o.value = `idle`), (c.value = ``));
},
{
flush: `post`,
immediate: !0,
},
),
watch(
() => r.token,
() => {
(S(), (o.value = `idle`), n.visible() && n.close());
},
{
flush: `sync`,
},
));
function j(e) {
// 其他标签页退出或更换账号时立即断开,不能等用户下一次远控操作才识别身份变化。
(e.key === `jwt` || e.key === null) &&
n.visible() &&
(S(), (o.value = `idle`), n.close());
}
return (
onMounted(() => window.addEventListener(`storage`, j)),
onBeforeUnmount(() => {
((f = !0), S(), window.removeEventListener(`storage`, j));
}),
{
state: o,
error: c,
fit: l,
credentialTypes: u,
connect: O,
disconnect: C,
submitCredentials: k,
sendCtrlAltDel: A,
}
);
}
var D = [`aria-labelledby`],
O = {
class: `console-header`,
},
k = {
class: `console-heading`,
},
A = [`id`],
j = {
class: `host-id`,
},
M = {
key: 0,
class: `console-target`,
},
ee = {
class: `console-toolbar`,
role: `toolbar`,
"aria-label": `远程控制台操作`,
},
te = {
class: `toolbar-group`,
},
ne = {
class: `toolbar-group`,
},
re = {
key: 0,
class: `toolbar-message`,
role: `status`,
},
ie = [`aria-busy`],
ae = [`aria-label`],
oe = {
key: 0,
class: `console-overlay`,
},
se = [`for`],
ce = [`for`],
N = [`for`],
P = {
key: 3,
class: `credential-error`,
role: `alert`,
},
F = {
key: 1,
class: `connection-message`,
role: `status`,
"aria-live": `polite`,
},
I = Object.assign(
_plugin_vue_export_helper_t(
defineComponent({
__name: `RemoteConsole`,
props: {
visible: {
type: Boolean,
},
hostId: {},
module: {},
hostName: {},
operatePassword: {},
},
emits: [`update:visible`],
setup(g, { emit: b }) {
// 面板状态负责凭据表单、全屏与显示文案,连接生命周期由上面的状态机统一管理。
let x = g,
C = b,
T = useId(),
I = ref(null),
L = ref(null),
R = ref(!1),
z = ref(``),
B = ref(``),
V = ref(``),
H = ref(``),
U = ref(``),
{
state: W,
error: le,
fit: G,
credentialTypes: K,
connect: q,
disconnect: J,
submitCredentials: ue,
sendCtrlAltDel: de,
} = E(I, {
visible: () => x.visible,
hostId: () => x.hostId,
module: () => x.module,
operatePassword: () => x.operatePassword,
close: () => C(`update:visible`, !1),
}),
Y = computed(
() => W.value === `creating` || W.value === `connecting`,
),
X = computed(
() =>
({
idle: `准备连接`,
creating: `正在建立会话`,
connecting: `正在连接`,
credentials: `等待验证`,
connected: `已连接`,
disconnected: `已断开`,
error: `连接失败`,
})[W.value],
),
Z = computed(() => x.hostName?.trim() || `服务器实例`);
function Q() {
// 状态或可见性变化就清除输入,避免旧会话的凭据留在重新打开的表单中。
((z.value = ``), (B.value = ``), (V.value = ``), (H.value = ``));
}
watch([W, () => x.visible], Q);
function fe() {
((H.value = ``),
ue({
password: z.value,
username: B.value,
target: V.value,
})
? Q()
: W.value === `credentials` &&
(H.value = `请填写所需的验证信息。`));
}
function pe() {
(J(), Q(), C(`update:visible`, !1));
}
async function me() {
// 全屏是浏览器交互能力;失败只显示提示,不影响现有远程连接。
U.value = ``;
try {
document.fullscreenElement === L.value
? await document.exitFullscreen()
: L.value?.requestFullscreen
? await L.value.requestFullscreen()
: (U.value = `当前浏览器不支持全屏,可使用浏览器缩放调整显示。`);
} catch {
U.value = `未能进入全屏,请重试。`;
}
}
function $() {
R.value = !!L.value && document.fullscreenElement === L.value;
}
return (
onMounted(() => document.addEventListener(`fullscreenchange`, $)),
onBeforeUnmount(() =>
document.removeEventListener(`fullscreenchange`, $),
),
(t, n) => {
let i = button,
a = inputtext;
return g.visible
? (openBlock(),
createElementBlock(
`section`,
{
key: 0,
ref_key: `surface`,
ref: L,
class: normalizeClass([
`remote-console`,
{
"is-fullscreen": unref(R),
},
]),
"aria-labelledby": `${unref(T)}-title`,
},
[
createElementVNode(`header`, O, [
(n[7] ||= createElementVNode(
`span`,
{
class: `console-mark`,
"aria-hidden": `true`,
},
[
createElementVNode(`i`, {
class: `pi pi-desktop`,
}),
],
-1,
)),
createElementVNode(`div`, k, [
(n[5] ||= createElementVNode(
`p`,
{
class: `console-brand`,
},
`破碎工坊云`,
-1,
)),
createElementVNode(
`h1`,
{
id: `${unref(T)}-title`,
},
[
(n[4] ||= createTextVNode(`远程控制台`, -1)),
createElementVNode(
`span`,
j,
`#` + toDisplayString(g.hostId),
1,
),
],
8,
A,
),
g.hostName
? (openBlock(),
createElementBlock(
`p`,
M,
toDisplayString(unref(Z)),
1,
))
: createCommentVNode(``, !0),
]),
createElementVNode(
`span`,
{
class: normalizeClass([
`connection-state`,
`state-${unref(W)}`,
]),
role: `status`,
"aria-live": `polite`,
},
[
(n[6] ||= createElementVNode(
`span`,
{
class: `state-dot`,
"aria-hidden": `true`,
},
null,
-1,
)),
createTextVNode(toDisplayString(unref(X)), 1),
],
2,
),
createVNode(i, {
icon: `pi pi-times`,
"aria-label": `关闭远程控制台`,
text: ``,
severity: `secondary`,
rounded: ``,
onClick: pe,
}),
]),
createElementVNode(`div`, ee, [
createElementVNode(`div`, te, [
createVNode(
i,
{
label: `适应窗口`,
icon: `pi pi-expand`,
outlined: !unref(G),
severity: unref(G) ? `primary` : `secondary`,
size: `small`,
"aria-pressed": unref(G),
onClick: (n[0] ||= (e) => (G.value = !unref(G))),
},
null,
8,
[`outlined`, `severity`, `aria-pressed`],
),
createVNode(
i,
{
label: unref(R) ? `退出全屏` : `全屏`,
icon: unref(R)
? `pi pi-window-minimize`
: `pi pi-window-maximize`,
outlined: ``,
severity: `secondary`,
size: `small`,
onClick: me,
},
null,
8,
[`label`, `icon`],
),
createVNode(
i,
{
label: `Ctrl + Alt + Del`,
outlined: ``,
severity: `secondary`,
size: `small`,
disabled: unref(W) !== `connected`,
onClick: unref(de),
},
null,
8,
[`disabled`, `onClick`],
),
]),
createElementVNode(`div`, ne, [
createVNode(
i,
{
label: `重新连接`,
icon: `pi pi-refresh`,
outlined: ``,
severity: `secondary`,
size: `small`,
disabled: unref(Y),
onClick: unref(q),
},
null,
8,
[`disabled`, `onClick`],
),
createVNode(
i,
{
label: `断开`,
icon: `pi pi-power-off`,
text: ``,
severity: `secondary`,
size: `small`,
disabled:
unref(W) === `idle` ||
unref(W) === `disconnected` ||
unref(W) === `error`,
onClick: unref(J),
},
null,
8,
[`disabled`, `onClick`],
),
]),
]),
unref(U)
? (openBlock(),
createElementBlock(
`p`,
re,
toDisplayString(unref(U)),
1,
))
: createCommentVNode(``, !0),
createElementVNode(
`div`,
{
class: `console-display`,
"aria-busy": unref(Y),
},
[
createElementVNode(
`div`,
{
ref_key: `screen`,
ref: I,
class: `console-screen`,
"aria-label": `${unref(Z)} 的远程屏幕`,
},
null,
8,
ae,
),
unref(W) === `connected`
? createCommentVNode(``, !0)
: (openBlock(),
createElementBlock(`div`, oe, [
unref(W) === `credentials`
? (openBlock(),
createElementBlock(
`form`,
{
key: 0,
class: `credential-form`,
onSubmit: withModifiers(fe, [
`prevent`,
]),
},
[
(n[8] ||= createElementVNode(
`i`,
{
class: `pi pi-lock state-icon`,
"aria-hidden": `true`,
},
null,
-1,
)),
(n[9] ||= createElementVNode(
`h3`,
null,
`验证控制台连接`,
-1,
)),
(n[10] ||= createElementVNode(
`p`,
null,
`请输入此控制台要求的连接凭据。`,
-1,
)),
unref(K).includes(`username`)
? (openBlock(),
createElementBlock(
Fragment,
{
key: 0,
},
[
createElementVNode(
`label`,
{
for: `${unref(T)}-username`,
},
`用户名`,
8,
se,
),
createVNode(
a,
{
id: `${unref(T)}-username`,
modelValue: unref(B),
"onUpdate:modelValue":
(n[1] ||= (e) =>
isRef(B)
? (B.value = e)
: null),
autocomplete: `off`,
fluid: ``,
},
null,
8,
[`id`, `modelValue`],
),
],
64,
))
: createCommentVNode(``, !0),
unref(K).includes(`target`)
? (openBlock(),
createElementBlock(
Fragment,
{
key: 1,
},
[
createElementVNode(
`label`,
{
for: `${unref(T)}-target`,
},
`目标`,
8,
ce,
),
createVNode(
a,
{
id: `${unref(T)}-target`,
modelValue: unref(V),
"onUpdate:modelValue":
(n[2] ||= (e) =>
isRef(V)
? (V.value = e)
: null),
autocomplete: `off`,
fluid: ``,
},
null,
8,
[`id`, `modelValue`],
),
],
64,
))
: createCommentVNode(``, !0),
unref(K).includes(`password`)
? (openBlock(),
createElementBlock(
Fragment,
{
key: 2,
},
[
createElementVNode(
`label`,
{
for: `${unref(T)}-password`,
},
`控制台密码`,
8,
N,
),
createVNode(
a,
{
id: `${unref(T)}-password`,
modelValue: unref(z),
"onUpdate:modelValue":
(n[3] ||= (e) =>
isRef(z)
? (z.value = e)
: null),
type: `password`,
autocomplete: `off`,
fluid: ``,
},
null,
8,
[`id`, `modelValue`],
),
],
64,
))
: createCommentVNode(``, !0),
unref(H)
? (openBlock(),
createElementBlock(
`p`,
P,
toDisplayString(unref(H)),
1,
))
: createCommentVNode(``, !0),
createVNode(i, {
type: `submit`,
label: `连接`,
fluid: ``,
}),
],
32,
))
: (openBlock(),
createElementBlock(`div`, F, [
createElementVNode(
`i`,
{
class: normalizeClass([
unref(Y)
? `pi pi-spinner pi-spin`
: unref(W) === `error`
? `pi pi-exclamation-circle`
: `pi pi-desktop`,
`state-icon`,
]),
"aria-hidden": `true`,
},
null,
2,
),
createElementVNode(
`h3`,
null,
toDisplayString(unref(X)),
1,
),
createElementVNode(
`p`,
null,
toDisplayString(
unref(le) ||
(unref(Y)
? `连接启动可能需要一些时间,请稍候。`
: unref(W) === `disconnected`
? `远程会话已结束,实例继续运行。`
: `正在准备您的远程屏幕。`),
),
1,
),
unref(W) === `error` ||
unref(W) === `disconnected`
? (openBlock(),
createBlock(
i,
{
key: 0,
label: `重新连接`,
icon: `pi pi-refresh`,
onClick: unref(q),
},
null,
8,
[`onClick`],
))
: createCommentVNode(``, !0),
])),
])),
],
8,
ie,
),
(n[11] ||= createElementVNode(
`footer`,
{
class: `console-footer`,
},
[
createElementVNode(
`span`,
null,
`点击远程屏幕后使用键盘与鼠标操作。`,
),
createElementVNode(
`span`,
null,
`关闭控制台不会关闭实例`,
),
],
-1,
)),
],
10,
D,
))
: createCommentVNode(``, !0);
}
);
},
}),
[[`__scopeId`, `data-v-e696489e`]],
),
{
__name: `RemoteConsole`,
},
);
export {};
export default I;
</script>