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.

795 lines
21 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 { ElForm, ElMessage, ElMessageBox } from "element-plus";
import { getVersion } from "@tauri-apps/api/app";
import { writeText } from "@tauri-apps/api/clipboard";
import { invoke } from "@tauri-apps/api/tauri";
import { computed, onMounted, onUnmounted, reactive, ref } from "vue";
import { api } from "../api";
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 APT_SOURCE_ENTRY =
"deb [arch=amd64 signed-by=/usr/share/keyrings/zyyun-archive-keyring.gpg] http://80.12.140.29:80/apt v10 main";
const APT_SOURCE_SETUP_COMMAND = `echo "${APT_SOURCE_ENTRY}" | sudo tee /etc/apt/sources.list.d/zyyun.list && sudo apt update`;
interface AptUpdateCheckResult {
packageName: string;
currentVersion: string;
installedVersion: string;
candidateVersion: string;
hasUpdate: boolean;
sourceAvailable: boolean;
updateCommand: 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" | "error",
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}`,
);
return;
}
sessionState = {
empUid: loginRes.operatorProfile.empUid,
winUid: 0,
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}`);
} 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) ??
"窗口初始化失败,请重试或更换窗口";
showMessage("error", 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();
sessionState = {
...sessionState,
winUid: Number(callerInit.windowUid ?? winUid),
empUid: Number(callerInit.empUid ?? sessionState.empUid ?? -1),
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);
showMessage("error", 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);
showMessage("error", 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);
showMessage("error", 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 仓库中是否有新版本,并引导复制升级命令。
*/
async function handleCheckUpdate(): Promise<void> {
if (checkingUpdate.value) {
return;
}
checkingUpdate.value = true;
try {
await log("info", `检查更新开始: package=call-client, currentVersion=${appVersion.value}`);
const result = await invoke<AptUpdateCheckResult>("check_apt_update", {
packageName: "call-client",
currentVersion: appVersion.value,
});
await log(
"info",
`检查更新结果: package=${result.packageName}, installed=${result.installedVersion}, candidate=${result.candidateVersion}, hasUpdate=${result.hasUpdate}, sourceAvailable=${result.sourceAvailable}`,
);
if (!result.sourceAvailable) {
await log("warn", `检查更新提示: 未检测到可用更新源, package=${result.packageName}`);
try {
await ElMessageBox.confirm(
[
"未检测到可用更新源,请先配置固定更新地址:",
APT_SOURCE_ENTRY,
"",
"点击“复制命令”后,在终端执行:",
APT_SOURCE_SETUP_COMMAND,
].join("\n"),
"更新源未配置",
{
confirmButtonText: "复制命令",
cancelButtonText: "关闭",
distinguishCancelAndClose: true,
type: "warning",
},
);
await writeText(APT_SOURCE_SETUP_COMMAND);
showMessage("success", "更新源初始化命令已复制到剪贴板");
} catch {
// 用户取消不提示错误。
}
return;
}
if (!result.hasUpdate) {
await log("info", `检查更新提示: 当前已是最新版本, version=${result.installedVersion}`);
showMessage("success", `当前已是最新版本(${result.installedVersion}`);
return;
}
await log(
"info",
`检查更新提示: 检测到新版本, installed=${result.installedVersion}, candidate=${result.candidateVersion}`,
);
try {
await ElMessageBox.confirm(
[
`检测到新版本:${result.candidateVersion}`,
`当前版本:${result.installedVersion}`,
"",
"点击“复制命令”后,在终端执行升级:",
result.updateCommand,
].join("\n"),
"发现新版本",
{
confirmButtonText: "复制命令",
cancelButtonText: "关闭",
distinguishCancelAndClose: true,
type: "info",
},
);
await writeText(result.updateCommand);
showMessage("success", "升级命令已复制到剪贴板");
} catch {
// 用户取消不提示错误。
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
showMessage("error", `检查更新失败:${message}`);
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.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>
</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;
}
:global(.narrow-window-message) {
min-width: 0 !important;
width: calc(100vw - 32px) !important;
left: 16px !important;
right: 16px !important;
}
</style>