创建项目

This commit is contained in:
浅念
2025-11-19 18:01:09 +08:00
commit 228a444ed0
371 changed files with 105839 additions and 0 deletions
+60
View File
@@ -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>
+63
View File
@@ -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>
+238
View File
@@ -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;
}
}
}
}
}
}
+272
View File
@@ -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>
+29
View File
@@ -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;
}
}
}
}
}
+190
View File
@@ -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>
+362
View File
@@ -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>