整合前端仓库并保留提交历史
This commit is contained in:
@@ -0,0 +1,400 @@
|
||||
<template>
|
||||
<transition name="ad-fade">
|
||||
<div v-if="!isHidden && displayedAds.length > 0" class="advertisement-wrapper" :class="[`ad-${type}`, position && type === 'float' ? `ad-position-${position}` : '']">
|
||||
<div
|
||||
v-for="ad in displayedAds"
|
||||
:key="ad.id"
|
||||
class="ad-item"
|
||||
>
|
||||
<!-- 简单模式 -->
|
||||
<component
|
||||
v-if="type === 'simple'"
|
||||
:is="SimpleModeRaw"
|
||||
:ad="ad"
|
||||
@click="handleClick"
|
||||
/>
|
||||
|
||||
<!-- 悬浮模式 -->
|
||||
<component
|
||||
v-else-if="type === 'float'"
|
||||
:is="FloatModeRaw"
|
||||
:ad="ad"
|
||||
:closable="closable"
|
||||
@click="handleClick"
|
||||
@close="handleClose"
|
||||
/>
|
||||
|
||||
<!-- 自定义扩展模式 -->
|
||||
<component
|
||||
v-else-if="customModeComponent"
|
||||
:is="customModeComponent"
|
||||
:ad="ad"
|
||||
v-bind="customModeProps"
|
||||
@click="handleClick"
|
||||
@close="handleClose"
|
||||
/>
|
||||
|
||||
<!-- 默认模式(向后兼容) -->
|
||||
<div
|
||||
v-else
|
||||
class="ad-card"
|
||||
@click="handleClick(ad)"
|
||||
>
|
||||
<div class="ad-image-wrapper">
|
||||
<img
|
||||
:src="ad.image"
|
||||
:alt="ad.title"
|
||||
class="ad-image"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div class="ad-gradient"></div>
|
||||
<div v-if="showTitle" class="ad-content">
|
||||
<h3 class="ad-title">{{ ad.title }}</h3>
|
||||
<p v-if="ad.description" class="ad-description">{{ ad.description }}</p>
|
||||
<span class="ad-badge">广告</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="closable"
|
||||
class="ad-close-btn"
|
||||
@click.stop="handleClose(ad)"
|
||||
title="关闭"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M18 6L6 18M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed, markRaw, shallowRef } from 'vue'
|
||||
import type { Component } from 'vue'
|
||||
import { getAdvertisements } from '~/api/site'
|
||||
import type { AdvertisementItem } from '~/api/site/types'
|
||||
import SimpleMode from './modes/simple.vue'
|
||||
import FloatMode from './modes/float.vue'
|
||||
import { getModeComponent, registerMode } from './modes'
|
||||
|
||||
interface Props {
|
||||
type?: string // 简化类型定义,支持字符串类型
|
||||
offset?: number
|
||||
limit?: number
|
||||
showTitle?: boolean
|
||||
closable?: boolean
|
||||
showTodayBtn?: boolean
|
||||
autoCloseDelay?: number
|
||||
position?: 'top' | 'center' | 'bottom'
|
||||
customModeProps?: Record<string, any>
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
type: 'simple',
|
||||
offset: 0,
|
||||
limit: 0,
|
||||
showTitle: false,
|
||||
closable: false,
|
||||
showTodayBtn: false,
|
||||
autoCloseDelay: 0,
|
||||
position: 'center',
|
||||
customModeProps: () => ({})
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: [ad: AdvertisementItem]
|
||||
close: [ad: AdvertisementItem]
|
||||
}>()
|
||||
|
||||
// 将组件标记为原始组件,避免响应式代理
|
||||
const SimpleModeRaw = markRaw(SimpleMode)
|
||||
const FloatModeRaw = markRaw(FloatMode)
|
||||
|
||||
// 使用这些标记为原始的组件
|
||||
|
||||
const ads = ref<AdvertisementItem[]>([])
|
||||
const isHidden = ref(false)
|
||||
const customModeComponent = shallowRef<Component | null>(null)
|
||||
|
||||
const COMPONENT_STORAGE_KEY = `ad_component_${props.type}_${props.offset}`
|
||||
|
||||
// 动态获取自定义模式组件
|
||||
onMounted(() => {
|
||||
if (!['simple', 'float'].includes(props.type!)) {
|
||||
const component = getModeComponent(props.type!)
|
||||
if (component) {
|
||||
customModeComponent.value = markRaw(component)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const checkTodayClosed = () => {
|
||||
const today = new Date().toDateString()
|
||||
const stored = localStorage.getItem(`ad_today_${COMPONENT_STORAGE_KEY}`)
|
||||
return stored === today
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (checkTodayClosed()) {
|
||||
isHidden.value = true
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
ads.value = await getAdvertisements()
|
||||
} catch (error) {
|
||||
console.error('获取广告失败:', error)
|
||||
}
|
||||
})
|
||||
|
||||
const displayedAds = computed(() => {
|
||||
const now = Date.now() / 1000
|
||||
const validAds = ads.value.filter(
|
||||
ad =>
|
||||
ad.enabled === 1 &&
|
||||
ad.start_time <= now &&
|
||||
ad.end_time >= now
|
||||
)
|
||||
const slicedAds = validAds.slice(props.offset)
|
||||
const result = props.limit > 0 ? slicedAds.slice(0, props.limit) : slicedAds
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const handleClick = (ad: AdvertisementItem) => {
|
||||
emit('click', ad)
|
||||
if (ad.link) {
|
||||
window.open(ad.link, '_blank')
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = (ad: AdvertisementItem) => {
|
||||
isHidden.value = true
|
||||
emit('close', ad)
|
||||
}
|
||||
|
||||
// 导出注册方法供外部使用
|
||||
defineExpose({
|
||||
registerMode
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
// 基础广告卡片
|
||||
.advertisement-wrapper {
|
||||
width: 100%;
|
||||
|
||||
&.ad-simple {
|
||||
display: block;
|
||||
}
|
||||
|
||||
&.ad-float {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.ad-item {
|
||||
display: block;
|
||||
}
|
||||
|
||||
// 悬浮广告定位
|
||||
.ad-float {
|
||||
position: fixed;
|
||||
right: 24px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 100;
|
||||
width: 200px;
|
||||
|
||||
.ad-item {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
// 右上位置
|
||||
.ad-float.ad-position-top {
|
||||
top: 24px;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
// 右中位置(默认)
|
||||
.ad-float.ad-position-center {
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
// 右下位置
|
||||
.ad-float.ad-position-bottom {
|
||||
top: auto;
|
||||
bottom: 24px;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
// 默认卡片样式(向后兼容)
|
||||
.ad-card {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
box-shadow:
|
||||
0 4px 6px rgba(0, 0, 0, 0.07),
|
||||
0 10px 20px rgba(0, 0, 0, 0.08);
|
||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
&:hover {
|
||||
transform: none;
|
||||
box-shadow:
|
||||
0 8px 12px rgba(102, 126, 234, 0.2),
|
||||
0 20px 40px rgba(0, 0, 0, 0.15);
|
||||
|
||||
.ad-close-btn {
|
||||
opacity: 1;
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
|
||||
.ad-image-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.ad-image {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.ad-gradient {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 60%;
|
||||
background: linear-gradient(
|
||||
to top,
|
||||
rgba(0, 0, 0, 0.8) 0%,
|
||||
rgba(0, 0, 0, 0.4) 50%,
|
||||
transparent 100%
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ad-content {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 24px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.ad-title {
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.ad-description {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-size: 14px;
|
||||
margin: 0 0 12px;
|
||||
line-height: 1.5;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.ad-badge {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ad-close-btn {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #666;
|
||||
opacity: 0;
|
||||
transform: rotate(0deg);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
|
||||
svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: #fff;
|
||||
color: #333;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 动画
|
||||
.ad-fade-enter-active,
|
||||
.ad-fade-leave-active {
|
||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.ad-fade-enter-from,
|
||||
.ad-fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
// 响应式
|
||||
@media (max-width: 768px) {
|
||||
.ad-float {
|
||||
right: 12px;
|
||||
width: 160px;
|
||||
|
||||
.ad-card {
|
||||
.ad-title {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.ad-description {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ad-card {
|
||||
&:hover {
|
||||
transform: none;
|
||||
box-shadow: 0 8px 12px rgba(102, 126, 234, 0.2);
|
||||
}
|
||||
|
||||
.ad-close-btn {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.ad-title {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.ad-description {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,137 @@
|
||||
<template>
|
||||
<div class="ad-float-card" @click="handleClick">
|
||||
<div class="ad-image-wrapper">
|
||||
<img :src="ad.image" :alt="ad.title" class="ad-image" />
|
||||
</div>
|
||||
<div class="ad-content">
|
||||
<h3 class="ad-title">{{ ad.title }}</h3>
|
||||
<p v-if="ad.description" class="ad-description">{{ ad.description }}</p>
|
||||
<span class="ad-badge">广告</span>
|
||||
</div>
|
||||
<button
|
||||
v-if="closable"
|
||||
class="ad-close-btn"
|
||||
@click.stop="handleClose"
|
||||
title="关闭"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M18 6L6 18M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AdvertisementItem } from '~/api/site/types'
|
||||
|
||||
interface Props {
|
||||
ad: AdvertisementItem
|
||||
closable?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
closable: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: [ad: AdvertisementItem]
|
||||
close: [ad: AdvertisementItem]
|
||||
}>()
|
||||
|
||||
const handleClick = () => {
|
||||
emit('click', props.ad)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
emit('close', props.ad)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.ad-float-card {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
cursor: pointer;
|
||||
|
||||
.ad-image-wrapper {
|
||||
width: 100%;
|
||||
border-radius: 1px 1px 0 0;
|
||||
}
|
||||
|
||||
.ad-image {
|
||||
width: 100%;
|
||||
height: 120px;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ad-content {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.ad-title {
|
||||
color: #1a1a1a;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
margin: 0 0 8px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ad-description {
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.ad-badge {
|
||||
display: inline-block;
|
||||
padding: 3px 6px;
|
||||
background: #f5f5f5;
|
||||
color: #999;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
border-radius: 3px;
|
||||
margin-top: 8px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.ad-close-btn {
|
||||
position: absolute;
|
||||
bottom: 6px;
|
||||
right: 6px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
opacity: 1;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
color: rgba(0, 0, 0, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { Component } from 'vue'
|
||||
|
||||
// 内置模式
|
||||
import SimpleMode from './simple.vue'
|
||||
import FloatMode from './float.vue'
|
||||
|
||||
// 动态加载自定义模式
|
||||
const customModes: Record<string, Component> = {
|
||||
simple: SimpleMode,
|
||||
float: FloatMode
|
||||
}
|
||||
|
||||
// 注册自定义模式
|
||||
export const registerMode = (name: string, component: Component) => {
|
||||
customModes[name] = component
|
||||
}
|
||||
|
||||
// 获取模式组件
|
||||
export const getModeComponent = (modeName: string): Component | undefined => {
|
||||
return customModes[modeName]
|
||||
}
|
||||
|
||||
// 获取所有可用模式
|
||||
export const getAvailableModes = (): string[] => {
|
||||
return Object.keys(customModes)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<div class="ad-simple-layout" @click="handleClick">
|
||||
<img :src="ad.image" :alt="ad.title" class="ad-simple-image" />
|
||||
<!-- <span class="ad-badge">广告</span> -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AdvertisementItem } from '~/api/site/types'
|
||||
|
||||
interface Props {
|
||||
ad: AdvertisementItem
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: [ad: AdvertisementItem]
|
||||
}>()
|
||||
|
||||
const handleClick = () => {
|
||||
emit('click', props.ad)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.ad-simple-layout {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
margin: 4px 0;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
border-radius: 1px;
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: rgb(218, 225, 225);
|
||||
}
|
||||
|
||||
.ad-simple-image {
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ad-badge {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
padding: 2px 6px;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
border-radius: 2px;
|
||||
line-height: 1;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,77 @@
|
||||
<template>
|
||||
<div v-if="shouldLoadAnalytics">
|
||||
<!-- 百度统计代码将被注入到这里 -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { useConfigStore } from '~/stores/config'
|
||||
|
||||
const configStore = useConfigStore()
|
||||
const shouldLoadAnalytics = ref(false)
|
||||
const analyticsLoaded = ref(false)
|
||||
|
||||
// 加载百度统计脚本
|
||||
const loadBaiduAnalytics = () => {
|
||||
if (!configStore.analytics.baidu_tongji_enabled || !configStore.analytics.baidu_tongji_code) {
|
||||
return
|
||||
}
|
||||
|
||||
if (analyticsLoaded.value) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 创建百度统计脚本
|
||||
const script = document.createElement('script')
|
||||
script.text = `
|
||||
var _hmt = _hmt || [];
|
||||
(function() {
|
||||
var hm = document.createElement("script");
|
||||
hm.src = "https://hm.baidu.com/hm.js?${configStore.analytics.baidu_tongji_code}";
|
||||
var s = document.getElementsByTagName("script")[0];
|
||||
s.parentNode.insertBefore(hm, s);
|
||||
})();
|
||||
`
|
||||
document.head.appendChild(script)
|
||||
analyticsLoaded.value = true
|
||||
console.log('百度统计已加载')
|
||||
} catch (error) {
|
||||
console.error('百度统计加载失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化 - 配置已在 app.vue 中统一加载,这里直接使用
|
||||
onMounted(() => {
|
||||
// 检查是否应该加载统计
|
||||
shouldLoadAnalytics.value = configStore.analytics.baidu_tongji_enabled === 1 && !!configStore.analytics.baidu_tongji_code
|
||||
|
||||
// 加载统计脚本
|
||||
if (shouldLoadAnalytics.value) {
|
||||
loadBaiduAnalytics()
|
||||
}
|
||||
})
|
||||
|
||||
// 监听配置变化
|
||||
watch(
|
||||
() => [
|
||||
configStore.analytics.baidu_tongji_enabled,
|
||||
configStore.analytics.baidu_tongji_code
|
||||
],
|
||||
() => {
|
||||
shouldLoadAnalytics.value = configStore.analytics.baidu_tongji_enabled === 1 && !!configStore.analytics.baidu_tongji_code
|
||||
if (shouldLoadAnalytics.value && !analyticsLoaded.value) {
|
||||
loadBaiduAnalytics()
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
// 这是一个无界面的组件,主要用于加载百度统计
|
||||
export default {
|
||||
name: 'BaiduAnalytics'
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,299 @@
|
||||
<template>
|
||||
<div class="icon-selector">
|
||||
<div
|
||||
class="select"
|
||||
@click="handleClick"
|
||||
:style="{ width: props.width }"
|
||||
:class="[size, { 'is-disabled': disabled }, { 'has-icon': selectValue }]"
|
||||
>
|
||||
<div class="icon">
|
||||
<Icon v-show="selectValue" :icon="selectValue" />
|
||||
</div>
|
||||
<div class="text">{{ props.text }}</div>
|
||||
<div class="arrow">
|
||||
<Icon icon="ant-design:down-outlined" class="arrow-icon" />
|
||||
<Icon icon="ant-design:close-outlined" @click.stop="clearIcon" class="clear-icon" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dialog title="选择图标" width="40%" v-model="visible" align-center>
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane v-for="prefix in iconifyPrefixs" :key="prefix" :label="prefix" :name="prefix">
|
||||
<el-scrollbar height="400px">
|
||||
<el-input v-model="searchText" placeholder="请输入图标名称"></el-input>
|
||||
<ul class="icons-list" style="margin-top: 10px">
|
||||
<li
|
||||
v-for="icon in paginatedIconifyList"
|
||||
:key="icon"
|
||||
@click="selectorIconifyIcon(icon)"
|
||||
>
|
||||
<i class="iconfont-sys">
|
||||
<Icon :icon="icon" />
|
||||
</i>
|
||||
</li>
|
||||
</ul>
|
||||
<el-pagination
|
||||
v-if="filteredIconifyList.length > iconifyPageSize"
|
||||
layout="prev, pager, next"
|
||||
:total="filteredIconifyList.length"
|
||||
:page-size="iconifyPageSize"
|
||||
v-model:current-page="iconifyCurrentPage"
|
||||
size="small"
|
||||
style="margin-top: 10px; justify-content: center"
|
||||
/>
|
||||
</el-scrollbar>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="Svg图标" name="svg">
|
||||
<el-scrollbar height="400px">
|
||||
<ul class="icons-list">
|
||||
<li v-for="icon in svgList" :key="icon" @click="selectorIconifyIcon(icon)">
|
||||
<i class="iconfont-sys">
|
||||
<Icon :icon="icon" />
|
||||
</i>
|
||||
</li>
|
||||
</ul>
|
||||
</el-scrollbar>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="visible = false">取 消</el-button>
|
||||
<el-button type="primary" @click="visible = false">确 定</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { listIcons } from '@iconify/vue'
|
||||
import { fetchIconsData } from '~/utils/icon-utils'
|
||||
import Icon from './iconify.vue'
|
||||
|
||||
defineOptions({ name: 'ArtIconSelector' })
|
||||
|
||||
const searchText = ref('')
|
||||
const activeTab = ref('ant-design')
|
||||
const iconifyCurrentPage = ref(1)
|
||||
const iconifyPageSize = ref(60)
|
||||
|
||||
const svgList = computed(() => {
|
||||
return listIcons('', 'svg')
|
||||
})
|
||||
|
||||
const iconifyMap = reactive<any>({})
|
||||
const iconifyPrefixs = ['ant-design', 'carbon', 'ic', 'lucide', 'ep', 'ri']
|
||||
|
||||
const iconifyList = computed(() => {
|
||||
return iconifyMap[activeTab.value]
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
iconifyPrefixs.forEach((prefix: any) => {
|
||||
fetchIconsData(prefix).then((icons) => {
|
||||
iconifyMap[prefix] = icons
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
watchDebounced(
|
||||
() => activeTab.value,
|
||||
() => {
|
||||
if (iconifyPrefixs.includes(activeTab.value)) {
|
||||
fetchIconsData(activeTab.value).then((icons) => {
|
||||
iconifyMap[activeTab.value] = icons
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const filteredIconifyList = computed<string[]>(() => {
|
||||
if (!searchText.value) {
|
||||
return iconifyList.value || []
|
||||
}
|
||||
return (iconifyList.value || []).filter((icon: any) => icon.includes(searchText.value))
|
||||
})
|
||||
|
||||
const paginatedIconifyList = computed<string[]>(() => {
|
||||
const start = (iconifyCurrentPage.value - 1) * iconifyPageSize.value
|
||||
const end = start + iconifyPageSize.value
|
||||
return filteredIconifyList.value.slice(start, end)
|
||||
})
|
||||
|
||||
type ComponentSize = 'large' | 'default' | 'small'
|
||||
|
||||
interface Props {
|
||||
iconType?: any
|
||||
modelValue?: string
|
||||
text?: string
|
||||
width?: string
|
||||
size?: ComponentSize
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
'update:modelValue': [value: string]
|
||||
getIcon: [value: string]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
iconType: '',
|
||||
modelValue: '',
|
||||
text: '图标选择器',
|
||||
width: '200px',
|
||||
size: 'default',
|
||||
disabled: false
|
||||
})
|
||||
|
||||
const emits = defineEmits<Emits>()
|
||||
|
||||
const selectValue = ref<string>(props.modelValue)
|
||||
const visible = ref<boolean>(false)
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal: string) => {
|
||||
selectValue.value = newVal
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(searchText, () => {
|
||||
iconifyCurrentPage.value = 1
|
||||
})
|
||||
|
||||
const selectorIconifyIcon = (icon: string): void => {
|
||||
selectValue.value = icon
|
||||
visible.value = false
|
||||
emits('update:modelValue', icon)
|
||||
emits('getIcon', icon)
|
||||
}
|
||||
|
||||
const handleClick = () => {
|
||||
if (!props.disabled) {
|
||||
visible.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const clearIcon = () => {
|
||||
selectValue.value = ''
|
||||
emits('update:modelValue', '')
|
||||
emits('getIcon', '')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.icon-selector {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.select {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
background: var(--el-fill-color-blank);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
&.is-disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--el-border-color);
|
||||
}
|
||||
}
|
||||
|
||||
&.has-icon {
|
||||
.text {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.large {
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
&.default {
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
&.small {
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.text {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.arrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
|
||||
.arrow-icon {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.clear-icon {
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.icons-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
||||
gap: 8px;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--el-color-primary);
|
||||
background: var(--el-color-primary-light-9);
|
||||
}
|
||||
|
||||
.iconfont-sys {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,141 @@
|
||||
# 图标组件使用说明
|
||||
|
||||
## 功能特性
|
||||
|
||||
- 支持多种图标库:Ant Design、Element Plus、Carbon、IC、Lucide、Ri 等
|
||||
- 支持自定义图标
|
||||
- 支持图标选择器组件
|
||||
- 支持离线模式
|
||||
|
||||
## 安装依赖
|
||||
|
||||
```bash
|
||||
pnpm add @iconify/vue @iconify/json
|
||||
```
|
||||
|
||||
## 使用方式
|
||||
|
||||
### 1. 基础图标组件 (Iconify)
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<Iconify icon="ant-design:home-outlined" />
|
||||
<Iconify icon="ep:home-filled" />
|
||||
<Iconify icon="carbon:home" />
|
||||
<Iconify icon="ic:baseline-home" />
|
||||
<Iconify icon="lucide:home" />
|
||||
<Iconify icon="ri:home-line" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Iconify from '~/components/iconify.vue'
|
||||
</script>
|
||||
```
|
||||
|
||||
### 2. 自定义尺寸和颜色
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<Iconify icon="ant-design:home-outlined" width="24" height="24" />
|
||||
<Iconify icon="ant-design:home-outlined" width="32" height="32" color="#409eff" />
|
||||
<Iconify icon="ant-design:home-outlined" size="1.5em" color="#67c23a" />
|
||||
</template>
|
||||
```
|
||||
|
||||
### 3. 图标选择器 (IconSelector)
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<IconSelector v-model="selectedIcon" text="选择图标" width="300px" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import IconSelector from '~/components/icon-selector.vue'
|
||||
|
||||
const selectedIcon = ref('')
|
||||
</script>
|
||||
```
|
||||
|
||||
### 4. 监听图标选择事件
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<IconSelector v-model="selectedIcon" @get-icon="handleGetIcon" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import IconSelector from '~/components/icon-selector.vue'
|
||||
|
||||
const selectedIcon = ref('')
|
||||
|
||||
const handleGetIcon = (icon: string) => {
|
||||
console.log('选择的图标:', icon)
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## 支持的图标库
|
||||
|
||||
| 图标库 | 前缀 | 示例 |
|
||||
|--------|------|------|
|
||||
| Ant Design | `ant-design` | `ant-design:home-outlined` |
|
||||
| Element Plus | `ep` | `ep:home-filled` |
|
||||
| Carbon | `carbon` | `carbon:home` |
|
||||
| IC | `ic` | `ic:baseline-home` |
|
||||
| Lucide | `lucide` | `lucide:home` |
|
||||
| Ri | `ri` | `ri:home-line` |
|
||||
|
||||
## 自定义添加图标库
|
||||
|
||||
### 1. 安装图标库
|
||||
|
||||
```bash
|
||||
pnpm add @iconify-json/your-icon-library
|
||||
```
|
||||
|
||||
### 2. 在 load-icon.ts 中添加
|
||||
|
||||
```typescript
|
||||
import YourIcons from '@iconify/json/json/your-icon-library.json'
|
||||
addCollection(YourIcons)
|
||||
```
|
||||
|
||||
### 3. 在 icon-selector.vue 中添加
|
||||
|
||||
```typescript
|
||||
const iconifyPrefixs = ['ant-design', 'carbon', 'ic', 'lucide', 'ep', 'ri', 'your-icon-library']
|
||||
```
|
||||
|
||||
## 配置说明
|
||||
|
||||
### 离线模式
|
||||
|
||||
在 `.env` 文件中配置:
|
||||
|
||||
```env
|
||||
VITE_APP_ICON_OFFLINE=true
|
||||
VITE_APP_ICONIFY_PREFIX=ant-design,carbon,ic,lucide,ep,ri
|
||||
```
|
||||
|
||||
### 图标选择器配置
|
||||
|
||||
| 属性 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| modelValue | string | '' | v-model 绑定的图标值 |
|
||||
| text | string | '图标选择器' | 显示文本 |
|
||||
| width | string | '200px' | 组件宽度 |
|
||||
| size | string | 'default' | 组件大小:large/default/small |
|
||||
| disabled | boolean | false | 是否禁用 |
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. 图标名称格式:`图标库名称:图标名称`
|
||||
2. 离线模式下需要预先加载所有图标数据
|
||||
3. 图标选择器支持搜索功能
|
||||
4. 图标选择器支持分页显示
|
||||
|
||||
## 示例代码
|
||||
|
||||
完整示例请参考项目中的使用场景。
|
||||
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<i class="iconfont-sys" v-if="iconType === 'unicode'" v-html="icon"></i>
|
||||
<i :class="`iconfont-sys ${icon}`" v-else-if="iconType === 'className'"></i>
|
||||
<ElIcon class="m-icon" v-else-if="typeof icon === 'string'">
|
||||
<Icon :icon="icon" :width="width" :height="height" :color="color"></Icon>
|
||||
</ElIcon>
|
||||
<ElIcon class="m-icon" v-else-if="typeof icon === 'object'"
|
||||
><component :is="icon" :width="width" :height="height" :color="color"></component
|
||||
></ElIcon>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import type { VNode } from 'vue'
|
||||
import { ElIcon } from 'element-plus'
|
||||
|
||||
const props = defineProps({
|
||||
icon: {
|
||||
type: [String, Object] as PropType<string | VNode>
|
||||
},
|
||||
width: {
|
||||
type: [String, Number],
|
||||
default: '1em'
|
||||
},
|
||||
height: {
|
||||
type: [String, Number],
|
||||
default: '1em'
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: undefined
|
||||
}
|
||||
})
|
||||
|
||||
const iconType = computed(() => {
|
||||
if (props.icon?.toString().startsWith('iconsys-')) {
|
||||
return 'className'
|
||||
} else if (props.icon?.toString().includes(':')) {
|
||||
return 'iconify'
|
||||
}
|
||||
return 'unicode'
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.m-icon {
|
||||
width: auto !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as Icon } from './iconify.vue'
|
||||
export { default as IconSelector } from './icon-selector.vue'
|
||||
export { default } from './iconify.vue'
|
||||
@@ -0,0 +1,565 @@
|
||||
<template>
|
||||
<div class="forget-password-container" :class="{'dark-mode': isDarkMode}">
|
||||
<div class="forget-password-form-container">
|
||||
<!-- 第一步:邮箱验证 -->
|
||||
<div v-if="currentStep === 1">
|
||||
<div class="forget-password-title">{{ t('auth.forget.password.title') }}</div>
|
||||
|
||||
<el-form
|
||||
:model="step1Data"
|
||||
ref="step1FormRef"
|
||||
:rules="step1Rules"
|
||||
:validate-on-rule-change="false"
|
||||
class="forget-password-form"
|
||||
>
|
||||
<el-form-item prop="email" class="form-item">
|
||||
<div class="input-container">
|
||||
<el-input
|
||||
v-model="step1Data.email"
|
||||
:placeholder="t('auth.forget.password.email_placeholder')"
|
||||
clearable
|
||||
class="forget-password-input"
|
||||
>
|
||||
<template #prefix>
|
||||
<Icon
|
||||
icon="ant-design:mail-outlined"
|
||||
:color="isDarkMode ? '#ffffff' : '#000000'"
|
||||
class="input-prefix-icon"
|
||||
/>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item prop="captcha_code" class="form-item">
|
||||
<div class="input-container">
|
||||
<el-input
|
||||
v-model="step1Data.captcha_code"
|
||||
:placeholder="t('auth.forget.password.captcha_placeholder')"
|
||||
class="forget-password-input"
|
||||
>
|
||||
<template #prefix>
|
||||
<Icon
|
||||
icon="ant-design:safety-outlined"
|
||||
:color="isDarkMode ? '#ffffff' : '#000000'"
|
||||
class="input-prefix-icon"
|
||||
/>
|
||||
</template>
|
||||
<template #suffix>
|
||||
<div class="captcha-image" @click="captcha.refresh()">
|
||||
<img v-if="captcha.image.value" :src="captcha.image.value" :alt="t('auth.forget.password.captcha_alt')" />
|
||||
<Icon v-else icon="ant-design:loading-outlined" :size="24" />
|
||||
</div>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item class="form-item">
|
||||
<el-button
|
||||
type="primary"
|
||||
class="next-step-button"
|
||||
@click="handleNextStep"
|
||||
>
|
||||
{{ t('auth.forget.password.next_step') }}
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
|
||||
<div class="login-link">
|
||||
{{ t('auth.forget.password.back_to_login') }}
|
||||
<span class="login-button" @click="switchToLogin">
|
||||
{{ t('auth.forget.password.login') }}
|
||||
</span>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- 第二步:设置新密码 -->
|
||||
<div v-else>
|
||||
<div class="forget-password-title">{{ t('auth.forget.password.reset_title') }}</div>
|
||||
|
||||
<!-- 验证码发送提示 -->
|
||||
<div class="verification-tip" v-if="verificationSent">
|
||||
{{ t('auth.forget.password.verification_sent', { email: step1Data.email }) }}<br>
|
||||
{{ t('auth.forget.password.verification_instruction') }}
|
||||
</div>
|
||||
|
||||
<el-form
|
||||
:model="step2Data"
|
||||
ref="step2FormRef"
|
||||
:rules="step2Rules"
|
||||
:validate-on-rule-change="false"
|
||||
class="forget-password-form"
|
||||
>
|
||||
<el-form-item prop="password" class="form-item">
|
||||
<div class="input-container">
|
||||
<el-input
|
||||
v-model="step2Data.password"
|
||||
:placeholder="t('auth.forget.password.password_placeholder')"
|
||||
type="password"
|
||||
show-password
|
||||
clearable
|
||||
class="forget-password-input"
|
||||
>
|
||||
<template #prefix>
|
||||
<Icon
|
||||
icon="ant-design:lock-outlined"
|
||||
:color="isDarkMode ? '#ffffff' : '#000000'"
|
||||
class="input-prefix-icon"
|
||||
/>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item prop="confirm_password" class="form-item">
|
||||
<div class="input-container">
|
||||
<el-input
|
||||
v-model="step2Data.confirm_password"
|
||||
:placeholder="t('auth.forget.password.confirm_password_placeholder')"
|
||||
type="password"
|
||||
show-password
|
||||
clearable
|
||||
class="forget-password-input"
|
||||
>
|
||||
<template #prefix>
|
||||
<Icon
|
||||
icon="ant-design:lock-outlined"
|
||||
:color="isDarkMode ? '#ffffff' : '#000000'"
|
||||
class="input-prefix-icon"
|
||||
/>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item prop="code" class="form-item">
|
||||
<div class="email-code-container">
|
||||
<div class="input-container">
|
||||
<el-input
|
||||
v-model="step2Data.code"
|
||||
:placeholder="t('auth.forget.password.verification_code_placeholder')"
|
||||
class="forget-password-input"
|
||||
>
|
||||
<template #prefix>
|
||||
<Icon
|
||||
icon="ant-design:key-outlined"
|
||||
:color="isDarkMode ? '#ffffff' : '#000000'"
|
||||
class="input-prefix-icon"
|
||||
/>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
<el-button
|
||||
type="primary"
|
||||
class="send-email-code-button"
|
||||
:disabled="emailCodeCountdown > 0"
|
||||
@click="handleSendEmailCode"
|
||||
>
|
||||
{{ emailCodeCountdown > 0 ? `${emailCodeCountdown}s` : t('auth.forget.password.resend_code') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item class="form-item">
|
||||
<el-button
|
||||
type="primary"
|
||||
class="reset-password-button"
|
||||
@click="handleResetPassword"
|
||||
>
|
||||
{{ t('auth.forget.password.reset_password') }}
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
|
||||
<div class="prev-step-container">
|
||||
<span class="prev-step" @click="handlePrevStep">
|
||||
<Icon icon="ant-design:arrow-left-outlined" />
|
||||
{{ t('auth.forget.password.prev_step') }}
|
||||
</span>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { forgetPassword, verifyEmail, sendEmailCode } from '@/api/auth'
|
||||
import type { FormInstance } from 'element-plus'
|
||||
import { Icon } from '~/components/icon'
|
||||
import { t } from '~/composables/lang'
|
||||
import { useCaptcha } from '~/composables/captcha'
|
||||
import validate from '~/utils/validate'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
// 暗黑模式状态
|
||||
const isDarkMode = computed(() => {
|
||||
const root = document.querySelector('html')
|
||||
return root?.classList.contains('dark') || false
|
||||
})
|
||||
|
||||
// 当前步骤
|
||||
const currentStep = ref(1)
|
||||
const isLoading = ref(false)
|
||||
const emailCodeCountdown = ref(0)
|
||||
const verificationSent = ref(false)
|
||||
let emailCodeTimer: any = null
|
||||
|
||||
// 第一步数据:邮箱 + 图片验证码
|
||||
const step1Data = reactive({
|
||||
email: '',
|
||||
captcha_code: '',
|
||||
captcha_key: ''
|
||||
})
|
||||
|
||||
// 第二步数据:新密码 + 确认密码 + 验证码
|
||||
const step2Data = reactive({
|
||||
password: '',
|
||||
confirm_password: '',
|
||||
code: ''
|
||||
})
|
||||
|
||||
// 存储邮箱,用于第二步
|
||||
const savedEmail = ref('')
|
||||
|
||||
// 表单引用
|
||||
const step1FormRef = ref<FormInstance>()
|
||||
const step2FormRef = ref<FormInstance>()
|
||||
|
||||
// 使用验证码组合式函数
|
||||
const captcha = useCaptcha(step1Data)
|
||||
|
||||
// 第一步验证规则
|
||||
const step1Rules = computed(() => ({
|
||||
email: [
|
||||
{
|
||||
type: 'string',
|
||||
required: true,
|
||||
message: t('auth.forget.password.email_required'),
|
||||
trigger: ['blur', 'change']
|
||||
},
|
||||
{
|
||||
validator: (_rule: any, value: string) => {
|
||||
return validate.email(value)
|
||||
},
|
||||
message: t('auth.forget.password.email_invalid'),
|
||||
trigger: ['blur']
|
||||
}
|
||||
],
|
||||
captcha_code: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
message: t('auth.forget.password.captcha_required'),
|
||||
trigger: ['blur', 'change']
|
||||
}
|
||||
}) as any)
|
||||
|
||||
// 第二步验证规则
|
||||
const step2Rules = computed(() => ({
|
||||
password: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
message: t('auth.forget.password.password_required'),
|
||||
trigger: ['blur', 'change']
|
||||
},
|
||||
confirm_password: [
|
||||
{
|
||||
type: 'string',
|
||||
required: true,
|
||||
message: t('auth.forget.password.confirm_password_required'),
|
||||
trigger: ['blur', 'change']
|
||||
},
|
||||
{
|
||||
validator: (_rule: any, value: string) => {
|
||||
return value === step2Data.password
|
||||
},
|
||||
message: t('auth.forget.password.password_mismatch'),
|
||||
trigger: ['blur', 'change']
|
||||
}
|
||||
],
|
||||
code: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
message: t('auth.forget.password.verification_code_required'),
|
||||
trigger: ['blur', 'change']
|
||||
}
|
||||
}) as any)
|
||||
|
||||
// 发送邮箱验证码
|
||||
const handleSendEmailCode = async () => {
|
||||
if (!step1Data.email || !validate.email(step1Data.email)) {
|
||||
ElMessage.error(t('auth.forget.password.email_invalid'))
|
||||
return
|
||||
}
|
||||
|
||||
if (emailCodeCountdown.value > 0) return
|
||||
|
||||
try {
|
||||
await sendEmailCode({
|
||||
email: step1Data.email,
|
||||
captcha_key: step1Data.captcha_key,
|
||||
captcha_code: step1Data.captcha_code
|
||||
})
|
||||
|
||||
ElMessage.success(t('auth.forget.password.code_sent'))
|
||||
emailCodeCountdown.value = 60
|
||||
|
||||
emailCodeTimer = setInterval(() => {
|
||||
emailCodeCountdown.value--
|
||||
if (emailCodeCountdown.value <= 0) {
|
||||
clearInterval(emailCodeTimer)
|
||||
}
|
||||
}, 1000)
|
||||
} catch (error) {
|
||||
captcha.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
// 组件挂载时加载验证码
|
||||
onMounted(() => {
|
||||
captcha.refresh()
|
||||
})
|
||||
|
||||
// 下一步:验证邮箱
|
||||
const handleNextStep = async () => {
|
||||
await step1FormRef.value?.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
|
||||
if (isLoading.value) return
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
// 发送验证码
|
||||
await sendEmailCode({
|
||||
email: step1Data.email,
|
||||
captcha_key: step1Data.captcha_key,
|
||||
captcha_code: step1Data.captcha_code
|
||||
})
|
||||
|
||||
savedEmail.value = step1Data.email
|
||||
verificationSent.value = true
|
||||
currentStep.value = 2
|
||||
|
||||
// 开始倒计时
|
||||
emailCodeCountdown.value = 60
|
||||
emailCodeTimer = setInterval(() => {
|
||||
emailCodeCountdown.value--
|
||||
if (emailCodeCountdown.value <= 0) {
|
||||
clearInterval(emailCodeTimer)
|
||||
}
|
||||
}, 1000)
|
||||
|
||||
ElMessage.success(t('auth.forget.password.code_sent_check_email'))
|
||||
} catch (error) {
|
||||
captcha.refresh()
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 上一步
|
||||
const handlePrevStep = () => {
|
||||
currentStep.value = 1
|
||||
verificationSent.value = false
|
||||
if (emailCodeTimer) {
|
||||
clearInterval(emailCodeTimer)
|
||||
emailCodeCountdown.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
// 重置密码
|
||||
const handleResetPassword = async () => {
|
||||
await step2FormRef.value?.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
|
||||
if (isLoading.value) return
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
await forgetPassword({
|
||||
email: savedEmail.value,
|
||||
code: step2Data.code,
|
||||
new_password: step2Data.password,
|
||||
confirm_password: step2Data.confirm_password
|
||||
})
|
||||
ElMessage.success(t('auth.forget.password.reset_success'))
|
||||
emit('typeChange', 'login')
|
||||
} catch (error) {
|
||||
captcha.refresh()
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 切换到登录
|
||||
const emit = defineEmits(['typeChange'])
|
||||
const switchToLogin = () => {
|
||||
emit('typeChange', 'login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.forget-password-container {
|
||||
width: 100%;
|
||||
|
||||
&.dark-mode {
|
||||
background: var(--el-bg-color);
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.forget-password-form-container {
|
||||
background: var(--el-bg-color);
|
||||
width: 100%;
|
||||
padding: 60px 30px;
|
||||
border-radius: var(--rounded-big);
|
||||
}
|
||||
|
||||
.forget-password-title {
|
||||
font-size: 24px;
|
||||
color: var(--el-text-color-primary);
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
font-family: 'OppoSans-R';
|
||||
}
|
||||
|
||||
.forget-password-tip {
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-secondary);
|
||||
text-align: center;
|
||||
margin-bottom: 40px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.verification-tip {
|
||||
background-color: #E8F5E8;
|
||||
color: #2E7D32;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 30px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.forget-password-form {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-item {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.captcha-image {
|
||||
height: 30px;
|
||||
cursor: pointer;
|
||||
|
||||
img {
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.iconify {
|
||||
font-size: 24px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.input-container {
|
||||
flex: 1;
|
||||
height: 50px;
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--el-bg-color);
|
||||
}
|
||||
|
||||
.forget-password-input {
|
||||
width: 100%;
|
||||
border: none;
|
||||
|
||||
:deep(.el-input__wrapper) {
|
||||
box-shadow: none;
|
||||
background: transparent;
|
||||
|
||||
.el-input__inner {
|
||||
color: var(--el-text-color-primary);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-input__suffix) {
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.input-prefix-icon {
|
||||
margin-right: 14px;
|
||||
}
|
||||
|
||||
.email-code-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.send-email-code-button {
|
||||
flex-shrink: 0;
|
||||
height: 50px;
|
||||
white-space: nowrap;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.login-link {
|
||||
text-align: center;
|
||||
margin-top: 20px;
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-secondary);
|
||||
|
||||
.login-button {
|
||||
color: var(--el-color-primary);
|
||||
cursor: pointer;
|
||||
margin-left: 5px;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.next-step-button,
|
||||
.reset-password-button {
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.prev-step-container {
|
||||
text-align: center;
|
||||
margin-top: 20px;
|
||||
|
||||
.prev-step {
|
||||
color: var(--el-color-primary);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,83 @@
|
||||
<template>
|
||||
<div class="loginPopup">
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
align-center
|
||||
width="450"
|
||||
:before-close="beforeClose"
|
||||
custom-class="login !rounded-[var(--rounded-big)]"
|
||||
:show-close="false"
|
||||
append-to-body
|
||||
>
|
||||
<login v-if="type === 'login' && dialogVisible" @typeChange="typeChange" />
|
||||
<register v-if="type === 'register' && dialogVisible" @typeChange="typeChange" />
|
||||
<forget-password v-if="type === 'forget-password' && dialogVisible" @typeChange="typeChange" />
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed, watch } from "vue";
|
||||
import login from "./login.vue";
|
||||
import register from "./register.vue";
|
||||
import forgetPassword from "./forget-password.vue";
|
||||
import { useMemberStore } from '~/stores/member';
|
||||
|
||||
const type = ref("login");
|
||||
|
||||
const memberStore = useMemberStore();
|
||||
//弹框状态
|
||||
const dialogVisible = computed(() => {
|
||||
return memberStore.loginPopup;
|
||||
});
|
||||
//弹框关闭
|
||||
const beforeClose = (next: any) => {
|
||||
memberStore.logClose();
|
||||
type.value = "login";
|
||||
next();
|
||||
};
|
||||
const handleClose = () => {
|
||||
memberStore.logClose();
|
||||
type.value = "login";
|
||||
};
|
||||
//判断当前是登录还是注册
|
||||
const typeChange = (val: any) => {
|
||||
type.value = val;
|
||||
};
|
||||
</script>
|
||||
<style>
|
||||
.login .el-dialog__header {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.login .el-dialog__body {
|
||||
padding: 0 !important;
|
||||
}
|
||||
.login .el-dialog__headerbtn {
|
||||
z-index: 99;
|
||||
}
|
||||
</style>
|
||||
<style lang="scss" scoped>
|
||||
:deep(.el-form-item) {
|
||||
.el-input__wrapper {
|
||||
box-shadow: unset !important;
|
||||
border-radius: 0;
|
||||
&.is-focus {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-error {
|
||||
.el-input__wrapper {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-form-item__error) {
|
||||
padding-top: 5px;
|
||||
}
|
||||
|
||||
.text-color {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,675 @@
|
||||
<template>
|
||||
<div class="login-container" :class="{'dark-mode': isDarkMode}">
|
||||
<!-- 登录方式切换图标 -->
|
||||
<div class="login-mode-toggle">
|
||||
<Icon
|
||||
v-if="isAccountLoginActive"
|
||||
@click="toggleLoginMode"
|
||||
icon="ant-design:qrcode-outlined"
|
||||
:color="isDarkMode ? '#ffffff' : '#000000'"
|
||||
size="50px"
|
||||
class="login-toggle-icon"
|
||||
/>
|
||||
<Icon
|
||||
v-else
|
||||
@click="toggleLoginMode"
|
||||
icon="ant-design:desktop-outlined"
|
||||
:color="isDarkMode ? '#ffffff' : '#000000'"
|
||||
size="50px"
|
||||
class="login-toggle-icon"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 账号密码登录表单 -->
|
||||
<div v-if="isAccountLoginActive" class="login-form-container">
|
||||
<div class="login-type-tabs">
|
||||
<div
|
||||
v-for="(item, index) in availableLoginTypes"
|
||||
:key="item.type"
|
||||
class="login-type-tab"
|
||||
:class="{
|
||||
'login-type-tab--active': currentLoginType === item.type,
|
||||
'login-type-tab--margin': index + 1 !== availableLoginTypes.length,
|
||||
}"
|
||||
@click="currentLoginType = item.type"
|
||||
>
|
||||
{{ item.title }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-form
|
||||
:model="formData"
|
||||
ref="formRef"
|
||||
:rules="formRules"
|
||||
:validate-on-rule-change="false"
|
||||
class="login-form"
|
||||
>
|
||||
<!-- 用户名密码登录 -->
|
||||
<div v-show="currentLoginType === 'username'" class="login-form-section">
|
||||
<el-form-item prop="username" class="form-item">
|
||||
<div class="input-container">
|
||||
<el-input
|
||||
v-model="formData.username"
|
||||
:placeholder="t('auth.login.username_placeholder')"
|
||||
clearable
|
||||
:inline-message="true"
|
||||
:readonly="isUsernameInputReadonly"
|
||||
@click="isUsernameInputReadonly = false"
|
||||
@blur="isUsernameInputReadonly = true"
|
||||
class="login-input"
|
||||
>
|
||||
<template #prefix>
|
||||
<Icon
|
||||
icon="ant-design:user-outlined"
|
||||
:color="isDarkMode ? '#ffffff' : '#000000'"
|
||||
class="input-prefix-icon"
|
||||
/>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item prop="password" class="form-item">
|
||||
<div class="input-container">
|
||||
<el-input
|
||||
v-model="formData.password"
|
||||
:placeholder="t('auth.login.password_placeholder')"
|
||||
type="password"
|
||||
clearable
|
||||
show-password
|
||||
class="login-input"
|
||||
>
|
||||
<template #prefix>
|
||||
<Icon
|
||||
icon="ant-design:lock-outlined"
|
||||
:color="isDarkMode ? '#ffffff' : '#000000'"
|
||||
class="input-prefix-icon"
|
||||
/>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<!-- 手机号登录 -->
|
||||
<div v-show="currentLoginType === 'mobile'" class="login-form-section">
|
||||
<el-form-item prop="mobile" class="form-item">
|
||||
<div class="input-container">
|
||||
<el-input
|
||||
v-model="formData.mobile"
|
||||
:placeholder="t('auth.login.mobile_placeholder')"
|
||||
clearable
|
||||
class="login-input"
|
||||
>
|
||||
<template #prefix>
|
||||
<Icon
|
||||
icon="ant-design:phone-outlined"
|
||||
:color="isDarkMode ? '#ffffff' : '#000000'"
|
||||
class="input-prefix-icon"
|
||||
/>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item prop="mobile_code" class="form-item">
|
||||
<div class="input-container">
|
||||
<el-input
|
||||
v-model="formData.mobile_code"
|
||||
:placeholder="t('auth.login.code_placeholder')"
|
||||
class="login-input"
|
||||
>
|
||||
<template #prefix>
|
||||
<Icon
|
||||
icon="ant-design:key-outlined"
|
||||
:color="isDarkMode ? '#ffffff' : '#000000'"
|
||||
class="input-prefix-icon"
|
||||
/>
|
||||
</template>
|
||||
<template #suffix>
|
||||
<sms-code
|
||||
:mobile="formData.mobile"
|
||||
type="login"
|
||||
v-model="formData.mobile_key"
|
||||
@click="handleSendSmsCode"
|
||||
ref="smsCodeRef"
|
||||
></sms-code>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<!-- 注册链接和忘记密码 -->
|
||||
<div class="form-actions">
|
||||
<el-button type="primary" link @click="switchToRegister" class="register-link">
|
||||
{{ t("auth.login.no_account") }},{{ t("auth.login.to_register") }}
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="currentLoginType === 'username'"
|
||||
type="primary"
|
||||
link
|
||||
@click="switchToForgetPassword"
|
||||
class="forget-password-link"
|
||||
>
|
||||
{{ t("auth.login.resetpwd") }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 登录按钮 -->
|
||||
<div class="login-button-container">
|
||||
<el-button
|
||||
type="primary"
|
||||
class="login-button"
|
||||
size="large"
|
||||
@click="handleLoginSubmit"
|
||||
:loading="isLoading"
|
||||
>
|
||||
{{ isLoading ? t("auth.login.logining") : t("auth.login.login") }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 协议同意 -->
|
||||
<div v-if="configStore.login.agreement_show" class="agreement-container">
|
||||
<span
|
||||
class="iconfont agreement-checkbox"
|
||||
:class="isAgreeProtocol ? 'icon-xuanze1' : 'icon-checkbox_nol'"
|
||||
@click="isAgreeProtocol = !isAgreeProtocol"
|
||||
></span>
|
||||
{{ t("agree_tips") }}
|
||||
<NuxtLink :to="protocolUrls.serviceUrl" target="_blank">
|
||||
<span class="agreement-link">{{ t("auth.register.user_agreement") }}</span>
|
||||
</NuxtLink>
|
||||
{{ t("and") }}
|
||||
<NuxtLink :to="protocolUrls.privacyUrl" target="_blank">
|
||||
<span class="agreement-link">{{ t("auth.register.privacy_agreement") }}</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- 微信扫码登录 -->
|
||||
<div v-else class="wechat-login-container">
|
||||
<div class="wechat-login-title">{{ t("auth.login.wechat_scan_login") }}</div>
|
||||
<div class="qrcode-container">
|
||||
<div class="qrcode-wrapper">
|
||||
<el-image v-if="wechatQrCode.url" :src="wechatQrCode.url" class="qrcode-image" />
|
||||
<div v-else class="qrcode-placeholder"></div>
|
||||
<div v-if="wechatQrCode.isExpired" class="qrcode-overlay">
|
||||
<span class="qrcode-error-text">{{ wechatQrCode.expiredMessage }}</span>
|
||||
<span @click="handleGenerateWechatQrCode" class="qrcode-refresh-link">{{ t("auth.login.click_refresh") }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wechat-tip">
|
||||
<span class="iconfont icon-weixin1 wechat-icon"></span>
|
||||
<span class="wechat-text">{{ t("auth.login.wechat_scan_tip") }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, reactive, computed, onUnmounted } from "vue";
|
||||
import { authenticateUser, authenticateWithMobile, authenticateWithWechat,generateWechatQrCode, checkWechatScanStatus } from "@/api/auth";
|
||||
import useConfigStore from "@/stores/config";
|
||||
import { useMemberStore } from "@/stores/member";
|
||||
import QRCode from "qrcode";
|
||||
import type { FormInstance } from "element-plus";
|
||||
import { Icon } from '~/components/icon'
|
||||
import {t}from "~/composables/lang";
|
||||
import { ElMessage } from "element-plus";
|
||||
import validate from "~/utils/validate";
|
||||
|
||||
// Store 实例
|
||||
const memberStore = useMemberStore();
|
||||
const configStore = useConfigStore();
|
||||
|
||||
// 暗黑模式状态
|
||||
const isDarkMode = computed(() => {
|
||||
const root = document.querySelector('html')
|
||||
return root?.classList.contains('dark') || false
|
||||
});
|
||||
|
||||
// 协议链接配置
|
||||
const protocolUrls = computed(() => {
|
||||
const basePath = location.pathname.includes("web") ? "/web/auth/agreement" : "/auth/agreement";
|
||||
return {
|
||||
serviceUrl: `${basePath}?key=service`,
|
||||
privacyUrl: `${basePath}?key=privacy`,
|
||||
};
|
||||
})as any;
|
||||
|
||||
// 登录模式状态
|
||||
const isAccountLoginActive = ref(true);
|
||||
const currentLoginType = ref("");
|
||||
let qrCodeTimer: any = null;
|
||||
|
||||
// 微信扫码登录状态
|
||||
const wechatQrCode = ref({
|
||||
url: "",
|
||||
key: "",
|
||||
isExpired: false,
|
||||
expiredMessage: t("qrcode_generate_failed"),
|
||||
});
|
||||
|
||||
// 表单数据
|
||||
const isLoading = ref(false);
|
||||
const isAgreeProtocol = ref(false);
|
||||
const isUsernameInputReadonly = ref(true);
|
||||
|
||||
const formData = reactive({
|
||||
username: "",
|
||||
password: "",
|
||||
mobile: "",
|
||||
mobile_code: "",
|
||||
mobile_key: "",
|
||||
});
|
||||
|
||||
// 表单引用
|
||||
const formRef = ref<FormInstance>();
|
||||
const smsCodeRef = ref<AnyObject | null>(null);
|
||||
|
||||
// 计算属性
|
||||
const availableLoginTypes = computed(() => {
|
||||
const types = [];
|
||||
if (configStore.login.is_username) {
|
||||
types.push({ type: "username", title: t("auth.login.username_login") });
|
||||
}
|
||||
if (configStore.login.is_mobile) {
|
||||
types.push({ type: "mobile", title: t("auth.login.mobile_login") });
|
||||
}
|
||||
currentLoginType.value = types[0] ? types[0].type : "";
|
||||
return types;
|
||||
});
|
||||
|
||||
const formRules = computed(() => ({
|
||||
username: {
|
||||
required: currentLoginType.value === "username",
|
||||
message: t("auth.login.username_placeholder"),
|
||||
trigger: ["blur", "change"],
|
||||
},
|
||||
password: {
|
||||
required: currentLoginType.value === "username",
|
||||
message: t("auth.login.password_placeholder"),
|
||||
trigger: ["blur", "change"],
|
||||
},
|
||||
mobile: [
|
||||
{
|
||||
required: currentLoginType.value === "mobile",
|
||||
message: t("mobile_placeholder"),
|
||||
trigger: ["blur", "change"],
|
||||
},
|
||||
{
|
||||
validator(rule: any, value: string, callback: any) {
|
||||
if (currentLoginType.value !== "mobile") return true;
|
||||
return validate.mobile(value);
|
||||
},
|
||||
message: t("auth.login.mobile_error"),
|
||||
trigger: ["blur"],
|
||||
},
|
||||
],
|
||||
mobile_code: {
|
||||
required: currentLoginType.value === "mobile",
|
||||
message: t("auth.login.sms_code_placeholder"),
|
||||
trigger: ["change"],
|
||||
},
|
||||
}));
|
||||
|
||||
// 生命周期
|
||||
onUnmounted(() => {
|
||||
clearTimeout(qrCodeTimer);
|
||||
});
|
||||
|
||||
// 初始化配置
|
||||
configStore.getWebGroupConfigs();
|
||||
|
||||
// 登录模式切换
|
||||
const toggleLoginMode = () => {
|
||||
if (!isAccountLoginActive.value) {
|
||||
isAccountLoginActive.value = !isAccountLoginActive.value;
|
||||
clearTimeout(qrCodeTimer);
|
||||
} else {
|
||||
// 检查是否开启微信登录
|
||||
if (!configStore.login?.is_wechat) {
|
||||
ElMessage.error(t('auth.login.wechat_login_not_available'));
|
||||
return;
|
||||
}
|
||||
isAccountLoginActive.value = !isAccountLoginActive.value;
|
||||
handleGenerateWechatQrCode();
|
||||
}
|
||||
};
|
||||
|
||||
// 微信扫码登录相关方法
|
||||
const handleGenerateWechatQrCode = async () => {
|
||||
try {
|
||||
const data = await generateWechatQrCode() as any;
|
||||
|
||||
|
||||
wechatQrCode.value.key = data.scene_id;
|
||||
|
||||
if (data.qr_code_url) {
|
||||
const qrCodeUrl = await QRCode.toDataURL(data.qr_code_url, {
|
||||
errorCorrectionLevel: "L",
|
||||
margin: 0,
|
||||
width: 100,
|
||||
});
|
||||
|
||||
wechatQrCode.value.url = qrCodeUrl;
|
||||
wechatQrCode.value.isExpired = false;
|
||||
|
||||
setTimeout(() => {
|
||||
checkScan(wechatQrCode.value.key);
|
||||
}, 1000);
|
||||
}
|
||||
} catch (error) {
|
||||
wechatQrCode.value.isExpired = true;
|
||||
wechatQrCode.value.expiredMessage = t("qrcode_generate_failed");
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查微信扫码登录状态
|
||||
* @param key - 扫码登录密钥
|
||||
*/
|
||||
const checkScan = (scene_id: string) => {
|
||||
checkWechatScanStatus({ scene_id })
|
||||
.then((data: any) => {
|
||||
switch (data.status) {
|
||||
case "wait":
|
||||
qrCodeTimer = setTimeout(() => {
|
||||
checkScan(wechatQrCode?.value.key);
|
||||
}, 1000);
|
||||
break;
|
||||
case "success":
|
||||
handleWechatLoginSuccess(data.login_data);
|
||||
break;
|
||||
case "fail":
|
||||
wechatQrCode.value.expiredMessage = data.fail_reason;
|
||||
wechatQrCode.value.isExpired = true;
|
||||
break;
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
wechatQrCode.value.isExpired = true;
|
||||
wechatQrCode.value.expiredMessage = error.msg || t("scan_check_failed");
|
||||
});
|
||||
};
|
||||
|
||||
const handleWechatLoginSuccess = (loginData: any) => {
|
||||
if (!loginData.access_token) {
|
||||
useCookie("openId").value = loginData.openid;
|
||||
navigateTo("/auth/bind");
|
||||
memberStore.logClose();
|
||||
} else {
|
||||
memberStore.setToken(loginData.access_token, loginData.refresh_token || '');
|
||||
memberStore.logClose();
|
||||
}
|
||||
};
|
||||
|
||||
// 表单处理方法
|
||||
const handleLoginSubmit = async () => {
|
||||
const isValid = await formRef.value?.validate();
|
||||
if (!isValid) return;
|
||||
|
||||
if (configStore.login.agreement_show && !isAgreeProtocol.value) {
|
||||
ElMessage.error(t("auth.login.is_agree_tips"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (isLoading.value) return;
|
||||
isLoading.value = true;
|
||||
|
||||
try {
|
||||
const loginMethod = currentLoginType.value === "username" ? authenticateUser : authenticateWithMobile;
|
||||
const data = await loginMethod(formData) as any;
|
||||
|
||||
await memberStore.setToken(data?.access_token || '', data?.refresh_token || '');
|
||||
memberStore.logClose();
|
||||
} catch (error) {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendSmsCode = async () => {
|
||||
const isValid = await formRef.value?.validateField("mobile");
|
||||
if (isValid) {
|
||||
smsCodeRef.value?.send();
|
||||
}
|
||||
};
|
||||
|
||||
// 组件通信
|
||||
const emit = defineEmits(["typeChange"]);
|
||||
const switchToRegister = () => {
|
||||
emit("typeChange", "register");
|
||||
};
|
||||
const switchToForgetPassword = () => {
|
||||
emit("typeChange", "forget-password");
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.login-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&.dark-mode {
|
||||
background: var(--el-bg-color);
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.login-mode-toggle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.login-toggle-icon {
|
||||
font-size: 50px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.login-form-container {
|
||||
background: var(--el-bg-color);
|
||||
width: 100%;
|
||||
padding: 60px 30px;
|
||||
border-radius: var(--rounded-big);
|
||||
}
|
||||
|
||||
.login-type-tabs {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.login-type-tab {
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
color: var(--el-text-color-secondary);
|
||||
line-height: 24px;
|
||||
font-family: "OppoSans-R";
|
||||
|
||||
&--active {
|
||||
color: var(--el-text-color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&--margin {
|
||||
margin-right: 70px;
|
||||
}
|
||||
}
|
||||
|
||||
.login-form {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-item {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.input-container {
|
||||
flex: 1;
|
||||
height: 50px;
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--el-bg-color);
|
||||
}
|
||||
|
||||
.login-input {
|
||||
width: 100%;
|
||||
border: none;
|
||||
|
||||
:deep(.el-input__wrapper) {
|
||||
box-shadow: none;
|
||||
background: transparent;
|
||||
|
||||
.el-input__inner {
|
||||
color: var(--el-text-color-primary);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.input-prefix-icon {
|
||||
margin-right: 14px;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.register-link,
|
||||
.forget-password-link {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.login-button-container {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.login-button {
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
border-radius: 8px;
|
||||
font-family: "OppoSans-M";
|
||||
}
|
||||
|
||||
.agreement-container {
|
||||
font-size: 12px;
|
||||
line-height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
margin-top: 20px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.agreement-checkbox {
|
||||
color: var(--el-color-primary);
|
||||
margin-right: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.agreement-link {
|
||||
color: var(--el-color-primary);
|
||||
margin: 0 4px;
|
||||
|
||||
&:hover {
|
||||
color: var(--el-color-primary-light-3);
|
||||
}
|
||||
}
|
||||
|
||||
.wechat-login-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 60px 30px;
|
||||
background: var(--el-bg-color);
|
||||
}
|
||||
|
||||
.wechat-login-title {
|
||||
font-size: 18px;
|
||||
color: var(--el-text-color-primary);
|
||||
font-weight: 600;
|
||||
line-height: 24px;
|
||||
font-family: "OppoSans-R";
|
||||
}
|
||||
|
||||
.qrcode-container {
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.qrcode-wrapper {
|
||||
padding: 20px;
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: var(--rounded-small);
|
||||
position: relative;
|
||||
background: var(--el-bg-color);
|
||||
}
|
||||
|
||||
.qrcode-image {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.qrcode-placeholder {
|
||||
width: 202px;
|
||||
height: 202px;
|
||||
background: var(--el-bg-color-page);
|
||||
}
|
||||
|
||||
.qrcode-overlay {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--el-bg-color-overlay);
|
||||
}
|
||||
|
||||
.qrcode-error-text {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.qrcode-refresh-link {
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
color: var(--el-color-primary);
|
||||
margin-top: 8px;
|
||||
|
||||
&:hover {
|
||||
color: var(--el-color-primary-light-3);
|
||||
}
|
||||
}
|
||||
|
||||
.wechat-tip {
|
||||
margin-top: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.wechat-icon {
|
||||
color: #00c22c;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.wechat-text {
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-left: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,563 @@
|
||||
<template>
|
||||
<div class="register-container" :class="{'dark-mode': isDarkMode}">
|
||||
<div class="register-form-container">
|
||||
<div class="register-type-tabs">
|
||||
<div
|
||||
v-for="(item, index) in registerTypes"
|
||||
:key="item.type"
|
||||
class="register-type-tab"
|
||||
:class="{
|
||||
'register-type-tab--active': currentType === item.type,
|
||||
'register-type-tab--margin': index + 1 !== registerTypes.length
|
||||
}"
|
||||
@click="currentType = item.type"
|
||||
>
|
||||
{{ item.title }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-form
|
||||
:model="formData"
|
||||
ref="formRef"
|
||||
:rules="formRules"
|
||||
:validate-on-rule-change="false"
|
||||
class="register-form"
|
||||
>
|
||||
<!-- 用户名注册 -->
|
||||
<div v-show="currentType === 'username'" class="register-form-section">
|
||||
<el-form-item prop="username" class="form-item">
|
||||
<div class="input-container">
|
||||
<el-input
|
||||
v-model="formData.username"
|
||||
:placeholder="t('auth.register.username_placeholder')"
|
||||
clearable
|
||||
:inline-message="true"
|
||||
:readonly="realNameInput"
|
||||
@click="realNameInput = false"
|
||||
@blur="realNameInput = true"
|
||||
class="register-input"
|
||||
>
|
||||
<template #prefix>
|
||||
<Icon
|
||||
icon="ant-design:user-outlined"
|
||||
:color="isDarkMode ? '#ffffff' : '#000000'"
|
||||
class="input-prefix-icon"
|
||||
/>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item prop="email" class="form-item">
|
||||
<div class="input-container">
|
||||
<el-input
|
||||
v-model="formData.email"
|
||||
:placeholder="t('auth.register.email_placeholder')"
|
||||
clearable
|
||||
class="register-input"
|
||||
>
|
||||
<template #prefix>
|
||||
<Icon
|
||||
icon="ant-design:mail-outlined"
|
||||
:color="isDarkMode ? '#ffffff' : '#000000'"
|
||||
class="input-prefix-icon"
|
||||
/>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item prop="password" class="form-item">
|
||||
<div class="input-container">
|
||||
<el-input
|
||||
v-model="formData.password"
|
||||
:placeholder="t('auth.register.password_placeholder')"
|
||||
type="password"
|
||||
clearable
|
||||
show-password
|
||||
class="register-input"
|
||||
>
|
||||
<template #prefix>
|
||||
<Icon
|
||||
icon="ant-design:lock-outlined"
|
||||
:color="isDarkMode ? '#ffffff' : '#000000'"
|
||||
class="input-prefix-icon"
|
||||
/>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item prop="confirmPassword" class="form-item">
|
||||
<div class="input-container">
|
||||
<el-input
|
||||
v-model="formData.confirm_password"
|
||||
:placeholder="t('auth.register.confirm_password_placeholder')"
|
||||
type="password"
|
||||
clearable
|
||||
show-password
|
||||
class="register-input"
|
||||
>
|
||||
<template #prefix>
|
||||
<Icon
|
||||
icon="ant-design:lock-outlined"
|
||||
:color="isDarkMode ? '#ffffff' : '#000000'"
|
||||
class="input-prefix-icon"
|
||||
/>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item prop="captchaCode" class="form-item">
|
||||
<div class="input-container">
|
||||
<el-input
|
||||
v-model="formData.captcha_code"
|
||||
:placeholder="t('auth.register.captcha_placeholder')"
|
||||
class="register-input"
|
||||
>
|
||||
<template #prefix>
|
||||
<Icon
|
||||
icon="ant-design:safety-outlined"
|
||||
:color="isDarkMode ? '#ffffff' : '#000000'"
|
||||
class="input-prefix-icon"
|
||||
/>
|
||||
</template>
|
||||
<template #suffix>
|
||||
<div class="captcha-image-container">
|
||||
<el-image
|
||||
:src="captcha.image.value"
|
||||
class="captcha-image"
|
||||
@click="captcha.refresh()"
|
||||
></el-image>
|
||||
</div>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<!-- 手机号注册 -->
|
||||
<div v-show="currentType === 'mobile' || configStore.login.is_bind_mobile" class="register-form-section">
|
||||
<el-form-item prop="mobile" class="form-item">
|
||||
<div class="input-container">
|
||||
<el-input
|
||||
v-model="formData.mobile"
|
||||
:placeholder="t('auth.register.mobile_placeholder')"
|
||||
clearable
|
||||
class="register-input"
|
||||
>
|
||||
<template #prefix>
|
||||
<Icon
|
||||
icon="ant-design:phone-outlined"
|
||||
:color="isDarkMode ? '#ffffff' : '#000000'"
|
||||
class="input-prefix-icon"
|
||||
/>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item prop="mobileCode" class="form-item">
|
||||
<div class="input-container">
|
||||
<el-input
|
||||
v-model="formData.mobile_code"
|
||||
:placeholder="t('auth.register.code_placeholder')"
|
||||
class="register-input"
|
||||
>
|
||||
<template #prefix>
|
||||
<Icon
|
||||
icon="ant-design:key-outlined"
|
||||
:color="isDarkMode ? '#ffffff' : '#000000'"
|
||||
class="input-prefix-icon"
|
||||
/>
|
||||
</template>
|
||||
<template #suffix>
|
||||
<sms-code
|
||||
:mobile="formData.mobile"
|
||||
type="login"
|
||||
v-model="formData.mobile_key"
|
||||
@click="sendSmsCode"
|
||||
ref="smsCodeRef"
|
||||
></sms-code>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<!-- 登录链接 -->
|
||||
<div class="form-actions">
|
||||
<el-button type="primary" link @click="switchToLogin" class="login-link">
|
||||
{{ t('auth.register.have_account') }},{{ t('auth.register.to_login') }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 注册按钮 -->
|
||||
<div class="register-button-container">
|
||||
<el-button
|
||||
type="primary"
|
||||
class="register-button"
|
||||
size="large"
|
||||
@click="handleRegister"
|
||||
:loading="isLoading"
|
||||
>
|
||||
{{ isLoading ? t('auth.register.registering') : t('auth.register.register') }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 协议同意 -->
|
||||
<div
|
||||
v-if="configStore.login.agreement_show"
|
||||
class="agreement-container"
|
||||
>
|
||||
<span
|
||||
class="iconfont agreement-checkbox"
|
||||
:class="isAgreeChecked ? 'icon-xuanze1' : 'icon-checkbox_nol'"
|
||||
@click="toggleAgreement"
|
||||
></span>
|
||||
{{ t('auth.register.register_agree_tips') }}
|
||||
<NuxtLink :to="protocolUrls.service" target="_blank">
|
||||
<span class="agreement-link">{{ t('auth.register.user_agreement') }}</span>
|
||||
</NuxtLink>
|
||||
{{ t('auth.register.and') }}
|
||||
<NuxtLink :to="protocolUrls.privacy" target="_blank">
|
||||
<span class="agreement-link">{{ t('auth.register.privacy_agreement') }}</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import { registerUser, registerWithMobile } from '@/api/auth'
|
||||
import { useConfigStore } from '@/stores/config'
|
||||
import { useMemberStore } from '@/stores/member'
|
||||
import { useCaptcha } from '~/composables/captcha'
|
||||
import type { FormInstance } from 'element-plus'
|
||||
import { Icon } from '~/components/icon'
|
||||
import {t} from '~/composables/lang'
|
||||
import validate from '~/utils/validate'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
|
||||
// 状态管理
|
||||
const memberStore = useMemberStore()
|
||||
const configStore = useConfigStore()
|
||||
|
||||
// 暗黑模式状态
|
||||
const isDarkMode = computed(() => {
|
||||
return configStore.isDarkMode
|
||||
})
|
||||
|
||||
// 初始化配置
|
||||
// 配置已在 app.vue 中统一加载,这里不再重复请求
|
||||
|
||||
// 协议链接配置
|
||||
const protocolUrls = computed(() => {
|
||||
const basePath = location.pathname.includes('web') ? '/web' : ''
|
||||
return {
|
||||
service: `${basePath}/auth/agreement?key=service`,
|
||||
privacy: `${basePath}/auth/agreement?key=privacy`
|
||||
}
|
||||
})
|
||||
|
||||
// 注册类型配置
|
||||
const currentType = ref('')
|
||||
const registerTypes = computed(() => {
|
||||
const types = []
|
||||
if (configStore.login.is_username) {
|
||||
types.push({ type: 'username', title: t('auth.register.username_register') })
|
||||
}
|
||||
if (configStore.login.is_mobile && !configStore.login.is_bind_mobile) {
|
||||
types.push({ type: 'mobile', title: t('auth.register.mobile_register') })
|
||||
}
|
||||
currentType.value = types[0]?.type || ''
|
||||
return types
|
||||
})
|
||||
|
||||
// 表单数据
|
||||
const isLoading = ref(false)
|
||||
const formData = reactive({
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirm_password: '',
|
||||
mobile: '',
|
||||
mobile_code: '',
|
||||
mobile_key: '',
|
||||
captcha_key: '',
|
||||
captcha_code: ''
|
||||
})
|
||||
|
||||
// 表单验证规则
|
||||
const formRef = ref<FormInstance>()
|
||||
const formRules = computed(() => ({
|
||||
username: {
|
||||
type: 'string',
|
||||
required: currentType.value === 'username',
|
||||
message: t('auth.register.username_placeholder'),
|
||||
trigger: ['blur', 'change'],
|
||||
},
|
||||
email: [
|
||||
{
|
||||
type: 'string',
|
||||
required: currentType.value === 'username',
|
||||
message: t('auth.register.email_required'),
|
||||
trigger: ['blur', 'change'],
|
||||
},
|
||||
{
|
||||
validator(rule: any, value: string, callback: any) {
|
||||
if (currentType.value !== 'username') return true
|
||||
return validate.email(value)
|
||||
},
|
||||
message: t('auth.register.email_error'),
|
||||
trigger: ['change', 'blur'],
|
||||
}
|
||||
],
|
||||
password: {
|
||||
type: 'string',
|
||||
required: currentType.value === 'username',
|
||||
message: t('auth.register.password_placeholder'),
|
||||
trigger: ['blur', 'change']
|
||||
},
|
||||
confirm_password: [
|
||||
{
|
||||
type: 'string',
|
||||
required: currentType.value === 'username',
|
||||
message: t('auth.register.confirm_password_placeholder'),
|
||||
trigger: ['blur', 'change']
|
||||
},
|
||||
{
|
||||
validator(rule: any, value: string, callback: any) {
|
||||
return value === formData.password
|
||||
},
|
||||
message: t('auth.register.confirm_password_error'),
|
||||
trigger: ['change', 'blur'],
|
||||
}
|
||||
],
|
||||
mobile: [
|
||||
{
|
||||
type: 'string',
|
||||
required: currentType.value === 'mobile' || configStore.login.is_bind_mobile,
|
||||
message: t('auth.register.mobile_placeholder'),
|
||||
trigger: ['blur', 'change'],
|
||||
},
|
||||
{
|
||||
validator(rule: any, value: string, callback: any) {
|
||||
if (currentType.value !== 'mobile' && !configStore.login.is_bind_mobile) return true
|
||||
return validate.mobile(value)
|
||||
},
|
||||
message: t('auth.register.mobile_error'),
|
||||
trigger: ['change', 'blur'],
|
||||
}
|
||||
],
|
||||
mobile_code: {
|
||||
type: 'string',
|
||||
required: currentType.value === 'mobile' || configStore.login.is_bind_mobile,
|
||||
message: t('auth.register.code_placeholder'),
|
||||
trigger: ['blur', 'change']
|
||||
},
|
||||
captcha_code: {
|
||||
type: 'string',
|
||||
required: currentType.value === 'username',
|
||||
message: t('auth.register.captcha_placeholder'),
|
||||
trigger: ['blur', 'change'],
|
||||
}
|
||||
}))
|
||||
|
||||
// 协议同意状态
|
||||
const isAgreeChecked = ref(false)
|
||||
const toggleAgreement = () => {
|
||||
isAgreeChecked.value = !isAgreeChecked.value
|
||||
}
|
||||
|
||||
// 注册处理
|
||||
const handleRegister = async () => {
|
||||
await formRef.value?.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
|
||||
// 检查协议同意
|
||||
if (configStore.login.agreement_show && !isAgreeChecked.value) {
|
||||
ElMessage.error(t('auth.register.is_agree_tips'))
|
||||
return
|
||||
}
|
||||
|
||||
if (isLoading.value) return
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
const registerApi = currentType.value === 'username' ? registerUser : registerWithMobile
|
||||
const data = await registerApi(formData) as any
|
||||
memberStore.setToken(data.access_token, data.refresh_token || '')
|
||||
memberStore.logClose()
|
||||
} catch (error) {
|
||||
isLoading.value = false
|
||||
captcha.refresh()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 验证码功能
|
||||
const captcha = useCaptcha(formData)
|
||||
captcha.refresh()
|
||||
|
||||
// 短信验证码
|
||||
const smsCodeRef = ref<AnyObject | null>(null)
|
||||
const sendSmsCode = async () => {
|
||||
await formRef.value?.validateField('mobile', (valid) => {
|
||||
if (valid) {
|
||||
smsCodeRef.value?.send()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 切换到登录
|
||||
const emit = defineEmits(['typeChange'])
|
||||
const switchToLogin = () => {
|
||||
emit('typeChange', 'login')
|
||||
}
|
||||
|
||||
// 输入框状态
|
||||
const realNameInput = ref(true)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.register-container {
|
||||
width: 100%;
|
||||
|
||||
&.dark-mode {
|
||||
background: var(--el-bg-color);
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.register-form-container {
|
||||
background: var(--el-bg-color);
|
||||
width: 100%;
|
||||
padding: 60px 30px;
|
||||
border-radius: var(--rounded-big);
|
||||
}
|
||||
|
||||
.register-type-tabs {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.register-type-tab {
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
color: var(--el-text-color-secondary);
|
||||
line-height: 24px;
|
||||
font-family: 'OppoSans-R';
|
||||
|
||||
&--active {
|
||||
color: var(--el-text-color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&--margin {
|
||||
margin-right: 70px;
|
||||
}
|
||||
}
|
||||
|
||||
.register-form {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-item {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.input-container {
|
||||
flex: 1;
|
||||
height: 50px;
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--el-bg-color);
|
||||
}
|
||||
|
||||
.register-input {
|
||||
width: 100%;
|
||||
border: none;
|
||||
|
||||
:deep(.el-input__wrapper) {
|
||||
box-shadow: none;
|
||||
background: transparent;
|
||||
|
||||
.el-input__inner {
|
||||
color: var(--el-text-color-primary);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.input-prefix-icon {
|
||||
margin-right: 14px;
|
||||
}
|
||||
|
||||
.captcha-image-container {
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.captcha-image {
|
||||
height: 30px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.login-link {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.register-button-container {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.register-button {
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
border-radius: 8px;
|
||||
font-family: 'OppoSans-M';
|
||||
}
|
||||
|
||||
.agreement-container {
|
||||
font-size: 12px;
|
||||
line-height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
margin-top: 20px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.agreement-checkbox {
|
||||
color: var(--el-color-primary);
|
||||
margin-right: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.agreement-link {
|
||||
color: var(--el-color-primary);
|
||||
margin: 0 4px;
|
||||
|
||||
&:hover {
|
||||
color: var(--el-color-primary-light-3);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,154 @@
|
||||
<template>
|
||||
<div v-if="configStore.search.keyword_enabled" class="search-box">
|
||||
<form
|
||||
:action="getSearchUrl()"
|
||||
method="get"
|
||||
target="_blank"
|
||||
class="search-form"
|
||||
@submit="handleSubmit"
|
||||
>
|
||||
<input
|
||||
v-model="searchKeyword"
|
||||
type="text"
|
||||
:name="getInputName()"
|
||||
:placeholder="placeholder"
|
||||
class="search-input"
|
||||
@input="handleInput"
|
||||
/>
|
||||
<button type="submit" class="search-button">
|
||||
<Icon icon="ant-design:search-outlined" />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { Icon } from '~/components/icon'
|
||||
import { useConfigStore } from '~/stores/config'
|
||||
|
||||
interface Props {
|
||||
placeholder?: string
|
||||
defaultValue?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
placeholder: '搜索...',
|
||||
defaultValue: ''
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
search: [keyword: string]
|
||||
input: [keyword: string]
|
||||
}>()
|
||||
|
||||
const configStore = useConfigStore()
|
||||
const searchKeyword = ref(props.defaultValue)
|
||||
|
||||
// 获取搜索URL
|
||||
const getSearchUrl = (): string => {
|
||||
if (configStore.search.keyword) {
|
||||
// 如果配置了关键字,使用关键字搜索
|
||||
return 'https://www.baidu.com/s'
|
||||
}
|
||||
return '#'
|
||||
}
|
||||
|
||||
// 获取输入框name属性
|
||||
const getInputName = (): string => {
|
||||
if (configStore.search.keyword) {
|
||||
return 'wd' // 百度搜索使用wd参数
|
||||
}
|
||||
return 'q'
|
||||
}
|
||||
|
||||
// 处理输入事件
|
||||
const handleInput = () => {
|
||||
emit('input', searchKeyword.value)
|
||||
}
|
||||
|
||||
// 处理提交事件
|
||||
const handleSubmit = (e: Event) => {
|
||||
e.preventDefault()
|
||||
emit('search', searchKeyword.value)
|
||||
|
||||
// 如果没有配置关键字,阻止默认提交行为
|
||||
if (!configStore.search.keyword) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
// 配置已在 app.vue 或系统初始化时加载
|
||||
// 这里不需要额外调用
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.search-box {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.search-form {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 4px;
|
||||
background-color: var(--el-fill-color-blank);
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--el-border-color-hover);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
}
|
||||
|
||||
.search-button {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background-color: var(--el-color-primary);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--el-color-primary-light-3);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: var(--el-color-primary-dark-2);
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,128 @@
|
||||
<template>
|
||||
<!-- SEO Meta 组件 - Nuxt 4.2+ 支持 -->
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, watchEffect } from 'vue'
|
||||
import { useHead, useRoute } from 'nuxt/app'
|
||||
import { useConfigStore } from '~/stores/config'
|
||||
|
||||
const route = useRoute()
|
||||
const configStore = useConfigStore()
|
||||
|
||||
// 从路由 meta 中读取页面自定义 SEO 配置
|
||||
const pageSeo = computed(() => ({
|
||||
// 支持 definePageMeta 和路由配置两种方式
|
||||
title: route.meta.title as string || '',
|
||||
description: route.meta.description as string || '',
|
||||
keywords: route.meta.keywords as string || '',
|
||||
image: route.meta.image as string || '',
|
||||
noIndex: route.meta.noIndex as boolean || false,
|
||||
noFollow: route.meta.noFollow as boolean || false
|
||||
}))
|
||||
|
||||
// SEO 配置数据 - 直接从 configStore 读取
|
||||
const seoConfig = computed(() => ({
|
||||
// 基础 SEO 配置
|
||||
title: configStore.siteSetting?.seo_title || 'madong通用快速开发框架',
|
||||
description: configStore.siteSetting?.seo_description || 'madong开源服务平台',
|
||||
keywords: configStore.siteSetting?.seo_keywords || ['madong', 'madong-admin', 'madong工作流'],
|
||||
siteName: configStore.siteSetting?.site_name || 'madong官网',
|
||||
siteUrl: configStore.siteSetting?.site_url || (typeof window !== 'undefined' ? window.location.origin : ''),
|
||||
shareImage: configStore.siteSetting?.share_image || '/images/share-default.png'
|
||||
}))
|
||||
|
||||
// 组合 SEO 标题(页面标题优先)
|
||||
const seoTitle = computed(() => {
|
||||
if (pageSeo.value.title) {
|
||||
return `${pageSeo.value.title} - ${seoConfig.value.siteName}`
|
||||
}
|
||||
return seoConfig.value.title
|
||||
})
|
||||
|
||||
// 组合 SEO 描述(页面描述优先)
|
||||
const seoDescription = computed(() => {
|
||||
return pageSeo.value.description || seoConfig.value.description
|
||||
})
|
||||
|
||||
// 组合 SEO 关键词(页面关键词优先)
|
||||
const seoKeywords = computed(() => {
|
||||
const baseKeywords = Array.isArray(seoConfig.value.keywords)
|
||||
? seoConfig.value.keywords.join(',')
|
||||
: seoConfig.value.keywords
|
||||
|
||||
if (pageSeo.value.keywords) {
|
||||
return `${pageSeo.value.keywords},${baseKeywords}`
|
||||
}
|
||||
return baseKeywords
|
||||
})
|
||||
|
||||
// 组合分享图片(页面图片优先)
|
||||
const seoImage = computed(() => {
|
||||
return pageSeo.value.image || seoConfig.value.shareImage
|
||||
})
|
||||
|
||||
// 构建完整的 SEO 元数据数组
|
||||
const seoMetaArray = computed(() => {
|
||||
const metaArray = [
|
||||
// 基础 SEO Meta
|
||||
{ name: 'description', content: seoDescription.value },
|
||||
{ name: 'keywords', content: seoKeywords.value },
|
||||
{ name: 'author', content: seoConfig.value.siteName },
|
||||
|
||||
// Open Graph (社交媒体分享)
|
||||
{ property: 'og:title', content: seoTitle.value },
|
||||
{ property: 'og:description', content: seoDescription.value },
|
||||
{ property: 'og:type', content: 'website' },
|
||||
{ property: 'og:url', content: `${seoConfig.value.siteUrl}${route.path}` },
|
||||
{ property: 'og:image', content: seoImage.value },
|
||||
{ property: 'og:site_name', content: seoConfig.value.siteName },
|
||||
|
||||
// Twitter Card
|
||||
{ name: 'twitter:card', content: 'summary_large_image' },
|
||||
{ name: 'twitter:title', content: seoTitle.value },
|
||||
{ name: 'twitter:description', content: seoDescription.value },
|
||||
{ name: 'twitter:image', content: seoImage.value }
|
||||
]
|
||||
|
||||
// 添加 robots meta(如果需要)
|
||||
if (pageSeo.value.noIndex || pageSeo.value.noFollow) {
|
||||
const robotsDirectives = []
|
||||
if (pageSeo.value.noIndex) robotsDirectives.push('noindex')
|
||||
if (pageSeo.value.noFollow) robotsDirectives.push('nofollow')
|
||||
metaArray.push({ name: 'robots', content: robotsDirectives.join(',') })
|
||||
}
|
||||
|
||||
return metaArray
|
||||
})
|
||||
|
||||
// 链接标签数组
|
||||
const linkArray = computed(() => [
|
||||
// Canonical URL
|
||||
{ rel: 'canonical', href: `${seoConfig.value.siteUrl}${route.path}` },
|
||||
// Favicon
|
||||
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }
|
||||
])
|
||||
|
||||
// 直接使用 useHead,它会自动响应式更新
|
||||
const headData = computed(() => ({
|
||||
title: seoTitle.value,
|
||||
htmlAttrs: {
|
||||
lang: 'zh-CN'
|
||||
},
|
||||
meta: seoMetaArray.value,
|
||||
link: linkArray.value
|
||||
}))
|
||||
|
||||
useHead(headData)
|
||||
|
||||
// 调试日志
|
||||
watchEffect(() => {
|
||||
// console.log('SEO 数据更新:', {
|
||||
// title: seoTitle.value,
|
||||
// description: seoDescription.value,
|
||||
// keywords: seoKeywords.value,
|
||||
// siteSetting: configStore.siteSetting
|
||||
// })
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,280 @@
|
||||
<template>
|
||||
<div class="sms-code-container">
|
||||
<el-button
|
||||
type="primary"
|
||||
link
|
||||
:disabled="!canSendCode"
|
||||
@click="handleSendCode"
|
||||
class="sms-code-button"
|
||||
>
|
||||
{{ buttonText }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="showCaptchaDialog"
|
||||
:title="t('captcha_title')"
|
||||
width="350px"
|
||||
:append-to-body="true"
|
||||
:align-center="true"
|
||||
class="captcha-dialog"
|
||||
>
|
||||
<el-form
|
||||
:model="captchaForm"
|
||||
ref="captchaFormRef"
|
||||
:rules="captchaRules"
|
||||
@submit.native.prevent
|
||||
class="captcha-form"
|
||||
>
|
||||
<el-form-item prop="captcha_code" class="captcha-form-item">
|
||||
<el-input
|
||||
v-model="captchaForm.captcha_code"
|
||||
:placeholder="t('captcha_placeholder')"
|
||||
class="captcha-input"
|
||||
>
|
||||
<template #suffix>
|
||||
<div class="captcha-image-wrapper">
|
||||
<el-image
|
||||
:src="captchaImage"
|
||||
class="captcha-image"
|
||||
@click="refreshCaptcha"
|
||||
></el-image>
|
||||
</div>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="closeCaptchaDialog" class="cancel-button">
|
||||
{{ t('cancel') }}
|
||||
</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="isLoading"
|
||||
@click="confirmSendCode"
|
||||
class="confirm-button"
|
||||
>
|
||||
{{ t('confirm') }}
|
||||
</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, reactive, computed, watch } from 'vue'
|
||||
import { sendSmsVerificationCode } from '@/api/system'
|
||||
import type { FormInstance } from 'element-plus'
|
||||
|
||||
interface SmsCodeProps {
|
||||
mobile?: string
|
||||
email?: string
|
||||
type: string
|
||||
modelValue: string
|
||||
}
|
||||
|
||||
interface CaptchaFormData {
|
||||
mobile?: string
|
||||
email?: string
|
||||
captcha_code: string
|
||||
captcha_key: string
|
||||
type: string
|
||||
}
|
||||
|
||||
const props = defineProps<SmsCodeProps>()
|
||||
const emit = defineEmits(['update:modelValue', 'click'])
|
||||
|
||||
// 响应式数据
|
||||
const isLoading = ref(false)
|
||||
const showCaptchaDialog = ref(false)
|
||||
const canSendCode = ref(true)
|
||||
const buttonText = ref('')
|
||||
const seconds = ref(90)
|
||||
let countdownTimer: number | undefined = undefined
|
||||
|
||||
// 验证码相关
|
||||
const captchaForm = reactive<CaptchaFormData>({
|
||||
mobile: props.mobile || '',
|
||||
email: props.email || '',
|
||||
captcha_code: '',
|
||||
captcha_key: '',
|
||||
type: props.type
|
||||
})
|
||||
|
||||
// 监听 props 变化,更新 captchaForm
|
||||
watch(() => props.mobile, (newVal) => {
|
||||
captchaForm.mobile = newVal || ''
|
||||
})
|
||||
watch(() => props.email, (newVal) => {
|
||||
captchaForm.email = newVal || ''
|
||||
})
|
||||
|
||||
const captchaImage = ref('')
|
||||
const captchaFormRef = ref<FormInstance>()
|
||||
|
||||
// 验证规则
|
||||
const captchaRules = reactive({
|
||||
captcha_code: {
|
||||
required: true,
|
||||
message: '请输入验证码',
|
||||
trigger: ['blur', 'change']
|
||||
}
|
||||
})
|
||||
|
||||
// 计算属性
|
||||
const smsValue = computed({
|
||||
get() {
|
||||
return props.modelValue
|
||||
},
|
||||
set(value) {
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
})
|
||||
|
||||
// 初始化按钮文本
|
||||
buttonText.value = t('get_sms_code')
|
||||
|
||||
// 发送短信验证码
|
||||
const sendSmsCode = async () => {
|
||||
captchaForm.mobile = props.mobile || ''
|
||||
captchaForm.email = props.email || ''
|
||||
|
||||
if (!canSendCode.value) return
|
||||
|
||||
try {
|
||||
isLoading.value = true
|
||||
const response = await sendSmsVerificationCode(captchaForm) as any
|
||||
|
||||
if (response?.code !== -1) {
|
||||
smsValue.value = response.data.key
|
||||
startCountdown()
|
||||
closeCaptchaDialog()
|
||||
} else {
|
||||
refreshCaptcha()
|
||||
}
|
||||
} catch (error) {
|
||||
refreshCaptcha()
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 开始倒计时
|
||||
const startCountdown = () => {
|
||||
canSendCode.value = false
|
||||
countdownTimer = setInterval(() => {
|
||||
if (seconds.value > 0) {
|
||||
seconds.value -= 1
|
||||
buttonText.value = `${seconds.value}${t('sms_code_change_text')}`
|
||||
} else {
|
||||
resetCountdown()
|
||||
}
|
||||
}, 1000) as any
|
||||
}
|
||||
|
||||
// 重置倒计时
|
||||
const resetCountdown = () => {
|
||||
clearInterval(countdownTimer)
|
||||
seconds.value = 90
|
||||
canSendCode.value = true
|
||||
buttonText.value = t('get_sms_code')
|
||||
}
|
||||
|
||||
// 刷新验证码
|
||||
const refreshCaptcha = async () => {
|
||||
try {
|
||||
// 这里需要调用验证码API,暂时使用空字符串
|
||||
captchaImage.value = ''
|
||||
captchaForm.captcha_code = ''
|
||||
} catch (error) {
|
||||
console.error('刷新验证码失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理发送验证码点击
|
||||
const handleSendCode = () => {
|
||||
emit('click')
|
||||
showCaptchaDialog.value = true
|
||||
refreshCaptcha()
|
||||
}
|
||||
|
||||
// 确认发送验证码
|
||||
const confirmSendCode = async () => {
|
||||
await captchaFormRef.value?.validate(async (valid) => {
|
||||
if (valid) {
|
||||
await sendSmsCode()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 关闭验证码对话框
|
||||
const closeCaptchaDialog = () => {
|
||||
showCaptchaDialog.value = false
|
||||
captchaForm.captcha_code = ''
|
||||
}
|
||||
|
||||
// 暴露方法给父组件
|
||||
defineExpose({
|
||||
send: sendSmsCode
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sms-code-container {
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sms-code-button {
|
||||
font-size: 12px;
|
||||
padding: 0;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.captcha-dialog {
|
||||
:deep(.el-dialog__header) {
|
||||
padding: 20px 20px 10px;
|
||||
}
|
||||
|
||||
:deep(.el-dialog__body) {
|
||||
padding: 10px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.captcha-form {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.captcha-form-item {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.captcha-input {
|
||||
:deep(.el-input__wrapper) {
|
||||
padding-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.captcha-image-wrapper {
|
||||
padding: 5px 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.captcha-image {
|
||||
height: 30px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.cancel-button,
|
||||
.confirm-button {
|
||||
min-width: 80px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user