From 27935cb9b3335a6622ad0713d99197afe397d5e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E8=96=AA=E5=90=8D?= <942005050@qq.com> Date: Tue, 6 Feb 2024 08:46:57 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A4=A7=E5=B0=8F=E5=86=99=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/views/error/403.vue | 8 + src/views/error/404.vue | 7 + src/views/error/500.vue | 7 + src/views/home/Index copy.vue | 381 ++++++++++ src/views/home/Index2.vue | 319 +++++++++ src/views/home/Indexbark.vue | 653 ++++++++++++++++++ src/views/home/echarts-data.ts | 181 +++++ src/views/home/index.vue | 227 ++++++ src/views/home/types.ts | 55 ++ src/views/login/components/LoginForm.vue | 287 ++++++++ src/views/login/components/LoginFormTitle.vue | 26 + src/views/login/components/MobileForm.vue | 225 ++++++ src/views/login/components/QrCodeForm.vue | 30 + src/views/login/components/RegisterForm.vue | 142 ++++ src/views/login/components/SSOLogin.vue | 199 ++++++ src/views/login/components/index.ts | 8 + src/views/login/components/useLogin.ts | 42 ++ src/views/login/login.vue | 111 +++ src/views/profile/components/BasicInfo.vue | 92 +++ src/views/profile/components/ProfileUser.vue | 99 +++ src/views/profile/components/ResetPwd.vue | 73 ++ src/views/profile/components/UserAvatar.vue | 39 ++ src/views/profile/components/UserSocial.vue | 94 +++ src/views/profile/components/index.ts | 7 + src/views/profile/index.vue | 64 ++ src/views/redirect/redirect.vue | 28 + 26 files changed, 3404 insertions(+) create mode 100644 src/views/error/403.vue create mode 100644 src/views/error/404.vue create mode 100644 src/views/error/500.vue create mode 100644 src/views/home/Index copy.vue create mode 100644 src/views/home/Index2.vue create mode 100644 src/views/home/Indexbark.vue create mode 100644 src/views/home/echarts-data.ts create mode 100644 src/views/home/index.vue create mode 100644 src/views/home/types.ts create mode 100644 src/views/login/components/LoginForm.vue create mode 100644 src/views/login/components/LoginFormTitle.vue create mode 100644 src/views/login/components/MobileForm.vue create mode 100644 src/views/login/components/QrCodeForm.vue create mode 100644 src/views/login/components/RegisterForm.vue create mode 100644 src/views/login/components/SSOLogin.vue create mode 100644 src/views/login/components/index.ts create mode 100644 src/views/login/components/useLogin.ts create mode 100644 src/views/login/login.vue create mode 100644 src/views/profile/components/BasicInfo.vue create mode 100644 src/views/profile/components/ProfileUser.vue create mode 100644 src/views/profile/components/ResetPwd.vue create mode 100644 src/views/profile/components/UserAvatar.vue create mode 100644 src/views/profile/components/UserSocial.vue create mode 100644 src/views/profile/components/index.ts create mode 100644 src/views/profile/index.vue create mode 100644 src/views/redirect/redirect.vue diff --git a/src/views/error/403.vue b/src/views/error/403.vue new file mode 100644 index 0000000..a3ec487 --- /dev/null +++ b/src/views/error/403.vue @@ -0,0 +1,8 @@ +<template> + <Error type="403" @error-click="push('/')" /> +</template> +<script lang="ts" setup> +defineOptions({ name: 'Error403' }) + +const { push } = useRouter() +</script> diff --git a/src/views/error/404.vue b/src/views/error/404.vue new file mode 100644 index 0000000..f6a08de --- /dev/null +++ b/src/views/error/404.vue @@ -0,0 +1,7 @@ +<template> + <Error @error-click="push('/')" /> +</template> +<script lang="ts" setup> +defineOptions({ name: 'Error404' }) +const { push } = useRouter() +</script> diff --git a/src/views/error/500.vue b/src/views/error/500.vue new file mode 100644 index 0000000..998487d --- /dev/null +++ b/src/views/error/500.vue @@ -0,0 +1,7 @@ +<template> + <Error type="500" @error-click="push('/')" /> +</template> +<script lang="ts" setup> +defineOptions({ name: 'Error500' }) +const { push } = useRouter() +</script> diff --git a/src/views/home/Index copy.vue b/src/views/home/Index copy.vue new file mode 100644 index 0000000..121ec6a --- /dev/null +++ b/src/views/home/Index copy.vue @@ -0,0 +1,381 @@ +<template> + <div> + <el-card shadow="never"> + <el-skeleton :loading="loading" animated> + <el-row :gutter="20" justify="space-between"> + <el-col :xl="12" :lg="12" :md="12" :sm="24" :xs="24"> + <div class="flex items-center"> + <img :src="avatar" alt="" class="mr-20px h-70px w-70px rounded-[50%]" /> + <div> + <div class="text-20px"> + {{ t('workplace.welcome') }} {{ username }} {{ t('workplace.happyDay') }} + </div> + <div class="mt-10px text-14px text-gray-500"> + {{ t('workplace.toady') }},20℃ - 32℃! + </div> + </div> + </div> + </el-col> + <el-col :xl="12" :lg="12" :md="12" :sm="24" :xs="24"> + <div class="h-70px flex items-center justify-end lt-sm:mt-10px"> + <div class="px-8px text-right"> + <div class="mb-20px text-14px text-gray-400">{{ t('workplace.project') }}</div> + <CountTo + class="text-20px" + :start-val="0" + :end-val="totalSate.project" + :duration="2600" + /> + </div> + <el-divider direction="vertical" /> + <div class="px-8px text-right"> + <div class="mb-20px text-14px text-gray-400">{{ t('workplace.toDo') }}</div> + <CountTo + class="text-20px" + :start-val="0" + :end-val="totalSate.todo" + :duration="2600" + /> + </div> + <el-divider direction="vertical" border-style="dashed" /> + <div class="px-8px text-right"> + <div class="mb-20px text-14px text-gray-400">{{ t('workplace.access') }}</div> + <CountTo + class="text-20px" + :start-val="0" + :end-val="totalSate.access" + :duration="2600" + /> + </div> + </div> + </el-col> + </el-row> + </el-skeleton> + </el-card> + </div> + + <el-row class="mt-5px" :gutter="20" justify="space-between"> + <el-col :xl="16" :lg="16" :md="24" :sm="24" :xs="24" class="mb-10px"> + <el-card shadow="never"> + <template #header> + <div class="h-3 flex justify-between"> + <span>{{ t('workplace.project') }}</span> + <el-link type="primary" :underline="false">{{ t('action.more') }}</el-link> + </div> + </template> + <el-skeleton :loading="loading" animated> + <el-row> + <el-col + v-for="(item, index) in projects" + :key="`card-${index}`" + :xl="8" + :lg="8" + :md="8" + :sm="24" + :xs="24" + > + <el-card shadow="hover"> + <div class="flex items-center"> + <Icon :icon="item.icon" :size="25" class="mr-10px" /> + <span class="text-16px">{{ item.name }}</span> + </div> + <div class="mt-15px text-14px text-gray-400">{{ t(item.message) }}</div> + <div class="mt-20px flex justify-between text-12px text-gray-400"> + <span>{{ item.personal }}</span> + <span>{{ formatTime(item.time, 'yyyy-MM-dd') }}</span> + </div> + </el-card> + </el-col> + </el-row> + </el-skeleton> + </el-card> + + <el-card shadow="never" class="mt-5px"> + <el-skeleton :loading="loading" animated> + <el-row :gutter="20" justify="space-between"> + <el-col :xl="10" :lg="10" :md="24" :sm="24" :xs="24"> + <el-card shadow="hover" class="mb-10px"> + <el-skeleton :loading="loading" animated> + <Echart :options="pieOptionsData" :height="280" /> + </el-skeleton> + </el-card> + </el-col> + <el-col :xl="14" :lg="14" :md="24" :sm="24" :xs="24"> + <el-card shadow="hover" class="mb-10px"> + <el-skeleton :loading="loading" animated> + <Echart :options="barOptionsData" :height="280" /> + </el-skeleton> + </el-card> + </el-col> + </el-row> + </el-skeleton> + </el-card> + </el-col> + <el-col :xl="8" :lg="8" :md="24" :sm="24" :xs="24" class="mb-10px"> + <el-card shadow="never"> + <template #header> + <div class="h-3 flex justify-between"> + <span>{{ t('workplace.shortcutOperation') }}</span> + </div> + </template> + <el-skeleton :loading="loading" animated> + <el-row> + <el-col v-for="item in shortcut" :key="`team-${item.name}`" :span="8" class="mb-10px"> + <div class="flex items-center"> + <Icon :icon="item.icon" class="mr-10px" /> + <el-link type="default" :underline="false" @click="setWatermark(item.name)"> + {{ item.name }} + </el-link> + </div> + </el-col> + </el-row> + </el-skeleton> + </el-card> + <el-card shadow="never" class="mt-10px"> + <template #header> + <div class="h-3 flex justify-between"> + <span>{{ t('workplace.notice') }}</span> + <el-link type="primary" :underline="false">{{ t('action.more') }}</el-link> + </div> + </template> + <el-skeleton :loading="loading" animated> + <div v-for="(item, index) in notice" :key="`dynamics-${index}`"> + <div class="flex items-center"> + <img :src="avatar" alt="" class="mr-20px h-35px w-35px rounded-[50%]" /> + <div> + <div class="text-14px"> + <Highlight :keys="item.keys.map((v) => t(v))"> + {{ item.type }} : {{ item.title }} + </Highlight> + </div> + <div class="mt-15px text-12px text-gray-400"> + {{ formatTime(item.date, 'yyyy-MM-dd') }} + </div> + </div> + </div> + <el-divider /> + </div> + </el-skeleton> + </el-card> + </el-col> + </el-row> +</template> +<script lang="ts" setup> +import { set } from 'lodash-es' +import { EChartsOption } from 'echarts' +import { formatTime } from '@/utils' + +import { useUserStore } from '@/store/modules/user' +import { useWatermark } from '@/hooks/web/useWatermark' +import avatarImg from '@/assets/imgs/avatar.gif' +import type { WorkplaceTotal, Project, Notice, Shortcut } from './types' +import { pieOptions, barOptions } from './echarts-data' + +defineOptions({ name: 'Home' }) + +const { t } = useI18n() +const userStore = useUserStore() +const { setWatermark } = useWatermark() +const loading = ref(true) +const avatar = userStore.getUser.avatar ? userStore.getUser.avatar : avatarImg +const username = userStore.getUser.nickname +const pieOptionsData = reactive<EChartsOption>(pieOptions) as EChartsOption +// 获取统计数 +let totalSate = reactive<WorkplaceTotal>({ + project: 0, + access: 0, + todo: 0 +}) + +const getCount = async () => { + const data = { + project: 40, + access: 2340, + todo: 10 + } + totalSate = Object.assign(totalSate, data) +} + +// 获取项目数 +let projects = reactive<Project[]>([]) +const getProject = async () => { + const data = [ + { + name: 'Github', + icon: 'akar-icons:github-fill', + message: 'workplace.introduction', + personal: 'Archer', + time: new Date() + }, + { + name: 'Vue', + icon: 'logos:vue', + message: 'workplace.introduction', + personal: 'Archer', + time: new Date() + }, + { + name: 'Angular', + icon: 'logos:angular-icon', + message: 'workplace.introduction', + personal: 'Archer', + time: new Date() + }, + { + name: 'React', + icon: 'logos:react', + message: 'workplace.introduction', + personal: 'Archer', + time: new Date() + }, + { + name: 'Webpack', + icon: 'logos:webpack', + message: 'workplace.introduction', + personal: 'Archer', + time: new Date() + }, + { + name: 'Vite', + icon: 'vscode-icons:file-type-vite', + message: 'workplace.introduction', + personal: 'Archer', + time: new Date() + } + ] + projects = Object.assign(projects, data) +} + +// 获取通知公告 +let notice = reactive<Notice[]>([]) +const getNotice = async () => { + const data = [ + { + title: '系统升级版本', + type: '通知', + keys: ['通知', '升级'], + date: new Date() + }, + { + title: '系统凌晨维护', + type: '公告', + keys: ['公告', '维护'], + date: new Date() + }, + { + title: '系统升级版本', + type: '通知', + keys: ['通知', '升级'], + date: new Date() + }, + { + title: '系统凌晨维护', + type: '公告', + keys: ['公告', '维护'], + date: new Date() + } + ] + notice = Object.assign(notice, data) +} + +// 获取快捷入口 +let shortcut = reactive<Shortcut[]>([]) + +const getShortcut = async () => { + const data = [ + { + name: 'Github', + icon: 'akar-icons:github-fill', + url: 'github.io' + }, + { + name: 'Vue', + icon: 'logos:vue', + url: 'vuejs.org' + }, + { + name: 'Vite', + icon: 'vscode-icons:file-type-vite', + url: 'https://vitejs.dev/' + }, + { + name: 'Angular', + icon: 'logos:angular-icon', + url: 'github.io' + }, + { + name: 'React', + icon: 'logos:react', + url: 'github.io' + }, + { + name: 'Webpack', + icon: 'logos:webpack', + url: 'github.io' + } + ] + shortcut = Object.assign(shortcut, data) +} + +// 用户来源 +const getUserAccessSource = async () => { + const data = [ + { value: 335, name: 'analysis.directAccess' }, + { value: 310, name: 'analysis.mailMarketing' }, + { value: 234, name: 'analysis.allianceAdvertising' }, + { value: 135, name: 'analysis.videoAdvertising' }, + { value: 1548, name: 'analysis.searchEngines' } + ] + set( + pieOptionsData, + 'legend.data', + data.map((v) => t(v.name)) + ) + pieOptionsData!.series![0].data = data.map((v) => { + return { + name: t(v.name), + value: v.value + } + }) +} +const barOptionsData = reactive<EChartsOption>(barOptions) as EChartsOption + +// 周活跃量 +const getWeeklyUserActivity = async () => { + const data = [ + { value: 13253, name: 'analysis.monday' }, + { value: 34235, name: 'analysis.tuesday' }, + { value: 26321, name: 'analysis.wednesday' }, + { value: 12340, name: 'analysis.thursday' }, + { value: 24643, name: 'analysis.friday' }, + { value: 1322, name: 'analysis.saturday' }, + { value: 1324, name: 'analysis.sunday' } + ] + set( + barOptionsData, + 'xAxis.data', + data.map((v) => t(v.name)) + ) + set(barOptionsData, 'series', [ + { + name: t('analysis.activeQuantity'), + data: data.map((v) => v.value), + type: 'bar' + } + ]) +} + +const getAllApi = async () => { + await Promise.all([ + getCount(), + getProject(), + getNotice(), + getShortcut(), + getUserAccessSource(), + getWeeklyUserActivity() + ]) + loading.value = false +} + +getAllApi() +</script> diff --git a/src/views/home/Index2.vue b/src/views/home/Index2.vue new file mode 100644 index 0000000..c9429ab --- /dev/null +++ b/src/views/home/Index2.vue @@ -0,0 +1,319 @@ +<template> + <el-row :class="prefixCls" :gutter="20" justify="space-between"> + <el-col :lg="6" :md="12" :sm="12" :xl="6" :xs="24"> + <el-card class="mb-20px" shadow="hover"> + <el-skeleton :loading="loading" :rows="2" animated> + <template #default> + <div :class="`${prefixCls}__item flex justify-between`"> + <div> + <div + :class="`${prefixCls}__item--icon ${prefixCls}__item--peoples p-16px inline-block rounded-6px`" + > + <Icon :size="40" icon="svg-icon:peoples" /> + </div> + </div> + <div class="flex flex-col justify-between"> + <div :class="`${prefixCls}__item--text text-16px text-gray-500 text-right`" + >{{ t('analysis.newUser') }} + </div> + <CountTo + :duration="2600" + :end-val="102400" + :start-val="0" + class="text-right text-20px font-700" + /> + </div> + </div> + </template> + </el-skeleton> + </el-card> + </el-col> + + <el-col :lg="6" :md="12" :sm="12" :xl="6" :xs="24"> + <el-card class="mb-20px" shadow="hover"> + <el-skeleton :loading="loading" :rows="2" animated> + <template #default> + <div :class="`${prefixCls}__item flex justify-between`"> + <div> + <div + :class="`${prefixCls}__item--icon ${prefixCls}__item--message p-16px inline-block rounded-6px`" + > + <Icon :size="40" icon="svg-icon:message" /> + </div> + </div> + <div class="flex flex-col justify-between"> + <div :class="`${prefixCls}__item--text text-16px text-gray-500 text-right`" + >{{ t('analysis.unreadInformation') }} + </div> + <CountTo + :duration="2600" + :end-val="81212" + :start-val="0" + class="text-right text-20px font-700" + /> + </div> + </div> + </template> + </el-skeleton> + </el-card> + </el-col> + + <el-col :lg="6" :md="12" :sm="12" :xl="6" :xs="24"> + <el-card class="mb-20px" shadow="hover"> + <el-skeleton :loading="loading" :rows="2" animated> + <template #default> + <div :class="`${prefixCls}__item flex justify-between`"> + <div> + <div + :class="`${prefixCls}__item--icon ${prefixCls}__item--money p-16px inline-block rounded-6px`" + > + <Icon :size="40" icon="svg-icon:money" /> + </div> + </div> + <div class="flex flex-col justify-between"> + <div :class="`${prefixCls}__item--text text-16px text-gray-500 text-right`" + >{{ t('analysis.transactionAmount') }} + </div> + <CountTo + :duration="2600" + :end-val="9280" + :start-val="0" + class="text-right text-20px font-700" + /> + </div> + </div> + </template> + </el-skeleton> + </el-card> + </el-col> + + <el-col :lg="6" :md="12" :sm="12" :xl="6" :xs="24"> + <el-card class="mb-20px" shadow="hover"> + <el-skeleton :loading="loading" :rows="2" animated> + <template #default> + <div :class="`${prefixCls}__item flex justify-between`"> + <div> + <div + :class="`${prefixCls}__item--icon ${prefixCls}__item--shopping p-16px inline-block rounded-6px`" + > + <Icon :size="40" icon="svg-icon:shopping" /> + </div> + </div> + <div class="flex flex-col justify-between"> + <div :class="`${prefixCls}__item--text text-16px text-gray-500 text-right`" + >{{ t('analysis.totalShopping') }} + </div> + <CountTo + :duration="2600" + :end-val="13600" + :start-val="0" + class="text-right text-20px font-700" + /> + </div> + </div> + </template> + </el-skeleton> + </el-card> + </el-col> + </el-row> + <el-row :gutter="20" justify="space-between"> + <el-col :lg="10" :md="24" :sm="24" :xl="10" :xs="24"> + <el-card class="mb-20px" shadow="hover"> + <el-skeleton :loading="loading" animated> + <Echart :height="300" :options="pieOptionsData" /> + </el-skeleton> + </el-card> + </el-col> + <el-col :lg="14" :md="24" :sm="24" :xl="14" :xs="24"> + <el-card class="mb-20px" shadow="hover"> + <el-skeleton :loading="loading" animated> + <Echart :height="300" :options="barOptionsData" /> + </el-skeleton> + </el-card> + </el-col> + <el-col :span="24"> + <el-card class="mb-20px" shadow="hover"> + <el-skeleton :loading="loading" :rows="4" animated> + <Echart :height="350" :options="lineOptionsData" /> + </el-skeleton> + </el-card> + </el-col> + </el-row> +</template> +<script lang="ts" setup> +import { set } from 'lodash-es' +import { EChartsOption } from 'echarts' + +import { useDesign } from '@/hooks/web/useDesign' +import type { AnalysisTotalTypes } from './types' +import { barOptions, lineOptions, pieOptions } from './echarts-data' + +defineOptions({ name: 'Home2' }) + +const { t } = useI18n() +const loading = ref(true) +const { getPrefixCls } = useDesign() +const prefixCls = getPrefixCls('panel') +const pieOptionsData = reactive<EChartsOption>(pieOptions) as EChartsOption + +let totalState = reactive<AnalysisTotalTypes>({ + users: 0, + messages: 0, + moneys: 0, + shoppings: 0 +}) + +const getCount = async () => { + const data = { + users: 102400, + messages: 81212, + moneys: 9280, + shoppings: 13600 + } + totalState = Object.assign(totalState, data) +} + +// 用户来源 +const getUserAccessSource = async () => { + const data = [ + { value: 335, name: 'analysis.directAccess' }, + { value: 310, name: 'analysis.mailMarketing' }, + { value: 234, name: 'analysis.allianceAdvertising' }, + { value: 135, name: 'analysis.videoAdvertising' }, + { value: 1548, name: 'analysis.searchEngines' } + ] + set( + pieOptionsData, + 'legend.data', + data.map((v) => t(v.name)) + ) + set(pieOptionsData, 'series.data', data) +} +const barOptionsData = reactive<EChartsOption>(barOptions) as EChartsOption + +// 周活跃量 +const getWeeklyUserActivity = async () => { + const data = [ + { value: 13253, name: 'analysis.monday' }, + { value: 34235, name: 'analysis.tuesday' }, + { value: 26321, name: 'analysis.wednesday' }, + { value: 12340, name: 'analysis.thursday' }, + { value: 24643, name: 'analysis.friday' }, + { value: 1322, name: 'analysis.saturday' }, + { value: 1324, name: 'analysis.sunday' } + ] + set( + barOptionsData, + 'xAxis.data', + data.map((v) => t(v.name)) + ) + set(barOptionsData, 'series', [ + { + name: t('analysis.activeQuantity'), + data: data.map((v) => v.value), + type: 'bar' + } + ]) +} + +const lineOptionsData = reactive<EChartsOption>(lineOptions) as EChartsOption + +// 每月销售总额 +const getMonthlySales = async () => { + const data = [ + { estimate: 100, actual: 120, name: 'analysis.january' }, + { estimate: 120, actual: 82, name: 'analysis.february' }, + { estimate: 161, actual: 91, name: 'analysis.march' }, + { estimate: 134, actual: 154, name: 'analysis.april' }, + { estimate: 105, actual: 162, name: 'analysis.may' }, + { estimate: 160, actual: 140, name: 'analysis.june' }, + { estimate: 165, actual: 145, name: 'analysis.july' }, + { estimate: 114, actual: 250, name: 'analysis.august' }, + { estimate: 163, actual: 134, name: 'analysis.september' }, + { estimate: 185, actual: 56, name: 'analysis.october' }, + { estimate: 118, actual: 99, name: 'analysis.november' }, + { estimate: 123, actual: 123, name: 'analysis.december' } + ] + set( + lineOptionsData, + 'xAxis.data', + data.map((v) => t(v.name)) + ) + set(lineOptionsData, 'series', [ + { + name: t('analysis.estimate'), + smooth: true, + type: 'line', + data: data.map((v) => v.estimate), + animationDuration: 2800, + animationEasing: 'cubicInOut' + }, + { + name: t('analysis.actual'), + smooth: true, + type: 'line', + itemStyle: {}, + data: data.map((v) => v.actual), + animationDuration: 2800, + animationEasing: 'quadraticOut' + } + ]) +} + +const getAllApi = async () => { + await Promise.all([getCount(), getUserAccessSource(), getWeeklyUserActivity(), getMonthlySales()]) + loading.value = false +} + +getAllApi() +</script> + +<style lang="scss" scoped> +$prefix-cls: #{$namespace}-panel; + +.#{$prefix-cls} { + &__item { + &--peoples { + color: #40c9c6; + } + + &--message { + color: #36a3f7; + } + + &--money { + color: #f4516c; + } + + &--shopping { + color: #34bfa3; + } + + &:hover { + :deep(.#{$namespace}-icon) { + color: #fff !important; + } + + .#{$prefix-cls}__item--icon { + transition: all 0.38s ease-out; + } + + .#{$prefix-cls}__item--peoples { + background: #40c9c6; + } + + .#{$prefix-cls}__item--message { + background: #36a3f7; + } + + .#{$prefix-cls}__item--money { + background: #f4516c; + } + + .#{$prefix-cls}__item--shopping { + background: #34bfa3; + } + } + } +} +</style> diff --git a/src/views/home/Indexbark.vue b/src/views/home/Indexbark.vue new file mode 100644 index 0000000..2627b64 --- /dev/null +++ b/src/views/home/Indexbark.vue @@ -0,0 +1,653 @@ +<template> + <div> + <ContentWrap class="search"> + <el-form + :inline="true" + :model="queryParams" + class="demo-form-inline" + style="margin-bottom: -17px" + > + <el-form-item label="" label-width="0px" style="width: calc(20% - 32px)"> + <el-select + v-model="queryParams.project" + placeholder="请选择项目" + clearable + style="width: 100%" + > + <el-option label="Zone one" value="shanghai" /> + <el-option label="Zone two" value="beijing" /> + </el-select> + </el-form-item> + <el-form-item label="" label-width="0px" style="width: calc(20% - 32px)"> + <el-input + v-model="queryParams.code" + placeholder="请输入物料编号" + clearable + style="width: 100%" + /> + </el-form-item> + <el-form-item label="" label-width="0px" style="width: calc(20% - 32px)"> + <el-select + v-model="queryParams.type" + placeholder="请选择检测类型" + clearable + style="width: 100%" + > + <el-option label="Zone one" value="shanghai" /> + <el-option label="Zone two" value="beijing" /> + </el-select> + </el-form-item> + <el-form-item label="" label-width="0px" style="width: calc(20% - 32px)"> + <el-input + v-model="queryParams.content" + placeholder="请输入检测内容" + clearable + style="width: 100%" + /> + </el-form-item> + <el-form-item label="" label-width="0px" style="width: calc(20% - 32px)"> + <el-date-picker + v-model="queryParams.date" + type="date" + placeholder="请选择日期" + clearable + /> + </el-form-item> + </el-form> + </ContentWrap> + <ContentWrap class="search"> + <div class="title">制程能力指数(CPK)</div> + <div class="cpk"> + <div class="cpk-item"> + <div class="cpk-item-1"> + <div class="cpk-item-label">CP</div> + <div class="cpk-item-value">0.25</div> + </div> + <div class="cpk-item-1"> + <div class="cpk-item-label">CPK</div> + <div class="cpk-item-value">0.11</div> + </div> + </div> + <div class="cpk-item"> + <div class="cpk-item-1"> + <div class="cpk-item-label">CP</div> + <div class="cpk-item-value">0.25</div> + </div> + <div class="cpk-item-1"> + <div class="cpk-item-label">CPK</div> + <div class="cpk-item-value">0.11</div> + </div> + </div> + <div class="cpk-item1"> + <div class="cpk-item-2"> + <div class="cpk-item-label"><span>零件号</span> <span>1662525525</span></div> + <div class="cpk-item-label"><span>检测内容</span> <span>25+0.6 G10</span></div> + </div> + <div class="cpk-item-2"> + <div class="cpk-item-label"><span>公差下限</span> <span>24.4</span></div> + <div class="cpk-item-label"><span>公差上限</span> <span>25.6</span></div> + </div> + <div class="cpk-item-2"> + <div class="cpk-item-label"><span>X中值</span> <span>24.4</span></div> + <div class="cpk-item-label"><span>R中值</span> <span>1.88</span></div> + </div> + <div class="cpk-item-2"> + <div class="cpk-item-label"><span>UCLx</span> <span>25.66666</span></div> + <div class="cpk-item-label"><span>UCLx</span> <span>23.55555</span></div> + </div> + <div class="cpk-item-2"> + <div class="cpk-item-label"><span>LCLrc</span> <span>25.66666</span></div> + <div class="cpk-item-label"><span>LCLrc</span> <span>0</span></div> + </div> + </div> + </div> + <table border="1" width="100%" align="center" border-collapse="collapse"> + <tr class="td-bg"> + <td colspan="2">样本</td> + <td>1</td> + <td>2</td> + <td>3</td> + <td>4</td> + <td>5</td> + <td>6</td> + <td>7</td> + <td>8</td> + <td>9</td> + <td>10</td> + <td>12</td> + <td>13</td> + <td>14</td> + <td>15</td> + <td>16</td> + <td>17</td> + <td>18</td> + <td>19</td> + <td>20</td> + <td>21</td> + <td>22</td> + <td>23</td> + <td>24</td> + <td>25</td> + </tr> + <tr> + <td rowspan="8" class="td-bg">检测记录</td> + <td class="td-bg">1</td> + <td>24</td> + <td>25</td> + <td>24</td> + <td>24</td> + <td>24</td> + <td>24</td> + </tr> + <tr> + <td class="td-bg">2</td> + <td>24</td> + <td>25</td> + <td>24</td> + <td>24</td> + <td>24</td> + <td>24</td> + </tr> + <tr> + <td class="td-bg">3</td> + <td>24</td> + <td>25</td> + <td>24</td> + <td>24</td> + <td>24</td> + <td>24</td> + </tr> + <tr> + <td class="td-bg">4</td> + <td>24</td> + <td>25</td> + <td>24</td> + <td>24</td> + <td>24</td> + <td>24</td> + </tr> + <tr> + <td class="td-bg">5</td> + <td>24</td> + <td>25</td> + <td>24</td> + <td>24</td> + <td>24</td> + <td>24</td> + </tr> + <tr> + <td class="td-bg">合计值</td> + <td>24</td> + <td>25</td> + <td>24</td> + <td>24</td> + <td>24</td> + <td>24</td> + </tr> + <tr> + <td class="td-bg">平均值</td> + <td>24</td> + <td>25</td> + <td>24</td> + <td>24</td> + <td>24</td> + <td>24</td> + </tr> + <tr> + <td class="td-bg">极差值</td> + <td>24</td> + <td>25</td> + <td>24</td> + <td>24</td> + <td>24</td> + <td>24</td> + </tr> + </table> + </ContentWrap> + <ContentWrap class="search"> + <div class="tabs"> + <div class="title" :class="tabIndex == 0 ? 'active' : ''" @click="tabIndex = 0">控制图</div> + <div class="title" :class="tabIndex == 1 ? 'active' : ''" @click="tabIndex = 1" + >CPK分析图</div + > + <div class="title" :class="tabIndex == 2 ? 'active' : ''" @click="tabIndex = 2" + >样本运行图</div + > + <div class="title" :class="tabIndex == 3 ? 'active' : ''" @click="tabIndex = 3" + >均值运行图</div + > + </div> + <div class="charts" v-show="tabIndex == 0"> + <div id="myEcharts1" :style="{ width: '50%', height: '300px' }"></div> + <div id="myEcharts2" :style="{ width: '50%', height: '300px' }"></div> + </div> + <div class="charts" v-show="tabIndex == 1"> + <div id="myEcharts3" :style="{ width: '80vw', height: '300px' }"></div> + </div> + <div class="charts" v-show="tabIndex == 2"> + <div id="myEcharts4" :style="{ width: '80vw', height: '300px' }"></div> + </div> + <div class="charts" v-show="tabIndex == 3"> + <div id="myEcharts5" :style="{ width: '80vw', height: '300px' }"></div> + </div> + </ContentWrap> + </div> +</template> +<script lang="ts" setup> +import * as echarts from 'echarts' +let echart = echarts +const queryParams = reactive({ + project: '', + code: '', + type: '', + content: '', + date: '' +}) +const tabIndex = ref(0) +// 设置延期未交付数据 +function setChart1() { + let chart = echart.init(document.getElementById('myEcharts1'), 'light') + // 把配置和数据放这里 + chart.setOption({ + title: { + text: 'RANGE CHART' + }, + tooltip: { + trigger: 'axis' + }, + legend: { + data: ['Highest', 'Lowest'], + top: '0', + right: '3%' + }, + grid: { + left: '3%', + right: '4%', + top: '15%', + bottom: '10%', + containLabel: true + }, + xAxis: { + type: 'category', + data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] + }, + yAxis: { + type: 'value' + }, + series: [ + { + name: 'Highest', + data: [150, 230, 224, 218, 135, 147, 260], + type: 'line', + markLine: { + data: [{ type: 'average', name: 'Avg' }] + } + }, + { + name: 'Lowest', + data: [344, 23, 56, 88, 95, 54, 45], + type: 'line' + } + ] + }) + + window.onresize = function () { + //自适应大小 + chart.resize() + } +} +function setChart2() { + let chart = echart.init(document.getElementById('myEcharts2'), 'light') + // 把配置和数据放这里 + chart.setOption({ + title: { + text: 'RANGE CHART' + }, + tooltip: { + trigger: 'axis' + }, + legend: { + data: ['Highest', 'Lowest'], + top: '0', + right: '3%' + }, + grid: { + left: '3%', + right: '4%', + top: '15%', + bottom: '10%', + containLabel: true + }, + xAxis: { + type: 'category', + data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] + }, + yAxis: { + type: 'value' + }, + series: [ + { + name: 'Highest', + data: [150, 230, 224, 218, 135, 147, 260], + type: 'line' + }, + { + name: 'Lowest', + data: [344, 23, 56, 88, 95, 54, 45], + type: 'line' + } + ] + }) + window.onresize = function () { + //自适应大小 + chart.resize() + } +} +function setChart3() { + let chart = echart.init(document.getElementById('myEcharts3'), 'light') + // 把配置和数据放这里 + chart.setOption({ + tooltip: { + trigger: 'axis', + axisPointer: { + type: 'cross', + crossStyle: { + color: '#999' + } + } + }, + legend: { + data: ['Evaporation', 'Precipitation', 'Temperature'] + }, + grid: { + left: '3%', + right: '4%', + top: '15%', + bottom: '10%', + containLabel: true + }, + xAxis: [ + { + type: 'category', + data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'], + axisPointer: { + type: 'shadow' + } + } + ], + yAxis: [ + { + type: 'value', + name: 'Precipitation', + min: 0, + max: 250, + interval: 50, + axisLabel: { + formatter: '{value} ml' + } + }, + { + type: 'value', + name: 'Temperature', + min: 0, + max: 25, + interval: 5, + axisLabel: { + formatter: '{value} °C' + } + } + ], + series: [ + { + name: '原数据频率', + type: 'bar', + tooltip: { + valueFormatter: function (value) { + return value + ' ml' + } + }, + data: [2.6, 5.9, 9.0, 26.4, 28.7, 70.7, 175.6, 182.2, 48.7, 18.8, 6.0, 2.3] + }, + { + name: '正态分布', + type: 'line', + yAxisIndex: 1, + tooltip: { + valueFormatter: function (value) { + return value + ' °C' + } + }, + data: [2.0, 2.2, 3.3, 4.5, 6.3, 10.2, 20.3, 23.4, 23.0, 16.5, 12.0, 6.2] + } + ] + }) + window.onresize = function () { + //自适应大小 + chart.resize() + } +} +function setChart4() { + let chart = echart.init(document.getElementById('myEcharts4'), 'light') + // 把配置和数据放这里 + chart.setOption({ + title: { + text: '样本运行图' + }, + tooltip: { + trigger: 'axis' + }, + legend: { + data: ['Highest', 'Lowest'], + top: '0', + right: '3%' + }, + grid: { + left: '3%', + right: '4%', + top: '15%', + bottom: '10%', + containLabel: true + }, + xAxis: { + type: 'category', + data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] + }, + yAxis: { + type: 'value' + }, + series: [ + { + name: 'Highest', + data: [150, 230, 224, 218, 135, 147, 260], + type: 'line' + }, + { + name: 'Lowest', + data: [344, 23, 56, 88, 95, 54, 45], + type: 'line' + } + ] + }) + window.onresize = function () { + //自适应大小 + chart.resize() + } +} +function setChart5() { + let chart = echart.init(document.getElementById('myEcharts5'), 'light') + // 把配置和数据放这里 + chart.setOption({ + title: { + text: '均值运行图' + }, + tooltip: { + trigger: 'axis' + }, + legend: { + data: ['Highest', 'Lowest'], + top: '0', + right: '3%' + }, + grid: { + left: '3%', + right: '4%', + top: '15%', + bottom: '10%', + containLabel: true + }, + xAxis: { + type: 'category', + data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] + }, + yAxis: { + type: 'value' + }, + series: [ + { + name: 'Highest', + data: [150, 230, 224, 218, 135, 147, 260], + type: 'line' + }, + { + name: 'Lowest', + data: [344, 23, 56, 88, 95, 54, 45], + type: 'line' + } + ] + }) + window.onresize = function () { + //自适应大小 + chart.resize() + } +} +onMounted(async () => { + setChart1() + setChart2() + setChart3() + setChart4() + setChart5() +}) +</script> +<style lang="scss" scoped> +.search { + background: white; +} +::v-deep.el-form-item--default .el-form-item__label { + padding: 0px 20px !important; + background: rgb(53, 158, 256) !important; + color: white !important; +} +.title { + margin-bottom: 20px; + margin-right: 20px; +} +.cpk { + display: flex; + align-items: center; + .cpk-item { + display: flex; + align-items: center; + width: 10%; + margin-right: 10px; + background: rgb(235, 243, 255); + border: 1px solid rgb(44, 135, 255); + height: 60px; + justify-content: center; + .cpk-item-1 { + text-align: center; + margin: 0px 10px; + .cpk-item-label { + color: #a8a8a8; + font-size: 12px; + } + .cpk-item-value { + color: #000000; + font-size: 16px; + } + } + } + .cpk-item1 { + background: rgb(245, 245, 245); + flex: 1; + + display: flex; + align-items: center; + justify-content: space-around; + height: 60px; + .cpk-item-2 { + .cpk-item-label { + color: #a8a8a8; + font-size: 12px; + span { + &:nth-child(1) { + width: 60px; + display: inline-block; + text-align: right; + margin-right: 6px; + } + &:nth-child(2) { + margin-right: 6px; + color: #000; + } + } + } + } + } +} +table { + width: 100%; + /*居中*/ + margin: 20px auto 0px; + /*边框*/ + /* border: 1px solid black; */ + + border-collapse: collapse; + /*设置背景颜色*/ + /* background-color: #bfa; */ +} + +/* + * 设置边框 + */ +td, +th { + border: 1px solid rgb(200, 200, 200); + text-align: center; + font-size: 14px; +} + +/* + * 设置隔行变色 + */ +// tbody > tr:nth-child(even) { +// background-color: #bfa; +// } + +/* + * 鼠标移入到tr以后,改变颜色 + */ +// tr:hover { +// background-color: #ff0; +// } +.td-bg { + background: rgb(231, 244, 248); +} +.tabs { + display: flex; + align-items: center; + > div { + height: 30px; + cursor: pointer; + } + .active { + color: rgb(44, 135, 255); + font-weight: bold; + border-bottom: 2px solid rgb(44, 135, 255); + } +} +.charts { + display: flex; + align-content: center; + justify-content: center; +} +</style> \ No newline at end of file diff --git a/src/views/home/echarts-data.ts b/src/views/home/echarts-data.ts new file mode 100644 index 0000000..0f39f4e --- /dev/null +++ b/src/views/home/echarts-data.ts @@ -0,0 +1,181 @@ +import { EChartsOption } from 'echarts' + +const { t } = useI18n() + +export const lineOptions: EChartsOption = { + title: { + text: t('analysis.monthlySales'), + left: 'center' + }, + xAxis: { + data: [ + ], + boundaryGap: false, + axisTick: { + show: false + } + }, + grid: { + left: 20, + right: 20, + bottom: 20, + top: 80, + containLabel: true + }, + tooltip: { + trigger: 'axis', + axisPointer: { + type: 'cross' + }, + padding: [5, 10] + }, + yAxis: { + axisTick: { + show: false + } + }, + legend: { + data: [], + top: 50 + }, + series: [ + { + name: "", + smooth: true, + type: 'line', + data: [], + animationDuration: 2800, + animationEasing: 'cubicInOut' + }, + { + name: t('analysis.actual'), + smooth: true, + type: 'line', + itemStyle: {}, + data: [], + animationDuration: 2800, + animationEasing: 'quadraticOut' + } + ] +} + +export const pieOptions: EChartsOption = { + title: { + text: "", + left: 'center' + }, + tooltip: { + trigger: 'item', + formatter: '{a} <br/>{b} : {c} ({d}%)' + }, + legend: { + orient: 'vertical', + left: 'left', + data: [ + ] + }, + series: [ + { + name: "", + type: 'pie', + radius: '55%', + center: ['50%', '60%'], + data: [ + ] + } + ] +} + +export const barOptions: EChartsOption = { + title: { + text: "", + left: 'center' + }, + tooltip: { + trigger: 'axis', + axisPointer: { + type: 'shadow' + } + }, + grid: { + left: 50, + right: 20, + bottom: 20 + }, + xAxis: { + type: 'category', + data: [ + ], + axisTick: { + alignWithLabel: true + } + }, + yAxis: { + type: 'value' + }, + series: [ + { + name: "", + data: [], + type: 'bar' + } + ] +} + +export const radarOption: EChartsOption = { + legend: { + data: [] + }, + radar: { + // shape: 'circle', + indicator: [ + + ] + }, + series: [ + { + name: "", + type: 'radar', + data: [ + + ] + } + ] +} + +export const wordOptions = { + series: [ + { + type: 'wordCloud', + gridSize: 2, + sizeRange: [12, 50], + rotationRange: [-90, 90], + shape: 'pentagon', + width: 600, + height: 400, + drawOutOfBound: true, + textStyle: { + color: function () { + return ( + 'rgb(' + + [ + Math.round(Math.random() * 160), + Math.round(Math.random() * 160), + Math.round(Math.random() * 160) + ].join(',') + + ')' + ) + } + }, + emphasis: { + textStyle: { + shadowBlur: 10, + shadowColor: '#333' + } + }, + data: [ + + ] + } + ] +} diff --git a/src/views/home/index.vue b/src/views/home/index.vue new file mode 100644 index 0000000..ad22b79 --- /dev/null +++ b/src/views/home/index.vue @@ -0,0 +1,227 @@ +<template> + <el-row :gutter="14" justify="space-between"> + <el-col :xl="12" :lg="12" :md="12" :sm="24" :xs="24" class="mb-14px"> + <el-card shadow="never"> + <Echart :options="barOptionsData" :height="280" /> + </el-card> + </el-col> + <el-col :xl="12" :lg="12" :md="12" :sm="24" :xs="24" class="mb-14px"> + <el-card shadow="never"> + <Echart :options="lineOptionsData" :height="280" /> + </el-card> + </el-col> + <el-col :xl="24" :lg="24" :md="24" :sm="24" :xs="24"> + <el-card shadow="never"> + <div class="title font-size-18px font-bold mb-10px">项目物料检测类型次数清单</div> + <el-table :data="tableData" style="width: 100%"> + <el-table-column label="序号" width="80" type="index" align="center" /> + <el-table-column prop="projectName" label="项目" align="center" /> + <el-table-column prop="itemCode" label="物料代码" align="center" /> + <el-table-column prop="itemName" label="物料名称" align="center" /> + <el-table-column prop="itemTypeName" label="检查类型" align="center" /> + <el-table-column prop="cishu" label="次数" align="center" /> + </el-table> + </el-card> + </el-col> + <el-col :xl="24" :lg="24" :md="24" :sm="24" :xs="24" class="mt-14px"> + <el-card shadow="never"> + <div class="title font-size-18px font-bold mb-10px">项目物料检测类型时间清单</div> + <el-table :data="tableData1" style="width: 100%" height="450px"> + <el-table-column label="序号" width="80" type="index" align="center" /> + <el-table-column prop="itemCode" label="物料代码" align="center" /> + <el-table-column prop="itemName" label="物料名称" align="center" /> + <el-table-column prop="itemTypeName" label="检测类型" align="center" /> + <el-table-column prop="times" label="导入时间" align="center" /> + <el-table-column prop="createName" label="操作人" align="center" /> + </el-table> + </el-card> + </el-col> + </el-row> +</template> +<script lang="ts" setup> +import { set } from 'lodash-es' +import { EChartsOption } from 'echarts' +import { lineOptions, barOptions } from './echarts-data' +import * as rescordAPI from '@/api/home/shouye' + +defineOptions({ name: 'Home' }) +const { t } = useI18n() +const tableData = ref([]) +const tableData1 = ref([]) +const echartsData = ref() +const barOptionsData = reactive<EChartsOption>(barOptions) as EChartsOption +const getWeeklyUserActivity = async () => { + const xdata = echartsData.value.days.map((item) => item) + const data = echartsData.value.CHROMATIC_ABERRATION + const data1 = echartsData.value.DIMENSION //尺寸 + const data2 = echartsData.value.GLOSS //光泽桔皮 + const data3 = echartsData.value.PROPERTY //性能 + // const data = [ + // { value: 13253, name: 'analysis.monday' }, + // { value: 34235, name: 'analysis.tuesday' }, + // { value: 26321, name: 'analysis.wednesday' }, + // { value: 12340, name: 'analysis.thursday' }, + // { value: 24643, name: 'analysis.friday' }, + // { value: 1322, name: 'analysis.saturday' }, + // { value: 1324, name: 'analysis.sunday' } + // ] + // const data1 = [ + // { value: 23444, name: 'analysis.monday' }, + // { value: 12344, name: 'analysis.tuesday' }, + // { value: 34455, name: 'analysis.wednesday' }, + // { value: 3434, name: 'analysis.thursday' }, + // { value: 43333, name: 'analysis.friday' }, + // { value: 45555, name: 'analysis.saturday' }, + // { value: 1324, name: 'analysis.sunday' } + // ] + // const data2 = [ + // { value: 4344, name: 'analysis.monday' }, + // { value: 34454, name: 'analysis.tuesday' }, + // { value: 4322, name: 'analysis.wednesday' }, + // { value: 9897, name: 'analysis.thursday' }, + // { value: 89076, name: 'analysis.friday' }, + // { value: 5434, name: 'analysis.saturday' }, + // { value: 67674, name: 'analysis.sunday' } + // ] + // const data3 = [ + // { value: 4344, name: 'analysis.monday' }, + // { value: 34454, name: 'analysis.tuesday' }, + // { value: 4322, name: 'analysis.wednesday' }, + // { value: 9897, name: 'analysis.thursday' }, + // { value: 89076, name: 'analysis.friday' }, + // { value: 5434, name: 'analysis.saturday' }, + // { value: 67674, name: 'analysis.sunday' } + // ] + set(barOptionsData, 'title', { + text: '检测类型导入次数(近7天)', + left: 0, + textStyle: { + fontSize: 14 + } + }) + set(barOptionsData, 'xAxis.data', xdata) + set(barOptionsData, 'legend', { + data: ['色差', '尺寸', '光泽桔皮', '性能'], + top: 0, + right: 20 + }) + set(barOptionsData, 'series', [ + { + name: '色差', + data: data, + // data: data.map((v) => v.value), + type: 'bar', + animationDuration: 2000, + animationEasing: 'cubicInOut' + }, + { + name: '尺寸', + data: data1, + type: 'bar', + animationDuration: 2000, + animationEasing: 'cubicInOut' + }, + { + name: '光泽桔皮', + data: data2, + type: 'bar', + animationDuration: 2000, + animationEasing: 'cubicInOut' + }, + { + name: '性能', + data: data3, + type: 'bar', + animationDuration: 2000, + animationEasing: 'cubicInOut' + } + ]) +} + +const lineOptionsData = reactive<EChartsOption>(lineOptions) as EChartsOption +const getWeeklyUserActivity1 = async () => { + const xdata = echartsData.value.days + const data = echartsData.value.CHROMATIC_ABERRATION //色差 + const data1 = echartsData.value.DIMENSION //尺寸 + const data2 = echartsData.value.GLOSS //光泽桔皮 + const data3 = echartsData.value.PROPERTY //性能 + set(lineOptionsData, 'title', { + text: '检测类型导入次数(近7天)', + left: 0, + textStyle: { + fontSize: 14 + } + }) + set(lineOptionsData, 'xAxis.data', xdata) + set(lineOptionsData, 'legend', { + data: ['色差', '尺寸', '光泽桔皮', '性能'], + top: 0, + right: 20 + }) + set(lineOptionsData, 'series', [ + { + name: '色差', + data: data, + smooth: true, + type: 'line', + animationDuration: 2000, + animationEasing: 'cubicInOut' + }, + { + name: '尺寸', + data: data1, + smooth: true, + type: 'line', + animationDuration: 2000, + animationEasing: 'cubicInOut' + }, + { + name: '光泽桔皮', + data: data2, + smooth: true, + type: 'line', + animationDuration: 2000, + animationEasing: 'cubicInOut' + }, + { + name: '性能', + data: data3, + smooth: true, + type: 'line', + animationDuration: 2000, + animationEasing: 'cubicInOut' + } + ]) +} +const handleResize = () => { + window.location.reload() +} +onMounted(async () => { + //初始化方法 + tableData.value = await rescordAPI.getSYFrequency() + tableData1.value = await rescordAPI.getSYTime() + echartsData.value = await rescordAPI.getSYEchartsData() + await getWeeklyUserActivity() + await getWeeklyUserActivity1() + window.addEventListener('resize', handleResize) +}) +onUnmounted(() => { + window.removeEventListener('resize', handleResize); +}) +</script> +<style lang="scss" scoped> +.title { + position: relative; + padding-left: 16px; + &::after { + content: ''; + position: absolute; + left: 0px; + width: 5px; + height: 20px; + background: #409eff; + top: 50%; + margin-top: -9px; + } +} +</style> diff --git a/src/views/home/types.ts b/src/views/home/types.ts new file mode 100644 index 0000000..e6313d3 --- /dev/null +++ b/src/views/home/types.ts @@ -0,0 +1,55 @@ +export type WorkplaceTotal = { + project: number + access: number + todo: number +} + +export type Project = { + name: string + icon: string + message: string + personal: string + time: Date | number | string +} + +export type Notice = { + title: string + type: string + keys: string[] + date: Date | number | string +} + +export type Shortcut = { + name: string + icon: string + url: string +} + +export type RadarData = { + personal: number + team: number + max: number + name: string +} +export type AnalysisTotalTypes = { + users: number + messages: number + moneys: number + shoppings: number +} + +export type UserAccessSource = { + value: number + name: string +} + +export type WeeklyUserActivity = { + value: number + name: string +} + +export type MonthlySales = { + name: string + estimate: number + actual: number +} diff --git a/src/views/login/components/LoginForm.vue b/src/views/login/components/LoginForm.vue new file mode 100644 index 0000000..740a6fc --- /dev/null +++ b/src/views/login/components/LoginForm.vue @@ -0,0 +1,287 @@ +<template> + <el-form v-show="getShow" ref="formLogin" :model="loginData.loginForm" :rules="LoginRules" class="login-form" + label-position="top" label-width="120px" size="large"> + <el-row style="margin-right: -10px; margin-left: -10px"> + <el-col :span="24" style="padding-right: 10px; padding-left: 10px"> + <el-form-item> + <!-- <LoginFormTitle style="width: 100%" /> --> + <div class="font-size-18px">您好,欢迎使用<span class="color-[rgb(74,154,236)]">长春华涛SPC系统</span></div> + </el-form-item> + </el-col> + <!-- <el-col :span="24" style="padding-right: 10px; padding-left: 10px"> + <el-form-item v-if="loginData.tenantEnable === 'true'" label="租户名称"> + <el-input v-model="loginData.loginForm.tenantName" :placeholder="t('login.tenantNamePlaceholder')" + link type="primary" /> + </el-form-item> + </el-col> --> + <el-col :span="24" style="padding-right: 10px; padding-left: 10px"> + <el-form-item label="用户名"> + <el-input v-model="loginData.loginForm.username" :placeholder="t('login.usernamePlaceholder')" + :prefix-icon="iconAvatar" style="height: 42px;" /> + </el-form-item> + </el-col> + <el-col :span="24" style="padding-right: 10px; padding-left: 10px"> + <el-form-item label="密码"> + <el-input v-model="loginData.loginForm.password" :placeholder="t('login.passwordPlaceholder')" + :prefix-icon="iconLock" show-password type="password" @keyup.enter="getCode()" style="height: 42px;" /> + </el-form-item> + </el-col> + <el-col :span="24" style="padding-right: 10px; padding-left: 10px"> + <el-form-item prop="code" label="验证码"> + <div class="flex w-[100%]"> + <el-input v-model="loginData.loginForm.code" :placeholder="t('login.codePlaceholder')" + style="width: 76%;margin-right: 10px;height: 42px;" @keyup.enter="handleLogin()"> + <template #prefix> + <img src="@/assets/imgs/code.png" alt="" style="width: 16px;height: 16px;" /> + </template> + </el-input> + <div class="login-code flex-1"> + <img :src="codeUrl" @click="getCode" class="login-code-img" /> + </div> + </div> + + </el-form-item> + </el-col> + + + <el-col :span="24" style="padding-right: 10px; padding-left: 10px; margin-top: -20px; margin-bottom: -20px"> + <el-form-item> + <el-row justify="space-between" style="width: 100%"> + <el-col :span="6"> + <el-checkbox v-model="loginData.loginForm.rememberMe"> + {{ t('login.remember') }} + </el-checkbox> + </el-col> + <el-col :offset="6" :span="12"> + <el-link style="float: right" type="primary">{{ t('login.forgetPassword') }}</el-link> + </el-col> + </el-row> + </el-form-item> + </el-col> + + <el-col :span="24" style="padding-right: 10px; padding-left: 10px"> + <el-form-item> + <XButton :loading="loginLoading" :title="t('login.login')" class="w-[100%]" type="primary" + @click="handleLogin()" /> + </el-form-item> + </el-col> + <!-- <Verify + ref="verify" + :captchaType="captchaType" + :imgSize="{ width: '400px', height: '200px' }" + mode="pop" + @success="handleLogin" + /> --> + </el-row> + </el-form> +</template> +<script lang="ts" setup> +import { ElLoading } from 'element-plus' +import LoginFormTitle from './LoginFormTitle.vue' +import type { RouteLocationNormalizedLoaded } from 'vue-router' + +import { useIcon } from '@/hooks/web/useIcon' + +import * as authUtil from '@/utils/auth' +import { usePermissionStore } from '@/store/modules/permission' +import * as LoginApi from '@/api/login' +import { LoginStateEnum, useFormValid, useLoginState } from './useLogin' +import { getCodeImg } from "@/api/login"; + +defineOptions({ name: 'LoginForm' }) + +const { t } = useI18n() +const message = useMessage() +const iconHouse = useIcon({ icon: 'ep:house' }) +const iconAvatar = useIcon({ icon: 'ep:avatar' }) +const iconLock = useIcon({ icon: 'ep:lock' }) +const formLogin = ref() +const { validForm } = useFormValid(formLogin) +const { setLoginState, getLoginState } = useLoginState() +const { currentRoute, push } = useRouter() +const permissionStore = usePermissionStore() +const redirect = ref<string>('') +const loginLoading = ref(false) +const verify = ref() +const captchaType = ref('blockPuzzle') // blockPuzzle 滑块 clickWord 点击文字 + +const getShow = computed(() => unref(getLoginState) === LoginStateEnum.LOGIN) +// 验证码开关 +const captchaEnabled = ref(true); +const codeUrl = ref(""); + +const LoginRules = { + tenantName: [required], + username: [required], + password: [required] +} +const loginData = reactive({ + isShowPassword: false, + captchaEnable: import.meta.env.VITE_APP_CAPTCHA_ENABLE, + tenantEnable: import.meta.env.VITE_APP_TENANT_ENABLE, + loginForm: { + tenantName: '长春华涛', + username: 'admin', + password: '123456', + captchaVerification: '', + rememberMe: false, + code: '', + uuid: '' + } +}) + +const socialList = [ + { icon: 'ant-design:github-filled', type: 0 }, + { icon: 'ant-design:wechat-filled', type: 30 }, + { icon: 'ant-design:alipay-circle-filled', type: 0 }, + { icon: 'ant-design:dingtalk-circle-filled', type: 20 } +] + +// 获取验证码 +// const getCode = async () => { +// // 情况一,未开启:则直接登录 +// if (loginData.captchaEnable === 'false') { +// await handleLogin({}) +// } else { +// // 情况二,已开启:则展示验证码;只有完成验证码的情况,才进行登录 +// // 弹出验证码 +// verify.value.show() +// } +// } +function getCode() { + getCodeImg().then(res => { + captchaEnabled.value = res.captchaEnabled === undefined ? true : res.captchaEnabled; + if (captchaEnabled.value) { + codeUrl.value = "data:image/gif;base64," + res.img; + loginData.loginForm.uuid = res.uuid; + } + }); +} +//获取租户ID +const getTenantId = async () => { + if (loginData.tenantEnable === 'true') { + const res = await LoginApi.getTenantIdByName(loginData.loginForm.tenantName) + authUtil.setTenantId(res) + } +} +// 记住我 +const getCookie = () => { + const loginForm = authUtil.getLoginForm() + if (loginForm) { + loginData.loginForm = { + ...loginData.loginForm, + username: loginForm.username ? loginForm.username : loginData.loginForm.username, + password: loginForm.password ? loginForm.password : loginData.loginForm.password, + rememberMe: loginForm.rememberMe ? true : false, + tenantName: loginForm.tenantName ? loginForm.tenantName : loginData.loginForm.tenantName + } + } +} +const loading = ref() // ElLoading.service 返回的实例 +// 登录 +const handleLogin = async (params) => { + loginLoading.value = true + try { + await getTenantId() + const data = await validForm() + if (!data) { + return + } + const res = await LoginApi.login(loginData.loginForm) + if (!res) { + getCode() + return + } + loading.value = ElLoading.service({ + lock: true, + text: '正在加载系统中...', + background: 'rgba(0, 0, 0, 0.7)' + }) + if (loginData.loginForm.rememberMe) { + authUtil.setLoginForm(loginData.loginForm) + } else { + authUtil.removeLoginForm() + } + authUtil.setToken(res) + if (!redirect.value) { + redirect.value = '/' + } + // 判断是否为SSO登录 + if (redirect.value.indexOf('sso') !== -1) { + window.location.href = window.location.href.replace('/login?redirect=', '') + } else { + console.log(redirect.value) + push({ path: redirect.value || permissionStore.addRouters[0].path }) + } + } finally { + getCode() + loginLoading.value = false + loading.value.close() + } +} + +// 社交登录 +const doSocialLogin = async (type: number) => { + if (type === 0) { + message.error('此方式未配置') + } else { + loginLoading.value = true + if (loginData.tenantEnable === 'true') { + await message.prompt('请输入租户名称', t('common.reminder')).then(async ({ value }) => { + const res = await LoginApi.getTenantIdByName(value) + authUtil.setTenantId(res) + }) + } + // 计算 redirectUri + const redirectUri = + location.origin + '/social-login?type=' + type + '&redirect=' + (redirect.value || '/') + // 进行跳转 + const res = await LoginApi.socialAuthRedirect(type, encodeURIComponent(redirectUri)) + console.log(33) + window.location.href = res + } +} +watch( + () => currentRoute.value, + (route: RouteLocationNormalizedLoaded) => { + redirect.value = route?.query?.redirect as string + }, + { + immediate: true + } +) +onMounted(() => { + getCode(); + getCookie() +}) +</script> + +<style lang="scss" scoped> +:deep(.anticon) { + &:hover { + color: var(--el-color-primary) !important; + } +} + +.login-code { + float: right; + width: 100%; + height: 38px; + + img { + width: 100%; + height: auto; + max-width: 100px; + vertical-align: middle; + cursor: pointer; + } +} +.login-form{ + background: white; + width: 100%; + box-shadow: 0px 0px 20px rgba(0,0,0,0.1) +} +::v-deep.el-form-item--large{ + // margin-bottom: 15px; +} +</style> diff --git a/src/views/login/components/LoginFormTitle.vue b/src/views/login/components/LoginFormTitle.vue new file mode 100644 index 0000000..cdf4fac --- /dev/null +++ b/src/views/login/components/LoginFormTitle.vue @@ -0,0 +1,26 @@ +<template> + <h2 class="enter-x mb-3 text-center text-2xl font-bold xl:text-center xl:text-3xl"> + {{ getFormTitle }} + </h2> +</template> +<script lang="ts" setup> +import { LoginStateEnum, useLoginState } from './useLogin' + +defineOptions({ name: 'LoginFormTitle' }) + +const { t } = useI18n() + +const { getLoginState } = useLoginState() + +const getFormTitle = computed(() => { + const titleObj = { + [LoginStateEnum.RESET_PASSWORD]: t('sys.login.forgetFormTitle'), + [LoginStateEnum.LOGIN]: t('sys.login.signInFormTitle'), + [LoginStateEnum.REGISTER]: t('sys.login.signUpFormTitle'), + [LoginStateEnum.MOBILE]: t('sys.login.mobileSignInFormTitle'), + [LoginStateEnum.QR_CODE]: t('sys.login.qrSignInFormTitle'), + [LoginStateEnum.SSO]: t('sys.login.ssoFormTitle') + } + return titleObj[unref(getLoginState)] +}) +</script> diff --git a/src/views/login/components/MobileForm.vue b/src/views/login/components/MobileForm.vue new file mode 100644 index 0000000..29f704c --- /dev/null +++ b/src/views/login/components/MobileForm.vue @@ -0,0 +1,225 @@ +<template> + <el-form + v-show="getShow" + ref="formSmsLogin" + :model="loginData.loginForm" + :rules="rules" + class="login-form" + label-position="top" + label-width="120px" + size="large" + > + <el-row style="margin-right: -10px; margin-left: -10px"> + <!-- 租户名 --> + <el-col :span="24" style="padding-right: 10px; padding-left: 10px"> + <el-form-item> + <LoginFormTitle style="width: 100%" /> + </el-form-item> + </el-col> + <el-col :span="24" style="padding-right: 10px; padding-left: 10px"> + <el-form-item v-if="loginData.tenantEnable === 'true'" prop="tenantName"> + <el-input + v-model="loginData.loginForm.tenantName" + :placeholder="t('login.tenantNamePlaceholder')" + :prefix-icon="iconHouse" + type="primary" + link + /> + </el-form-item> + </el-col> + <!-- 手机号 --> + <el-col :span="24" style="padding-right: 10px; padding-left: 10px"> + <el-form-item prop="mobileNumber"> + <el-input + v-model="loginData.loginForm.mobileNumber" + :placeholder="t('login.mobileNumberPlaceholder')" + :prefix-icon="iconCellphone" + /> + </el-form-item> + </el-col> + <!-- 验证码 --> + <el-col :span="24" style="padding-right: 10px; padding-left: 10px"> + <el-form-item prop="code"> + <el-row :gutter="5" justify="space-between" style="width: 100%"> + <el-col :span="24"> + <el-input + v-model="loginData.loginForm.code" + :placeholder="t('login.codePlaceholder')" + :prefix-icon="iconCircleCheck" + > + <!-- <el-button class="w-[100%]"> --> + <template #append> + <span + v-if="mobileCodeTimer <= 0" + class="getMobileCode" + style="cursor: pointer" + @click="getSmsCode" + > + {{ t('login.getSmsCode') }} + </span> + <span v-if="mobileCodeTimer > 0" class="getMobileCode" style="cursor: pointer"> + {{ mobileCodeTimer }}秒后可重新获取 + </span> + </template> + </el-input> + <!-- </el-button> --> + </el-col> + </el-row> + </el-form-item> + </el-col> + <!-- 登录按钮 / 返回按钮 --> + <el-col :span="24" style="padding-right: 10px; padding-left: 10px"> + <el-form-item> + <XButton + :loading="loginLoading" + :title="t('login.login')" + class="w-[100%]" + type="primary" + @click="signIn()" + /> + </el-form-item> + </el-col> + <el-col :span="24" style="padding-right: 10px; padding-left: 10px"> + <el-form-item> + <XButton + :loading="loginLoading" + :title="t('login.backLogin')" + class="w-[100%]" + @click="handleBackLogin()" + /> + </el-form-item> + </el-col> + </el-row> + </el-form> +</template> +<script lang="ts" setup> +import type { RouteLocationNormalizedLoaded } from 'vue-router' + +import { useIcon } from '@/hooks/web/useIcon' + +import { setTenantId, setToken } from '@/utils/auth' +import { usePermissionStore } from '@/store/modules/permission' +import { getTenantIdByName, sendSmsCode, smsLogin } from '@/api/login' +import LoginFormTitle from './LoginFormTitle.vue' +import { LoginStateEnum, useFormValid, useLoginState } from './useLogin' + +defineOptions({ name: 'MobileForm' }) + +const { t } = useI18n() +const message = useMessage() +const permissionStore = usePermissionStore() +const { currentRoute, push } = useRouter() +const formSmsLogin = ref() +const loginLoading = ref(false) +const iconHouse = useIcon({ icon: 'ep:house' }) +const iconCellphone = useIcon({ icon: 'ep:cellphone' }) +const iconCircleCheck = useIcon({ icon: 'ep:circle-check' }) +const { validForm } = useFormValid(formSmsLogin) +const { handleBackLogin, getLoginState } = useLoginState() +const getShow = computed(() => unref(getLoginState) === LoginStateEnum.MOBILE) + +const rules = { + tenantName: [required], + mobileNumber: [required], + code: [required] +} +const loginData = reactive({ + codeImg: '', + tenantEnable: import.meta.env.VITE_APP_TENANT_ENABLE, + token: '', + loading: { + signIn: false + }, + loginForm: { + uuid: '', + tenantName: '长春华涛', + mobileNumber: '', + code: '' + } +}) +const smsVO = reactive({ + smsCode: { + mobile: '', + scene: 21 + }, + loginSms: { + mobile: '', + code: '' + } +}) +const mobileCodeTimer = ref(0) +const redirect = ref<string>('') +const getSmsCode = async () => { + await getTenantId() + smsVO.smsCode.mobile = loginData.loginForm.mobileNumber + await sendSmsCode(smsVO.smsCode).then(async () => { + message.success(t('login.SmsSendMsg')) + // 设置倒计时 + mobileCodeTimer.value = 60 + let msgTimer = setInterval(() => { + mobileCodeTimer.value = mobileCodeTimer.value - 1 + if (mobileCodeTimer.value <= 0) { + clearInterval(msgTimer) + } + }, 1000) + }) +} +watch( + () => currentRoute.value, + (route: RouteLocationNormalizedLoaded) => { + redirect.value = route?.query?.redirect as string + }, + { + immediate: true + } +) +// 获取租户 ID +const getTenantId = async () => { + if (loginData.tenantEnable === 'true') { + const res = await getTenantIdByName(loginData.loginForm.tenantName) + setTenantId(res) + } +} +// 登录 +const signIn = async () => { + await getTenantId() + const data = await validForm() + if (!data) return + ElLoading.service({ + lock: true, + text: '正在加载系统中...', + background: 'rgba(0, 0, 0, 0.7)' + }) + loginLoading.value = true + smsVO.loginSms.mobile = loginData.loginForm.mobileNumber + smsVO.loginSms.code = loginData.loginForm.code + await smsLogin(smsVO.loginSms) + .then(async (res) => { + setToken(res) + if (!redirect.value) { + redirect.value = '/' + } + push({ path: redirect.value || permissionStore.addRouters[0].path }) + }) + .catch(() => {}) + .finally(() => { + loginLoading.value = false + setTimeout(() => { + const loadingInstance = ElLoading.service() + loadingInstance.close() + }, 400) + }) +} +</script> + +<style lang="scss" scoped> +:deep(.anticon) { + &:hover { + color: var(--el-color-primary) !important; + } +} + +.smsbtn { + margin-top: 33px; +} +</style> diff --git a/src/views/login/components/QrCodeForm.vue b/src/views/login/components/QrCodeForm.vue new file mode 100644 index 0000000..31d2845 --- /dev/null +++ b/src/views/login/components/QrCodeForm.vue @@ -0,0 +1,30 @@ +<template> + <el-row v-show="getShow" style="margin-right: -10px; margin-left: -10px"> + <el-col :span="24" style="padding-right: 10px; padding-left: 10px"> + <LoginFormTitle style="width: 100%" /> + </el-col> + <el-col :span="24" style="padding-right: 10px; padding-left: 10px"> + <el-card class="mb-10px text-center" shadow="hover"> + <Qrcode :logo="logoImg" /> + </el-card> + </el-col> + <el-divider class="enter-x">{{ t('login.qrcode') }}</el-divider> + <el-col :span="24" style="padding-right: 10px; padding-left: 10px"> + <div class="mt-15px w-[100%]"> + <XButton :title="t('login.backLogin')" class="w-[100%]" @click="handleBackLogin()" /> + </div> + </el-col> + </el-row> +</template> +<script lang="ts" setup> +import logoImg from '@/assets/imgs/logo.png' + +import LoginFormTitle from './LoginFormTitle.vue' +import { LoginStateEnum, useLoginState } from './useLogin' + +defineOptions({ name: 'QrCodeForm' }) + +const { t } = useI18n() +const { handleBackLogin, getLoginState } = useLoginState() +const getShow = computed(() => unref(getLoginState) === LoginStateEnum.QR_CODE) +</script> diff --git a/src/views/login/components/RegisterForm.vue b/src/views/login/components/RegisterForm.vue new file mode 100644 index 0000000..23b3bd4 --- /dev/null +++ b/src/views/login/components/RegisterForm.vue @@ -0,0 +1,142 @@ +<template> + <Form + v-show="getShow" + :rules="rules" + :schema="schema" + class="dark:(border-1 border-[var(--el-border-color)] border-solid)" + hide-required-asterisk + label-position="top" + size="large" + @register="register" + > + <template #title> + <LoginFormTitle style="width: 100%" /> + </template> + + <template #code="form"> + <div class="w-[100%] flex"> + <el-input v-model="form['code']" :placeholder="t('login.codePlaceholder')" /> + </div> + </template> + + <template #register> + <div class="w-[100%]"> + <XButton + :loading="loading" + :title="t('login.register')" + class="w-[100%]" + type="primary" + @click="loginRegister()" + /> + </div> + <div class="mt-15px w-[100%]"> + <XButton :title="t('login.hasUser')" class="w-[100%]" @click="handleBackLogin()" /> + </div> + </template> + </Form> +</template> +<script lang="ts" setup> +import type { FormRules } from 'element-plus' + +import { useForm } from '@/hooks/web/useForm' +import { useValidator } from '@/hooks/web/useValidator' +import LoginFormTitle from './LoginFormTitle.vue' +import { LoginStateEnum, useLoginState } from './useLogin' +import { FormSchema } from '@/types/form' + +defineOptions({ name: 'RegisterForm' }) + +const { t } = useI18n() +const { required } = useValidator() +const { register, elFormRef } = useForm() +const { handleBackLogin, getLoginState } = useLoginState() +const getShow = computed(() => unref(getLoginState) === LoginStateEnum.REGISTER) + +const schema = reactive<FormSchema[]>([ + { + field: 'title', + colProps: { + span: 24 + } + }, + { + field: 'username', + label: t('login.username'), + value: '', + component: 'Input', + colProps: { + span: 24 + }, + componentProps: { + placeholder: t('login.usernamePlaceholder') + } + }, + { + field: 'password', + label: t('login.password'), + value: '', + component: 'InputPassword', + colProps: { + span: 24 + }, + componentProps: { + style: { + width: '100%' + }, + strength: true, + placeholder: t('login.passwordPlaceholder') + } + }, + { + field: 'check_password', + label: t('login.checkPassword'), + value: '', + component: 'InputPassword', + colProps: { + span: 24 + }, + componentProps: { + style: { + width: '100%' + }, + strength: true, + placeholder: t('login.passwordPlaceholder') + } + }, + { + field: 'code', + label: t('login.code'), + colProps: { + span: 24 + } + }, + { + field: 'register', + colProps: { + span: 24 + } + } +]) + +const rules: FormRules = { + username: [required()], + password: [required()], + check_password: [required()], + code: [required()] +} + +const loading = ref(false) + +const loginRegister = async () => { + const formRef = unref(elFormRef) + formRef?.validate(async (valid) => { + if (valid) { + try { + loading.value = true + } finally { + loading.value = false + } + } + }) +} +</script> diff --git a/src/views/login/components/SSOLogin.vue b/src/views/login/components/SSOLogin.vue new file mode 100644 index 0000000..f31ab0e --- /dev/null +++ b/src/views/login/components/SSOLogin.vue @@ -0,0 +1,199 @@ +<template> + <div v-show="ssoVisible" class="form-cont"> + <!-- 应用名 --> + <LoginFormTitle style="width: 100%" /> + <el-tabs class="form" style="float: none" value="uname"> + <el-tab-pane :label="client.name" name="uname" /> + </el-tabs> + <div> + <el-form :model="formData" class="login-form"> + <!-- 授权范围的选择 --> + 此第三方应用请求获得以下权限: + <el-form-item prop="scopes"> + <el-checkbox-group v-model="formData.scopes"> + <el-checkbox + v-for="scope in queryParams.scopes" + :key="scope" + :label="scope" + style="display: block; margin-bottom: -10px" + > + {{ formatScope(scope) }} + </el-checkbox> + </el-checkbox-group> + </el-form-item> + <!-- 下方的登录按钮 --> + <el-form-item class="w-1/1"> + <el-button + :loading="formLoading" + class="w-6/10" + type="primary" + @click.prevent="handleAuthorize(true)" + > + <span v-if="!formLoading">同意授权</span> + <span v-else>授 权 中...</span> + </el-button> + <el-button class="w-3/10" @click.prevent="handleAuthorize(false)">拒绝</el-button> + </el-form-item> + </el-form> + </div> + </div> +</template> +<script lang="ts" setup> +import LoginFormTitle from './LoginFormTitle.vue' +import * as OAuth2Api from '@/api/login/oauth2' +import { LoginStateEnum, useLoginState } from './useLogin' +import type { RouteLocationNormalizedLoaded } from 'vue-router' + +defineOptions({ name: 'SSOLogin' }) + +const route = useRoute() // 路由 +const { currentRoute } = useRouter() // 路由 +const { getLoginState, setLoginState } = useLoginState() + +const client = ref({ + // 客户端信息 + name: '', + logo: '' +}) +interface queryType { + responseType: string + clientId: string + redirectUri: string + state: string + scopes: string[] +} +const queryParams = reactive<queryType>({ + // URL 上的 client_id、scope 等参数 + responseType: '', + clientId: '', + redirectUri: '', + state: '', + scopes: [] // 优先从 query 参数获取;如果未传递,从后端获取 +}) +const ssoVisible = computed(() => unref(getLoginState) === LoginStateEnum.SSO) // 是否展示 SSO 登录的表单 +interface formType { + scopes: string[] +} +const formData = reactive<formType>({ + scopes: [] // 已选中的 scope 数组 +}) +const formLoading = ref(false) // 表单是否提交中 + +/** 初始化授权信息 */ +const init = async () => { + // 防止在没有登录的情况下循环弹窗 + if (typeof route.query.client_id === 'undefined') return + // 解析参数 + // 例如说【自动授权不通过】:client_id=default&redirect_uri=https%3A%2F%2Fwww.iocoder.cn&response_type=code&scope=user.read%20user.write + // 例如说【自动授权通过】:client_id=default&redirect_uri=https%3A%2F%2Fwww.iocoder.cn&response_type=code&scope=user.read + queryParams.responseType = route.query.response_type as string + queryParams.clientId = route.query.client_id as string + queryParams.redirectUri = route.query.redirect_uri as string + queryParams.state = route.query.state as string + if (route.query.scope) { + queryParams.scopes = (route.query.scope as string).split(' ') + } + + // 如果有 scope 参数,先执行一次自动授权,看看是否之前都授权过了。 + if (queryParams.scopes.length > 0) { + const data = await doAuthorize(true, queryParams.scopes, []) + if (data) { + location.href = data + return + } + } + + // 获取授权页的基本信息 + const data = await OAuth2Api.getAuthorize(queryParams.clientId) + client.value = data.client + // 解析 scope + let scopes + // 1.1 如果 params.scope 非空,则过滤下返回的 scopes + if (queryParams.scopes.length > 0) { + scopes = [] + for (const scope of data.scopes) { + if (queryParams.scopes.indexOf(scope.key) >= 0) { + scopes.push(scope) + } + } + // 1.2 如果 params.scope 为空,则使用返回的 scopes 设置它 + } else { + scopes = data.scopes + for (const scope of scopes) { + queryParams.scopes.push(scope.key) + } + } + // 生成已选中的 checkedScopes + for (const scope of scopes) { + if (scope.value) { + formData.scopes.push(scope.key) + } + } +} + +/** 处理授权的提交 */ +const handleAuthorize = async (approved) => { + // 计算 checkedScopes + uncheckedScopes + let checkedScopes + let uncheckedScopes + if (approved) { + // 同意授权,按照用户的选择 + checkedScopes = formData.scopes + uncheckedScopes = queryParams.scopes.filter((item) => checkedScopes.indexOf(item) === -1) + } else { + // 拒绝,则都是取消 + checkedScopes = [] + uncheckedScopes = queryParams.scopes + } + // 提交授权的请求 + formLoading.value = true + try { + const data = await doAuthorize(false, checkedScopes, uncheckedScopes) + if (!data) { + return + } + location.href = data + } finally { + formLoading.value = false + } +} + +/** 调用授权 API 接口 */ +const doAuthorize = (autoApprove, checkedScopes, uncheckedScopes) => { + return OAuth2Api.authorize( + queryParams.responseType, + queryParams.clientId, + queryParams.redirectUri, + queryParams.state, + autoApprove, + checkedScopes, + uncheckedScopes + ) +} + +/** 格式化 scope 文本 */ +const formatScope = (scope) => { + // 格式化 scope 授权范围,方便用户理解。 + // 这里仅仅是一个 demo,可以考虑录入到字典数据中,例如说字典类型 "system_oauth2_scope",它的每个 scope 都是一条字典数据。 + switch (scope) { + case 'user.read': + return '访问你的个人信息' + case 'user.write': + return '修改你的个人信息' + default: + return scope + } +} + +/** 监听当前路由为 SSOLogin 时,进行数据的初始化 */ +watch( + () => currentRoute.value, + (route: RouteLocationNormalizedLoaded) => { + if (route.name === 'SSOLogin') { + setLoginState(LoginStateEnum.SSO) + init() + } + }, + { immediate: true } +) +</script> diff --git a/src/views/login/components/index.ts b/src/views/login/components/index.ts new file mode 100644 index 0000000..204ad73 --- /dev/null +++ b/src/views/login/components/index.ts @@ -0,0 +1,8 @@ +import LoginForm from './LoginForm.vue' +import MobileForm from './MobileForm.vue' +import LoginFormTitle from './LoginFormTitle.vue' +import RegisterForm from './RegisterForm.vue' +import QrCodeForm from './QrCodeForm.vue' +import SSOLoginVue from './SSOLogin.vue' + +export { LoginForm, MobileForm, LoginFormTitle, RegisterForm, QrCodeForm, SSOLoginVue } diff --git a/src/views/login/components/useLogin.ts b/src/views/login/components/useLogin.ts new file mode 100644 index 0000000..b4a02f8 --- /dev/null +++ b/src/views/login/components/useLogin.ts @@ -0,0 +1,42 @@ +import { Ref } from 'vue' + +export enum LoginStateEnum { + LOGIN, + REGISTER, + RESET_PASSWORD, + MOBILE, + QR_CODE, + SSO +} + +const currentState = ref(LoginStateEnum.LOGIN) + +export function useLoginState() { + function setLoginState(state: LoginStateEnum) { + currentState.value = state + } + const getLoginState = computed(() => currentState.value) + + function handleBackLogin() { + setLoginState(LoginStateEnum.LOGIN) + } + + return { + setLoginState, + getLoginState, + handleBackLogin + } +} + +export function useFormValid<T extends Object = any>(formRef: Ref<any>) { + async function validForm() { + const form = unref(formRef) + if (!form) return + const data = await form.validate() + return data as T + } + + return { + validForm + } +} diff --git a/src/views/login/login.vue b/src/views/login/login.vue new file mode 100644 index 0000000..48965b7 --- /dev/null +++ b/src/views/login/login.vue @@ -0,0 +1,111 @@ +<template> + <div + :class="prefixCls" + class="relative h-[100%] lt-xl:bg-[var(--login-bg-color)] lt-md:px-10px lt-sm:px-10px lt-xl:px-10px" + > + + <div class="box"> + <img src="../../assets/imgs/logo_huatao.png" alt="" class="logo"/> + + <div class="absolute w-[33%] mx-auto h-full flex right-[7%]"> + <div class="relative flex-1 p-30px dark:bg-[var(--login-bg-color)] lt-sm:p-10px"> + <!-- 右上角的主题、语言选择 --> + <div + class="flex items-center justify-between text-white at-2xl:justify-end at-xl:justify-end" + > + <div class="flex items-center at-2xl:hidden at-xl:hidden"> + <img alt="" class="mr-10px h-48px w-48px" src="@/assets/imgs/logo.png" /> + <span class="text-20px font-bold">{{ underlineToHump(appStore.getTitle) }}</span> + </div> + <div class="flex items-center justify-end space-x-10px"> + <!-- <ThemeSwitch /> --> + <LocaleDropdown class="dark:text-white lt-xl:text-white" /> + </div> + </div> + <!-- 右边的登录界面 --> + <Transition appear enter-active-class="animate__animated animate__bounceInRight"> + <div + class="m-auto h-full w-[100%] flex items-center at-2xl:max-w-500px at-lg:max-w-500px at-md:max-w-500px at-xl:max-w-500px" + > + <!-- 账号登录 --> + <LoginForm class="m-auto h-auto p-20px lt-xl:(rounded-3xl light:bg-white)" /> + <!-- 手机登录 --> + <MobileForm class="m-auto h-auto p-20px lt-xl:(rounded-3xl light:bg-white)" /> + <!-- 二维码登录 --> + <QrCodeForm class="m-auto h-auto p-20px lt-xl:(rounded-3xl light:bg-white)" /> + <!-- 注册 --> + <RegisterForm class="m-auto h-auto p-20px lt-xl:(rounded-3xl light:bg-white)" /> + <!-- 三方登录 --> + <SSOLoginVue class="m-auto h-auto p-20px lt-xl:(rounded-3xl light:bg-white)" /> + </div> + </Transition> + </div> + </div> + <div class="footer"> + <div>Copyright ©2012-2023闻荫科技吉ICP备100114504646号-6 ICP证书:吉B2-2018005497977吉林工商吉公网安备3101150204979086755号</div> + <div>出版物经营许可证(吉)批字第Y8813号广播电视节目制作经营许可正(吉)字第03283号网络文化经营许可证吉网文{2012)1460-055号</div> + </div> + </div> + </div> +</template> +<script lang="ts" setup> +import { underlineToHump } from '@/utils' + +import { useDesign } from '@/hooks/web/useDesign' +import { useAppStore } from '@/store/modules/app' +import { ThemeSwitch } from '@/layout/components/ThemeSwitch' +import { LocaleDropdown } from '@/layout/components/LocaleDropdown' + +import { LoginForm, MobileForm, QrCodeForm, RegisterForm, SSOLoginVue } from './components' + +defineOptions({ name: 'Login' }) + +const { t } = useI18n() +const appStore = useAppStore() +const { getPrefixCls } = useDesign() +const prefixCls = getPrefixCls('login') +</script> + +<style lang="scss" scoped> +$prefix-cls: #{$namespace}-login; + +.#{$prefix-cls} { + overflow: auto; + + &__left { + &::before { + position: absolute; + top: 0; + left: 0; + z-index: -1; + width: 100%; + height: 100%; + background-image: url('@/assets/svgs/login-bg.svg'); + background-position: center; + background-repeat: no-repeat; + content: ''; + } + } +} +.box{ + background: url(../../assets/imgs/bg_login.png) no-repeat top left; + background-size: 100% 100%; + width: 100%; + height:100%; +} +.logo{ + position: absolute; + left: 4vw; + top: 5vh; + width: 30%; +} +.footer{ + position: absolute; + bottom:4vh; + text-align: center; + padding: 0px 30px; + width: calc(100% - 60px); + color: #989898; + font-size: 14px; +} +</style> diff --git a/src/views/profile/components/BasicInfo.vue b/src/views/profile/components/BasicInfo.vue new file mode 100644 index 0000000..e2189b1 --- /dev/null +++ b/src/views/profile/components/BasicInfo.vue @@ -0,0 +1,92 @@ +<template> + <Form ref="formRef" :labelWidth="200" :rules="rules" :schema="schema"> + <template #sex="form"> + <el-radio-group v-model="form['sex']"> + <el-radio :label="1">{{ t('profile.user.man') }}</el-radio> + <el-radio :label="2">{{ t('profile.user.woman') }}</el-radio> + </el-radio-group> + </template> + </Form> + <div style="text-align: center"> + <XButton :title="t('common.save')" type="primary" @click="submit()" /> + <XButton :title="t('common.reset')" type="danger" @click="init()" /> + </div> +</template> +<script lang="ts" setup> +import type { FormRules } from 'element-plus' +import { FormSchema } from '@/types/form' +import type { FormExpose } from '@/components/Form' +import { + getUserProfile, + updateUserProfile, + UserProfileUpdateReqVO +} from '@/api/system/user/profile' + +defineOptions({ name: 'BasicInfo' }) + +const { t } = useI18n() +const message = useMessage() // 消息弹窗 +// 表单校验 +const rules = reactive<FormRules>({ + nickname: [{ required: true, message: t('profile.rules.nickname'), trigger: 'blur' }], + email: [ + { required: true, message: t('profile.rules.mail'), trigger: 'blur' }, + { + type: 'email', + message: t('profile.rules.truemail'), + trigger: ['blur', 'change'] + } + ], + mobile: [ + { required: true, message: t('profile.rules.phone'), trigger: 'blur' }, + { + pattern: /^1[3|4|5|6|7|8|9][0-9]\d{8}$/, + message: t('profile.rules.truephone'), + trigger: 'blur' + } + ] +}) +const schema = reactive<FormSchema[]>([ + { + field: 'nickname', + label: t('profile.user.nickname'), + component: 'Input' + }, + { + field: 'mobile', + label: t('profile.user.mobile'), + component: 'Input' + }, + { + field: 'email', + label: t('profile.user.email'), + component: 'Input' + }, + { + field: 'sex', + label: t('profile.user.sex'), + component: 'InputNumber', + value: 0 + } +]) +const formRef = ref<FormExpose>() // 表单 Ref +const submit = () => { + const elForm = unref(formRef)?.getElFormRef() + if (!elForm) return + elForm.validate(async (valid) => { + if (valid) { + const data = unref(formRef)?.formModel as UserProfileUpdateReqVO + await updateUserProfile(data) + message.success(t('common.updateSuccess')) + await init() + } + }) +} +const init = async () => { + const res = await getUserProfile() + unref(formRef)?.setValues(res) +} +onMounted(async () => { + await init() +}) +</script> diff --git a/src/views/profile/components/ProfileUser.vue b/src/views/profile/components/ProfileUser.vue new file mode 100644 index 0000000..b493499 --- /dev/null +++ b/src/views/profile/components/ProfileUser.vue @@ -0,0 +1,99 @@ +<template> + <div> + <div class="text-center"> + <UserAvatar :img="userInfo?.avatar" /> + </div> + <ul class="list-group list-group-striped"> + <li class="list-group-item"> + <Icon class="mr-5px" icon="ep:user" /> + {{ t('profile.user.username') }} + <div class="pull-right">{{ userInfo?.username }}</div> + </li> + <li class="list-group-item"> + <Icon class="mr-5px" icon="ep:phone" /> + {{ t('profile.user.mobile') }} + <div class="pull-right">{{ userInfo?.mobile }}</div> + </li> + <li class="list-group-item"> + <Icon class="mr-5px" icon="fontisto:email" /> + {{ t('profile.user.email') }} + <div class="pull-right">{{ userInfo?.email }}</div> + </li> + <li class="list-group-item"> + <Icon class="mr-5px" icon="carbon:tree-view-alt" /> + {{ t('profile.user.dept') }} + <div v-if="userInfo?.dept" class="pull-right">{{ userInfo?.dept.name }}</div> + </li> + <li class="list-group-item"> + <Icon class="mr-5px" icon="ep:suitcase" /> + {{ t('profile.user.posts') }} + <div v-if="userInfo?.posts" class="pull-right"> + {{ userInfo?.posts.map((post) => post.name).join(',') }} + </div> + </li> + <li class="list-group-item"> + <Icon class="mr-5px" icon="icon-park-outline:peoples" /> + {{ t('profile.user.roles') }} + <div v-if="userInfo?.roles" class="pull-right"> + {{ userInfo?.roles.map((role) => role.name).join(',') }} + </div> + </li> + <li class="list-group-item"> + <Icon class="mr-5px" icon="ep:calendar" /> + {{ t('profile.user.createTime') }} + <div class="pull-right">{{ formatDate(userInfo?.createTime) }}</div> + </li> + </ul> + </div> +</template> +<script lang="ts" setup> +import { formatDate } from '@/utils/formatTime' +import UserAvatar from './UserAvatar.vue' + +import { getUserProfile, ProfileVO } from '@/api/system/user/profile' + +defineOptions({ name: 'ProfileUser' }) + +const { t } = useI18n() +const userInfo = ref<ProfileVO>() +const getUserInfo = async () => { + const users = await getUserProfile() + userInfo.value = users +} +onMounted(async () => { + await getUserInfo() +}) +</script> + +<style scoped> +.text-center { + position: relative; + height: 120px; + text-align: center; +} + +.list-group-striped > .list-group-item { + padding-right: 0; + padding-left: 0; + border-right: 0; + border-left: 0; + border-radius: 0; +} + +.list-group { + padding-left: 0; + list-style: none; +} + +.list-group-item { + padding: 11px 0; + margin-bottom: -1px; + font-size: 13px; + border-top: 1px solid #e7eaec; + border-bottom: 1px solid #e7eaec; +} + +.pull-right { + float: right !important; +} +</style> diff --git a/src/views/profile/components/ResetPwd.vue b/src/views/profile/components/ResetPwd.vue new file mode 100644 index 0000000..477be91 --- /dev/null +++ b/src/views/profile/components/ResetPwd.vue @@ -0,0 +1,73 @@ +<template> + <el-form ref="formRef" :model="password" :rules="rules" :label-width="200"> + <el-form-item :label="t('profile.password.oldPassword')" prop="oldPassword"> + <InputPassword v-model="password.oldPassword" /> + </el-form-item> + <el-form-item :label="t('profile.password.newPassword')" prop="newPassword"> + <InputPassword v-model="password.newPassword" strength /> + </el-form-item> + <el-form-item :label="t('profile.password.confirmPassword')" prop="confirmPassword"> + <InputPassword v-model="password.confirmPassword" strength /> + </el-form-item> + <el-form-item> + <XButton :title="t('common.save')" type="primary" @click="submit(formRef)" /> + <XButton :title="t('common.reset')" type="danger" @click="reset(formRef)" /> + </el-form-item> + </el-form> +</template> +<script lang="ts" setup> +import type { FormInstance, FormRules } from 'element-plus' + +import { InputPassword } from '@/components/InputPassword' +import { updateUserPassword } from '@/api/system/user/profile' + +defineOptions({ name: 'ResetPwd' }) + +const { t } = useI18n() +const message = useMessage() +const formRef = ref<FormInstance>() +const password = reactive({ + oldPassword: '', + newPassword: '', + confirmPassword: '' +}) + +// 表单校验 +const equalToPassword = (_rule, value, callback) => { + if (password.newPassword !== value) { + callback(new Error(t('profile.password.diffPwd'))) + } else { + callback() + } +} + +const rules = reactive<FormRules>({ + oldPassword: [ + { required: true, message: t('profile.password.oldPwdMsg'), trigger: 'blur' }, + { min: 6, max: 20, message: t('profile.password.pwdRules'), trigger: 'blur' } + ], + newPassword: [ + { required: true, message: t('profile.password.newPwdMsg'), trigger: 'blur' }, + { min: 6, max: 20, message: t('profile.password.pwdRules'), trigger: 'blur' } + ], + confirmPassword: [ + { required: true, message: t('profile.password.cfPwdMsg'), trigger: 'blur' }, + { required: true, validator: equalToPassword, trigger: 'blur' } + ] +}) + +const submit = (formEl: FormInstance | undefined) => { + if (!formEl) return + formEl.validate(async (valid) => { + if (valid) { + await updateUserPassword(password.oldPassword, password.newPassword) + message.success(t('common.updateSuccess')) + } + }) +} + +const reset = (formEl: FormInstance | undefined) => { + if (!formEl) return + formEl.resetFields() +} +</script> diff --git a/src/views/profile/components/UserAvatar.vue b/src/views/profile/components/UserAvatar.vue new file mode 100644 index 0000000..c20168f --- /dev/null +++ b/src/views/profile/components/UserAvatar.vue @@ -0,0 +1,39 @@ +<template> + <div class="change-avatar"> + <CropperAvatar + ref="cropperRef" + :btnProps="{ preIcon: 'ant-design:cloud-upload-outlined' }" + :showBtn="false" + :value="img" + width="120px" + @change="handelUpload" + /> + </div> +</template> +<script lang="ts" setup> +import { propTypes } from '@/utils/propTypes' +import { uploadAvatar } from '@/api/system/user/profile' +import { CropperAvatar } from '@/components/Cropper' + +defineOptions({ name: 'UserAvatar' }) + +defineProps({ + img: propTypes.string.def('') +}) + +const cropperRef = ref() +const handelUpload = async ({ data }) => { + await uploadAvatar({ avatarFile: data }) + cropperRef.value.close() +} +</script> + +<style lang="scss" scoped> +.change-avatar { + img { + display: block; + margin-bottom: 15px; + border-radius: 50%; + } +} +</style> diff --git a/src/views/profile/components/UserSocial.vue b/src/views/profile/components/UserSocial.vue new file mode 100644 index 0000000..2f021ab --- /dev/null +++ b/src/views/profile/components/UserSocial.vue @@ -0,0 +1,94 @@ +<template> + <el-table :data="socialUsers" :show-header="false"> + <el-table-column fixed="left" title="序号" type="seq" width="60" /> + <el-table-column align="left" label="社交平台" width="120"> + <template #default="{ row }"> + <img :src="row.img" alt="" class="h-5 align-middle" /> + <p class="mr-5">{{ row.title }}</p> + </template> + </el-table-column> + <el-table-column align="center" label="操作"> + <template #default="{ row }"> + <template v-if="row.openid"> + 已绑定 + <XTextButton class="mr-5" title="(解绑)" type="primary" @click="unbind(row)" /> + </template> + <template v-else> + 未绑定 + <XTextButton class="mr-5" title="(绑定)" type="primary" @click="bind(row)" /> + </template> + </template> + </el-table-column> + </el-table> +</template> +<script lang="ts" setup> +import { SystemUserSocialTypeEnum } from '@/utils/constants' +import { getUserProfile, ProfileVO } from '@/api/system/user/profile' +import { socialAuthRedirect, socialBind, socialUnbind } from '@/api/system/user/socialUser' + +defineOptions({ name: 'UserSocial' }) + +const message = useMessage() +const socialUsers = ref<any[]>([]) +const userInfo = ref<ProfileVO>() + +const initSocial = async () => { + const res = await getUserProfile() + userInfo.value = res + for (const i in SystemUserSocialTypeEnum) { + const socialUser = { ...SystemUserSocialTypeEnum[i] } + socialUsers.value.push(socialUser) + if (userInfo.value?.socialUsers) { + for (const j in userInfo.value.socialUsers) { + if (socialUser.type === userInfo.value.socialUsers[j].type) { + socialUser.openid = userInfo.value.socialUsers[j].openid + break + } + } + } + } +} +const route = useRoute() +const bindSocial = () => { + // 社交绑定 + const type = route.query.type + const code = route.query.code + const state = route.query.state + if (!code) { + return + } + socialBind(type, code, state).then(() => { + message.success('绑定成功') + initSocial() + }) +} +const bind = (row) => { + const redirectUri = location.origin + '/user/profile?type=' + row.type + // 进行跳转 + socialAuthRedirect(row.type, encodeURIComponent(redirectUri)).then((res) => { + window.location.href = res + }) +} +const unbind = async (row) => { + const res = await socialUnbind(row.type, row.openid) + if (res) { + row.openid = undefined + } + message.success('解绑成功') +} + +onMounted(async () => { + await initSocial() +}) + +watch( + () => route, + (newRoute) => { + bindSocial() + console.log(newRoute) + }, + { + immediate: true + } +) +</script> diff --git a/src/views/profile/components/index.ts b/src/views/profile/components/index.ts new file mode 100644 index 0000000..9e1883c --- /dev/null +++ b/src/views/profile/components/index.ts @@ -0,0 +1,7 @@ +import BasicInfo from './BasicInfo.vue' +import ProfileUser from './ProfileUser.vue' +import ResetPwd from './ResetPwd.vue' +import UserAvatarVue from './UserAvatar.vue' +import UserSocial from './UserSocial.vue' + +export { BasicInfo, ProfileUser, ResetPwd, UserAvatarVue, UserSocial } diff --git a/src/views/profile/index.vue b/src/views/profile/index.vue new file mode 100644 index 0000000..e813f04 --- /dev/null +++ b/src/views/profile/index.vue @@ -0,0 +1,64 @@ +<template> + <div class="flex"> + <el-card class="user w-1/3" shadow="hover"> + <template #header> + <div class="card-header"> + <span>{{ t('profile.user.title') }}</span> + </div> + </template> + <ProfileUser /> + </el-card> + <el-card class="user ml-3 w-2/3" shadow="hover"> + <template #header> + <div class="card-header"> + <span>{{ t('profile.info.title') }}</span> + </div> + </template> + <div> + <el-tabs v-model="activeName" tab-position="top" style="height: 400px" class="profile-tabs"> + <el-tab-pane :label="t('profile.info.basicInfo')" name="basicInfo"> + <BasicInfo /> + </el-tab-pane> + <el-tab-pane :label="t('profile.info.resetPwd')" name="resetPwd"> + <ResetPwd /> + </el-tab-pane> + <!-- <el-tab-pane :label="t('profile.info.userSocial')" name="userSocial"> + <UserSocial /> + </el-tab-pane> --> + </el-tabs> + </div> + </el-card> + </div> +</template> +<script setup lang="ts" name="Profile"> +import { BasicInfo, ProfileUser, ResetPwd, UserSocial } from './components/' +const { t } = useI18n() + +const activeName = ref('basicInfo') +</script> +<style scoped> +.user { + max-height: 960px; + padding: 15px 20px 20px; +} + +.card-header { + display: flex; + justify-content: center; + align-items: center; +} + +:deep(.el-card .el-card__header, .el-card .el-card__body) { + padding: 15px !important; +} + +.profile-tabs > .el-tabs__content { + padding: 32px; + font-weight: 600; + color: #6b778c; +} + +.el-tabs--left .el-tabs__content { + height: 100%; +} +</style> diff --git a/src/views/redirect/redirect.vue b/src/views/redirect/redirect.vue new file mode 100644 index 0000000..f7717ce --- /dev/null +++ b/src/views/redirect/redirect.vue @@ -0,0 +1,28 @@ +<template> + <div></div> +</template> +<script lang="ts" setup> +defineOptions({ name: 'Redirect' }) + +const { currentRoute, replace } = useRouter() +const { params, query } = unref(currentRoute) +const { path, _redirect_type = 'path' } = params + +Reflect.deleteProperty(params, '_redirect_type') +Reflect.deleteProperty(params, 'path') + +const _path = Array.isArray(path) ? path.join('/') : path + +if (_redirect_type === 'name') { + replace({ + name: _path, + query, + params + }) +} else { + replace({ + path: _path.startsWith('/') ? _path : '/' + _path, + query + }) +} +</script>