feat: 前端搭建
This commit is contained in:
34
Yi.Ai.Vue3/src/components/DeepThinking/index.vue
Normal file
34
Yi.Ai.Vue3/src/components/DeepThinking/index.vue
Normal 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>
|
||||
150
Yi.Ai.Vue3/src/components/FilesSelect/index.vue
Normal file
150
Yi.Ai.Vue3/src/components/FilesSelect/index.vue
Normal 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>
|
||||
149
Yi.Ai.Vue3/src/components/IconSelect/index.vue
Normal file
149
Yi.Ai.Vue3/src/components/IconSelect/index.vue
Normal 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>
|
||||
93
Yi.Ai.Vue3/src/components/IconSelect/requireIcons.ts
Normal file
93
Yi.Ai.Vue3/src/components/IconSelect/requireIcons.ts
Normal 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;
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
287
Yi.Ai.Vue3/src/components/LoginDialog/index.vue
Normal file
287
Yi.Ai.Vue3/src/components/LoginDialog/index.vue
Normal 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>
|
||||
151
Yi.Ai.Vue3/src/components/ModelSelect/index.vue
Normal file
151
Yi.Ai.Vue3/src/components/ModelSelect/index.vue
Normal 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>
|
||||
203
Yi.Ai.Vue3/src/components/Popover/index.vue
Normal file
203
Yi.Ai.Vue3/src/components/Popover/index.vue
Normal 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>
|
||||
39
Yi.Ai.Vue3/src/components/SvgIcon/index.vue
Normal file
39
Yi.Ai.Vue3/src/components/SvgIcon/index.vue
Normal 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>
|
||||
36
Yi.Ai.Vue3/src/components/WelecomeText/index.vue
Normal file
36
Yi.Ai.Vue3/src/components/WelecomeText/index.vue
Normal 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>
|
||||
Reference in New Issue
Block a user