创建项目
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<div class="feishu-callback">
|
||||
<div v-if="loading">正在登录,请稍候...</div>
|
||||
<div v-else-if="error">{{ error }}</div>
|
||||
<div v-else>登录成功,正在跳转...</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { UserService } from '@/api/usersApi'
|
||||
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const userStore = useUserStore()
|
||||
const router = useRouter()
|
||||
onMounted(async () => {
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const code = urlParams.get('code')
|
||||
const state = urlParams.get('state')
|
||||
if (!code) {
|
||||
error.value = '未获取到飞书授权 code'
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
try {
|
||||
// 调用后端接口换 token,接口路径请根据实际情况调整
|
||||
const { data } = await UserService.login({
|
||||
feishu_code: code
|
||||
})
|
||||
// 存储token和用户信息
|
||||
userStore.setToken(data.access_token, data.refresh_token, data.expires_in)
|
||||
const userInfo = await UserService.getUserInfo()
|
||||
userStore.setUserInfo(userInfo)
|
||||
userStore.setLoginStatus(true)
|
||||
userStore.sethome_page(data.home_url)
|
||||
localStorage.setItem('home_page', data.home_url)
|
||||
router.push(data.home_url)
|
||||
// router.replace('/')
|
||||
} catch (e: any) {
|
||||
error.value = e?.response?.data?.message || '登录失败,请重试'
|
||||
router.replace('/')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.feishu-callback {
|
||||
min-height: 200px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
color: #333;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<div class="login register">
|
||||
<LoginLeftView></LoginLeftView>
|
||||
<div class="right-wrap">
|
||||
<div class="header">
|
||||
<ArtLogo class="icon" />
|
||||
<h1>{{ systemName }}</h1>
|
||||
</div>
|
||||
<div class="login-wrap">
|
||||
<div class="form">
|
||||
<h3 class="title">{{ $t('forgetPassword.title') }}</h3>
|
||||
<p class="sub-title">{{ $t('forgetPassword.subTitle') }}</p>
|
||||
<div class="input-wrap">
|
||||
<span class="input-label" v-if="showInputLabel">账号</span>
|
||||
<ElInput :placeholder="$t('forgetPassword.placeholder')" v-model.trim="username" />
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 15px">
|
||||
<ElButton
|
||||
class="login-btn"
|
||||
type="primary"
|
||||
@click="register"
|
||||
:loading="loading"
|
||||
v-ripple
|
||||
>
|
||||
{{ $t('forgetPassword.submitBtnText') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 15px">
|
||||
<ElButton class="back-btn" plain @click="toLogin">
|
||||
{{ $t('forgetPassword.backBtnText') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import AppConfig from '@/config'
|
||||
import { RoutesAlias } from '@/router/routesAlias'
|
||||
|
||||
defineOptions({ name: 'ForgetPassword' })
|
||||
|
||||
const router = useRouter()
|
||||
const showInputLabel = ref(false)
|
||||
|
||||
const systemName = AppConfig.systemInfo.name
|
||||
const username = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
const register = async () => {}
|
||||
|
||||
const toLogin = () => {
|
||||
router.push(RoutesAlias.Login)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use '../login/index';
|
||||
</style>
|
||||
@@ -0,0 +1,238 @@
|
||||
@use '@styles/variables.scss' as *;
|
||||
|
||||
.login {
|
||||
background: url('@imgs/login/pcbg1.png') no-repeat center center;
|
||||
background-size: 100% 100%;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
|
||||
.el-input__inner {
|
||||
&:focus {
|
||||
border: 1px solid #4e83fd;
|
||||
}
|
||||
}
|
||||
|
||||
.el-input--medium .el-input__inner {
|
||||
height: var(--el-component-custom-height);
|
||||
line-height: var(--el-component-custom-height);
|
||||
}
|
||||
|
||||
.right-wrap {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
|
||||
.top-right-wrap {
|
||||
position: fixed;
|
||||
top: 30px;
|
||||
right: 30px;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 5px;
|
||||
margin-left: 15px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: all 0.3s;
|
||||
|
||||
i {
|
||||
font-size: 18px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--main-color) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.login-wrap {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 440px;
|
||||
height: 610px;
|
||||
padding: 0 5px;
|
||||
margin: auto;
|
||||
overflow: hidden;
|
||||
background-size: cover;
|
||||
border-radius: 5px;
|
||||
|
||||
.form {
|
||||
box-sizing: border-box;
|
||||
// height: 100%;
|
||||
widows: 100%;
|
||||
padding: 40px 30px;
|
||||
background: rgb(0 10 34 / 50%);
|
||||
border-radius: 18px;
|
||||
|
||||
.title {
|
||||
margin-left: -2px;
|
||||
font-size: 34px;
|
||||
font-weight: 600;
|
||||
color: var(--art-text-gray-900) !important;
|
||||
}
|
||||
|
||||
.sub-title {
|
||||
margin-top: 10px;
|
||||
font-size: 14px;
|
||||
color: var(--art-text-gray-500) !important;
|
||||
}
|
||||
|
||||
.input-wrap {
|
||||
margin-top: 25px;
|
||||
|
||||
.input-label {
|
||||
display: block;
|
||||
padding-bottom: 8px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: var(--art-text-gray-800);
|
||||
}
|
||||
}
|
||||
|
||||
.account-select :deep(.el-select__wrapper),
|
||||
.el-input,
|
||||
.login-btn {
|
||||
height: 50px !important;
|
||||
}
|
||||
|
||||
.drag-verify {
|
||||
position: relative;
|
||||
// padding-bottom: 20px;
|
||||
margin-top: 25px;
|
||||
|
||||
.drag-verify-content {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
box-sizing: border-box;
|
||||
user-select: none;
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s;
|
||||
|
||||
&.error {
|
||||
border-color: #f56c6c;
|
||||
}
|
||||
}
|
||||
|
||||
.error-text {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
margin-top: 10px;
|
||||
font-size: 13px;
|
||||
color: #f56c6c;
|
||||
transition: all 0.3s;
|
||||
|
||||
&.show-error-text {
|
||||
transform: translateY(40px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.forget-password {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 10px;
|
||||
font-size: 14px;
|
||||
color: var(--art-text-gray-500);
|
||||
|
||||
a {
|
||||
color: var(--main-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
width: 100%;
|
||||
height: 40px !important;
|
||||
color: #fff;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
width: 100%;
|
||||
height: 40px !important;
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 20px;
|
||||
font-size: 14px;
|
||||
color: var(--art-text-gray-800);
|
||||
|
||||
a {
|
||||
color: var(--main-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: $device-ipad-pro) {
|
||||
.login {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
|
||||
.right-wrap {
|
||||
margin: auto;
|
||||
|
||||
.login-wrap {
|
||||
position: relative;
|
||||
width: 440px;
|
||||
height: auto;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
|
||||
.form {
|
||||
margin-top: 10vh;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: $device-phone) {
|
||||
.login {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
|
||||
.right-wrap {
|
||||
box-sizing: border-box;
|
||||
width: 100% !important;
|
||||
padding: 0 30px;
|
||||
margin: auto;
|
||||
|
||||
.login-wrap {
|
||||
width: 100%;
|
||||
|
||||
.form {
|
||||
margin-top: 12vh;
|
||||
|
||||
.input-wrap {
|
||||
.input-label {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.input-wrap,
|
||||
.drag-verify {
|
||||
margin-top: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
<template>
|
||||
<div class="login">
|
||||
<LoginLeftView></LoginLeftView>
|
||||
|
||||
<div class="right-wrap">
|
||||
<div class="top-right-wrap"></div>
|
||||
<div class="header">
|
||||
<ArtLogo class="icon" />
|
||||
<h1>{{ systemName }}</h1>
|
||||
</div>
|
||||
<div class="login-wrap">
|
||||
<div class="form">
|
||||
<div class="login-type-tabs">
|
||||
<span class="active">欢迎登录</span>
|
||||
</div>
|
||||
<ElForm
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="rules"
|
||||
@keyup.enter="handleSubmit"
|
||||
style="margin-top: 25px"
|
||||
>
|
||||
<ElFormItem prop="username">
|
||||
<ElInput :placeholder="$t('login.placeholder[0]')" v-model.trim="formData.username">
|
||||
<template #prefix>
|
||||
<span class="input-label">账号</span>
|
||||
</template>
|
||||
</ElInput>
|
||||
</ElFormItem>
|
||||
<ElFormItem prop="password">
|
||||
<ElInput
|
||||
:placeholder="$t('login.placeholder[1]')"
|
||||
v-model.trim="formData.password"
|
||||
type="password"
|
||||
radius="8px"
|
||||
autocomplete="off"
|
||||
show-password
|
||||
>
|
||||
<template #prefix>
|
||||
<span class="input-label">密码</span>
|
||||
</template>
|
||||
</ElInput>
|
||||
</ElFormItem>
|
||||
<div class="drag-verify">
|
||||
<div class="drag-verify-content" :class="{ error: !isPassing && isClickPass }">
|
||||
<ArtDragVerify
|
||||
ref="dragVerify"
|
||||
v-model:value="isPassing"
|
||||
:width="width < 500 ? 260 : 380"
|
||||
:text="$t('login.sliderText')"
|
||||
textColor="var(--art-gray-800)"
|
||||
:successText="$t('login.sliderSuccessText')"
|
||||
:progressBarBg="getCssVar('--el-color-primary')"
|
||||
background="var(--art-gray-200)"
|
||||
handlerBg="var(--art-main-bg-color)"
|
||||
/>
|
||||
</div>
|
||||
<p class="error-text" :class="{ 'show-error-text': !isPassing && isClickPass }">{{
|
||||
$t('login.placeholder[2]')
|
||||
}}</p>
|
||||
</div>
|
||||
<div class="login-bottom-row">
|
||||
<p>
|
||||
<!-- {{ $t('login.noAccount') }}
|
||||
<RouterLink :to="RoutesAlias.Register" style="color: #5d87ff">{{
|
||||
$t('login.register')
|
||||
}}</RouterLink> -->
|
||||
</p>
|
||||
<ElCheckbox v-model="formData.rememberPassword">{{
|
||||
$t('login.rememberPwd')
|
||||
}}</ElCheckbox>
|
||||
</div>
|
||||
<div style="margin-top: 30px">
|
||||
<ElButton
|
||||
class="login-btn"
|
||||
type="primary"
|
||||
@click="handleSubmit"
|
||||
:loading="loading"
|
||||
v-ripple
|
||||
>
|
||||
{{ $t('login.btnText') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</ElForm>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import AppConfig from '@/config'
|
||||
import { RoutesAlias } from '@/router/routesAlias'
|
||||
import { ElNotification, ElMessage } from 'element-plus'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { getCssVar } from '@/utils/ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { HttpError } from '@/utils/http/error'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { commonService } from '@/api/commonApi'
|
||||
|
||||
defineOptions({ name: 'Login' })
|
||||
|
||||
const { t } = useI18n()
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
|
||||
const dragVerify = ref()
|
||||
|
||||
const userStore = useUserStore()
|
||||
const router = useRouter()
|
||||
const isPassing = ref(false)
|
||||
const isClickPass = ref(false)
|
||||
const systemName = AppConfig.systemInfo.name
|
||||
const formRef = ref<FormInstance>()
|
||||
const formData = reactive({
|
||||
account: '',
|
||||
username: '',
|
||||
password: '',
|
||||
rememberPassword: true
|
||||
})
|
||||
const rules = computed<FormRules>(() => ({
|
||||
username: [{ required: true, message: t('login.placeholder[0]'), trigger: 'blur' }],
|
||||
password: [{ required: true, message: t('login.placeholder[1]'), trigger: 'blur' }]
|
||||
}))
|
||||
|
||||
const loading = ref(false)
|
||||
const { width } = useWindowSize()
|
||||
//获取背景图
|
||||
const getbg = async () => {
|
||||
const res = await commonService.getSetting({ code: 'pc_loginbg' })
|
||||
if (res.value_pull) {
|
||||
const loginElement = document.getElementsByClassName('login')[0] as HTMLElement
|
||||
const bannerStr = res.value_pull.trim()
|
||||
if (bannerStr && loginElement) {
|
||||
// 按逗号分隔,过滤空字符串
|
||||
loginElement.style.backgroundImage = `url(${
|
||||
bannerStr
|
||||
.split(',')
|
||||
.map((url: string) => url.trim())
|
||||
.filter((url: any) => url)[0]
|
||||
})`
|
||||
}
|
||||
// document.body.style.backgroundImage = `url(${res.value_pull})`
|
||||
}
|
||||
}
|
||||
|
||||
// 登录
|
||||
const handleSubmit = async () => {
|
||||
if (!formRef.value) return
|
||||
|
||||
try {
|
||||
// 表单验证
|
||||
const valid = await formRef.value.validate()
|
||||
if (!valid) return
|
||||
|
||||
// 拖拽验证
|
||||
if (!isPassing.value) {
|
||||
isClickPass.value = true
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
|
||||
// 登录请求
|
||||
const { username, password } = formData
|
||||
|
||||
const { data } = await UserService.login({
|
||||
account: username,
|
||||
password
|
||||
})
|
||||
|
||||
// 验证token
|
||||
if (!data.access_token) {
|
||||
throw new Error('Login failed - no token received')
|
||||
}
|
||||
|
||||
// 存储token和用户信息
|
||||
userStore.setToken(data.access_token, data.refresh_token, data.expires_in)
|
||||
const userInfo = await UserService.getUserInfo()
|
||||
userStore.setUserInfo(userInfo)
|
||||
userStore.setLoginStatus(true)
|
||||
userStore.sethome_page(data.home_url)
|
||||
localStorage.setItem('home_page', data.home_url)
|
||||
// 登录成功处理
|
||||
showLoginSuccessNotice()
|
||||
router.push(data.home_url)
|
||||
} catch (error) {
|
||||
// 处理 HttpError
|
||||
if (error instanceof HttpError) {
|
||||
// console.log(error.code)
|
||||
} else {
|
||||
// 处理非 HttpError
|
||||
ElMessage.error('登录失败,请稍后重试')
|
||||
console.error('[Login] Unexpected error:', error)
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
resetDragVerify()
|
||||
}
|
||||
}
|
||||
|
||||
// 重置拖拽验证
|
||||
const resetDragVerify = () => {
|
||||
dragVerify.value.reset()
|
||||
}
|
||||
|
||||
// 登录成功提示
|
||||
const showLoginSuccessNotice = () => {
|
||||
setTimeout(() => {
|
||||
ElNotification({
|
||||
title: t('login.success.title'),
|
||||
type: 'success',
|
||||
duration: 2500,
|
||||
zIndex: 10000,
|
||||
message: `${t('login.success.message')}, ${systemName}!`
|
||||
})
|
||||
}, 150)
|
||||
}
|
||||
onMounted(() => {
|
||||
getbg()
|
||||
// 组件挂载时的初始化逻辑
|
||||
})
|
||||
|
||||
import { UserService } from '@/api/usersApi'
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use './index';
|
||||
.login-type-tabs {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 20px;
|
||||
span {
|
||||
padding: 8px 24px;
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
&.active {
|
||||
color: #fff;
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
// border-bottom: 2px solid #409eff;
|
||||
}
|
||||
}
|
||||
}
|
||||
.input-label {
|
||||
color: #333;
|
||||
}
|
||||
.login-bottom-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
// margin-top: 16px;
|
||||
}
|
||||
.login-btn {
|
||||
width: 100%;
|
||||
padding: 28px;
|
||||
margin-top: 60px;
|
||||
border-radius: 30px;
|
||||
border: none;
|
||||
background: #009688;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 20px;
|
||||
}
|
||||
.login-btn:hover {
|
||||
background-color: #009688;
|
||||
border-color: #009688;
|
||||
color: #fff;
|
||||
outline: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,29 @@
|
||||
.register {
|
||||
.right-wrap {
|
||||
.login-wrap {
|
||||
.form {
|
||||
.el-form {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.privacy-policy {
|
||||
margin-top: 15px;
|
||||
|
||||
:deep(.el-checkbox__label) {
|
||||
color: #333 !important;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--main-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
.register-btn {
|
||||
width: 100%;
|
||||
height: 40px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
<template>
|
||||
<div class="login register">
|
||||
<LoginLeftView></LoginLeftView>
|
||||
<div class="right-wrap">
|
||||
<div class="header">
|
||||
<ArtLogo class="icon" />
|
||||
<h1>{{ systemName }}</h1>
|
||||
</div>
|
||||
<div class="login-wrap">
|
||||
<div class="form">
|
||||
<h3 class="title">{{ $t('register.title') }}</h3>
|
||||
<p class="sub-title">{{ $t('register.subTitle') }}</p>
|
||||
<ElForm ref="formRef" :model="formData" :rules="rules" label-position="top">
|
||||
<ElFormItem prop="username">
|
||||
<ElInput
|
||||
v-model.trim="formData.username"
|
||||
:placeholder="$t('register.placeholder[0]')"
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem prop="password">
|
||||
<ElInput
|
||||
v-model.trim="formData.password"
|
||||
:placeholder="$t('register.placeholder[1]')"
|
||||
type="password"
|
||||
autocomplete="off"
|
||||
show-password
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem prop="confirmPassword">
|
||||
<ElInput
|
||||
v-model.trim="formData.confirmPassword"
|
||||
:placeholder="$t('register.placeholder[2]')"
|
||||
type="password"
|
||||
autocomplete="off"
|
||||
@keyup.enter="register"
|
||||
show-password
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<!-- <ElFormItem prop="agreement">
|
||||
<ElCheckbox v-model="formData.agreement">
|
||||
{{ $t('register.agreeText') }}
|
||||
<router-link
|
||||
style="color: var(--main-color); text-decoration: none"
|
||||
to="/privacy-policy"
|
||||
>{{ $t('register.privacyPolicy') }}</router-link
|
||||
>
|
||||
</ElCheckbox>
|
||||
</ElFormItem> -->
|
||||
|
||||
<div style="margin-top: 15px">
|
||||
<ElButton
|
||||
class="register-btn"
|
||||
type="primary"
|
||||
@click="register"
|
||||
:loading="loading"
|
||||
v-ripple
|
||||
>
|
||||
{{ $t('register.submitBtnText') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>
|
||||
{{ $t('register.hasAccount') }}
|
||||
<router-link :to="RoutesAlias.Login">{{ $t('register.toLogin') }}</router-link>
|
||||
</p>
|
||||
</div>
|
||||
</ElForm>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import AppConfig from '@/config'
|
||||
import { RoutesAlias } from '@/router/routesAlias'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { UserService } from '@/api/usersApi'
|
||||
|
||||
defineOptions({ name: 'Register' })
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const router = useRouter()
|
||||
const formRef = ref<FormInstance>()
|
||||
|
||||
const systemName = AppConfig.systemInfo.name
|
||||
const loading = ref(false)
|
||||
|
||||
const formData = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
agreement: false
|
||||
})
|
||||
|
||||
const validatePass = (rule: any, value: string, callback: any) => {
|
||||
if (value === '') {
|
||||
callback(new Error(t('register.placeholder[1]')))
|
||||
} else {
|
||||
if (formData.confirmPassword !== '') {
|
||||
formRef.value?.validateField('confirmPassword')
|
||||
}
|
||||
callback()
|
||||
}
|
||||
}
|
||||
|
||||
const validatePass2 = (rule: any, value: string, callback: any) => {
|
||||
if (value === '') {
|
||||
callback(new Error(t('register.rule[0]')))
|
||||
} else if (value !== formData.password) {
|
||||
callback(new Error(t('register.rule[1]')))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
}
|
||||
|
||||
const rules = reactive<FormRules>({
|
||||
username: [
|
||||
{ required: true, message: t('register.placeholder[0]'), trigger: 'blur' },
|
||||
{ min: 3, max: 20, message: t('register.rule[2]'), trigger: 'blur' }
|
||||
],
|
||||
password: [
|
||||
{ required: true, validator: validatePass, trigger: 'blur' },
|
||||
{ min: 6, message: t('register.rule[3]'), trigger: 'blur' }
|
||||
],
|
||||
confirmPassword: [{ required: true, validator: validatePass2, trigger: 'blur' }],
|
||||
agreement: [
|
||||
{
|
||||
validator: (rule: any, value: boolean, callback: any) => {
|
||||
if (!value) {
|
||||
callback(new Error(t('register.rule[4]')))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
},
|
||||
trigger: 'change'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const register = async () => {
|
||||
if (!formRef.value) return
|
||||
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
loading.value = true
|
||||
// 注册请求
|
||||
const { username, password,confirmPassword } = formData
|
||||
|
||||
const { code } = await UserService.register({
|
||||
account: username,
|
||||
password,
|
||||
confirm_password:confirmPassword
|
||||
})
|
||||
// 验证token
|
||||
loading.value = false
|
||||
if (code!= 200) {
|
||||
throw new Error('Login failed - no token received')
|
||||
}else{
|
||||
ElMessage.success('注册成功')
|
||||
toLogin()
|
||||
}
|
||||
|
||||
// 模拟注册请求
|
||||
// setTimeout(() => {
|
||||
|
||||
// }, 1000)
|
||||
} catch (error) {
|
||||
console.log('验证失败', error)
|
||||
}
|
||||
}
|
||||
|
||||
const toLogin = () => {
|
||||
setTimeout(() => {
|
||||
router.push(RoutesAlias.Login)
|
||||
}, 1000)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use '../login/index' as login;
|
||||
@use './index' as register;
|
||||
</style>
|
||||
@@ -0,0 +1,362 @@
|
||||
<template>
|
||||
<div class="signature-page" :class="{ landscape }">
|
||||
<div class="header">电子签名</div>
|
||||
<div class="canvas-wrap">
|
||||
<canvas
|
||||
ref="canvasRef"
|
||||
class="canvas"
|
||||
:style="{ pointerEvents: locked ? 'none' : 'auto' }"
|
||||
@touchstart.prevent="onStart"
|
||||
@touchmove.prevent="onMove"
|
||||
@touchend.prevent="onEnd"
|
||||
@mousedown.prevent="onMouseDown"
|
||||
@mousemove.prevent="onMouseMove"
|
||||
@mouseup.prevent="onMouseUp"
|
||||
></canvas>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<el-button :disabled="locked" @click="clearCanvas">清空</el-button>
|
||||
<!-- <el-button :disabled="locked" @click="toggleLandscape">横屏</el-button> -->
|
||||
<el-button type="primary" :disabled="locked" :loading="submitting" @click="saveSignature">
|
||||
保存
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="tip">
|
||||
{{ locked ? '签名已保存,页面已锁定' : '请在画布上手写签名,然后点击保存' }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useUploadHeaders } from '@/composables/useUploadHeaders'
|
||||
// import { CrmService } from '@/api/crmApi'
|
||||
import { commonService } from '@/api/commonApi'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
|
||||
const route = useRoute()
|
||||
const userId = route.query.userId as string | undefined
|
||||
const userStore = useUserStore()
|
||||
// 优先使用URL传入的token,否则取当前应用内的token
|
||||
const urlToken =
|
||||
(route.query.accessToken as string | undefined) || (route.query.token as string | undefined)
|
||||
// 其他头部信息从URL透传(便于直达页调用接口)
|
||||
const urlLang = route.query.lang as string | undefined
|
||||
const urlCorp =
|
||||
(route.query.companyabbr as string | undefined) ||
|
||||
(route.query.corp as string | undefined) ||
|
||||
userStore.companyabbr
|
||||
const urlLoginType =
|
||||
(route.query.loginType as string | undefined) || (route.query.type as string | undefined)
|
||||
const urlSys =
|
||||
(route.query.systemsabbr as string | undefined) ||
|
||||
(route.query.sys as string | undefined) ||
|
||||
userStore.systemsabbr ||
|
||||
'fdesk'
|
||||
const effectiveToken = urlToken || userStore.accessToken
|
||||
|
||||
// 从URL参数获取公司信息并存储到userStore(如果路由守卫没有处理)
|
||||
const companyid =
|
||||
(route.query.companyid as string | undefined) || (route.query.corpid as string | undefined)
|
||||
const companyabbr =
|
||||
(route.query.companyabbr as string | undefined) || (route.query.corp as string | undefined)
|
||||
if (companyid && companyabbr && (!userStore.companyid || !userStore.companyabbr)) {
|
||||
userStore.setCompany(companyid, companyabbr)
|
||||
}
|
||||
|
||||
// 从URL参数获取系统信息并存储到userStore(如果路由守卫没有处理)
|
||||
const systemsabbr =
|
||||
(route.query.systemsabbr as string | undefined) || (route.query.sys as string | undefined)
|
||||
const systemsid =
|
||||
(route.query.systemsid as string | undefined) || (route.query.sysid as string | undefined)
|
||||
if (systemsid && systemsabbr && (!userStore.systemsid || !userStore.systemsabbr)) {
|
||||
userStore.setSystems(systemsid, systemsabbr)
|
||||
}
|
||||
|
||||
// 从URL参数获取登录类型并存储到userStore(如果路由守卫没有处理)
|
||||
if (urlLoginType && !userStore.loginType) {
|
||||
userStore.setLoginType(urlLoginType as 'feishu' | 'upwd')
|
||||
}
|
||||
|
||||
// 从URL参数获取token并存储到userStore(如果路由守卫没有处理)
|
||||
if (urlToken && !userStore.accessToken) {
|
||||
userStore.setToken(urlToken)
|
||||
}
|
||||
// 调试信息:打印初始化时的userStore状态
|
||||
// console.log('Signature page initialized:', {
|
||||
// urlParams: {
|
||||
// userId,
|
||||
// urlToken: urlToken ? 'exists' : 'missing',
|
||||
// urlLang,
|
||||
// urlCorp,
|
||||
// urlLoginType,
|
||||
// urlSys
|
||||
// },
|
||||
// userStore: {
|
||||
// companyabbr: userStore.companyabbr,
|
||||
// companyid: userStore.companyid,
|
||||
// systemsabbr: userStore.systemsabbr,
|
||||
// systemsid: userStore.systemsid,
|
||||
// language: userStore.language,
|
||||
// loginType: userStore.loginType,
|
||||
// accessToken: userStore.accessToken ? 'exists' : 'missing',
|
||||
// isLogin: userStore.isLogin
|
||||
// }
|
||||
// })
|
||||
|
||||
const { uploadHeaders } = useUploadHeaders()
|
||||
// 基于URL参数动态构造上传头,优先使用URL透传值
|
||||
const dynamicHeaders = computed(() => {
|
||||
const headers: Record<string, string> = { ...(uploadHeaders.value || {}) }
|
||||
|
||||
// 语言设置
|
||||
if (urlLang) headers['Fdesk-Language'] = urlLang
|
||||
|
||||
// 公司标识 - 优先使用URL参数,其次使用userStore,最后使用默认值
|
||||
const corpAbbr = urlCorp || userStore.companyabbr || 'default'
|
||||
headers['Fdesk-Corp-Abbr'] = corpAbbr
|
||||
|
||||
// 登录类型
|
||||
if (urlLoginType) headers['Fdesk-Login-Type'] = urlLoginType
|
||||
|
||||
// 系统标识
|
||||
headers['Fdesk-System-Code'] = urlSys
|
||||
|
||||
// 认证令牌
|
||||
if (effectiveToken) headers['Fdesk-Auth-Token'] = 'Bearer ' + effectiveToken
|
||||
|
||||
// 调试信息:打印请求头
|
||||
// console.log('Signature page headers:', headers)
|
||||
// console.log('URL params:', {
|
||||
// urlLang,
|
||||
// urlCorp,
|
||||
// urlLoginType,
|
||||
// urlSys,
|
||||
// effectiveToken
|
||||
// })
|
||||
// console.log('Final corp abbr:', corpAbbr)
|
||||
|
||||
return headers
|
||||
})
|
||||
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||||
const ctx = ref<CanvasRenderingContext2D | null>(null)
|
||||
const drawing = ref(false)
|
||||
const submitting = ref(false)
|
||||
const landscape = ref(false)
|
||||
const locked = ref(false)
|
||||
|
||||
const resizeCanvas = () => {
|
||||
const canvas = canvasRef.value
|
||||
if (!canvas) return
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
let width = Math.min(window.innerWidth - 32, 800)
|
||||
let height = Math.round(width * 0.5)
|
||||
if (landscape.value && window.innerHeight > 0) {
|
||||
// 横屏时尽量占满高度
|
||||
const h = Math.min(window.innerHeight - 120, 500)
|
||||
height = h
|
||||
width = Math.round(h * 2)
|
||||
}
|
||||
canvas.style.width = width + 'px'
|
||||
canvas.style.height = height + 'px'
|
||||
canvas.width = Math.floor(width * dpr)
|
||||
canvas.height = Math.floor(height * dpr)
|
||||
const c = canvas.getContext('2d')!
|
||||
c.scale(dpr, dpr)
|
||||
c.lineWidth = 3
|
||||
c.lineCap = 'round'
|
||||
c.lineJoin = 'round'
|
||||
c.strokeStyle = '#111'
|
||||
ctx.value = c
|
||||
// 背景白色
|
||||
c.fillStyle = '#fff'
|
||||
c.fillRect(0, 0, width, height)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
resizeCanvas()
|
||||
window.addEventListener('resize', resizeCanvas)
|
||||
})
|
||||
|
||||
const getPoint = (e: TouchEvent | MouseEvent) => {
|
||||
const canvas = canvasRef.value!
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
let clientX = 0
|
||||
let clientY = 0
|
||||
if (e instanceof TouchEvent) {
|
||||
const t = e.touches[0] || e.changedTouches[0]
|
||||
clientX = t.clientX
|
||||
clientY = t.clientY
|
||||
} else {
|
||||
clientX = (e as MouseEvent).clientX
|
||||
clientY = (e as MouseEvent).clientY
|
||||
}
|
||||
return { x: clientX - rect.left, y: clientY - rect.top }
|
||||
}
|
||||
|
||||
const onStart = (e: TouchEvent) => {
|
||||
if (locked.value) return
|
||||
drawing.value = true
|
||||
const p = getPoint(e)
|
||||
ctx.value!.beginPath()
|
||||
ctx.value!.moveTo(p.x, p.y)
|
||||
}
|
||||
const onMove = (e: TouchEvent) => {
|
||||
if (locked.value) return
|
||||
if (!drawing.value) return
|
||||
const p = getPoint(e)
|
||||
ctx.value!.lineTo(p.x, p.y)
|
||||
ctx.value!.stroke()
|
||||
}
|
||||
const onEnd = () => {
|
||||
if (locked.value) return
|
||||
drawing.value = false
|
||||
}
|
||||
|
||||
const onMouseDown = (e: MouseEvent) => onStart(e as any)
|
||||
const onMouseMove = (e: MouseEvent) => onMove(e as any)
|
||||
const onMouseUp = () => onEnd()
|
||||
|
||||
const clearCanvas = () => {
|
||||
if (locked.value) return
|
||||
resizeCanvas()
|
||||
}
|
||||
|
||||
const dataURLToFile = (dataURL: string, filename: string) => {
|
||||
const arr = dataURL.split(',')
|
||||
const mime = arr[0].match(/:(.*?);/)?.[1] || 'image/png'
|
||||
const bstr = atob(arr[1])
|
||||
let n = bstr.length
|
||||
const u8arr = new Uint8Array(n)
|
||||
while (n--) u8arr[n] = bstr.charCodeAt(n)
|
||||
return new File([u8arr], filename, { type: mime })
|
||||
}
|
||||
|
||||
const uploadUrl = `${import.meta.env.VITE_API_URL}/ticket/upload?scene=user_signature`
|
||||
|
||||
const requestFullscreen = async (el: HTMLElement) => {
|
||||
const anyEl = el as any
|
||||
if (anyEl.requestFullscreen) return anyEl.requestFullscreen()
|
||||
if (anyEl.webkitRequestFullscreen) return anyEl.webkitRequestFullscreen()
|
||||
if (anyEl.msRequestFullscreen) return anyEl.msRequestFullscreen()
|
||||
}
|
||||
|
||||
const lockOrientationLandscape = async () => {
|
||||
const anyScr = screen as any
|
||||
if (anyScr.orientation && anyScr.orientation.lock) {
|
||||
try {
|
||||
await anyScr.orientation.lock('landscape')
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isLimitedWebview = () => /MicroMessenger|Lark|Feishu|DingTalk/i.test(navigator.userAgent)
|
||||
|
||||
const toggleLandscape = async () => {
|
||||
landscape.value = !landscape.value
|
||||
if (landscape.value) {
|
||||
// 在微信/飞书等内置浏览器中,强制横屏普遍受限,采用 CSS 旋转兜底
|
||||
if (!isLimitedWebview()) {
|
||||
await requestFullscreen(document.documentElement)
|
||||
await lockOrientationLandscape()
|
||||
}
|
||||
}
|
||||
setTimeout(resizeCanvas, 200)
|
||||
}
|
||||
|
||||
const saveSignature = async () => {
|
||||
if (!userId) {
|
||||
ElMessage.error('缺少用户信息')
|
||||
return
|
||||
}
|
||||
try {
|
||||
submitting.value = true
|
||||
const canvas = canvasRef.value!
|
||||
const dataUrl = canvas.toDataURL('image/png')
|
||||
const file = dataURLToFile(dataUrl, `signature_${userId}_${Date.now()}.png`)
|
||||
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
|
||||
const headers: Record<string, string> = { ...(dynamicHeaders.value || {}) }
|
||||
// 兼容后端可能验 Authorization 的情况
|
||||
if (effectiveToken) headers['Authorization'] = `Bearer ${effectiveToken}`
|
||||
|
||||
// 调试信息:打印上传请求详情
|
||||
// console.log('Upload URL:', uploadUrl)
|
||||
// console.log('Upload headers:', headers)
|
||||
// console.log('UserStore state:', {
|
||||
// systemsabbr: userStore.systemsabbr,
|
||||
// systemsid: userStore.systemsid,
|
||||
// companyabbr: userStore.companyabbr,
|
||||
// companyid: userStore.companyid,
|
||||
// language: userStore.language,
|
||||
// loginType: userStore.loginType
|
||||
// })
|
||||
|
||||
const res = await fetch(uploadUrl, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: form
|
||||
})
|
||||
const json = await res.json()
|
||||
// console.log('Upload response:', json)
|
||||
if (json.code !== 200) throw new Error(json.message || '上传失败')
|
||||
|
||||
// 将签名保存到个人信息(假设 crmuseredit 接受 signature 和 signature_pic_full 字段)
|
||||
const res1 = await commonService.signatureEdit({
|
||||
sign_img: json.data.file_name
|
||||
})
|
||||
|
||||
ElMessage.success(res1?.message || '签名已保存')
|
||||
locked.value = true
|
||||
// 完成后可提示返回或自动关闭
|
||||
// setTimeout(() => {
|
||||
// if (window.history.length > 1) window.history.back()
|
||||
// }, 800)
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '保存失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.signature-page {
|
||||
padding: 16px;
|
||||
}
|
||||
.header {
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
font-size: 18px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.canvas-wrap {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
.canvas {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 6px;
|
||||
touch-action: none;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.tip {
|
||||
text-align: center;
|
||||
color: #909399;
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user