feat: 适配5.x后端,重构前端架构

主要变更:
1. 重构API目录结构,采用模块化设计,适配5.x后端接口规范
2. 优化布局组件,提升用户界面体验
3. 增强国际化支持,完善多语言配置
4. 新增权限控制功能,支持前端路由权限管理
5. 优化认证流程,支持JWT认证机制
6. 新增中间件和插件,提升系统扩展性
7. 优化状态管理,采用更清晰的模块化设计
8. 新增搜索框、广告等组件,丰富页面功能
9. 更改包管理工具从npm到pnpm,提升依赖管理效率
10. 完善配置系统,支持多环境配置

适配5.x后端特性:
- 采用新的API接口规范
- 支持JWT认证机制
- 适配后端权限控制体系
- 支持后端国际化接口
- 优化数据交互方式
This commit is contained in:
Mr.April
2026-04-07 11:01:11 +08:00
parent 1b83f1890b
commit d84d972e73
127 changed files with 103052 additions and 14661 deletions
+400
View File
@@ -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>
+77
View File
@@ -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>
-56
View File
@@ -1,56 +0,0 @@
<script lang="ts">
import type { CSSProperties } from 'vue'
export default defineComponent({
name: 'Icon',
props: {
name: {
type: String,
required: true,
},
size: {
type: [String, Number],
default: '18px',
},
color: {
type: String,
default: '#000000',
},
attr: {
type: Object,
default: () => {},
},
},
setup(props:any) {
// 添加isExternal函数定义
const isExternal = (path: string): boolean => {
return /^(https?:|mailto:|tel:)/.test(path)
}
const iconStyle = computed((): CSSProperties => {
const { size, color } = props
// 修复:添加类型检查,确保size是字符串才能调用replace方法
let fontSize = typeof size === 'string' ? `${size.replace('px', '')}px` : `${size}px`
return {
fontSize: fontSize,
color: color,
}
})
if (props.name.startsWith('el-icon-')) {
// 提取去掉前缀后的组件名
const iconName = props.name.replace('el-icon-', '')
return () =>
h(resolveComponent('el-icon'), { class: [props.name, 'icon'], style: iconStyle.value, ...props.attr }, () =>
h(resolveComponent(iconName))
)
} else if (props.name.startsWith('local-') || isExternal(props.name)) {
const name = props.name.replace('local-', '')
return () => h(resolveComponent('nuxt-icon'), { class: [props.name, 'icon'], name: name, style: iconStyle.value, ...props.attr })
} else {
// 处理fa前缀和其他普通图标
return () => h('i', { class: [props.name, 'icon'], style: iconStyle.value, ...props.attr })
}
},
})
</script>
+299
View File
@@ -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>
+141
View File
@@ -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. 图标选择器支持分页显示
## 示例代码
完整示例请参考项目中的使用场景。
+49
View File
@@ -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>
+3
View File
@@ -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>
+3
View File
@@ -11,6 +11,7 @@
>
<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>
@@ -18,6 +19,8 @@
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");
+71 -45
View File
@@ -5,7 +5,7 @@
<Icon
v-if="isAccountLoginActive"
@click="toggleLoginMode"
name="local-qr"
icon="ant-design:qrcode-outlined"
:color="isDarkMode ? '#ffffff' : '#000000'"
size="50px"
class="login-toggle-icon"
@@ -13,7 +13,7 @@
<Icon
v-else
@click="toggleLoginMode"
name="local-pc"
icon="ant-design:desktop-outlined"
:color="isDarkMode ? '#ffffff' : '#000000'"
size="50px"
class="login-toggle-icon"
@@ -50,7 +50,7 @@
<div class="input-container">
<el-input
v-model="formData.username"
:placeholder="t('username_placeholder')"
:placeholder="t('auth.login.username_placeholder')"
clearable
:inline-message="true"
:readonly="isUsernameInputReadonly"
@@ -60,7 +60,7 @@
>
<template #prefix>
<Icon
name="el-icon-User"
icon="ant-design:user-outlined"
:color="isDarkMode ? '#ffffff' : '#000000'"
class="input-prefix-icon"
/>
@@ -73,7 +73,7 @@
<div class="input-container">
<el-input
v-model="formData.password"
:placeholder="t('password_placeholder')"
:placeholder="t('auth.login.password_placeholder')"
type="password"
clearable
show-password
@@ -81,7 +81,7 @@
>
<template #prefix>
<Icon
name="el-icon-Lock"
icon="ant-design:lock-outlined"
:color="isDarkMode ? '#ffffff' : '#000000'"
class="input-prefix-icon"
/>
@@ -97,13 +97,13 @@
<div class="input-container">
<el-input
v-model="formData.mobile"
:placeholder="t('mobile_placeholder')"
:placeholder="t('auth.login.mobile_placeholder')"
clearable
class="login-input"
>
<template #prefix>
<Icon
name="el-icon-Phone"
icon="ant-design:phone-outlined"
:color="isDarkMode ? '#ffffff' : '#000000'"
class="input-prefix-icon"
/>
@@ -116,12 +116,12 @@
<div class="input-container">
<el-input
v-model="formData.mobile_code"
:placeholder="t('code_placeholder')"
:placeholder="t('auth.login.code_placeholder')"
class="login-input"
>
<template #prefix>
<Icon
name="el-icon-Key"
icon="ant-design:key-outlined"
:color="isDarkMode ? '#ffffff' : '#000000'"
class="input-prefix-icon"
/>
@@ -140,10 +140,19 @@
</el-form-item>
</div>
<!-- 注册链接 -->
<!-- 注册链接和忘记密码 -->
<div class="form-actions">
<el-button type="primary" link @click="switchToRegister" class="register-link">
{{ t("no_account") }},{{ t("to_register") }}
{{ 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>
@@ -156,7 +165,7 @@
@click="handleLoginSubmit"
:loading="isLoading"
>
{{ isLoading ? t("logining") : t("login") }}
{{ isLoading ? t("auth.login.logining") : t("auth.login.login") }}
</el-button>
</div>
@@ -169,11 +178,11 @@
></span>
{{ t("agree_tips") }}
<NuxtLink :to="protocolUrls.serviceUrl" target="_blank">
<span class="agreement-link">{{ t("user_agreement") }}</span>
<span class="agreement-link">{{ t("auth.register.user_agreement") }}</span>
</NuxtLink>
{{ t("and") }}
<NuxtLink :to="protocolUrls.privacyUrl" target="_blank">
<span class="agreement-link">{{ t("privacy_agreement") }}</span>
<span class="agreement-link">{{ t("auth.register.privacy_agreement") }}</span>
</NuxtLink>
</div>
</el-form>
@@ -181,19 +190,19 @@
<!-- 微信扫码登录 -->
<div v-else class="wechat-login-container">
<div class="wechat-login-title">{{ t("wechat_scan_login") }}</div>
<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("click_refresh") }}</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("wechat_scan_tip") }}</span>
<span class="wechat-text">{{ t("auth.login.wechat_scan_tip") }}</span>
</div>
</div>
</div>
@@ -204,8 +213,13 @@
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();
@@ -213,7 +227,8 @@ const configStore = useConfigStore();
// 暗黑模式状态
const isDarkMode = computed(() => {
return configStore.isDarkMode;
const root = document.querySelector('html')
return root?.classList.contains('dark') || false
});
// 协议链接配置
@@ -259,10 +274,10 @@ const smsCodeRef = ref<AnyObject | null>(null);
const availableLoginTypes = computed(() => {
const types = [];
if (configStore.login.is_username) {
types.push({ type: "username", title: t("username_login") });
types.push({ type: "username", title: t("auth.login.username_login") });
}
if (configStore.login.is_mobile) {
types.push({ type: "mobile", title: t("mobile_login") });
types.push({ type: "mobile", title: t("auth.login.mobile_login") });
}
currentLoginType.value = types[0] ? types[0].type : "";
return types;
@@ -271,12 +286,12 @@ const availableLoginTypes = computed(() => {
const formRules = computed(() => ({
username: {
required: currentLoginType.value === "username",
message: t("username_placeholder"),
message: t("auth.login.username_placeholder"),
trigger: ["blur", "change"],
},
password: {
required: currentLoginType.value === "username",
message: t("password_placeholder"),
message: t("auth.login.password_placeholder"),
trigger: ["blur", "change"],
},
mobile: [
@@ -290,13 +305,13 @@ const formRules = computed(() => ({
if (currentLoginType.value !== "mobile") return true;
return validate.mobile(value);
},
message: t("mobile_error"),
message: t("auth.login.mobile_error"),
trigger: ["blur"],
},
],
mobile_code: {
required: currentLoginType.value === "mobile",
message: t("code_placeholder"),
message: t("auth.login.sms_code_placeholder"),
trigger: ["change"],
},
}));
@@ -307,28 +322,34 @@ onUnmounted(() => {
});
// 初始化配置
configStore.getLoginConfig();
configStore.getWebGroupConfigs();
// 登录模式切换
const toggleLoginMode = () => {
isAccountLoginActive.value = !isAccountLoginActive.value;
if (!isAccountLoginActive.value) {
handleGenerateWechatQrCode();
} else {
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 response = await generateWechatQrCode() as any;
const data = response.data;
const data = await generateWechatQrCode() as any;
wechatQrCode.value.key = data.scene_id;
wechatQrCode.value.key = data.key;
if (data.url) {
const qrCodeUrl = await QRCode.toDataURL(data.url, {
if (data.qr_code_url) {
const qrCodeUrl = await QRCode.toDataURL(data.qr_code_url, {
errorCorrectionLevel: "L",
margin: 0,
width: 100,
@@ -351,10 +372,9 @@ const handleGenerateWechatQrCode = async () => {
* 检查微信扫码登录状态
* @param key - 扫码登录密钥
*/
const checkScan = (key: string) => {
checkWechatScanStatus({ key })
.then((res: any) => {
const data = res.data;
const checkScan = (scene_id: string) => {
checkWechatScanStatus({ scene_id })
.then((data: any) => {
switch (data.status) {
case "wait":
qrCodeTimer = setTimeout(() => {
@@ -377,12 +397,12 @@ const checkScan = (key: string) => {
};
const handleWechatLoginSuccess = (loginData: any) => {
if (!loginData.token) {
if (!loginData.access_token) {
useCookie("openId").value = loginData.openid;
navigateTo("/auth/bind");
memberStore.logClose();
} else {
memberStore.setToken(loginData.token);
memberStore.setToken(loginData.access_token, loginData.refresh_token || '');
memberStore.logClose();
}
};
@@ -393,7 +413,7 @@ const handleLoginSubmit = async () => {
if (!isValid) return;
if (configStore.login.agreement_show && !isAgreeProtocol.value) {
ElMessage.error(t("is_agree_tips"));
ElMessage.error(t("auth.login.is_agree_tips"));
return;
}
@@ -402,9 +422,9 @@ const handleLoginSubmit = async () => {
try {
const loginMethod = currentLoginType.value === "username" ? authenticateUser : authenticateWithMobile;
const response = await loginMethod(formData) as any;
const data = await loginMethod(formData) as any;
await memberStore.setToken(response?.data?.token || "");
await memberStore.setToken(data?.access_token || '', data?.refresh_token || '');
memberStore.logClose();
} catch (error) {
isLoading.value = false;
@@ -423,6 +443,9 @@ const emit = defineEmits(["typeChange"]);
const switchToRegister = () => {
emit("typeChange", "register");
};
const switchToForgetPassword = () => {
emit("typeChange", "forget-password");
};
</script>
<style lang="scss" scoped>
@@ -521,10 +544,13 @@ const switchToRegister = () => {
.form-actions {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
gap: 10px;
}
.register-link {
.register-link,
.forget-password-link {
font-size: 12px;
}
+93 -50
View File
@@ -29,7 +29,7 @@
<div class="input-container">
<el-input
v-model="formData.username"
:placeholder="t('username_placeholder')"
:placeholder="t('auth.register.username_placeholder')"
clearable
:inline-message="true"
:readonly="realNameInput"
@@ -39,7 +39,26 @@
>
<template #prefix>
<Icon
name="el-icon-User"
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"
/>
@@ -52,7 +71,7 @@
<div class="input-container">
<el-input
v-model="formData.password"
:placeholder="t('password_placeholder')"
:placeholder="t('auth.register.password_placeholder')"
type="password"
clearable
show-password
@@ -60,7 +79,7 @@
>
<template #prefix>
<Icon
name="el-icon-Lock"
icon="ant-design:lock-outlined"
:color="isDarkMode ? '#ffffff' : '#000000'"
class="input-prefix-icon"
/>
@@ -72,8 +91,8 @@
<el-form-item prop="confirmPassword" class="form-item">
<div class="input-container">
<el-input
v-model="formData.confirmPassword"
:placeholder="t('confirm_password_placeholder')"
v-model="formData.confirm_password"
:placeholder="t('auth.register.confirm_password_placeholder')"
type="password"
clearable
show-password
@@ -81,7 +100,7 @@
>
<template #prefix>
<Icon
name="el-icon-Lock"
icon="ant-design:lock-outlined"
:color="isDarkMode ? '#ffffff' : '#000000'"
class="input-prefix-icon"
/>
@@ -93,13 +112,13 @@
<el-form-item prop="captchaCode" class="form-item">
<div class="input-container">
<el-input
v-model="formData.captchaCode"
:placeholder="t('captcha_placeholder')"
v-model="formData.captcha_code"
:placeholder="t('auth.register.captcha_placeholder')"
class="register-input"
>
<template #prefix>
<Icon
name="local-verify"
icon="ant-design:safety-outlined"
:color="isDarkMode ? '#ffffff' : '#000000'"
class="input-prefix-icon"
/>
@@ -124,13 +143,13 @@
<div class="input-container">
<el-input
v-model="formData.mobile"
:placeholder="t('mobile_placeholder')"
:placeholder="t('auth.register.mobile_placeholder')"
clearable
class="register-input"
>
<template #prefix>
<Icon
name="el-icon-Phone"
icon="ant-design:phone-outlined"
:color="isDarkMode ? '#ffffff' : '#000000'"
class="input-prefix-icon"
/>
@@ -142,13 +161,13 @@
<el-form-item prop="mobileCode" class="form-item">
<div class="input-container">
<el-input
v-model="formData.mobileCode"
:placeholder="t('code_placeholder')"
v-model="formData.mobile_code"
:placeholder="t('auth.register.code_placeholder')"
class="register-input"
>
<template #prefix>
<Icon
name="el-icon-Key"
icon="ant-design:key-outlined"
:color="isDarkMode ? '#ffffff' : '#000000'"
class="input-prefix-icon"
/>
@@ -157,7 +176,7 @@
<sms-code
:mobile="formData.mobile"
type="login"
v-model="formData.mobileKey"
v-model="formData.mobile_key"
@click="sendSmsCode"
ref="smsCodeRef"
></sms-code>
@@ -170,7 +189,7 @@
<!-- 登录链接 -->
<div class="form-actions">
<el-button type="primary" link @click="switchToLogin" class="login-link">
{{ t('have_account') }},{{ t('to_login') }}
{{ t('auth.register.have_account') }},{{ t('auth.register.to_login') }}
</el-button>
</div>
@@ -183,7 +202,7 @@
@click="handleRegister"
:loading="isLoading"
>
{{ isLoading ? t('registering') : t('register') }}
{{ isLoading ? t('auth.register.registering') : t('auth.register.register') }}
</el-button>
</div>
@@ -197,13 +216,13 @@
:class="isAgreeChecked ? 'icon-xuanze1' : 'icon-checkbox_nol'"
@click="toggleAgreement"
></span>
{{ t('register_agree_tips') }}
{{ t('auth.register.register_agree_tips') }}
<NuxtLink :to="protocolUrls.service" target="_blank">
<span class="agreement-link">{{ t('user_agreement') }}</span>
<span class="agreement-link">{{ t('auth.register.user_agreement') }}</span>
</NuxtLink>
{{ t('and') }}
{{ t('auth.register.and') }}
<NuxtLink :to="protocolUrls.privacy" target="_blank">
<span class="agreement-link">{{ t('privacy_agreement') }}</span>
<span class="agreement-link">{{ t('auth.register.privacy_agreement') }}</span>
</NuxtLink>
</div>
</el-form>
@@ -214,8 +233,15 @@
<script lang="ts" setup>
import { ref, reactive, computed } from 'vue'
import { registerUser, registerWithMobile } from '@/api/auth'
import useConfigStore from '@/stores/config'
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()
@@ -227,7 +253,7 @@ const isDarkMode = computed(() => {
})
// 初始化配置
configStore.getLoginConfig()
// 配置已在 app.vue 中统一加载,这里不再重复请求
// 协议链接配置
const protocolUrls = computed(() => {
@@ -243,10 +269,10 @@ const currentType = ref('')
const registerTypes = computed(() => {
const types = []
if (configStore.login.is_username) {
types.push({ type: 'username', title: t('username_register') })
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('mobile_register') })
types.push({ type: 'mobile', title: t('auth.register.mobile_register') })
}
currentType.value = types[0]?.type || ''
return types
@@ -256,13 +282,14 @@ const registerTypes = computed(() => {
const isLoading = ref(false)
const formData = reactive({
username: '',
email: '',
password: '',
confirmPassword: '',
confirm_password: '',
mobile: '',
mobileCode: '',
mobileKey: '',
captchaKey: '',
captchaCode: ''
mobile_code: '',
mobile_key: '',
captcha_key: '',
captcha_code: ''
})
// 表单验证规则
@@ -271,27 +298,43 @@ const formRules = computed(() => ({
username: {
type: 'string',
required: currentType.value === 'username',
message: t('username_placeholder'),
message: t('auth.register.username_placeholder'),
trigger: ['blur', 'change'],
},
password: {
type: 'string',
required: currentType.value === 'username',
message: t('password_placeholder'),
trigger: ['blur', 'change']
},
confirmPassword: [
email: [
{
type: 'string',
required: currentType.value === 'username',
message: t('confirm_password_placeholder'),
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('confirm_password_error'),
message: t('auth.register.confirm_password_error'),
trigger: ['change', 'blur'],
}
],
@@ -299,7 +342,7 @@ const formRules = computed(() => ({
{
type: 'string',
required: currentType.value === 'mobile' || configStore.login.is_bind_mobile,
message: t('mobile_placeholder'),
message: t('auth.register.mobile_placeholder'),
trigger: ['blur', 'change'],
},
{
@@ -307,20 +350,20 @@ const formRules = computed(() => ({
if (currentType.value !== 'mobile' && !configStore.login.is_bind_mobile) return true
return validate.mobile(value)
},
message: t('mobile_error'),
message: t('auth.register.mobile_error'),
trigger: ['change', 'blur'],
}
],
mobileCode: {
mobile_code: {
type: 'string',
required: currentType.value === 'mobile' || configStore.login.is_bind_mobile,
message: t('code_placeholder'),
message: t('auth.register.code_placeholder'),
trigger: ['blur', 'change']
},
captchaCode: {
captcha_code: {
type: 'string',
required: currentType.value === 'username',
message: t('captcha_placeholder'),
message: t('auth.register.captcha_placeholder'),
trigger: ['blur', 'change'],
}
}))
@@ -338,7 +381,7 @@ const handleRegister = async () => {
// 检查协议同意
if (configStore.login.agreement_show && !isAgreeChecked.value) {
ElMessage.error(t('is_agree_tips'))
ElMessage.error(t('auth.register.is_agree_tips'))
return
}
@@ -347,8 +390,8 @@ const handleRegister = async () => {
try {
const registerApi = currentType.value === 'username' ? registerUser : registerWithMobile
const res = await registerApi(formData)
memberStore.setToken(res.data.token)
const data = await registerApi(formData) as any
memberStore.setToken(data.access_token, data.refresh_token || '')
memberStore.logClose()
} catch (error) {
isLoading.value = false
+154
View File
@@ -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>
+128
View File
@@ -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>
+19 -7
View File
@@ -63,18 +63,20 @@
</template>
<script lang="ts" setup>
import { ref, reactive, computed } from 'vue'
import { ref, reactive, computed, watch } from 'vue'
import { sendSmsVerificationCode } from '@/api/system'
import type { FormInstance } from 'element-plus'
interface SmsCodeProps {
mobile: string
mobile?: string
email?: string
type: string
modelValue: string
}
interface CaptchaFormData {
mobile: string
mobile?: string
email?: string
captcha_code: string
captcha_key: string
type: string
@@ -93,12 +95,21 @@ let countdownTimer: number | undefined = undefined
// 验证码相关
const captchaForm = reactive<CaptchaFormData>({
mobile: '',
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>()
@@ -126,14 +137,15 @@ buttonText.value = t('get_sms_code')
// 发送短信验证码
const sendSmsCode = async () => {
captchaForm.mobile = props.mobile
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()