初始化项目
This commit is contained in:
50
resources/admin/src/pages/home/iconPickerDemo.vue
Normal file
50
resources/admin/src/pages/home/iconPickerDemo.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<div class="icon-picker-demo">
|
||||
<a-card title="图标选择器演示" style="max-width: 600px; margin: 20px auto;">
|
||||
<a-form :label-col="{ span: 4 }" :wrapper-col="{ span: 20 }">
|
||||
<a-form-item label="选择图标">
|
||||
<sc-icon-picker v-model="selectedIcon" @change="handleIconChange" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="选中值">
|
||||
<a-input v-model:value="selectedIcon" readonly />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="图标预览">
|
||||
<div v-if="selectedIcon" class="icon-preview">
|
||||
<component :is="selectedIcon" style="font-size: 48px;" />
|
||||
</div>
|
||||
<a-empty v-else description="未选择图标" :image="Empty.PRESENTED_IMAGE_SIMPLE" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { Empty } from 'ant-design-vue'
|
||||
import ScIconPicker from '@/components/scIconPicker/index.vue'
|
||||
|
||||
const selectedIcon = ref('')
|
||||
|
||||
const handleIconChange = (value) => {
|
||||
console.log('图标已选择:', value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.icon-picker-demo {
|
||||
padding: 20px;
|
||||
|
||||
.icon-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
border: 1px dashed #d9d9d9;
|
||||
border-radius: 6px;
|
||||
min-height: 88px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
47
resources/admin/src/pages/home/index.vue
Normal file
47
resources/admin/src/pages/home/index.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<div class="home-page">
|
||||
<a-skeleton v-if="loading" active />
|
||||
<component v-else :is="dashboardComponent" @on-mounted="handleMounted" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed, defineAsyncComponent } from 'vue'
|
||||
import config from '@/config'
|
||||
|
||||
// 定义组件名称
|
||||
defineOptions({
|
||||
name: 'HomePage',
|
||||
})
|
||||
|
||||
const loading = ref(true)
|
||||
const dashboard = ref(config.DASHBOARD_LAYOUT || 'work')
|
||||
|
||||
// 动态导入组件
|
||||
const components = {
|
||||
work: defineAsyncComponent(() => import('./work/index.vue')),
|
||||
widgets: defineAsyncComponent(() => import('./widgets/index.vue')),
|
||||
}
|
||||
|
||||
const dashboardComponent = computed(() => {
|
||||
return components[dashboard.value] || components.work
|
||||
})
|
||||
|
||||
const handleMounted = () => {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 模拟加载延迟
|
||||
setTimeout(() => {
|
||||
loading.value = false
|
||||
}, 300)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.home-page {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
27
resources/admin/src/pages/home/widgets/components/about.vue
Normal file
27
resources/admin/src/pages/home/widgets/components/about.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<a-card :bordered="true" title="关于项目" class="about-card">
|
||||
<p>高性能 / 精致 / 优雅。基于Vue3 + Ant Design Vue 的中后台前端解决方案,如果喜欢就点个星星支持一下。</p>
|
||||
<p>
|
||||
<a href="https://gitee.com/lolicode/scui" target="_blank">
|
||||
<img src="https://gitee.com/lolicode/scui/badge/star.svg?theme=dark" alt="star" style="vertical-align: middle;">
|
||||
</a>
|
||||
</p>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// 定义组件名称
|
||||
defineOptions({
|
||||
name: 'AboutWidget',
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.about-card {
|
||||
p {
|
||||
color: #999;
|
||||
margin-top: 10px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
132
resources/admin/src/pages/home/widgets/components/echarts.vue
Normal file
132
resources/admin/src/pages/home/widgets/components/echarts.vue
Normal file
@@ -0,0 +1,132 @@
|
||||
<template>
|
||||
<a-card :bordered="true" title="实时收入">
|
||||
<a-spin :spinning="loading">
|
||||
<div ref="chartRef" style="width: 100%; height: 300px;"></div>
|
||||
</a-spin>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import * as echarts from 'echarts'
|
||||
|
||||
// 定义组件名称
|
||||
defineOptions({
|
||||
name: 'EchartsWidget',
|
||||
})
|
||||
|
||||
const chartRef = ref(null)
|
||||
const loading = ref(true)
|
||||
let chart = null
|
||||
let timer = null
|
||||
|
||||
// 初始化图表
|
||||
const initChart = () => {
|
||||
if (!chartRef.value) return
|
||||
|
||||
chart = echarts.init(chartRef.value)
|
||||
|
||||
// 生成初始数据
|
||||
const now = new Date()
|
||||
const xData = []
|
||||
const yData = []
|
||||
|
||||
for (let i = 29; i >= 0; i--) {
|
||||
const time = new Date(now.getTime() - i * 2000)
|
||||
xData.unshift(time.toLocaleTimeString().replace(/^\D*/, ''))
|
||||
yData.push(Math.round(Math.random() * 0))
|
||||
}
|
||||
|
||||
const option = {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
},
|
||||
xAxis: {
|
||||
boundaryGap: false,
|
||||
type: 'category',
|
||||
data: xData,
|
||||
},
|
||||
yAxis: [
|
||||
{
|
||||
type: 'value',
|
||||
name: '价格',
|
||||
splitLine: {
|
||||
show: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: '收入',
|
||||
type: 'line',
|
||||
symbol: 'none',
|
||||
lineStyle: {
|
||||
width: 1,
|
||||
color: '#409EFF',
|
||||
},
|
||||
areaStyle: {
|
||||
opacity: 0.1,
|
||||
color: '#79bbff',
|
||||
},
|
||||
data: yData,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
chart.setOption(option)
|
||||
|
||||
// 模拟实时更新
|
||||
timer = setInterval(() => {
|
||||
const newTime = new Date().toLocaleTimeString().replace(/^\D*/, '')
|
||||
const newValue = Math.round(Math.random() * 100)
|
||||
|
||||
xData.shift()
|
||||
xData.push(newTime)
|
||||
|
||||
yData.shift()
|
||||
yData.push(newValue)
|
||||
|
||||
chart.setOption({
|
||||
xAxis: {
|
||||
data: xData,
|
||||
},
|
||||
series: [
|
||||
{
|
||||
data: yData,
|
||||
},
|
||||
],
|
||||
})
|
||||
}, 2100)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 模拟加载延迟
|
||||
setTimeout(() => {
|
||||
loading.value = false
|
||||
initChart()
|
||||
}, 500)
|
||||
|
||||
// 监听窗口大小变化
|
||||
window.addEventListener('resize', handleResize)
|
||||
})
|
||||
|
||||
const handleResize = () => {
|
||||
if (chart) {
|
||||
chart.resize()
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer) {
|
||||
clearInterval(timer)
|
||||
}
|
||||
if (chart) {
|
||||
chart.dispose()
|
||||
}
|
||||
window.removeEventListener('resize', handleResize)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
// 样式根据需要添加
|
||||
</style>
|
||||
@@ -0,0 +1,8 @@
|
||||
import { markRaw } from 'vue'
|
||||
const resultComps = {}
|
||||
const files = import.meta.glob('./*.vue', { eager: true })
|
||||
Object.keys(files).forEach((fileName) => {
|
||||
let comp = files[fileName]
|
||||
resultComps[fileName.replace(/^\.\/(.*)\.\w+$/, '$1')] = comp.default
|
||||
})
|
||||
export default markRaw(resultComps)
|
||||
63
resources/admin/src/pages/home/widgets/components/info.vue
Normal file
63
resources/admin/src/pages/home/widgets/components/info.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<div class="info-card">
|
||||
<div class="header">
|
||||
<span class="title">系统信息</span>
|
||||
</div>
|
||||
<a-spin :spinning="loading">
|
||||
<a-descriptions bordered :column="1">
|
||||
<a-descriptions-item v-for="(item, index) in sysInfo" :key="index" :label="item.label">
|
||||
{{ item.values }}
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</a-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import systemApi from '@/api/system'
|
||||
|
||||
// 定义组件名称
|
||||
defineOptions({
|
||||
name: 'InfoWidget',
|
||||
})
|
||||
|
||||
const loading = ref(true)
|
||||
const sysInfo = ref([])
|
||||
|
||||
const getSystemList = async () => {
|
||||
try {
|
||||
const res = await systemApi.info.get()
|
||||
if (res.code === 1) {
|
||||
sysInfo.value = res.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取系统信息失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getSystemList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.info-card {
|
||||
background-color: #ffffff;
|
||||
padding: 16px;
|
||||
border-radius: 10px;
|
||||
margin: 16px 0;
|
||||
.header{
|
||||
padding-bottom: 10px;
|
||||
.title{
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
:deep(.ant-descriptions-item-label) {
|
||||
width: 140px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<a-card :bordered="true" title="进度环">
|
||||
<div class="progress">
|
||||
<a-progress type="dashboard" :percent="85.5" :width="160">
|
||||
<template #format="percent">
|
||||
<div class="percentage-value">{{ percent }}%</div>
|
||||
<div class="percentage-label">当前进度</div>
|
||||
</template>
|
||||
</a-progress>
|
||||
</div>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// 定义组件名称
|
||||
defineOptions({
|
||||
name: 'ProgressWidget',
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.progress {
|
||||
text-align: center;
|
||||
|
||||
.percentage-value {
|
||||
font-size: 28px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.percentage-label {
|
||||
font-size: 12px;
|
||||
margin-top: 10px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
93
resources/admin/src/pages/home/widgets/components/sms.vue
Normal file
93
resources/admin/src/pages/home/widgets/components/sms.vue
Normal file
@@ -0,0 +1,93 @@
|
||||
<template>
|
||||
<a-row :gutter="10">
|
||||
<a-col :span="4">
|
||||
<a-card :bordered="true" title="今日数量">
|
||||
<a-statistic :value="count.today" :formatter="formatNumber" />
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="4">
|
||||
<a-card :bordered="true" title="昨日数量">
|
||||
<a-statistic :value="count.yesterday" :formatter="formatNumber" />
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="4">
|
||||
<a-card :bordered="true" title="本周数量">
|
||||
<a-statistic :value="count.week" :formatter="formatNumber" />
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="4">
|
||||
<a-card :bordered="true" title="上周数量">
|
||||
<a-statistic :value="count.last_week" :formatter="formatNumber" />
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="4">
|
||||
<a-card :bordered="true" title="今年数量">
|
||||
<a-statistic :value="count.year" :formatter="formatNumber" />
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="4">
|
||||
<a-card :bordered="true" title="总数量">
|
||||
<a-statistic :value="count.all" :formatter="formatNumber" />
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import systemApi from '@/api/system'
|
||||
|
||||
// 定义组件名称
|
||||
defineOptions({
|
||||
name: 'SmsWidget',
|
||||
})
|
||||
|
||||
const count = ref({})
|
||||
|
||||
const getSmsCount = async () => {
|
||||
try {
|
||||
// 注意:API中可能没有短信统计接口,这里使用模拟数据
|
||||
// 如果有接口,请取消注释以下代码
|
||||
// const res = await systemApi.sms.count.get()
|
||||
// if (res.code === 1) {
|
||||
// count.value = res.data
|
||||
// }
|
||||
|
||||
// 模拟数据
|
||||
count.value = {
|
||||
today: 1234,
|
||||
yesterday: 5678,
|
||||
week: 45678,
|
||||
last_week: 43210,
|
||||
year: 567890,
|
||||
all: 1234567
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取短信统计失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const formatNumber = (value) => {
|
||||
return value.toLocaleString()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getSmsCount()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
:deep(.ant-card-head-title) {
|
||||
padding: 12px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
:deep(.ant-card-body) {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
:deep(.ant-statistic-content) {
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
72
resources/admin/src/pages/home/widgets/components/time.vue
Normal file
72
resources/admin/src/pages/home/widgets/components/time.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<a-card :bordered="true" title="时钟" class="time-card">
|
||||
<div class="time">
|
||||
<h2>{{ time }}</h2>
|
||||
<p>{{ day }}</p>
|
||||
</div>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import tool from '@/utils/tool'
|
||||
|
||||
// 定义组件名称
|
||||
defineOptions({
|
||||
name: 'TimeWidget',
|
||||
})
|
||||
|
||||
const time = ref('')
|
||||
const day = ref('')
|
||||
let timer = null
|
||||
|
||||
const showTime = () => {
|
||||
time.value = tool.dateFormat(new Date(), 'hh:mm:ss')
|
||||
day.value = tool.dateFormat(new Date(), 'yyyy年MM月dd日')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
showTime()
|
||||
timer = setInterval(() => {
|
||||
showTime()
|
||||
}, 1000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer) {
|
||||
clearInterval(timer)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.time-card {
|
||||
background: linear-gradient(to right, #8e54e9, #4776e6);
|
||||
color: #fff;
|
||||
|
||||
:deep(.ant-card-head-title) {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
:deep(.ant-card-head) {
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
:deep(.ant-card-body) {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.time {
|
||||
h2 {
|
||||
font-size: 40px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 14px;
|
||||
margin-top: 13px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
69
resources/admin/src/pages/home/widgets/components/ver.vue
Normal file
69
resources/admin/src/pages/home/widgets/components/ver.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<div class="ver-card">
|
||||
<div class="header">
|
||||
<span class="title">版本信息</span>
|
||||
</div>
|
||||
<a-spin :spinning="loading">
|
||||
<a-descriptions bordered :column="1">
|
||||
<a-descriptions-item v-for="(item, index) in sysInfo" :key="index" :label="item.label">
|
||||
{{ item.values }}
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</a-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import systemApi from '@/api/system'
|
||||
|
||||
// 定义组件名称
|
||||
defineOptions({
|
||||
name: 'VerWidget',
|
||||
})
|
||||
|
||||
const loading = ref(true)
|
||||
const sysInfo = ref([])
|
||||
|
||||
const getSystemList = async () => {
|
||||
try {
|
||||
const res = await systemApi.version.get()
|
||||
if (res.code === 1) {
|
||||
sysInfo.value = res.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取版本信息失败:', error)
|
||||
// 使用模拟数据作为fallback
|
||||
sysInfo.value = [
|
||||
{ label: '系统版本', values: '1.0.0' },
|
||||
{ label: '框架版本', values: 'Vue 3.x' },
|
||||
{ label: '构建时间', values: '2024-01-01' },
|
||||
]
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getSystemList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.ver-card {
|
||||
background-color: #ffffff;
|
||||
padding: 16px;
|
||||
border-radius: 10px;
|
||||
margin: 16px 0;
|
||||
.header{
|
||||
padding-bottom: 10px;
|
||||
.title{
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
:deep(.ant-descriptions-item-label) {
|
||||
width: 160px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<a-card :bordered="true" title="欢迎">
|
||||
<div class="welcome">
|
||||
<div class="logo">
|
||||
<img :src="logoImage" alt="logo">
|
||||
<h2>VueAdmin</h2>
|
||||
</div>
|
||||
<div class="tips">
|
||||
<div class="tips-item">
|
||||
<div class="tips-item-icon"><MenuOutlined /></div>
|
||||
<div class="tips-item-message">这里是项目控制台,你可以点击右上方的"自定义"按钮来添加移除或者移动部件。</div>
|
||||
</div>
|
||||
<div class="tips-item">
|
||||
<div class="tips-item-icon"><RocketOutlined /></div>
|
||||
<div class="tips-item-message">在提高前端算力、减少带宽请求和代码执行力上多次优化,并且持续着。</div>
|
||||
</div>
|
||||
<div class="tips-item">
|
||||
<div class="tips-item-icon"><CoffeeOutlined /></div>
|
||||
<div class="tips-item-message">项目目的:让前端工作更快乐</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { MenuOutlined, RocketOutlined, CoffeeOutlined } from '@ant-design/icons-vue'
|
||||
import logoImage from '@/assets/images/logo.png'
|
||||
|
||||
// 定义组件名称
|
||||
defineOptions({
|
||||
name: 'WelcomeWidget',
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.welcome {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.welcome .logo {
|
||||
text-align: center;
|
||||
padding: 0 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
img {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 30px;
|
||||
font-weight: normal;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.tips {
|
||||
padding: 0 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.tips-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 7.5px 0;
|
||||
}
|
||||
|
||||
.tips-item-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
font-size: 18px;
|
||||
margin-right: 20px;
|
||||
color: #1890ff;
|
||||
background: rgba(24, 144, 255, 0.1);
|
||||
}
|
||||
|
||||
.tips-item-message {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
505
resources/admin/src/pages/home/widgets/index.vue
Normal file
505
resources/admin/src/pages/home/widgets/index.vue
Normal file
@@ -0,0 +1,505 @@
|
||||
<template>
|
||||
<div :class="['widgets-home', customizing ? 'customizing' : '']" ref="main">
|
||||
<div class="widgets-content">
|
||||
<div class="widgets-top">
|
||||
<div class="widgets-top-title">控制台</div>
|
||||
<div class="widgets-top-actions">
|
||||
<a-button v-if="customizing" type="primary" shape="round" @click="handleSave">
|
||||
<template #icon>
|
||||
<CheckOutlined />
|
||||
</template>
|
||||
完成
|
||||
</a-button>
|
||||
<a-button v-else type="primary" shape="round" @click="handleCustom">
|
||||
<template #icon>
|
||||
<EditOutlined />
|
||||
</template>
|
||||
自定义
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="widgets" ref="widgetsRef">
|
||||
<div class="widgets-wrapper">
|
||||
<div v-if="nowCompsList.length <= 0" class="no-widgets">
|
||||
<a-empty description="没有部件啦" :image="Empty.PRESENTED_IMAGE_SIMPLE" />
|
||||
</div>
|
||||
<a-row :gutter="15">
|
||||
<a-col v-for="(item, index) in grid.layout" :key="index" :md="item" :xs="24">
|
||||
<div class="draggable-wrapper">
|
||||
<draggable v-model="grid.compsList[index]" item-key="key" :animation="200"
|
||||
handle=".customize-overlay" group="widgets" class="draggable-box">
|
||||
<template #item="{ element }">
|
||||
<div class="widgets-item">
|
||||
<component :is="allComps[element]" />
|
||||
<div v-if="customizing" class="customize-overlay">
|
||||
<a-button class="close" type="primary" ghost shape="circle"
|
||||
@click="removeComp(element)">
|
||||
<template #icon>
|
||||
<CloseOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
<label>
|
||||
<component :is="allComps[element].icon" />
|
||||
{{ allComps[element].title }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 自定义侧边栏 -->
|
||||
<a-drawer v-if="customizing" :open="customizing" :width="360" placement="right" :closable="false" :mask="false"
|
||||
class="widgets-drawer">
|
||||
<template #title>
|
||||
<div class="widgets-aside-title">
|
||||
<PlusCircleOutlined /> 添加部件
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #extra>
|
||||
<a-button type="text" @click="handleClose">
|
||||
<template #icon>
|
||||
<CloseOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</template>
|
||||
|
||||
<!-- 布局选择 -->
|
||||
<div class="select-layout">
|
||||
<h3>选择布局</h3>
|
||||
<div class="select-layout-options">
|
||||
<div class="select-layout-item item01" :class="{ active: grid.layout.join(',') === '12,6,6' }"
|
||||
@click="setLayout([12, 6, 6])">
|
||||
<a-row :gutter="2">
|
||||
<a-col :span="12"><span></span></a-col>
|
||||
<a-col :span="6"><span></span></a-col>
|
||||
<a-col :span="6"><span></span></a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
<div class="select-layout-item item02" :class="{ active: grid.layout.join(',') === '24,16,8' }"
|
||||
@click="setLayout([24, 16, 8])">
|
||||
<a-row :gutter="2">
|
||||
<a-col :span="24"><span></span></a-col>
|
||||
<a-col :span="16"><span></span></a-col>
|
||||
<a-col :span="8"><span></span></a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
<div class="select-layout-item item03" :class="{ active: grid.layout.join(',') === '24' }"
|
||||
@click="setLayout([24])">
|
||||
<a-row :gutter="2">
|
||||
<a-col :span="24"><span></span></a-col>
|
||||
<a-col :span="24"><span></span></a-col>
|
||||
<a-col :span="24"><span></span></a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 部件列表 -->
|
||||
<div class="widgets-list">
|
||||
<h3>可用部件</h3>
|
||||
<div v-if="myCompsList.length <= 0" class="widgets-list-nodata">
|
||||
<a-empty description="没有部件啦" :image="Empty.PRESENTED_IMAGE_SIMPLE" />
|
||||
</div>
|
||||
<div v-for="item in myCompsList" :key="item.key" class="widgets-list-item">
|
||||
<div class="item-logo">
|
||||
<component :is="item.icon" />
|
||||
</div>
|
||||
<div class="item-info">
|
||||
<h2>{{ item.title }}</h2>
|
||||
<p>{{ item.description }}</p>
|
||||
</div>
|
||||
<div class="item-actions">
|
||||
<a-button type="primary" @click="addComp(item)">
|
||||
<template #icon>
|
||||
<PlusOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<a-button @click="handleResetDefault">恢复默认</a-button>
|
||||
</template>
|
||||
</a-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted, nextTick } from 'vue'
|
||||
import { Empty } from 'ant-design-vue'
|
||||
import draggable from 'vuedraggable'
|
||||
import allComps from './components'
|
||||
import config from '@/config'
|
||||
|
||||
// 定义组件名称
|
||||
defineOptions({
|
||||
name: 'WidgetsPage',
|
||||
})
|
||||
|
||||
const customizing = ref(false)
|
||||
const widgetsRef = ref(null)
|
||||
const defaultGrid = config.DEFAULT_GRID
|
||||
const grid = reactive({ layout: [], compsList: [] })
|
||||
|
||||
// 初始化
|
||||
const initGrid = () => {
|
||||
const savedGrid = localStorage.getItem('widgetsGrid')
|
||||
if (savedGrid) {
|
||||
try {
|
||||
const parsed = JSON.parse(savedGrid)
|
||||
grid.layout = parsed.layout
|
||||
grid.compsList = parsed.compsList
|
||||
} catch {
|
||||
resetToDefault()
|
||||
}
|
||||
} else {
|
||||
resetToDefault()
|
||||
}
|
||||
}
|
||||
|
||||
const resetToDefault = () => {
|
||||
grid.layout = [...defaultGrid.layout]
|
||||
grid.compsList = defaultGrid.compsList.map((arr) => [...arr])
|
||||
}
|
||||
|
||||
// 计算属性
|
||||
const allCompsList = computed(() => {
|
||||
const list = []
|
||||
for (const key in allComps) {
|
||||
list.push({
|
||||
key,
|
||||
title: allComps[key].title,
|
||||
icon: allComps[key].icon,
|
||||
description: allComps[key].description,
|
||||
})
|
||||
}
|
||||
const myCompKeys = grid.compsList.flat()
|
||||
list.forEach((comp) => {
|
||||
comp.disabled = myCompKeys.includes(comp.key)
|
||||
})
|
||||
return list
|
||||
})
|
||||
|
||||
const myCompsList = computed(() => {
|
||||
return allCompsList.value.filter((item) => !item.disabled)
|
||||
})
|
||||
|
||||
const nowCompsList = computed(() => {
|
||||
return grid.compsList.flat()
|
||||
})
|
||||
|
||||
// 方法
|
||||
const handleCustom = () => {
|
||||
customizing.value = true
|
||||
const oldWidth = widgetsRef.value?.offsetWidth || 0
|
||||
nextTick(() => {
|
||||
if (widgetsRef.value) {
|
||||
const scale = widgetsRef.value.offsetWidth / oldWidth
|
||||
widgetsRef.value.style.setProperty('transform', `scale(${scale})`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const setLayout = (layout) => {
|
||||
grid.layout = layout
|
||||
if (layout.join(',') === '24') {
|
||||
grid.compsList[0] = [...grid.compsList[0], ...grid.compsList[1], ...grid.compsList[2]]
|
||||
grid.compsList[1] = []
|
||||
grid.compsList[2] = []
|
||||
}
|
||||
}
|
||||
|
||||
const addComp = (item) => {
|
||||
grid.compsList[0].push(item.key)
|
||||
}
|
||||
|
||||
const removeComp = (key) => grid.compsList.forEach((list, index) => {
|
||||
grid.compsList[index] = list.filter((k) => k !== key)
|
||||
})
|
||||
|
||||
const handleSave = () => {
|
||||
customizing.value = false
|
||||
if (widgetsRef.value) {
|
||||
widgetsRef.value.style.removeProperty('transform')
|
||||
}
|
||||
localStorage.setItem('widgetsGrid', JSON.stringify(grid))
|
||||
emit('on-mounted')
|
||||
}
|
||||
|
||||
const handleResetDefault = () => {
|
||||
customizing.value = false
|
||||
if (widgetsRef.value) {
|
||||
widgetsRef.value.style.removeProperty('transform')
|
||||
}
|
||||
resetToDefault()
|
||||
localStorage.removeItem('widgetsGrid')
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
customizing.value = false
|
||||
if (widgetsRef.value) {
|
||||
widgetsRef.value.style.removeProperty('transform')
|
||||
}
|
||||
}
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
initGrid()
|
||||
emit('on-mounted')
|
||||
})
|
||||
|
||||
const emit = defineEmits(['on-mounted'])
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.widgets-home {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.widgets-content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.widgets-top {
|
||||
margin-bottom: 15px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.widgets-top-title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.widgets {
|
||||
transform-origin: top left;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
|
||||
.customizing .widgets-wrapper {
|
||||
margin-right: -360px;
|
||||
}
|
||||
|
||||
.customizing .widgets-wrapper .ant-col {
|
||||
padding-bottom: 15px;
|
||||
}
|
||||
|
||||
.customizing .widgets-wrapper .draggable-wrapper {
|
||||
border: 1px dashed #1890ff;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.customizing .widgets-wrapper .no-widgets {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.customizing .widgets-item {
|
||||
position: relative;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.customize-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.customize-overlay:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.customize-overlay label {
|
||||
background: #1890ff;
|
||||
color: #fff;
|
||||
height: 40px;
|
||||
padding: 0 30px;
|
||||
border-radius: 40px;
|
||||
font-size: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: grab;
|
||||
margin-top: 8px;
|
||||
|
||||
.anticon {
|
||||
margin-right: 15px;
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.customize-overlay .close {
|
||||
position: absolute;
|
||||
top: 15px;
|
||||
right: 15px;
|
||||
}
|
||||
|
||||
.widgets-list {
|
||||
margin-top: 24px;
|
||||
|
||||
h3 {
|
||||
font-size: 14px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.widgets-list-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
padding: 15px;
|
||||
align-items: center;
|
||||
background: #fafafa;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 8px;
|
||||
transition: background 0.3s;
|
||||
|
||||
&:hover {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
}
|
||||
|
||||
.widgets-list-item .item-logo {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: rgba(180, 180, 180, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
margin-right: 15px;
|
||||
color: #6a8bad;
|
||||
}
|
||||
|
||||
.widgets-list-item .item-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.widgets-list-item .item-info h2 {
|
||||
font-size: 16px;
|
||||
font-weight: normal;
|
||||
cursor: default;
|
||||
margin: 0 0 4px 0;
|
||||
}
|
||||
|
||||
.widgets-list-item .item-info p {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
cursor: default;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.widgets-wrapper .sortable-ghost {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.select-layout {
|
||||
margin-bottom: 24px;
|
||||
|
||||
h3 {
|
||||
font-size: 14px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.select-layout-options {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.select-layout-item {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border: 2px solid #d9d9d9;
|
||||
padding: 5px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: all 0.3s;
|
||||
|
||||
span {
|
||||
display: block;
|
||||
background: #d9d9d9;
|
||||
height: 46px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
&.item02 span {
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
&.item02 .ant-col:nth-child(1) span {
|
||||
height: 14px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
&.item03 span {
|
||||
height: 14px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: #1890ff;
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: #1890ff;
|
||||
|
||||
span {
|
||||
background: #1890ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.widgets-drawer {
|
||||
:deep(.ant-drawer-body) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.widgets-aside-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.anticon {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.customizing .widgets {
|
||||
transform: scale(1) !important;
|
||||
}
|
||||
|
||||
.customizing .widgets-drawer {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.customizing .widgets-wrapper {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
469
resources/admin/src/pages/home/work/components/myapp.vue
Normal file
469
resources/admin/src/pages/home/work/components/myapp.vue
Normal file
@@ -0,0 +1,469 @@
|
||||
<template>
|
||||
<div class="my-app">
|
||||
<a-card :bordered="false">
|
||||
<template #title>
|
||||
<div class="card-title">
|
||||
<SettingOutlined />
|
||||
<span>我的常用应用</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #extra>
|
||||
<a-button type="primary" @click="showDrawer">
|
||||
<template #icon>
|
||||
<PlusOutlined />
|
||||
</template>
|
||||
添加应用
|
||||
</a-button>
|
||||
</template>
|
||||
|
||||
<div v-if="myApps.length === 0" class="empty-state">
|
||||
<a-empty description="暂无常用应用,请点击上方按钮添加" />
|
||||
</div>
|
||||
|
||||
<div v-else class="apps-grid">
|
||||
<draggable v-model="myApps" item-key="path" :animation="200" ghost-class="ghost" drag-class="dragging"
|
||||
class="draggable-grid">
|
||||
<template #item="{ element }">
|
||||
<div class="app-item" @click="handleAppClick(element)">
|
||||
<div class="app-icon">
|
||||
<component :is="getIconComponent(element.meta?.icon)" />
|
||||
</div>
|
||||
<div class="app-name">{{ element.meta?.title }}</div>
|
||||
<div class="app-description">{{ element.meta?.description || '点击打开' }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
<!-- 添加应用抽屉 -->
|
||||
<a-drawer v-model:open="drawerVisible" title="管理应用" :width="650" placement="right">
|
||||
<div class="drawer-content">
|
||||
<div class="app-section">
|
||||
<div class="section-header">
|
||||
<h3>
|
||||
<StarFilled />
|
||||
我的常用
|
||||
</h3>
|
||||
<span class="count">{{ myApps.length }} 个应用</span>
|
||||
</div>
|
||||
<p class="tips">拖拽卡片调整顺序,点击移除按钮移除应用</p>
|
||||
<draggable v-model="myApps" item-key="path" :animation="200" ghost-class="drawer-ghost"
|
||||
drag-class="drawer-dragging" class="drawer-grid" group="apps">
|
||||
<template #item="{ element }">
|
||||
<div class="drawer-app-card">
|
||||
<div class="remove-btn" @click.stop="removeApp(element.path)">
|
||||
<CloseOutlined />
|
||||
</div>
|
||||
<div class="app-icon">
|
||||
<component :is="getIconComponent(element.meta?.icon)" />
|
||||
</div>
|
||||
<div class="app-name">{{ element.meta?.title }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div v-if="myApps.length === 0" class="empty-zone">
|
||||
<a-empty description="暂无常用应用,从下方拖入应用" :image="false" />
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
</div>
|
||||
|
||||
<a-divider style="margin: 24px 0" />
|
||||
|
||||
<div class="app-section">
|
||||
<div class="section-header">
|
||||
<h3>
|
||||
<AppstoreOutlined />
|
||||
全部应用
|
||||
</h3>
|
||||
<span class="count">{{ allApps.length }} 个可用</span>
|
||||
</div>
|
||||
<p class="tips">拖拽卡片到上方添加为常用应用</p>
|
||||
<draggable v-model="allApps" item-key="path" :animation="200" ghost-class="drawer-ghost"
|
||||
drag-class="drawer-dragging" class="drawer-grid" group="apps">
|
||||
<template #item="{ element }">
|
||||
<div class="drawer-app-card">
|
||||
<div class="app-icon">
|
||||
<component :is="getIconComponent(element.meta?.icon)" />
|
||||
</div>
|
||||
<div class="app-name">{{ element.meta?.title }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div v-if="allApps.length === 0" class="empty-zone">
|
||||
<a-empty description="所有应用已添加" :image="false" />
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<a-button @click="drawerVisible = false">取消</a-button>
|
||||
<a-button type="primary" @click="handleSave">保存设置</a-button>
|
||||
</template>
|
||||
</a-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { message } from 'ant-design-vue'
|
||||
import draggable from 'vuedraggable'
|
||||
import { useUserStore } from '@/stores/modules/user'
|
||||
import * as icons from '@ant-design/icons-vue'
|
||||
|
||||
// 定义组件名称
|
||||
defineOptions({
|
||||
name: 'MyApp',
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 从菜单中提取所有应用项(扁平化菜单树)
|
||||
const extractMenuItems = (menus) => {
|
||||
const items = []
|
||||
|
||||
const traverse = (menuList) => {
|
||||
for (const menu of menuList) {
|
||||
// 只添加有路径的菜单项(排除父级菜单项)
|
||||
if (menu.path && (!menu.children || menu.children.length === 0)) {
|
||||
items.push(menu)
|
||||
}
|
||||
// 递归处理子菜单
|
||||
if (menu.children && menu.children.length > 0) {
|
||||
traverse(menu.children)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
traverse(menus)
|
||||
return items
|
||||
}
|
||||
|
||||
// 获取所有可用应用
|
||||
const allAvailableApps = computed(() => {
|
||||
return extractMenuItems(userStore.menu || [])
|
||||
})
|
||||
|
||||
const drawerVisible = ref(false)
|
||||
const myApps = ref([])
|
||||
const allApps = ref([])
|
||||
|
||||
// 获取图标组件
|
||||
const getIconComponent = (iconName) => {
|
||||
return icons[iconName] || icons.FileTextOutlined
|
||||
}
|
||||
|
||||
// 从本地存储加载数据
|
||||
const loadApps = () => {
|
||||
const savedApps = localStorage.getItem('myApps')
|
||||
if (savedApps) {
|
||||
const savedPaths = JSON.parse(savedApps)
|
||||
myApps.value = allAvailableApps.value.filter(app => savedPaths.includes(app.path))
|
||||
} else {
|
||||
// 默认显示前4个应用
|
||||
myApps.value = allAvailableApps.value.slice(0, 4)
|
||||
}
|
||||
updateAllApps()
|
||||
}
|
||||
|
||||
// 更新全部应用列表(排除已添加的)
|
||||
const updateAllApps = () => {
|
||||
const myAppPaths = myApps.value.map(app => app.path)
|
||||
allApps.value = allAvailableApps.value.filter(app => !myAppPaths.includes(app.path))
|
||||
}
|
||||
|
||||
// 显示抽屉
|
||||
const showDrawer = () => {
|
||||
drawerVisible.value = true
|
||||
updateAllApps()
|
||||
}
|
||||
|
||||
// 移除应用
|
||||
const removeApp = (path) => {
|
||||
const index = myApps.value.findIndex(app => app.path === path)
|
||||
if (index > -1) {
|
||||
myApps.value.splice(index, 1)
|
||||
updateAllApps()
|
||||
}
|
||||
}
|
||||
|
||||
// 应用点击处理
|
||||
const handleAppClick = (app) => {
|
||||
if (app.path) {
|
||||
router.push(app.path)
|
||||
} else {
|
||||
message.warning('该应用没有配置路由')
|
||||
}
|
||||
}
|
||||
|
||||
// 保存设置
|
||||
const handleSave = () => {
|
||||
const appPaths = myApps.value.map(app => app.path)
|
||||
localStorage.setItem('myApps', JSON.stringify(appPaths))
|
||||
message.success('保存成功')
|
||||
drawerVisible.value = false
|
||||
}
|
||||
|
||||
// 初始化
|
||||
loadApps()
|
||||
|
||||
// 监听菜单变化,重新加载应用
|
||||
watch(
|
||||
() => userStore.menu,
|
||||
() => {
|
||||
loadApps()
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.my-app {
|
||||
padding: 20px;
|
||||
|
||||
.card-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
.apps-grid {
|
||||
.draggable-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.app-item {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
background: #fafafa;
|
||||
border-radius: 8px;
|
||||
cursor: grab;
|
||||
transition: all 0.3s;
|
||||
border: 1px solid #f0f0f0;
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: #fff;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
transform: translateY(-2px);
|
||||
border-color: #1890ff;
|
||||
}
|
||||
|
||||
&.dragging {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&.ghost {
|
||||
opacity: 0.3;
|
||||
background: #e6f7ff;
|
||||
border-color: #1890ff;
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.app-icon {
|
||||
font-size: 40px;
|
||||
color: #1890ff;
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.app-name {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.app-description {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 抽屉样式 - 使用全局样式避免深度问题
|
||||
:deep(.my-app .ant-drawer-body) {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
:deep(.my-app .ant-drawer-footer) {
|
||||
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.drawer-content {
|
||||
.app-section {
|
||||
margin-bottom: 0;
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
|
||||
h3 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: #262626;
|
||||
|
||||
.anticon {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.count {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
background: #f5f5f5;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.tips {
|
||||
font-size: 13px;
|
||||
color: #8c8c8c;
|
||||
margin-bottom: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.empty-zone {
|
||||
text-align: center;
|
||||
padding: 30px 20px;
|
||||
background: #fafafa;
|
||||
border: 2px dashed #d9d9d9;
|
||||
border-radius: 8px;
|
||||
|
||||
.ant-empty-description {
|
||||
color: #bfbfbf;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 抽屉内卡片式网格布局
|
||||
.drawer-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 12px;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.drawer-app-card {
|
||||
position: relative;
|
||||
padding: 20px 16px;
|
||||
text-align: center;
|
||||
background: #fff;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 8px;
|
||||
cursor: grab;
|
||||
transition: all 0.25s ease;
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: #1890ff;
|
||||
box-shadow: 0 2px 8px rgba(24, 144, 255, 0.1);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.remove-btn {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #ff4d4f;
|
||||
color: #fff;
|
||||
border-radius: 50%;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.25s;
|
||||
|
||||
&:hover {
|
||||
background: #ff7875;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .remove-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.app-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin: 0 auto 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #e6f7ff 0%, #bae7ff 100%);
|
||||
border-radius: 12px;
|
||||
font-size: 24px;
|
||||
color: #1890ff;
|
||||
|
||||
:deep(.anticon) {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.app-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #262626;
|
||||
margin-bottom: 4px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.app-description {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
// 拖拽时的样式
|
||||
.drawer-ghost {
|
||||
opacity: 0.4;
|
||||
background: #e6f7ff;
|
||||
border-color: #1890ff;
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.drawer-dragging {
|
||||
opacity: 0.6;
|
||||
transform: scale(0.95);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
21
resources/admin/src/pages/home/work/index.vue
Normal file
21
resources/admin/src/pages/home/work/index.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<div class="work-page">
|
||||
<MyApp />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import MyApp from './components/myapp.vue'
|
||||
|
||||
// 定义组件名称
|
||||
defineOptions({
|
||||
name: 'WorkPage',
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.work-page {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
163
resources/admin/src/pages/login/index.vue
Normal file
163
resources/admin/src/pages/login/index.vue
Normal file
@@ -0,0 +1,163 @@
|
||||
<template>
|
||||
<div class="auth-container">
|
||||
<div class="tech-decoration">
|
||||
<div class="tech-circle"></div>
|
||||
<div class="tech-circle"></div>
|
||||
<div class="tech-circle"></div>
|
||||
</div>
|
||||
|
||||
<div class="auth-card">
|
||||
<div class="auth-header">
|
||||
<h1 class="auth-title">欢迎回来</h1>
|
||||
<p class="auth-subtitle">登录您的账户继续探索科技世界</p>
|
||||
</div>
|
||||
|
||||
<a-form ref="loginFormRef" :model="loginForm" :rules="loginRules" class="auth-form" @finish="handleLogin" layout="vertical">
|
||||
<a-form-item name="username">
|
||||
<a-input v-model:value="loginForm.username" placeholder="请输入用户名/邮箱" size="large">
|
||||
<template #prefix>
|
||||
<UserOutlined />
|
||||
</template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item name="password">
|
||||
<a-input-password v-model:value="loginForm.password" placeholder="请输入密码" size="large" @pressEnter="handleLogin">
|
||||
<template #prefix>
|
||||
<LockOutlined />
|
||||
</template>
|
||||
</a-input-password>
|
||||
</a-form-item>
|
||||
|
||||
<div class="auth-links">
|
||||
<a-checkbox v-model:checked="loginForm.rememberMe" class="remember-me"> 记住我 </a-checkbox>
|
||||
<router-link to="/reset-password" class="forgot-password"> 忘记密码? </router-link>
|
||||
</div>
|
||||
|
||||
<a-form-item>
|
||||
<a-button type="primary" :loading="loading" size="large" html-type="submit" block>
|
||||
{{ loading ? '登录中...' : '登录' }}
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
<div class="auth-footer">
|
||||
<p class="auth-footer-text">
|
||||
还没有账户?
|
||||
<router-link to="/userRegister" class="auth-link"> 立即注册 </router-link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { UserOutlined, LockOutlined } from '@ant-design/icons-vue'
|
||||
import { useUserStore } from '@/stores/modules/user'
|
||||
import auth from '@/api/auth'
|
||||
import config from '@/config'
|
||||
import '@/assets/style/auth.scss'
|
||||
|
||||
defineOptions({
|
||||
name: 'LoginPage',
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const loginFormRef = ref(null)
|
||||
const loading = ref(false)
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
// Login form data
|
||||
const loginForm = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
rememberMe: false,
|
||||
})
|
||||
|
||||
// Form validation rules
|
||||
const loginRules = {
|
||||
username: [
|
||||
{ required: true, message: '请输入用户名或邮箱' },
|
||||
{ min: 3, max: 50, message: '长度在 3 到 50 个字符' },
|
||||
],
|
||||
password: [
|
||||
{ required: true, message: '请输入密码' },
|
||||
{ min: 6, message: '密码长度不能少于 6 位' },
|
||||
],
|
||||
}
|
||||
|
||||
// Handle login
|
||||
const handleLogin = async () => {
|
||||
if (!loginFormRef.value) return
|
||||
|
||||
try {
|
||||
// Validate form
|
||||
await loginFormRef.value.validate()
|
||||
loading.value = true
|
||||
|
||||
// 1. Call login API
|
||||
const loginResponse = await auth.login.post({
|
||||
username: loginForm.username,
|
||||
password: loginForm.password,
|
||||
})
|
||||
|
||||
// Check if login was successful
|
||||
if (loginResponse.code !== 200) {
|
||||
throw new Error(loginResponse.message || '登录失败')
|
||||
}
|
||||
|
||||
const loginData = loginResponse.data
|
||||
|
||||
// 2. Store token persistently (接口返回的是 token 字段)
|
||||
if (loginData.token) {
|
||||
userStore.setToken(loginData.token)
|
||||
}
|
||||
|
||||
// Store user information if available (登录接口已返回用户信息)
|
||||
if (loginData.user) {
|
||||
userStore.setUserInfo(loginData.user)
|
||||
}
|
||||
|
||||
// 3. Store menu and permissions from login response
|
||||
// 根据接口文档,登录接口已返回 menu 和 permissions 数据
|
||||
if (loginData.menu) {
|
||||
userStore.setMenu(loginData.menu)
|
||||
}
|
||||
|
||||
if (loginData.permissions) {
|
||||
userStore.setPermissions(loginData.permissions)
|
||||
}
|
||||
|
||||
// Success message
|
||||
message.success('登录成功!')
|
||||
|
||||
// Redirect to dashboard or redirect parameter
|
||||
setTimeout(() => {
|
||||
// Get redirect from query parameter
|
||||
const redirect = route.query.redirect
|
||||
|
||||
if (redirect) {
|
||||
// If there's a redirect parameter, go there
|
||||
router.push(redirect)
|
||||
} else {
|
||||
// Otherwise, go to configured dashboard URL
|
||||
router.push(config.DASHBOARD_URL)
|
||||
}
|
||||
}, 500)
|
||||
} catch (error) {
|
||||
// Clear user data on login failure
|
||||
userStore.logout()
|
||||
|
||||
// Show error message
|
||||
const errorMsg = error.response?.data?.msg || error.msg || error.message || '登录失败,请检查用户名和密码'
|
||||
message.error(errorMsg)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
164
resources/admin/src/pages/login/resetPassword.vue
Normal file
164
resources/admin/src/pages/login/resetPassword.vue
Normal file
@@ -0,0 +1,164 @@
|
||||
<template>
|
||||
<div class="auth-container">
|
||||
<div class="tech-decoration">
|
||||
<div class="tech-circle"></div>
|
||||
<div class="tech-circle"></div>
|
||||
<div class="tech-circle"></div>
|
||||
</div>
|
||||
|
||||
<div class="auth-card">
|
||||
<div class="auth-header">
|
||||
<h1 class="auth-title">找回密码</h1>
|
||||
<p class="auth-subtitle">输入您的邮箱,我们将发送重置密码链接</p>
|
||||
</div>
|
||||
|
||||
<a-form ref="forgotFormRef" :model="forgotForm" :rules="forgotRules" class="auth-form" @finish="handleSubmit" layout="vertical">
|
||||
<a-form-item name="email">
|
||||
<a-input v-model:value="forgotForm.email" placeholder="请输入注册邮箱" size="large" @pressEnter="handleSubmit">
|
||||
<template #prefix>
|
||||
<MailOutlined />
|
||||
</template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item name="captcha" v-if="showCaptcha">
|
||||
<div style="display: flex; gap: 12px">
|
||||
<a-input v-model:value="forgotForm.captcha" placeholder="请输入验证码" size="large" style="flex: 1">
|
||||
<template #prefix>
|
||||
<SafetyOutlined />
|
||||
</template>
|
||||
</a-input>
|
||||
<a-button type="default" size="large" :disabled="captchaDisabled" @click="sendCaptcha">
|
||||
{{ captchaButtonText }}
|
||||
</a-button>
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<a-button type="primary" :loading="loading" size="large" html-type="submit" block>
|
||||
{{ loading ? '提交中...' : '发送重置链接' }}
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
<div class="auth-footer">
|
||||
<p class="auth-footer-text">
|
||||
想起密码了?
|
||||
<router-link to="/login" class="auth-link"> 返回登录 </router-link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { MailOutlined, SafetyOutlined } from '@ant-design/icons-vue'
|
||||
import '@/assets/style/auth.scss'
|
||||
|
||||
defineOptions({
|
||||
name: 'ResetPasswordPage',
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
const forgotFormRef = ref(null)
|
||||
const loading = ref(false)
|
||||
const showCaptcha = ref(false)
|
||||
const captchaDisabled = ref(false)
|
||||
const countdown = ref(60)
|
||||
|
||||
// Forgot password form data
|
||||
const forgotForm = reactive({
|
||||
email: '',
|
||||
captcha: '',
|
||||
})
|
||||
|
||||
// Captcha button text
|
||||
const captchaButtonText = ref('获取验证码')
|
||||
|
||||
// Form validation rules
|
||||
const forgotRules = {
|
||||
email: [
|
||||
{ required: true, message: '请输入邮箱地址' },
|
||||
{ type: 'email', message: '请输入正确的邮箱地址' },
|
||||
],
|
||||
captcha: [
|
||||
{ required: true, message: '请输入验证码' },
|
||||
{ len: 6, message: '验证码为6位数字' },
|
||||
],
|
||||
}
|
||||
|
||||
// Send captcha code
|
||||
const sendCaptcha = async () => {
|
||||
if (!forgotForm.email) {
|
||||
message.warning('请先输入邮箱地址')
|
||||
return
|
||||
}
|
||||
|
||||
// Validate email format
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
if (!emailRegex.test(forgotForm.email)) {
|
||||
message.warning('请输入正确的邮箱地址')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Simulate API call - Replace with actual API call
|
||||
// Example: const response = await sendCaptchaApi(forgotForm.email)
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
|
||||
message.success('验证码已发送至您的邮箱')
|
||||
|
||||
// Start countdown
|
||||
captchaDisabled.value = true
|
||||
const timer = setInterval(() => {
|
||||
countdown.value--
|
||||
captchaButtonText.value = `${countdown.value}秒后重试`
|
||||
|
||||
if (countdown.value <= 0) {
|
||||
clearInterval(timer)
|
||||
captchaDisabled.value = false
|
||||
captchaButtonText.value = '获取验证码'
|
||||
countdown.value = 60
|
||||
}
|
||||
}, 1000)
|
||||
|
||||
showCaptcha.value = true
|
||||
} catch (error) {
|
||||
console.error('Send captcha failed:', error)
|
||||
message.error('发送验证码失败,请稍后重试')
|
||||
}
|
||||
}
|
||||
|
||||
// Handle submit
|
||||
const handleSubmit = async () => {
|
||||
if (!forgotFormRef.value) return
|
||||
|
||||
try {
|
||||
await forgotFormRef.value.validate()
|
||||
loading.value = true
|
||||
|
||||
// Simulate API call - Replace with actual API call
|
||||
// Example: const response = await forgotPasswordApi(forgotForm)
|
||||
|
||||
// Simulated delay
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500))
|
||||
|
||||
// Success message
|
||||
message.success('密码重置链接已发送至您的邮箱,请注意查收')
|
||||
|
||||
// Redirect to login page
|
||||
setTimeout(() => {
|
||||
router.push('/login')
|
||||
}, 2000)
|
||||
} catch (error) {
|
||||
console.error('Forgot password failed:', error)
|
||||
message.error('提交失败,请检查邮箱地址和验证码')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
161
resources/admin/src/pages/login/userRegister.vue
Normal file
161
resources/admin/src/pages/login/userRegister.vue
Normal file
@@ -0,0 +1,161 @@
|
||||
<template>
|
||||
<div class="auth-container">
|
||||
<div class="tech-decoration">
|
||||
<div class="tech-circle"></div>
|
||||
<div class="tech-circle"></div>
|
||||
<div class="tech-circle"></div>
|
||||
</div>
|
||||
|
||||
<div class="auth-card">
|
||||
<div class="auth-header">
|
||||
<h1 class="auth-title">创建账户</h1>
|
||||
<p class="auth-subtitle">加入我们,开启科技之旅</p>
|
||||
</div>
|
||||
|
||||
<a-form ref="registerFormRef" :model="registerForm" :rules="registerRules" class="auth-form" @finish="handleRegister" layout="vertical">
|
||||
<a-form-item name="username">
|
||||
<a-input v-model:value="registerForm.username" placeholder="请输入用户名" size="large">
|
||||
<template #prefix>
|
||||
<UserOutlined />
|
||||
</template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item name="email">
|
||||
<a-input v-model:value="registerForm.email" placeholder="请输入邮箱地址" size="large">
|
||||
<template #prefix>
|
||||
<MailOutlined />
|
||||
</template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item name="password">
|
||||
<a-input-password v-model:value="registerForm.password" placeholder="请输入密码(至少6位)" size="large">
|
||||
<template #prefix>
|
||||
<LockOutlined />
|
||||
</template>
|
||||
</a-input-password>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item name="confirmPassword">
|
||||
<a-input-password v-model:value="registerForm.confirmPassword" placeholder="请再次输入密码" size="large" @pressEnter="handleRegister">
|
||||
<template #prefix>
|
||||
<LockOutlined />
|
||||
</template>
|
||||
</a-input-password>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item name="agreeTerms">
|
||||
<a-checkbox v-model:checked="registerForm.agreeTerms" class="remember-me">
|
||||
我已阅读并同意
|
||||
<a href="#" class="auth-link">服务条款</a>
|
||||
和
|
||||
<a href="#" class="auth-link">隐私政策</a>
|
||||
</a-checkbox>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<a-button type="primary" :loading="loading" size="large" html-type="submit" block>
|
||||
{{ loading ? '注册中...' : '注册' }}
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
<div class="auth-footer">
|
||||
<p class="auth-footer-text">
|
||||
已有账户?
|
||||
<router-link to="/login" class="auth-link"> 立即登录 </router-link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { UserOutlined, MailOutlined, LockOutlined } from '@ant-design/icons-vue'
|
||||
import '@/assets/style/auth.scss'
|
||||
|
||||
defineOptions({
|
||||
name: 'RegisterPage',
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
const registerFormRef = ref(null)
|
||||
const loading = ref(false)
|
||||
|
||||
// Register form data
|
||||
const registerForm = reactive({
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
agreeTerms: false,
|
||||
})
|
||||
|
||||
// Form validation rules
|
||||
const registerRules = {
|
||||
username: [
|
||||
{ required: true, message: '请输入用户名' },
|
||||
{ min: 3, max: 20, message: '长度在 3 到 20 个字符' },
|
||||
],
|
||||
email: [
|
||||
{ required: true, message: '请输入邮箱地址' },
|
||||
{ type: 'email', message: '请输入正确的邮箱地址' },
|
||||
],
|
||||
password: [
|
||||
{ required: true, message: '请输入密码' },
|
||||
{ min: 6, message: '密码长度不能少于 6 位' },
|
||||
],
|
||||
confirmPassword: [
|
||||
{ required: true, message: '请再次输入密码' },
|
||||
{
|
||||
validator: (rule, value) => {
|
||||
if (value !== registerForm.password) {
|
||||
return Promise.reject('两次输入的密码不一致')
|
||||
}
|
||||
return Promise.resolve()
|
||||
},
|
||||
},
|
||||
],
|
||||
agreeTerms: [
|
||||
{
|
||||
type: 'enum',
|
||||
enum: [true],
|
||||
message: '请阅读并同意服务条款和隐私政策',
|
||||
transform: (value) => value || false,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
// Handle register
|
||||
const handleRegister = async () => {
|
||||
if (!registerFormRef.value) return
|
||||
|
||||
try {
|
||||
await registerFormRef.value.validate()
|
||||
loading.value = true
|
||||
|
||||
// Simulate API call - Replace with actual API call
|
||||
// Example: const response = await registerApi(registerForm)
|
||||
|
||||
// Simulated delay
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500))
|
||||
|
||||
// Success message
|
||||
message.success('注册成功!正在跳转到登录页面...')
|
||||
|
||||
// Redirect to login page
|
||||
setTimeout(() => {
|
||||
router.push('/login')
|
||||
}, 1500)
|
||||
} catch (error) {
|
||||
console.error('Register failed:', error)
|
||||
message.error('注册失败,请稍后重试')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
105
resources/admin/src/pages/ucenter/components/BasicInfo.vue
Normal file
105
resources/admin/src/pages/ucenter/components/BasicInfo.vue
Normal file
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<scForm :form-items="formItems" :initial-values="initialValues" :loading="loading" @finish="handleFinish"
|
||||
@reset="handleReset" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import scForm from '@/components/scForm/index.vue'
|
||||
import api from '@/api/auth'
|
||||
import { useUserStore } from '@/stores/modules/user'
|
||||
|
||||
const props = defineProps({
|
||||
userInfo: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update'])
|
||||
|
||||
const userStore = useUserStore()
|
||||
const loading = ref(false)
|
||||
|
||||
// 表单初始值
|
||||
const initialValues = computed(() => ({
|
||||
username: props.userInfo.username || '',
|
||||
nickname: props.userInfo.nickname || '',
|
||||
mobile: props.userInfo.mobile || '',
|
||||
email: props.userInfo.email || '',
|
||||
gender: props.userInfo.gender || 0,
|
||||
birthday: props.userInfo.birthday || null,
|
||||
bio: props.userInfo.bio || '',
|
||||
}))
|
||||
|
||||
// 表单项配置
|
||||
const formItems = [
|
||||
{ field: 'username', label: '用户名', type: 'input' },
|
||||
{
|
||||
field: 'nickname', label: '昵称', type: 'input', required: true,
|
||||
rules: [
|
||||
{ required: true, message: '请输入昵称', trigger: 'blur' },
|
||||
{ min: 2, max: 20, message: '昵称长度在 2 到 20 个字符', trigger: 'blur' },
|
||||
],
|
||||
},
|
||||
{
|
||||
field: 'phone', label: '手机号', type: 'input',
|
||||
rules: [{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' }],
|
||||
},
|
||||
{
|
||||
field: 'email', label: '邮箱', type: 'input',
|
||||
rules: [{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' }],
|
||||
},
|
||||
{
|
||||
field: 'gender', label: '性别', type: 'radio',
|
||||
options: [
|
||||
{ label: '男', value: 1 },
|
||||
{ label: '女', value: 2 },
|
||||
{ label: '保密', value: 0 },
|
||||
],
|
||||
},
|
||||
{ field: 'remark', label: '个人简介', type: 'textarea', rows: 4, maxLength: 200, showCount: true, },
|
||||
]
|
||||
|
||||
// 表单提交
|
||||
const handleFinish = async (values) => {
|
||||
try {
|
||||
loading.value = true
|
||||
|
||||
// 调用更新当前用户信息接口
|
||||
let res = await api.users.edit.post({
|
||||
username: values.username,
|
||||
nickname: values.nickname,
|
||||
mobile: values.mobile,
|
||||
email: values.email,
|
||||
gender: values.gender,
|
||||
remark: values.remark,
|
||||
})
|
||||
|
||||
if (!res || res.code !== 1) {
|
||||
throw new Error(res.message || '保存失败,请重试')
|
||||
}
|
||||
// 重新获取用户信息
|
||||
const response = await api.user.get()
|
||||
if (response && response.data) {
|
||||
userStore.setUserInfo(response.data)
|
||||
}
|
||||
|
||||
// 通知父组件更新
|
||||
emit('update', values)
|
||||
message.success('保存成功')
|
||||
} catch (error) {
|
||||
message.error(error.message || '保存失败,请重试')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
const handleReset = () => {
|
||||
message.info('已重置')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
81
resources/admin/src/pages/ucenter/components/Password.vue
Normal file
81
resources/admin/src/pages/ucenter/components/Password.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<scForm :form-items="formItems" :initial-values="initialValues" :loading="loading" submit-text="修改密码"
|
||||
@finish="handleFinish" @reset="handleReset" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import scForm from '@/components/scForm/index.vue'
|
||||
|
||||
const emit = defineEmits(['success'])
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
// 表单初始值
|
||||
const initialValues = {
|
||||
oldPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: '',
|
||||
}
|
||||
|
||||
// 表单项配置
|
||||
const formItems = [
|
||||
{
|
||||
field: 'oldPassword',
|
||||
label: '原密码',
|
||||
type: 'password',
|
||||
required: true,
|
||||
rules: [{ required: true, message: '请输入原密码', trigger: 'blur' }],
|
||||
},
|
||||
{
|
||||
field: 'newPassword',
|
||||
label: '新密码',
|
||||
type: 'password',
|
||||
required: true,
|
||||
rules: [
|
||||
{ required: true, message: '请输入新密码', trigger: 'blur' },
|
||||
{ min: 6, max: 20, message: '密码长度在 6 到 20 个字符', trigger: 'blur' },
|
||||
],
|
||||
},
|
||||
{
|
||||
field: 'confirmPassword',
|
||||
label: '确认密码',
|
||||
type: 'password',
|
||||
required: true,
|
||||
rules: [
|
||||
{ required: true, message: '请再次输入新密码', trigger: 'blur' },
|
||||
{
|
||||
validator: (rule, value) => {
|
||||
if (value !== initialValues.newPassword) {
|
||||
return Promise.reject('两次输入的密码不一致')
|
||||
}
|
||||
return Promise.resolve()
|
||||
},
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// 表单提交
|
||||
const handleFinish = (values) => {
|
||||
loading.value = true
|
||||
// 模拟接口请求
|
||||
setTimeout(() => {
|
||||
message.success('密码修改成功,请重新登录')
|
||||
emit('success')
|
||||
handleReset()
|
||||
loading.value = false
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
const handleReset = () => {
|
||||
initialValues.oldPassword = ''
|
||||
initialValues.newPassword = ''
|
||||
initialValues.confirmPassword = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
73
resources/admin/src/pages/ucenter/components/ProfileInfo.vue
Normal file
73
resources/admin/src/pages/ucenter/components/ProfileInfo.vue
Normal file
@@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<div class="profile-info">
|
||||
<div class="avatar-wrapper">
|
||||
<a-avatar :size="100" :src="userInfo.avatar" @click="handleAvatarClick">
|
||||
{{ userInfo.nickname?.charAt(0) }}
|
||||
</a-avatar>
|
||||
</div>
|
||||
<div class="user-name">{{ userInfo.nickname || userInfo.username }}</div>
|
||||
<a-tag :color="userInfo.status === 1 ? 'green' : 'red'">
|
||||
{{ userInfo.status === 1 ? '正常' : '禁用' }}
|
||||
</a-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
userInfo: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['avatar-click'])
|
||||
|
||||
const handleAvatarClick = () => {
|
||||
emit('avatar-click')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.profile-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 20px 0;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
color: #fff;
|
||||
|
||||
.avatar-wrapper {
|
||||
margin-bottom: 12px;
|
||||
cursor: pointer;
|
||||
|
||||
.ant-avatar {
|
||||
border: 3px solid rgba(255, 255, 255, 0.3);
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
font-size: 40px;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(255, 255, 255, 0.6);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.ant-tag {
|
||||
margin: 0;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
85
resources/admin/src/pages/ucenter/components/Security.vue
Normal file
85
resources/admin/src/pages/ucenter/components/Security.vue
Normal file
@@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<a-list :data-source="securityList" item-layout="horizontal">
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<a-list-item-meta>
|
||||
<template #title>
|
||||
{{ item.title }}
|
||||
</template>
|
||||
<template #description>
|
||||
{{ item.description }}
|
||||
</template>
|
||||
</a-list-item-meta>
|
||||
<template #actions>
|
||||
<a-button type="primary" size="small" @click="handleAction(item.action)">
|
||||
{{ item.buttonText }}
|
||||
</a-button>
|
||||
</template>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
|
||||
const emit = defineEmits(['change-password'])
|
||||
|
||||
const securityList = ref([
|
||||
{
|
||||
title: '登录密码',
|
||||
description: '用于登录系统的密码,建议定期更换',
|
||||
buttonText: '修改',
|
||||
action: 'password',
|
||||
},
|
||||
{
|
||||
title: '手机验证',
|
||||
description: '用于接收重要通知和安全验证',
|
||||
buttonText: '已绑定',
|
||||
action: 'phone',
|
||||
},
|
||||
{
|
||||
title: '邮箱验证',
|
||||
description: '用于接收重要通知和账号找回',
|
||||
buttonText: '已绑定',
|
||||
action: 'email',
|
||||
},
|
||||
{
|
||||
title: '登录设备',
|
||||
description: '查看和管理已登录的设备',
|
||||
buttonText: '查看',
|
||||
action: 'device',
|
||||
},
|
||||
])
|
||||
|
||||
const handleAction = (action) => {
|
||||
switch (action) {
|
||||
case 'password':
|
||||
emit('change-password')
|
||||
break
|
||||
case 'phone':
|
||||
message.info('手机绑定功能开发中')
|
||||
break
|
||||
case 'email':
|
||||
message.info('邮箱绑定功能开发中')
|
||||
break
|
||||
case 'device':
|
||||
message.info('登录设备管理功能开发中')
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
:deep(.ant-list-item) {
|
||||
padding: 20px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
:deep(.ant-list-item:last-child) {
|
||||
border-bottom: none;
|
||||
}
|
||||
</style>
|
||||
210
resources/admin/src/pages/ucenter/index.vue
Normal file
210
resources/admin/src/pages/ucenter/index.vue
Normal file
@@ -0,0 +1,210 @@
|
||||
<template>
|
||||
<div class="ucenter">
|
||||
<a-card>
|
||||
<a-row :gutter="24">
|
||||
<a-col :span="6">
|
||||
<ProfileInfo :user-info="userInfo" @avatar-click="showAvatarModal = true" />
|
||||
<a-menu v-model:selectedKeys="selectedKeys" mode="inline" class="menu">
|
||||
<a-menu-item key="basic">
|
||||
<UserOutlined />
|
||||
基本信息
|
||||
</a-menu-item>
|
||||
<a-menu-item key="password">
|
||||
<LockOutlined />
|
||||
修改密码
|
||||
</a-menu-item>
|
||||
<a-menu-item key="security">
|
||||
<SafetyOutlined />
|
||||
账号安全
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</a-col>
|
||||
<a-col :span="18">
|
||||
<div class="content-wrapper">
|
||||
<BasicInfo v-if="selectedKeys[0] === 'basic'" :user-info="userInfo"
|
||||
@update="handleUpdateUserInfo" />
|
||||
<Password v-else-if="selectedKeys[0] === 'password'" @success="handlePasswordSuccess" />
|
||||
<Security v-else-if="selectedKeys[0] === 'security'" @change-password="handleChangePassword" />
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-card>
|
||||
|
||||
<!-- 头像上传弹窗 -->
|
||||
<a-modal v-model:open="showAvatarModal" title="更换头像" :confirm-loading="loading" @ok="handleAvatarUpload"
|
||||
@cancel="showAvatarModal = false">
|
||||
<div class="avatar-upload">
|
||||
<a-upload list-type="picture-card" :max-count="1" :before-upload="beforeUpload"
|
||||
@change="handleAvatarChange" :file-list="avatarFileList">
|
||||
<div v-if="avatarFileList.length === 0">
|
||||
<PlusOutlined />
|
||||
<div class="ant-upload-text">上传头像</div>
|
||||
</div>
|
||||
</a-upload>
|
||||
<div class="upload-tip">
|
||||
<a-typography-text type="secondary"> 支持 JPG、PNG 格式,文件大小不超过 2MB </a-typography-text>
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { PlusOutlined, UserOutlined, LockOutlined, SafetyOutlined } from '@ant-design/icons-vue'
|
||||
import dayjs from 'dayjs'
|
||||
import ProfileInfo from './components/ProfileInfo.vue'
|
||||
import BasicInfo from './components/BasicInfo.vue'
|
||||
import Password from './components/Password.vue'
|
||||
import Security from './components/Security.vue'
|
||||
import { useUserStore } from '@/stores/modules/user'
|
||||
import api from '@/api/auth'
|
||||
|
||||
defineOptions({
|
||||
name: 'UserCenter',
|
||||
})
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 用户信息
|
||||
const userInfo = ref({})
|
||||
|
||||
// 选中的菜单
|
||||
const selectedKeys = ref(['basic'])
|
||||
|
||||
// 头像上传
|
||||
const showAvatarModal = ref(false)
|
||||
const avatarFileList = ref([])
|
||||
const loading = ref(false)
|
||||
|
||||
// 初始化用户信息
|
||||
const initUserInfo = async () => {
|
||||
try {
|
||||
// 从 store 获取用户信息
|
||||
const storeUserInfo = userStore.userInfo
|
||||
if (storeUserInfo) {
|
||||
userInfo.value = storeUserInfo
|
||||
} else {
|
||||
// 如果 store 中没有用户信息,则从接口获取
|
||||
const response = await api.user.get()
|
||||
if (response && response.data) {
|
||||
userStore.setUserInfo(response.data)
|
||||
userInfo.value = response.data
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
message.error(err.message || '获取用户信息失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 更新用户信息
|
||||
const handleUpdateUserInfo = (data) => {
|
||||
// 更新本地用户信息
|
||||
Object.assign(userInfo.value, data)
|
||||
|
||||
// 如果 birthday 有值,转换为 dayjs 对象
|
||||
if (data.birthday && typeof data.birthday === 'string') {
|
||||
userInfo.value.birthday = dayjs(data.birthday)
|
||||
}
|
||||
}
|
||||
|
||||
// 密码修改成功
|
||||
const handlePasswordSuccess = () => {
|
||||
// 密码修改成功后的处理
|
||||
}
|
||||
|
||||
// 切换到密码修改页面
|
||||
const handleChangePassword = () => {
|
||||
selectedKeys.value = ['password']
|
||||
}
|
||||
|
||||
// 头像上传前校验
|
||||
const beforeUpload = (file) => {
|
||||
const isJpgOrPng = file.type === 'image/jpeg' || file.type === 'image/png'
|
||||
if (!isJpgOrPng) {
|
||||
message.error('只能上传 JPG/PNG 格式的文件!')
|
||||
return false
|
||||
}
|
||||
const isLt2M = file.size / 1024 / 1024 < 2
|
||||
if (!isLt2M) {
|
||||
message.error('图片大小不能超过 2MB!')
|
||||
return false
|
||||
}
|
||||
return false // 阻止自动上传
|
||||
}
|
||||
|
||||
// 头像文件变化
|
||||
const handleAvatarChange = ({ fileList }) => {
|
||||
avatarFileList.value = fileList
|
||||
}
|
||||
|
||||
// 上传头像
|
||||
const handleAvatarUpload = () => {
|
||||
if (avatarFileList.value.length === 0) {
|
||||
message.warning('请先选择头像')
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
// 模拟上传
|
||||
setTimeout(() => {
|
||||
const file = avatarFileList.value[0]
|
||||
userInfo.value.avatar = URL.createObjectURL(file.originFileObj)
|
||||
message.success('头像更新成功')
|
||||
showAvatarModal.value = false
|
||||
avatarFileList.value = []
|
||||
loading.value = false
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
initUserInfo()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.ucenter {
|
||||
padding: 16px;
|
||||
.content-wrapper {
|
||||
padding: 24px;
|
||||
background: #fafafa;
|
||||
border-radius: 8px;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.menu {
|
||||
margin-top: 16px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
|
||||
.ant-menu-item {
|
||||
border-radius: 6px;
|
||||
margin: 4px 0;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
&.ant-menu-item-selected {
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
|
||||
&::after {
|
||||
border-right-width: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.avatar-upload {
|
||||
.upload-tip {
|
||||
margin-top: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.ant-card-head-title) {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user