feat: 前端搭建

This commit is contained in:
Gsh
2025-06-17 22:37:37 +08:00
parent 4830be6388
commit 0cd795f57a
1228 changed files with 23627 additions and 1 deletions

View File

@@ -0,0 +1,34 @@
<!-- 深度思考按钮 -->
<script setup lang="ts">
import { useChatStore } from '@/stores/modules/chat';
const chatStore = useChatStore();
const isDeepThinking = computed(() => chatStore.isDeepThinking);
// 切换是否深度思考
function setIsDeepThinking() {
chatStore.setDeepThinking(!chatStore.isDeepThinking);
}
</script>
<template>
<div
:class="{ 'is-select': isDeepThinking }"
class="deep-thinking-btn flex items-center p-10px rounded-10px rounded-15px cursor-pointer font-size-12px border-1px border-[rgba(0,0,0,0.08)] border-solid hover:bg-[rgba(0,0,0,.04)]"
@click="setIsDeepThinking"
>
<el-icon>
<ElementPlus />
</el-icon>
<span>深度思考</span>
</div>
</template>
<style scoped lang="scss">
.deep-thinking-btn.is-select {
color: var(--el-color-primary, #409eff);
border: 1px solid var(--el-color-primary, #409eff);
border-radius: 15px;
}
</style>

View File

@@ -0,0 +1,150 @@
<!-- 文件上传 -->
<script setup lang="ts">
import type { FilesCardProps } from 'vue-element-plus-x/types/FilesCard';
import { useFileDialog } from '@vueuse/core';
import { ElMessage } from 'element-plus';
import Popover from '@/components/Popover/index.vue';
import SvgIcon from '@/components/SvgIcon/index.vue';
import { useFilesStore } from '@/stores/modules/files';
type FilesList = FilesCardProps & {
file: File;
};
const filesStore = useFilesStore();
/* 弹出面板 开始 */
const popoverStyle = ref({
padding: '4px',
height: 'fit-content',
background: 'var(--el-bg-color, #fff)',
border: '1px solid var(--el-border-color-light)',
borderRadius: '8px',
boxShadow: '0 2px 12px 0 rgba(0, 0, 0, 0.1)',
});
const popoverRef = ref();
/* 弹出面板 结束 */
const { reset, open, onChange } = useFileDialog({
// 允许所有图片文件,文档文件,音视频文件
accept: 'image/*,video/*,audio/*,application/*',
directory: false, // 是否允许选择文件夹
multiple: true, // 是否允许多选
});
onChange((files) => {
if (!files)
return;
console.log('files', files);
const arr = [] as FilesList[];
for (let i = 0; i < files!.length; i++) {
const file = files![i];
arr.push({
uid: crypto.randomUUID(), // 不写 uid文件列表展示不出来elx 1.2.0 bug 待修复
name: file.name,
fileSize: file.size,
file,
maxWidth: '200px',
showDelIcon: true, // 显示删除图标
imgPreview: true, // 显示图片预览
imgVariant: 'square', // 图片预览的形状
url: URL.createObjectURL(file), // 图片预览地址
});
}
filesStore.setFilesList([...filesStore.filesList, ...arr]);
// 重置文件选择器
nextTick(() => reset());
});
function handleUploadFiles() {
open();
popoverRef.value.hide();
}
</script>
<template>
<div class="files-select">
<Popover
ref="popoverRef"
placement="top-start"
:offset="[4, 0]"
popover-class="popover-content"
:popover-style="popoverStyle"
trigger="clickTarget"
>
<template #trigger>
<div
class="flex items-center gap-4px p-10px rounded-10px cursor-pointer font-size-14px border-1px border-[rgba(0,0,0,0.08)] border-solid hover:bg-[rgba(0,0,0,.04)]"
>
<el-icon>
<Paperclip />
</el-icon>
</div>
</template>
<div class="popover-content-box">
<div
class="popover-content-item flex items-center gap-4px p-10px rounded-10px cursor-pointer font-size-14px hover:bg-[rgba(0,0,0,.04)]"
@click="handleUploadFiles"
>
<el-icon>
<Upload />
</el-icon>
<div class="font-size-14px">
上传文件或图片
</div>
</div>
<Popover
placement="right-end"
:offset="[8, 4]"
popover-class="popover-content"
:popover-style="popoverStyle"
trigger="hover"
:hover-delay="100"
>
<template #trigger>
<div
class="popover-content-item flex items-center gap-4px p-10px rounded-10px cursor-pointer font-size-14px hover:bg-[rgba(0,0,0,.04)]"
>
<SvgIcon name="code" size="16" />
<div class="font-size-14px">
上传代码
</div>
<el-icon class="ml-auto">
<ArrowRight />
</el-icon>
</div>
</template>
<div class="popover-content-box">
<div
class="popover-content-item flex items-center gap-4px p-10px rounded-10px cursor-pointer font-size-14px hover:bg-[rgba(0,0,0,.04)]"
@click="
() => {
ElMessage.warning('暂未开放');
}
"
>
代码文件
</div>
<div
class="popover-content-item flex items-center gap-4px p-10px rounded-10px cursor-pointer font-size-14px hover:bg-[rgba(0,0,0,.04)]"
@click="
() => {
ElMessage.warning('暂未开放');
}
"
>
代码文件夹
</div>
</div>
</Popover>
</div>
</Popover>
</div>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,149 @@
<script setup lang="ts">
import { useClipboard } from '@vueuse/core';
import SvgIcon from '@/components/SvgIcon/index.vue';
import icons from './requireIcons';
const emits = defineEmits(['selected']);
const { copy } = useClipboard();
const name = ref('');
const iconList = ref(icons);
console.log(icons);
function filterIcons() {
iconList.value = JSON.parse(JSON.stringify(icons));
if (name.value) {
let index = 0;
iconList.value.forEach((icons) => {
iconList.value[index].iconList = icons.iconList.filter(item => item.includes(name.value));
index++;
});
}
}
function selectedIcon(name: string) {
emits('selected', name);
copy(name);
document.body.click();
}
</script>
<template>
<div class="icons-container">
<div class="icon-body">
<el-tabs type="border-card">
<div class="icon-search-box">
<el-input
v-model="name"
clearable
placeholder="请输入图标名称"
@clear="filterIcons"
@input="filterIcons"
>
<template #prefix>
<el-icon>
<search />
</el-icon>
</template>
</el-input>
</div>
<el-tab-pane
v-for="classify of iconList"
:key="classify.classifyName"
:label="classify.classifyName"
>
<div class="grid-container">
<div v-for="item of classify.iconList" :key="item" @click="selectedIcon(item)">
<div class="icon-item flex-center flex-col gap-3px">
<SvgIcon :name="item" />
<span class="icon_name text-overflow max-w-80px">
{{ item }}
</span>
</div>
</div>
</div>
</el-tab-pane>
</el-tabs>
</div>
</div>
</template>
<style rel="stylesheet/scss" scoped lang="scss">
// 菜单图标选择样式
.el-popover {
.grid-container {
position: relative;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(40px, 1fr));
}
.icon-item {
width: fit-content !important;
height: fit-content;
padding: 0 4px;
margin: 3px 0 !important;
font-size: 18px;
text-align: center;
cursor: pointer;
}
.icon-item:hover {
box-shadow: 1px 1px 10px 0 #a1a1a1;
}
.el-tab-pane {
height: 200px;
overflow: auto;
}
.icon_name {
display: none;
}
}
// 菜单选择页面样式
.icons-container {
.icon-body {
padding: 10px;
}
.icon_name {
display: block;
}
overflow: hidden;
.grid-container {
position: relative;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
height: 500px;
margin-top: 12px;
overflow: hidden auto;
border-top: 1px solid #eeeeee;
border-left: 1px solid #eeeeee;
}
.icon-item {
width: 100% !important;
padding: 16px 0;
margin: 0 !important;
margin-right: -1px;
margin-bottom: -1px;
text-align: center;
border-right: 1px solid #eeeeee;
border-bottom: 1px solid #eeeeee;
}
span {
display: block;
margin-top: 4px;
font-size: 16px;
}
.disabled {
pointer-events: none;
}
.grid {
border-top: 1px solid #eeeeee;
}
}
.icons-container svg {
span,
svg {
font-size: 24px !important;
color: #606266;
}
}
</style>

View File

@@ -0,0 +1,93 @@
const Base = import.meta.glob('@/assets/icons/svg/*.svg');
const Buildings = import.meta.glob('@/assets/icons/Buildings/*.svg');
const Business = import.meta.glob('@/assets/icons/Business/*.svg');
const Device = import.meta.glob('@/assets/icons/Device/*.svg');
const Document = import.meta.glob('@/assets/icons/Document/*.svg');
const Others = import.meta.glob('@/assets/icons/Others/*.svg');
const System = import.meta.glob('@/assets/icons/System/*.svg');
const User = import.meta.glob('@/assets/icons/User/*.svg');
const BaseIcons = [];
for (const path in Base) {
const p = path.split('assets/icons/svg/')[1].split('.svg')[0];
BaseIcons.push(p);
}
const BuildingsIcons = [];
for (const path in Buildings) {
const p = path.split('assets/icons/Buildings/')[1].split('.svg')[0];
BuildingsIcons.push(p);
}
const BusinessIcons = [];
for (const path in Business) {
const p = path.split('assets/icons/Business/')[1].split('.svg')[0];
BusinessIcons.push(p);
}
const DeviceIcons = [];
for (const path in Device) {
const p = path.split('assets/icons/Device/')[1].split('.svg')[0];
DeviceIcons.push(p);
}
const DocumentIcons = [];
for (const path in Document) {
const p = path.split('assets/icons/Document/')[1].split('.svg')[0];
DocumentIcons.push(p);
}
const OthersIcons = [];
for (const path in Others) {
const p = path.split('assets/icons/Others/')[1].split('.svg')[0];
OthersIcons.push(p);
}
const SystemIcons = [];
for (const path in System) {
const p = path.split('assets/icons/System/')[1].split('.svg')[0];
SystemIcons.push(p);
}
const UserIcons = [];
for (const path in User) {
const p = path.split('assets/icons/User/')[1].split('.svg')[0];
UserIcons.push(p);
}
const icons = [
{
classifyName: '用户',
iconList: UserIcons,
},
{
classifyName: '建筑',
iconList: BuildingsIcons,
},
{
classifyName: '办公',
iconList: BusinessIcons,
},
{
classifyName: '设备',
iconList: DeviceIcons,
},
{
classifyName: '文档',
iconList: DocumentIcons,
},
{
classifyName: '系统',
iconList: SystemIcons,
},
{
classifyName: '其他',
iconList: OthersIcons,
},
{
classifyName: '默认',
iconList: BaseIcons,
},
];
export default icons;

View File

@@ -0,0 +1,120 @@
<!-- 账号密码登录表单 -->
<script lang="ts" setup>
import type { FormInstance, FormRules } from 'element-plus';
import type { LoginDTO } from '@/api/auth/types';
import { reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { login } from '@/api';
import { useUserStore } from '@/stores';
import { useLoginFormStore } from '@/stores/modules/loginForm';
import { useSessionStore } from '@/stores/modules/session';
const userStore = useUserStore();
const sessionStore = useSessionStore();
const loginFromStore = useLoginFormStore();
const formRef = ref<FormInstance>();
const formModel = reactive<LoginDTO>({
username: '',
password: '',
});
const rules = reactive<FormRules<LoginDTO>>({
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
});
const router = useRouter();
async function handleSubmit() {
try {
await formRef.value?.validate();
const res = await login(formModel);
console.log(res, 'res');
res.data.token && userStore.setToken(res.data.token);
res.data.userInfo && userStore.setUserInfo(res.data.userInfo);
ElMessage.success('登录成功');
userStore.closeLoginDialog();
// 立刻获取回话列表
await sessionStore.requestSessionList(1, true);
router.replace('/');
}
catch (error) {
console.error('请求错误:', error);
}
}
</script>
<template>
<div class="custom-form">
<el-form
ref="formRef"
:model="formModel"
:rules="rules"
style="width: 230px"
@submit.prevent="handleSubmit"
>
<el-form-item prop="username">
<el-input v-model="formModel.username" placeholder="请输入用户名">
<template #prefix>
<el-icon>
<User />
</el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="formModel.password"
placeholder="请输入密码"
type="password"
show-password
>
<template #prefix>
<el-icon>
<Lock />
</el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" style="width: 100%" native-type="submit">
登录
</el-button>
</el-form-item>
</el-form>
<!-- 注册登录 -->
<div class="form-tip font-size-12px flex items-center">
<span>没有账号</span>
<span
class="c-[var(--el-color-primar,#409eff)] cursor-pointer"
@click="loginFromStore.setLoginFormType('RegistrationForm')"
>
立即注册
</span>
</div>
</div>
</template>
<style scoped lang="scss">
.custom-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.form-group {
display: flex;
gap: 8px;
align-items: center;
}
.login-btn {
padding: 12px;
margin-top: 24px;
color: white;
cursor: pointer;
background: #409eff;
border: none;
border-radius: 4px;
}
</style>

View File

@@ -0,0 +1,198 @@
<!-- 注册表单 -->
<script lang="ts" setup>
import type { FormInstance, FormRules } from 'element-plus';
import type { RegisterDTO } from '@/api/auth/types';
import { useCountdown } from '@vueuse/core';
import { reactive, ref } from 'vue';
import { emailCode, register } from '@/api';
import { useLoginFormStore } from '@/stores/modules/loginForm';
const loginFromStore = useLoginFormStore();
const countdown = shallowRef(60);
const { start, stop, resume } = useCountdown(countdown, {
onComplete() {
resume();
},
onTick() {
countdown.value--;
},
});
const formRef = ref<FormInstance>();
const formModel = ref<RegisterDTO>({
username: '',
password: '',
code: '',
confirmPassword: '',
});
const rules = reactive<FormRules<RegisterDTO>>({
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
confirmPassword: [
{ required: true, message: '请输入确认密码', trigger: 'blur' },
{
validator: (_, value) => {
if (value !== formModel.value.password) {
return new Error('两次输入的密码不一致');
}
return true;
},
trigger: 'change',
},
],
username: [
{ required: true, message: '请输入邮箱', trigger: 'blur' },
{
validator: (_, value) => {
if (!isEmail(value)) {
return new Error('请输入正确的邮箱');
}
return true;
},
trigger: 'blur',
},
],
});
function isEmail(email: string) {
const emailRegex = /^[\w.-]+@[a-z0-9.-]+\.[a-z]{2,4}$/i;
return emailRegex.test(email);
}
async function handleSubmit() {
try {
await formRef.value?.validate();
const params: RegisterDTO = {
username: formModel.value.username,
password: formModel.value.password,
code: formModel.value.code,
};
await register(params);
ElMessage.success('注册成功');
formRef.value?.resetFields();
resume();
}
catch (error) {
console.error('请求错误:', error);
}
}
// 获取验证码
async function getEmailCode() {
if (formModel.value.username === '') {
ElMessage.error('请输入邮箱');
return;
}
if (!isEmail(formModel.value.username)) {
return;
}
if (countdown.value > 0 && countdown.value < 60) {
return;
}
try {
start();
await emailCode({ username: formModel.value.username });
ElMessage.success('验证码发送成功');
}
catch (error) {
console.error('请求错误:', error);
stop();
}
}
</script>
<template>
<div class="custom-form">
<el-form
ref="formRef"
:model="formModel"
:rules="rules"
style="width: 230px"
@submit.prevent="handleSubmit"
>
<el-form-item prop="username">
<el-input v-model="formModel.username" placeholder="请输入邮箱" autocomplete="off">
<template #prefix>
<el-icon>
<Message />
</el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item prop="code">
<el-input v-model="formModel.code" placeholder="请输入验证码" autocomplete="off">
<template #prefix>
<el-icon>
<Bell />
</el-icon>
</template>
<template #suffix>
<div class="font-size-14px cursor-pointer bg-[var(0,0,0,0.4)]" @click="getEmailCode">
{{ countdown === 0 || countdown === 60 ? "获取验证码" : `${countdown} s` }}
</div>
</template>
</el-input>
</el-form-item>
<el-form-item prop="password">
<el-input v-model="formModel.password" placeholder="请输入密码" autocomplete="off">
<template #prefix>
<el-icon>
<Unlock />
</el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item prop="confirmPassword">
<el-input v-model="formModel.confirmPassword" placeholder="请确认密码" autocomplete="off">
<template #prefix>
<el-icon>
<Lock />
</el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" style="width: 100%" native-type="submit">
注册
</el-button>
</el-form-item>
</el-form>
<!-- 返回登录 -->
<div class="form-tip font-size-12px flex items-center">
<span>已有账号</span>
<span
class="c-[var(--el-color-primar,#409eff)] cursor-pointer"
@click="loginFromStore.setLoginFormType('AccountPassword')"
>
返回登录
</span>
</div>
</div>
</template>
<style scoped lang="scss">
.custom-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.form-group {
display: flex;
gap: 8px;
align-items: center;
}
.login-btn {
padding: 12px;
margin-top: 24px;
color: white;
cursor: pointer;
background: #409eff;
border: none;
border-radius: 4px;
}
</style>

View File

@@ -0,0 +1,44 @@
<!-- 手机号验证码登录表单 -->
<script lang="ts" setup>
// 表单逻辑由用户自行实现
</script>
<template>
<div class="custom-form">
<!-- 此处放入用户自己的表单组件 -->
<div class="form-group">
<label>手机号</label>
<input type="text" placeholder="请输入手机号">
</div>
<div class="form-group">
<label>验证码</label>
<input type="text" placeholder="请输入验证码">
<button>获取验证码</button>
</div>
<button class="login-btn">
登录
</button>
</div>
</template>
<style scoped>
.custom-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.form-group {
display: flex;
gap: 8px;
align-items: center;
}
.login-btn {
padding: 12px;
margin-top: 24px;
color: white;
cursor: pointer;
background: #409eff;
border: none;
border-radius: 4px;
}
</style>

View File

@@ -0,0 +1,262 @@
<script lang="ts" setup>
import { Check, Picture as IconPicture, Refresh } from '@element-plus/icons-vue';
import { useCountdown } from '@vueuse/core';
import { useQRCode } from '@vueuse/integrations/useQRCode';
import { onBeforeUnmount, onMounted, ref, shallowRef } from 'vue';
// 响应式状态
const urlText = shallowRef('');
const qrCodeUrl = useQRCode(urlText);
const isExpired = ref(false);
const isScanned = ref(false); // 新增:是否已扫码
const isConfirming = ref(false); // 新增:是否进入确认登录阶段
const confirmCountdownSeconds = shallowRef(180); // 确认登录倒计时3分钟
// 二维码倒计时实例
const { start: qrStart, stop: qrStop } = useCountdown(shallowRef(60), {
interval: 1000,
onComplete: () => {
isExpired.value = true;
stopPolling(); // 二维码过期时停止轮询
},
});
// 确认登录倒计时实例
const { start: confirmStart, stop: confirmStop } = useCountdown(confirmCountdownSeconds, {
interval: 1000,
onComplete: () => {
isExpired.value = true;
isConfirming.value = false;
stopPolling(); // 确认倒计时结束时停止轮询
},
});
// 轮询相关
let scanPolling: number | null = null;
let confirmPolling: number | null = null;
// 模拟后端接口 这里返回新的二维码地址
async function fetchNewQRCode() {
await new Promise(resolve => setTimeout(resolve, 500));
return `https://login-api.com/qr/${Date.now()}`;
}
// 模拟后端接口 这里返回是否已扫码
async function checkScanStatus() {
// 模拟扫码状态接口(实际应调用后端接口)
await new Promise(resolve => setTimeout(resolve, 300));
return Math.random() > 0.3; // 30%概率未扫码70%概率已扫码
}
// 模拟后端接口 这里返回扫码后是否已确认
async function checkConfirmStatus() {
// 模拟确认登录接口(实际应调用后端接口)
await new Promise(resolve => setTimeout(resolve, 200));
return Math.random() > 0.5; // 50%概率已确认
}
// 模拟登录逻辑 如果在客户端已确认,则会调用这个方法进行登录
async function mockLogin() {
// 模拟登录成功逻辑
console.log('模拟调用登录接口...');
await new Promise(resolve => setTimeout(resolve, 500));
console.log('模拟调用登录成功...');
}
/** 停止所有轮询 */
function stopPolling() {
if (scanPolling)
clearInterval(scanPolling);
if (confirmPolling)
clearInterval(confirmPolling);
scanPolling = null;
confirmPolling = null;
}
/** 刷新二维码 */
async function handleRefresh() {
isExpired.value = false;
isScanned.value = false;
isConfirming.value = false;
stopPolling();
qrStart(shallowRef(60));
const newUrl = await fetchNewQRCode();
urlText.value = newUrl;
}
/** 启动扫码状态轮询 */
function startScanPolling() {
scanPolling = setInterval(async () => {
if (!isExpired.value && !isScanned.value) {
const scanned = await checkScanStatus();
if (scanned) {
isScanned.value = true;
isConfirming.value = true;
confirmStart(confirmCountdownSeconds); // 启动确认倒计时
startConfirmPolling(); // 开始确认登录轮询
stopPolling(); // 停止扫码轮询
}
}
}, 2000); // 每2秒轮询一次
}
/** 启动确认登录轮询 */
function startConfirmPolling() {
confirmPolling = setInterval(async () => {
if (isConfirming.value && !isExpired.value) {
const confirmed = await checkConfirmStatus();
if (confirmed) {
stopPolling();
confirmStop();
await mockLogin();
handleRefresh(); // 登录成功后刷新二维码
}
}
}, 2000); // 每2秒轮询一次
}
/** 组件初始化 */
onMounted(async () => {
const initialUrl = await fetchNewQRCode();
urlText.value = initialUrl;
qrStart();
startScanPolling(); // 初始启动扫码轮询
});
/** 组件卸载清理 */
onBeforeUnmount(() => {
qrStop();
confirmStop();
stopPolling();
});
</script>
<template>
<div class="qr-wrapper">
<div class="tip">
请使用手机扫码登录
</div>
<div class="qr-img-wrapper">
<el-image v-loading="!qrCodeUrl" :src="qrCodeUrl" alt="登录二维码" class="qr-img">
<template #error>
<el-icon><IconPicture /></el-icon>
</template>
</el-image>
<!-- 过期覆盖层 -->
<div v-if="isExpired" class="expired-overlay" @click.stop="handleRefresh">
<div class="expired-content">
<p class="expired-text">
二维码失效
</p>
<el-button class="refresh-btn" link>
<el-icon><Refresh /></el-icon>
点击刷新
</el-button>
</div>
</div>
<!-- 扫码成功覆盖层 -->
<div v-if="isScanned && !isExpired" class="scanned-overlay">
<div class="scanned-content">
<p class="scanned-text">
<el-icon class="success-icon">
<Check />
</el-icon>
已扫码
</p>
<p class="scanned-text">
请在手机端确认登录
</p>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.qr-wrapper {
display: flex;
flex-direction: column;
gap: 8px;
align-items: center;
.tip {
font-size: 16px;
font-weight: 500;
color: #303133;
}
.qr-img-wrapper {
position: relative;
width: 180px;
height: 180px;
padding: 12px;
overflow: hidden;
border: 1px solid #f0f2f5;
border-radius: 16px;
box-shadow: 0 2px 12px 0 rgb(0 0 0 / 8%);
.qr-img {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
.el-icon {
font-size: 18px;
color: #909399;
}
}
.expired-overlay,
.scanned-overlay {
position: absolute;
top: 0;
left: 0;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
border-radius: 16px;
}
.expired-overlay {
cursor: pointer;
background: hsl(0deg 0% 100% / 95%);
.expired-content {
display: flex;
flex-direction: column;
gap: 8px;
text-align: center;
.expired-text {
font-size: 14px;
color: #909399;
}
}
}
.scanned-overlay {
cursor: default;
background: hsl(120deg 60% 97% / 95%);
.scanned-content {
display: flex;
flex-direction: column;
gap: 8px;
align-items: center;
.success-icon {
font-size: 18px;
color: #67c23a;
}
.scanned-text {
display: flex;
gap: 8px;
align-items: center;
font-size: 14px;
color: #606266;
}
.countdown-text {
font-size: 12px;
color: #909399;
}
}
}
}
}
</style>

View File

@@ -0,0 +1,287 @@
<script lang="ts" setup>
import { ref, watch } from 'vue';
import logoPng from '@/assets/images/logo.png';
import SvgIcon from '@/components/SvgIcon/index.vue';
import { useUserStore } from '@/stores';
import { useLoginFormStore } from '@/stores/modules/loginForm';
import AccountPassword from './components/FormLogin/AccountPassword.vue';
import RegistrationForm from './components/FormLogin/RegistrationForm.vue';
import QrCodeLogin from './components/QrCodeLogin/index.vue';
const userStore = useUserStore();
const loginFromStore = useLoginFormStore();
const loginFormType = computed(() => loginFromStore.LoginFormType);
// 使用 defineModel 定义双向绑定的 visible需 Vue 3.4+
const visible = defineModel<boolean>('visible');
const showMask = ref(false); // 控制遮罩层显示的独立状态
const isQrMode = ref(false);
// 监听 visible 变化,控制遮罩层显示时机
watch(
visible,
(newVal) => {
if (newVal) {
// 恢复默认
isQrMode.value = false;
// 显示时立即展示遮罩
showMask.value = true;
}
},
{ immediate: true },
);
// 切换二维码登录
function toggleLoginMode() {
isQrMode.value = !isQrMode.value;
}
// 点击遮罩层关闭对话框(触发过渡动画)
function handleMaskClick() {
// 触发离开动画
userStore.closeLoginDialog();
}
// 过渡动画结束回调
function onAfterLeave() {
if (!visible.value) {
showMask.value = false; // 动画结束后隐藏遮罩
}
}
</script>
<template>
<!-- 使用 Teleport 将内容传送至 body -->
<Teleport to="body">
<div v-show="showMask" class="mask" @click.self="handleMaskClick">
<!-- 仅对弹框应用过渡动画 -->
<Transition name="dialog-zoom" @after-leave="onAfterLeave">
<div v-show="visible" class="glass-dialog">
<div class="left-section">
<div class="logo-wrap">
<img :src="logoPng" class="logo-img">
<span class="logo-text">Element Plus X</span>
</div>
<div class="ad-banner">
<SvgIcon name="p-bangong" class-name="animate-up-down" />
</div>
</div>
<div class="right-section">
<div class="mode-toggle" @click.stop="toggleLoginMode">
<SvgIcon v-if="!isQrMode" name="erweimadenglu" />
<SvgIcon v-else name="zhanghaodenglu" />
</div>
<div class="content-wrapper">
<div v-if="!isQrMode" class="form-box">
<!-- 表单容器父组件可以自定定义表单插槽 -->
<slot name="form">
<!-- 父组件不用插槽则显示默认表单 默认使用 AccountPassword 组件 -->
<div v-if="loginFormType === 'AccountPassword'" class="form-container">
<span class="content-title"> 登录后免费使用完整功能 </span>
<el-divider content-position="center">
账号密码登录
</el-divider>
<AccountPassword />
</div>
<div v-if="loginFormType === 'RegistrationForm'" class="form-container">
<span class="content-title"> 登录后免费使用完整功能 </span>
<el-divider content-position="center">
邮箱注册账号
</el-divider>
<RegistrationForm />
</div>
</slot>
</div>
<div v-else class="qr-container">
<QrCodeLogin />
</div>
</div>
</div>
</div>
</Transition>
</div>
</Teleport>
</template>
<style scoped lang="scss">
/* 动画样式(仅作用于弹框) */
.dialog-zoom-enter-active,
.dialog-zoom-leave-active {
transition: all 0.3s ease-in-out;
transform-origin: center;
}
.dialog-zoom-enter-from,
.dialog-zoom-leave-to {
opacity: 0;
transform: scale(0.8);
}
.dialog-zoom-enter-to,
.dialog-zoom-leave-from {
opacity: 1;
transform: scale(1);
}
/* 遮罩层样式 */
.mask {
position: fixed;
top: 0;
left: 0;
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
width: 100vw;
height: 100vh;
overflow: hidden;
user-select: none;
background-color: rgb(0 0 0 / 50%);
backdrop-filter: blur(3px);
opacity: 1;
transition: opacity 0.3s;
}
.mask[hidden] {
opacity: 0;
}
/* 对话框容器样式 */
.glass-dialog {
z-index: 1000;
display: flex;
width: fit-content;
max-width: 90%;
height: var(--login-dialog-height);
padding: var(--login-dialog-padding);
overflow: hidden;
background-color: #ffffff;
border-radius: var(--login-dialog-border-radius);
box-shadow: 0 4px 24px rgb(0 0 0 / 10%);
}
/* 以下样式与原代码一致,未修改 */
.left-section {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: calc(var(--login-dialog-width) / 2);
padding: var(--login-dialog-section-padding);
background: linear-gradient(
233deg,
rgb(113 161 255 / 60%) 17.67%,
rgb(154 219 255 / 60%) 70.4%
);
}
.left-section .logo-wrap {
display: flex;
gap: 8px;
align-items: center;
justify-content: center;
margin-top: 24px;
}
.left-section .logo-wrap .logo-img {
width: 40px;
height: 40px;
padding: 4px;
background: var(--login-dialog-logo-background);
filter: drop-shadow(0 4px 4px rgb(0 0 0 / 10%));
border-radius: 12px;
box-shadow: 0 2px 12px 0 rgb(0 0 0 / 8%);
}
.left-section .logo-wrap .logo-text {
font-size: 16px;
font-weight: 600;
color: var(--login-dialog-logo-text-color);
}
.left-section .ad-banner {
position: relative;
width: 100%;
height: 100%;
}
.left-section .ad-banner .svg-icon {
position: absolute;
width: 100%;
height: 310px;
}
.right-section {
position: relative;
display: flex;
flex-direction: column;
width: calc(var(--login-dialog-width) / 2);
padding: var(--login-dialog-section-padding);
}
.right-section .content-wrapper {
flex: 1;
padding: 8px 0;
overflow: hidden;
}
.right-section .content-title {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
font-size: 20px;
font-weight: 700;
}
.right-section .mode-toggle {
position: absolute;
top: 16px;
right: 16px;
font-size: 24px;
color: var(--login-dialog-mode-toggle-color);
cursor: pointer;
transition: color 0.3s;
}
.right-section .form-container,
.right-section .qr-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.right-section .form-box {
place-self: center center;
width: 260px;
height: 100%;
padding: var(--login-dialog-section-padding);
border-radius: var(--login-dialog-border-radius);
}
@media (width <= 800px) {
.left-section {
display: none !important;
}
.glass-dialog {
height: var(--login-dialog-height);
padding: var(--login-dialog-padding);
}
.right-section {
padding: calc(var(--login-dialog-section-padding) - 8px);
}
.content-wrapper {
padding: 4px 0;
}
}
.animate-up-down {
animation: up-down 5s linear 0ms infinite;
}
@keyframes up-down {
0% {
transform: translateY(0);
}
50% {
transform: translateY(-20px);
}
100% {
transform: translateY(0);
}
}
</style>

View File

@@ -0,0 +1,151 @@
<!-- 切换模型 -->
<script setup lang="ts">
import type { GetSessionListVO } from '@/api/model/types';
import Popover from '@/components/Popover/index.vue';
import SvgIcon from '@/components/SvgIcon/index.vue';
import { useModelStore } from '@/stores/modules/model';
const modelStore = useModelStore();
onMounted(async () => {
await modelStore.requestModelList();
// 设置默认模型
if (
modelStore.modelList.length > 0
&& (!modelStore.currentModelInfo || !modelStore.currentModelInfo.modelName)
) {
modelStore.setCurrentModelInfo(modelStore.modelList[0]);
}
});
const currentModelName = computed(
() => modelStore.currentModelInfo && modelStore.currentModelInfo.modelName,
);
const popoverList = computed(() => modelStore.modelList);
/* 弹出面板 开始 */
const popoverStyle = ref({
width: '200px',
padding: '4px',
height: 'fit-content',
background: 'var(--el-bg-color, #fff)',
border: '1px solid var(--el-border-color-light)',
borderRadius: '8px',
boxShadow: '0 2px 12px 0 rgba(0, 0, 0, 0.1)',
});
const popoverRef = ref();
// 显示
async function showPopover() {
// 获取最新的模型列表
await modelStore.requestModelList();
}
// 点击
function handleClick(item: GetSessionListVO) {
modelStore.setCurrentModelInfo(item);
popoverRef.value?.hide?.();
}
</script>
<template>
<div class="model-select">
<Popover
ref="popoverRef"
placement="top-start"
:offset="[4, 0]"
popover-class="popover-content"
:popover-style="popoverStyle"
trigger="clickTarget"
@show="showPopover"
>
<!-- 触发元素插槽 -->
<template #trigger>
<div
class="model-select-box select-none flex items-center gap-4px p-10px rounded-10px cursor-pointer font-size-12px border-[rgba()]"
>
<div class="model-select-box-icon">
<SvgIcon name="models" size="12" />
</div>
<div class="model-select-box-text font-size-12px">
{{ currentModelName }}
</div>
</div>
</template>
<div class="popover-content-box">
<div
v-for="item in popoverList"
:key="item.id"
class="popover-content-box-items w-full rounded-8px select-none transition-all transition-duration-300 flex items-center hover:cursor-pointer hover:bg-[rgba(0,0,0,.04)]"
>
<Popover
trigger-class="popover-trigger-item-text"
popover-class="rounded-tooltip"
placement="right"
trigger="hover"
:offset="[12, 0]"
>
<template #trigger>
<div
class="popover-content-box-item p-4px font-size-12px text-overflow line-height-16px"
:class="{ 'bg-[rgba(0,0,0,.04)] is-select': item.modelName === currentModelName }"
@click="handleClick(item)"
>
{{ item.modelName }}
</div>
</template>
<div
class="popover-content-box-item-text text-wrap max-w-200px rounded-lg p-8px font-size-12px line-height-tight"
>
{{ item.remark }}
</div>
</Popover>
</div>
</div>
</Popover>
</div>
</template>
<style scoped lang="scss">
.model-select-box {
color: var(--el-color-primary, #409eff);
background: var(--el-color-primary-light-9, rgb(235.9 245.3 255));
border: 1px solid var(--el-color-primary, #409eff);
border-radius: 10px;
}
.popover-content-box-item.is-select {
font-weight: 700;
color: var(--el-color-primary, #409eff);
}
.popover-content-box {
display: flex;
flex-direction: column;
gap: 4px;
height: 200px;
overflow: hidden auto;
.popover-content-box-items {
:deep() {
.popover-trigger-item-text {
width: 100%;
}
}
}
.popover-content-box-item-text {
color: white;
background-color: black;
}
// 滚动条样式
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-track {
background: #f5f5f5;
}
&::-webkit-scrollbar-thumb {
background: #cccccc;
border-radius: 4px;
}
}
</style>

View File

@@ -0,0 +1,203 @@
<script setup lang="ts">
import type { CSSProperties } from 'vue';
import { arrow, autoUpdate, flip, offset, shift, useFloating } from '@floating-ui/vue';
import { onClickOutside } from '@vueuse/core';
export type PopoverPlacement
= | 'top'
| 'top-start'
| 'top-end'
| 'bottom'
| 'bottom-start'
| 'bottom-end'
| 'left'
| 'left-start'
| 'left-end'
| 'right'
| 'right-start'
| 'right-end';
export type Offset = [number, number];
export interface PopoverProps {
placement?: PopoverPlacement;
offset?: Offset;
popoverStyle?: CSSProperties;
popoverClass?: string;
trigger?: 'hover' | 'click' | 'clickTarget';
triggerStyle?: CSSProperties;
triggerClass?: string;
hoverDelay?: number; // 悬停延迟关闭时间ms
}
const props = withDefaults(defineProps<PopoverProps>(), {
placement: 'bottom',
offset: () => [0, 0],
trigger: 'hover',
hoverDelay: 0, // 默认300ms延迟关闭
});
const emits = defineEmits<{
(e: 'show'): void;
(e: 'hide'): void;
}>();
const triggerRef = ref<HTMLElement | null>(null);
const popoverRef = ref<HTMLElement | null>(null);
const floatingArrow = ref<HTMLElement | null>(null);
const showPopover = ref(false);
let hideTimeout: ReturnType<typeof setTimeout> | null = null;
// 新增:记录鼠标是否在触发元素或内容区域内
const isHovering = ref(false);
const { floatingStyles } = useFloating(triggerRef, popoverRef, {
placement: props.placement,
transform: false,
whileElementsMounted: autoUpdate,
middleware: [
shift(),
flip(),
arrow({ element: floatingArrow }),
offset({
mainAxis: props.offset[0],
crossAxis: props.offset[1],
}),
],
});
function show() {
if (!showPopover.value) {
showPopover.value = true;
emits('show');
}
// 显示时强制清除定时器(无论是否在悬停)
if (hideTimeout)
clearTimeout(hideTimeout);
hideTimeout = null;
}
function hide() {
if (showPopover.value) {
showPopover.value = false;
emits('hide');
}
hideTimeout = null;
}
defineExpose({ show, hide });
watch(showPopover, (newValue) => {
if (newValue && props.trigger !== 'hover') {
onClickOutside(popoverRef, () => hide(), {
ignore: [triggerRef] as any[],
});
}
});
// 触发元素鼠标事件调整同步isHovering状态
function handleTriggerMouseEnter() {
if (props.trigger === 'hover') {
isHovering.value = true; // 进入触发元素
show();
}
}
function handleTriggerMouseLeave() {
if (props.trigger === 'hover') {
isHovering.value = false; // 离开触发元素
// 仅当鼠标不在内容区域时,才设置延迟关闭
scheduleHideIfNeeded();
}
}
// 内容区域鼠标事件调整同步isHovering状态
function handlePopoverMouseEnter() {
if (props.trigger === 'hover') {
isHovering.value = true; // 进入内容区域
if (hideTimeout)
clearTimeout(hideTimeout); // 取消关闭
}
}
function handlePopoverMouseLeave() {
if (props.trigger === 'hover') {
isHovering.value = false; // 离开内容区域
// 仅当鼠标不在触发元素时,才设置延迟关闭
scheduleHideIfNeeded();
}
}
// 新增:统一延迟关闭逻辑(仅当完全离开两个区域时触发)
function scheduleHideIfNeeded() {
// 如果鼠标仍在任一区域isHovering为true不关闭
if (isHovering.value)
return;
// 否则设置延迟关闭
hideTimeout = setTimeout(() => {
if (!isHovering.value) {
// 再次确认是否仍离开
hide();
}
}, props.hoverDelay);
}
function handleClick() {
if (props.trigger === 'click') {
showPopover.value ? hide() : show();
}
else if (props.trigger === 'clickTarget' && !showPopover.value) {
show();
}
}
</script>
<template>
<div
ref="triggerRef"
class="popover-trigger"
:class="[props.triggerClass]"
:style="[props.triggerStyle]"
@mouseenter="handleTriggerMouseEnter"
@mouseleave="handleTriggerMouseLeave"
@click="handleClick"
>
<slot name="trigger" />
</div>
<Teleport to="body">
<Transition name="popover-fade">
<div
v-if="showPopover"
ref="popoverRef"
:style="[floatingStyles, props.popoverStyle]"
class="popover-content-box"
:class="[props.popoverClass]"
@mouseenter="handlePopoverMouseEnter"
@mouseleave="handlePopoverMouseLeave"
>
<slot />
</div>
</Transition>
</Teleport>
</template>
<style scoped lang="scss">
.popover-fade-enter-active,
.popover-fade-leave-active {
transition:
opacity 0.2s ease,
transform 0.2s ease;
will-change: transform, opacity;
}
.popover-fade-enter-from,
.popover-fade-leave-to {
opacity: 0;
transform: scale(0.95);
}
.popover-fade-enter-to,
.popover-fade-leave-from {
opacity: 1;
transform: scale(1);
}
</style>

View File

@@ -0,0 +1,39 @@
<script setup lang="ts">
const props = defineProps<{
className?: string;
name: string;
color?: string;
size?: string;
}>();
const iconName = computed(() => `#icon-${props.name}`);
const svgClass = computed(() => {
if (props.className) {
return `svg-icon ${props.className}`;
}
return 'svg-icon';
});
</script>
<template>
<svg :class="svgClass" aria-hidden="true" :style="{ fontSize: size }">
<use :xlink:href="iconName" :fill="color" />
</svg>
</template>
<style scope lang="scss">
.sub-el-icon,
.nav-icon {
position: relative;
display: inline-block;
margin-right: 12px;
font-size: 15px;
}
.svg-icon {
position: relative;
width: 1em;
height: 1em;
vertical-align: -2px;
fill: currentColor;
}
</style>

View File

@@ -0,0 +1,36 @@
<!-- 欢迎提示词 -->
<script setup lang="ts">
import { Typewriter } from 'vue-element-plus-x';
import { useTimeGreeting } from '@/hooks/useTimeGreeting';
import { useUserStore } from '@/stores';
const greeting = useTimeGreeting();
const userStore = useUserStore();
const username = computed(() => userStore.userInfo?.username ?? '我是 Element Plus X');
</script>
<template>
<div
class="welcome-text w-full flex flex-wrap items-center justify-center text-center text-lg font-semibold mb-32px mt-12px font-size-32px line-height-32px"
>
<Typewriter
:content="`${greeting}好,${username}`"
:typing="{
step: 2,
interval: 45,
}"
:is-fog="{
bgColor: '#fff',
}"
/>
</div>
</template>
<style scoped lang="scss">
:deep {
.typer-container {
overflow: initial;
}
}
</style>