You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

940 lines
27 KiB
Vue

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<script setup lang="ts">
import { Close, Lock, Minus, User } from "@element-plus/icons-vue";
import { ElDialog, ElForm, ElMessage, ElProgress } from "element-plus";
// 报错统一走 host/dialog 原生 message勿用 ElMessage.error
import { getVersion } from "@tauri-apps/api/app";
import { listen } from "@tauri-apps/api/event";
import { invoke } from "@tauri-apps/api/tauri";
import { computed, onMounted, onUnmounted, reactive, ref } from "vue";
import { api } from "../api";
import { confirmNative, showErrorNative, showInfoNative, showWarningNative } from "../host/dialog";
import { getAllConfig, mergeConfig } from "../host/config";
import { log } from "../host/logger";
import { setSession } from "../host/session";
import type { SessionState } from "../host/types";
import {
closeWindow,
minimizeWindow,
openMainWindow,
startWindowDragging,
} from "../host/window";
/** el-form 需要稳定引用,勿使用每次渲染新建的 `{ username, password }` 对象 */
const loginForm = reactive({
username: "admin",
password: "admin",
});
const loginType = ref("NSFW");
const isLoading = ref(false);
const formRef = ref<InstanceType<typeof ElForm>>();
const isLoginSuccessed = ref(false);
const selectedWin = ref("");
const cachedWinKey = ref("");
const options = ref<Array<{ label: string; value: string }>>([]);
const appVersion = ref("0.1.0");
const checkingUpdate = ref(false);
const ZYYUN_APT_SETUP_PROGRESS_EVENT = "zyyun-apt-setup-progress";
const aptSetupDialogVisible = ref(false);
const aptSetupPercent = ref(0);
const aptSetupHint = ref("");
const aptSetupFinished = ref(false);
const aptSetupProgressStatus = ref<"success" | "exception" | undefined>(undefined);
/** 默认紫云 apt 源行;可通过配置项 apt_source_deb_line 覆盖(见 ~/.config/com.ziyun.callclient/config.json */
const DEFAULT_APT_SOURCE_DEB_LINE =
"deb [arch=amd64 signed-by=/usr/share/keyrings/zyyun-archive-keyring.gpg] http://80.12.140.29:80/apt v10 main";
const aptSourceDebLine = ref(DEFAULT_APT_SOURCE_DEB_LINE);
interface AptUpdateCheckResult {
packageName: string;
currentVersion: string;
installedVersion: string;
candidateVersion: string;
hasUpdate: boolean;
sourceAvailable: boolean;
updateCommand: string;
}
interface AptSetupProgressPayload {
percent: number;
message: string;
}
let sessionState: SessionState = {
empUid: null,
winUid: null,
queueToken: null,
refreshToken: null,
};
const rules = {
username: [{ required: true, message: "请输入用户名", trigger: "blur" }],
password: [
{ required: true, message: "请输入密码", trigger: "blur" },
{ min: 5, message: "密码长度不能少于5位", trigger: "blur" },
],
};
const isFormValid = computed(
() => loginForm.username.trim() !== "" && loginForm.password.trim() !== "",
);
const isWindowSelected = computed(() => selectedWin.value !== "");
/**
* 在窄窗口中使用居中消息,避免提示框被裁剪。
*/
function showMessage(type: "success" | "warning", message: string): void {
ElMessage({
type,
message,
offset: 52,
grouping: true,
customClass: "narrow-window-message",
});
}
/**
* 从窗口选择返回账号登录步骤。
*/
function backToUserForm(): void {
isLoginSuccessed.value = false;
}
/**
* 处理账号登录。
*/
async function handleLogin(): Promise<void> {
if (!isFormValid.value) {
showMessage("warning", "请输入用户名和密码");
return;
}
const form = formRef.value;
if (!form) {
return;
}
isLoading.value = true;
try {
await form.validate();
} catch {
isLoading.value = false;
return;
}
try {
const loginRes = await api.user.login({
loginMode: loginType.value,
clientType: "CALLER",
username: loginForm.username,
password: loginForm.password,
hallRegNum: "",
});
if (!loginRes?.queueToken) {
await log(
"warn",
`登录失败: 接口未返回有效 queueToken, user=${loginForm.username}`,
);
await showErrorNative("登录失败:服务器未返回有效凭据,请稍后重试。", "登录");
return;
}
sessionState = {
empUid: loginRes.operatorProfile.empUid,
/** 未选窗口前不写占位数字 0避免与「无效窗口」混淆且与 MainView `<=0` 判定一致 */
winUid: null,
queueToken: loginRes.queueToken,
refreshToken: loginRes.refreshToken,
};
await setSession(sessionState);
const winList = await api.window.list();
options.value = winList.windows.map((win) => ({
label: win.windowName,
value: win.windowUid.toString(),
}));
if (
cachedWinKey.value &&
options.value.some((item) => item.value === cachedWinKey.value)
) {
selectedWin.value = cachedWinKey.value;
}
isLoginSuccessed.value = true;
await log(
"info",
`登录成功: user=${loginForm.username}, empUid=${sessionState.empUid}`,
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await log("error", `登录失败: user=${loginForm.username}, ${message}`);
await showErrorNative(message || "登录失败", "登录");
} finally {
isLoading.value = false;
}
}
/**
* 处理窗口登录确认。
*/
async function handleWindowLogin(): Promise<void> {
isLoading.value = true;
try {
const winUid = Number.parseInt(selectedWin.value, 10);
if (!Number.isFinite(winUid) || winUid <= 0) {
showMessage("warning", "请选择有效窗口");
return;
}
const selected = options.value.find(
(item) => item.value === selectedWin.value,
);
const initRes = await api.action.init({
empUid: Number(sessionState.empUid ?? -1),
windowUid: winUid,
});
const initSuccess =
((initRes as { data?: { success?: boolean } }).data?.success ??
(initRes as { success?: boolean }).success) === true;
if (!initSuccess) {
const initData = (initRes as { data?: { message?: string } }).data;
const msg =
(typeof initData?.message === "string" && initData.message.trim() !== ""
? initData.message
: null) ??
(typeof (initRes as { message?: string }).message === "string"
? (initRes as { message?: string }).message
: null) ??
"窗口初始化失败,请重试或更换窗口";
await showErrorNative(String(msg), "窗口初始化");
await log("warn", `call-terminal/init 未成功: windowUid=${winUid}`);
return;
}
await mergeConfig({
last_username: loginForm.username.trim(),
selected_win_key: selectedWin.value,
selected_win_value: selected?.label ?? "",
selected_win_uid: winUid,
});
sessionState.winUid = winUid;
await setSession(sessionState);
// 登录完成后拉取 caller-init并将返回 data 作为运行时缓存写入 session。
try {
const callerInit = await api.action.callerInit();
const callerWin = Number(callerInit.windowUid);
const resolvedWinUid =
Number.isFinite(callerWin) && callerWin > 0 ? callerWin : winUid;
const callerEmp = Number(callerInit.empUid);
const resolvedEmpUid = Number.isFinite(callerEmp) ? callerEmp : Number(sessionState.empUid ?? -1);
sessionState = {
...sessionState,
winUid: resolvedWinUid,
empUid: resolvedEmpUid,
windowName: String(callerInit.windowName ?? ""),
empName: String(callerInit.empName ?? ""),
autoCallEnabled: Boolean(callerInit.autoCallEnabled),
autoCallWaitSeconds: Number(callerInit.autoCallWaitSeconds ?? 0),
autoStartEnabled: Boolean(callerInit.autoStartEnabled),
autoStartWaitSeconds: Number(callerInit.autoStartWaitSeconds ?? 0),
callInHistoryEnabled: Boolean(callerInit.callInHistoryEnabled),
callNotifyForward: Number(callerInit.callNotifyForward ?? 0),
callNotifyMode: Number(callerInit.callNotifyMode ?? 0),
callNotifySmsUrl: String(callerInit.callNotifySmsUrl ?? ""),
callTransferEnabled: Boolean(callerInit.callTransferEnabled),
realnameCheckApi: String(callerInit.realnameCheckApi ?? ""),
rankWaitSeconds: Number(callerInit.rankWaitSeconds ?? 0),
ziyunServiceUrl: String(callerInit.ziyunServiceUrl ?? ""),
};
await setSession(sessionState);
await log(
"info",
`caller-init 缓存完成: windowUid=${sessionState.winUid}, empUid=${sessionState.empUid}`,
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await log("warn", `caller-init 调用失败,继续使用本地会话: ${message}`);
}
await openMainWindow();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await showErrorNative(message || "打开主窗口失败", "打开主窗口");
await log("error", `打开主窗口失败: ${message}`);
} finally {
isLoading.value = false;
}
}
async function handleMinimizeClick(event: MouseEvent): Promise<void> {
event.preventDefault();
event.stopPropagation();
try {
await minimizeWindow();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await showErrorNative(message || "最小化窗口失败", "最小化");
await log("error", `最小化窗口失败: ${message}`);
}
}
async function handleCloseClick(event: MouseEvent): Promise<void> {
event.preventDefault();
event.stopPropagation();
try {
await closeWindow();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await showErrorNative(message || "关闭窗口失败", "关闭窗口");
await log("error", `关闭窗口失败: ${message}`);
}
}
async function handleDragMouseDown(event: MouseEvent): Promise<void> {
if (event.button !== 0) {
return;
}
try {
await startWindowDragging();
} catch {
// 失败时仍可回退到 data-tauri-drag-region 行为。
}
}
/**
* 处理回车键触发登录。
*/
function handleKeyPress(event: KeyboardEvent): void {
if (event.key === "Enter" && isFormValid.value && !isLoginSuccessed.value) {
void handleLogin();
}
}
/**
* 聚焦时选中输入内容。
*/
function handleInputFocus(event: Event): void {
const target = event.target as HTMLInputElement;
target.select();
}
/**
* 自动写入紫云 apt 源并 apt-get updateRust 通过事件推送进度。
*/
async function runZyyunAptSourceSetupWithProgress(debLine: string): Promise<void> {
aptSetupDialogVisible.value = true;
aptSetupFinished.value = false;
aptSetupPercent.value = 0;
aptSetupHint.value = "准备中…";
aptSetupProgressStatus.value = undefined;
const unlisten = await listen<AptSetupProgressPayload>(ZYYUN_APT_SETUP_PROGRESS_EVENT, (e) => {
const p = e.payload;
aptSetupPercent.value = Math.min(100, Math.max(0, Number(p.percent) || 0));
aptSetupHint.value = typeof p.message === "string" ? p.message : "";
});
const trimmed = debLine.trim();
const debPreview =
trimmed.length > 220 ? `${trimmed.slice(0, 220)}…(共 ${trimmed.length} 字符)` : trimmed;
await log(
"info",
`[apt-setup][前端] 即将 invoke setup_zyyun_apt_source, debLen=${trimmed.length}, debPreview=${debPreview}`,
);
try {
await invoke("setup_zyyun_apt_source", { debLine: trimmed });
aptSetupProgressStatus.value = "success";
await log("info", "[apt-setup][前端] setup_zyyun_apt_source invoke 成功结束");
} catch (error) {
aptSetupProgressStatus.value = "exception";
const message = error instanceof Error ? error.message : String(error);
await log("error", `[apt-setup][前端] setup_zyyun_apt_source invoke 失败: ${message}`);
throw error;
} finally {
unlisten();
aptSetupFinished.value = true;
}
}
/**
* 检查 apt 仓库中是否有新版本;配源与升级均在应用内由固定 Rust 脚本 + pkexec 完成,不向用户暴露可手写的 shell 命令。
*/
async function handleCheckUpdate(): Promise<void> {
if (checkingUpdate.value) {
return;
}
checkingUpdate.value = true;
try {
await log(
"info",
`检查更新(前端): 开始, package=call-client, currentVersion=${appVersion.value}`,
);
const invokeCheck = (): Promise<AptUpdateCheckResult> =>
invoke<AptUpdateCheckResult>("check_apt_update", {
packageName: "call-client",
currentVersion: appVersion.value,
});
let result = await invokeCheck();
await log(
"info",
`检查更新(前端): 首次 check_apt_update 返回 installed=${result.installedVersion}, candidate=${result.candidateVersion}, sourceAvailable=${result.sourceAvailable}, hasUpdate=${result.hasUpdate}`,
);
if (!result.sourceAvailable) {
const cfg = await getAllConfig();
const alreadyConfigured = cfg.zyyun_apt_source_configured === true;
if (alreadyConfigured) {
await log(
"warn",
"检查更新: 已记录自动配置过 apt 源,但候选版本仍不可用,请人工排查网络或 GPG 密钥",
);
await showWarningNative(
"本机已自动配置过紫云软件源,但 apt 仍无法解析候选版本。请联系管理员检查内网仓库、网络或密钥文件;勿在终端自行执行来源不明的更新脚本。",
"检查更新",
);
return;
}
const doConfigure = await confirmNative({
title: "配置更新源",
message:
"未检测到可用更新源。是否使用管理员权限由本程序自动写入紫云软件源并执行 apt 更新?(将弹出 pkexec 授权,请输入管理员密码;无需在终端自行输入命令。)",
okLabel: "开始配置",
cancelLabel: "取消",
});
if (!doConfigure) {
await log("info", "检查更新(前端): 用户在「配置更新源」对话框选择取消");
await showInfoNative(
[
"未执行自动配置。稍后可再次点击「检查更新」,在提示中选择「开始配置」并输入管理员密码,",
"由本程序自动完成软件源与索引更新。",
"",
"请勿在终端自行编写或执行未经验证的更新命令;若仍无法使用,请联系管理员。",
].join("\n"),
"更新源未配置",
);
return;
}
await log("info", "检查更新(前端): 用户确认自动配置 apt 源,开始 runZyyunAptSourceSetupWithProgress");
await runZyyunAptSourceSetupWithProgress(aptSourceDebLine.value);
await mergeConfig({ zyyun_apt_source_configured: true });
await log("info", "已写入配置: zyyun_apt_source_configured=true");
result = await invokeCheck();
await log(
"info",
`检查更新(前端): 自动配置后再次 check_apt_update 返回 installed=${result.installedVersion}, candidate=${result.candidateVersion}, sourceAvailable=${result.sourceAvailable}, hasUpdate=${result.hasUpdate}`,
);
if (!result.sourceAvailable) {
await log("warn", "自动配置 apt 源后仍无法解析候选版本");
await showErrorNative(
[
"自动配置已完成,但 apt 仍无法解析候选版本。",
"请联系管理员检查内网 apt 仓库、GPG 与网络;请勿在终端自行执行未经验证的安装脚本。",
"",
"您可稍后再次点击「检查更新」重试。",
].join("\n"),
"更新源不可用",
{ logActions: "file" },
);
return;
}
}
if (!result.hasUpdate) {
await log("info", `检查更新提示: 当前已是最新版本, version=${result.installedVersion}`);
showMessage("success", `当前已是最新版本(${result.installedVersion}`);
return;
}
await log(
"info",
`检查更新提示: 检测到新版本, installed=${result.installedVersion}, candidate=${result.candidateVersion}`,
);
const doUpgrade = await confirmNative({
title: "发现新版本",
message: [
`检测到新版本:${result.candidateVersion}`,
`当前已安装:${result.installedVersion}`,
"",
"升级将由本程序在授权后固定执行(更新索引并仅升级 call-client需在弹出框中输入管理员密码。",
"请勿在终端自行编写或执行更新脚本。",
].join("\n"),
okLabel: "应用内升级",
cancelLabel: "稍后再说",
});
if (!doUpgrade) {
await log("info", "检查更新(前端): 用户选择稍后再升级");
return;
}
await log(
"info",
"检查更新(前端): 用户选择应用内 apt 升级invoke upgrade_call_client_via_apt",
);
await invoke("upgrade_call_client_via_apt");
await log("info", "检查更新(前端): upgrade_call_client_via_apt 已成功返回");
try {
appVersion.value = await getVersion();
} catch {
// 忽略版本读取失败
}
showMessage(
"success",
`升级已执行。界面显示版本:${appVersion.value}。若仍为旧版本,请重启本客户端后再试。`,
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await showErrorNative(`检查更新失败:${message}`, "检查更新", {
logActions: "file",
});
await log("error", `检查更新失败: ${message}`);
} finally {
checkingUpdate.value = false;
}
}
onMounted(async () => {
try {
appVersion.value = await getVersion();
} catch {
appVersion.value = "0.1.0";
}
const config = await getAllConfig();
if (
typeof config.apt_source_deb_line === "string" &&
config.apt_source_deb_line.trim() !== ""
) {
aptSourceDebLine.value = config.apt_source_deb_line.trim();
}
if (typeof config.last_username === "string" && config.last_username.trim()) {
loginForm.username = config.last_username;
}
if (
typeof config.selected_win_key === "string" &&
config.selected_win_key.trim()
) {
cachedWinKey.value = config.selected_win_key;
selectedWin.value = config.selected_win_key;
}
window.addEventListener("keydown", handleKeyPress);
});
onUnmounted(() => {
window.removeEventListener("keydown", handleKeyPress);
});
</script>
<template>
<div class="login-container">
<div class="background-elements">
<div class="circle circle-1"></div>
<div class="circle circle-2"></div>
</div>
<div class="login-header">
<div
class="login-header-drag"
data-tauri-drag-region
@dblclick.prevent.stop
@mousedown="handleDragMouseDown"
></div>
<div class="login-header-actions">
<button
class="control-button"
type="button"
@mousedown.stop
@click="handleMinimizeClick"
>
<el-icon class="control-icon">
<component :is="Minus" />
</el-icon>
</button>
<button
class="control-button"
type="button"
@mousedown.stop
@dblclick.prevent.stop
@click="handleCloseClick"
>
<el-icon class="control-icon">
<component :is="Close" />
</el-icon>
</button>
</div>
</div>
<div class="login-main">
<div
class="header-section"
data-tauri-drag-region
@dblclick.prevent.stop
@mousedown="handleDragMouseDown"
>
<div class="app-info" data-tauri-drag-region>
<h1 class="app-title" data-tauri-drag-region>紫云智慧大厅</h1>
<h2 class="app-subtitle" data-tauri-drag-region>呼叫客户端系统</h2>
</div>
</div>
<div class="form-section">
<div class="form-wrapper">
<div v-show="!isLoginSuccessed" class="user-form">
<div class="form-header">
<p class="form-subtitle">请选择登录方式并输入账号信息</p>
</div>
<div class="type-selector">
<el-radio-group v-model="loginType" size="large">
<el-radio-button value="NSFW">综合管理平台账号</el-radio-button>
<el-radio-button value="QUEUE">自建账号</el-radio-button>
</el-radio-group>
</div>
<el-form
ref="formRef"
:model="loginForm"
:rules="rules"
class="login-form"
label-position="top"
@submit.prevent
>
<el-form-item label="账号" prop="username">
<el-input
v-model="loginForm.username"
:prefix-icon="User"
placeholder="请输入用户名/手机号"
size="large"
@focus="handleInputFocus"
/>
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input
v-model="loginForm.password"
:prefix-icon="Lock"
type="password"
placeholder="请输入登录密码"
size="large"
show-password
@focus="handleInputFocus"
/>
</el-form-item>
<div class="form-actions">
<el-button
type="primary"
size="large"
:loading="isLoading"
:disabled="!isFormValid"
class="login-button"
@click="handleLogin"
>
{{ isLoading ? "登录中..." : "立即登录" }}
</el-button>
</div>
</el-form>
</div>
<div v-show="isLoginSuccessed" class="window-form">
<div class="form-header">
<p class="form-subtitle">请选择登录窗口</p>
</div>
<el-form>
<el-form-item class="window-select-item">
<el-select
v-model="selectedWin"
placeholder="请选择登录窗口"
size="large"
>
<el-option
v-for="item in options"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<div class="form-actions">
<el-button
type="primary"
size="large"
:loading="isLoading"
:disabled="!isWindowSelected"
class="login-button"
@click="handleWindowLogin"
>
{{ isLoading ? "登录中..." : "立即登录" }}
</el-button>
</div>
<div class="form-actions">
<el-button
type="primary"
size="large"
class="login-button"
@click="backToUserForm"
>
返回
</el-button>
</div>
</el-form>
</div>
<div class="version-info">
<div class="version-row">
<div class="version">版本号V{{ appVersion }}</div>
<el-button
text
type="primary"
size="small"
:loading="checkingUpdate"
@click="handleCheckUpdate"
>
检查更新
</el-button>
</div>
<div class="copyright">© 2023 紫云科技 版权所有</div>
</div>
</div>
</div>
</div>
<el-dialog
v-model="aptSetupDialogVisible"
title="正在配置更新源"
width="440px"
class="apt-setup-dialog"
:close-on-click-modal="false"
:close-on-press-escape="aptSetupFinished"
:show-close="aptSetupFinished"
>
<el-progress
:percentage="aptSetupPercent"
:status="aptSetupProgressStatus"
:stroke-width="16"
/>
<p class="apt-setup-hint">{{ aptSetupHint }}</p>
</el-dialog>
</div>
</template>
<style lang="scss" scoped>
.login-container {
width: 100vw;
height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
background: linear-gradient(135deg, #004d99 0%, #003b7a 100%);
overflow: hidden;
position: relative;
border-radius: 5px;
padding: 0 16px;
}
.background-elements {
position: absolute;
width: 100%;
height: 100%;
pointer-events: none;
.circle {
position: absolute;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
&.circle-1 {
width: 300px;
height: 300px;
top: -100px;
left: -100px;
}
&.circle-2 {
width: 200px;
height: 200px;
right: -80px;
bottom: -80px;
}
}
}
.login-header {
width: 100%;
height: 32px;
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
}
.login-header-drag {
flex: 1;
height: 100%;
}
.login-header-actions {
display: flex;
align-items: center;
}
.control-button {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
color: white;
border-radius: 4px;
-webkit-app-region: no-drag;
border: none;
background: transparent;
cursor: pointer;
}
.control-button,
.control-button * {
cursor: pointer;
}
.control-button:hover {
background: rgba(255, 255, 255, 0.1);
}
.control-icon {
font-size: 20px;
}
.login-main {
width: 100%;
max-width: 420px;
z-index: 1;
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
padding-bottom: 16px;
}
.header-section {
text-align: center;
margin-bottom: 10px;
position: relative;
z-index: 2;
}
.app-title {
font-size: 28px;
font-weight: 700;
color: white;
margin-bottom: 8px;
}
.app-subtitle {
font-size: 16px;
color: rgba(255, 255, 255, 0.9);
font-weight: 500;
}
.form-wrapper {
background: rgba(255, 255, 255, 0.95);
border-radius: 5px;
padding: 28px 30px 22px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
}
.form-header {
text-align: center;
margin-bottom: 25px;
}
.form-subtitle {
color: #666;
font-size: 13px;
}
.type-selector {
margin-bottom: 25px;
:deep(.el-radio-group) {
width: 100%;
}
:deep(.el-radio-button) {
flex: 1;
}
:deep(.el-radio-button__inner) {
width: 100%;
}
}
.form-actions {
.login-button {
width: 100%;
margin-top: 10px;
}
}
.window-select-item {
margin: 80px 0;
}
.version-info {
margin-top: 18px;
text-align: center;
color: #999;
font-size: 12px;
padding-top: 10px;
border-top: 1px solid rgba(0, 0, 0, 0.1);
}
.version-row {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.version {
margin-bottom: 0;
}
.copyright {
margin-top: 5px;
}
.apt-setup-hint {
margin-top: 12px;
font-size: 13px;
color: #606266;
line-height: 1.45;
}
:global(.narrow-window-message) {
min-width: 0 !important;
width: calc(100vw - 32px) !important;
left: 16px !important;
right: 16px !important;
}
</style>