first commit

This commit is contained in:
2026-01-10 10:04:08 +08:00
commit 1cc427cbb0
291 changed files with 51036 additions and 0 deletions

View File

@@ -0,0 +1,62 @@
<template>
<div class="flex w-full h-screen">
<LoginLeftView />
<div class="relative flex-1">
<AuthTopBar />
<div class="auth-right-wrap">
<div class="form">
<h3 class="title">{{ $t('forgetPassword.title') }}</h3>
<p class="sub-title">{{ $t('forgetPassword.subTitle') }}</p>
<div class="mt-5">
<span class="input-label" v-if="showInputLabel">账号</span>
<ElInput
class="custom-height"
:placeholder="$t('forgetPassword.placeholder')"
v-model.trim="username"
/>
</div>
<div style="margin-top: 15px">
<ElButton
class="w-full custom-height"
type="primary"
@click="register"
:loading="loading"
v-ripple
>
{{ $t('forgetPassword.submitBtnText') }}
</ElButton>
</div>
<div style="margin-top: 15px">
<ElButton class="w-full custom-height" plain @click="toLogin">
{{ $t('forgetPassword.backBtnText') }}
</ElButton>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
defineOptions({ name: 'ForgetPassword' })
const router = useRouter()
const showInputLabel = ref(false)
const username = ref('')
const loading = ref(false)
const register = async () => {}
const toLogin = () => {
router.push({ name: 'Login' })
}
</script>
<style scoped>
@import '../login/style.css';
</style>

View File

@@ -0,0 +1,284 @@
<!-- 登录页面 -->
<template>
<div class="flex w-full h-screen">
<LoginLeftView />
<div class="relative flex-1">
<AuthTopBar />
<div class="auth-right-wrap">
<div class="form">
<h3 class="title">{{ $t('login.title') }}</h3>
<p class="sub-title">{{ $t('login.subTitle') }}</p>
<ElForm
ref="formRef"
:model="formData"
:rules="rules"
:key="formKey"
@keyup.enter="handleSubmit"
style="margin-top: 25px"
>
<ElFormItem prop="account">
<ElSelect v-model="formData.account" @change="setupAccount">
<ElOption
v-for="account in accounts"
:key="account.key"
:label="account.label"
:value="account.key"
>
<span>{{ account.label }}</span>
</ElOption>
</ElSelect>
</ElFormItem>
<ElFormItem prop="username">
<ElInput
class="custom-height"
:placeholder="$t('login.placeholder.username')"
v-model.trim="formData.username"
/>
</ElFormItem>
<ElFormItem prop="password">
<ElInput
class="custom-height"
:placeholder="$t('login.placeholder.password')"
v-model.trim="formData.password"
type="password"
autocomplete="off"
show-password
/>
</ElFormItem>
<!-- 推拽验证 -->
<div class="relative pb-5 mt-6">
<div
class="relative z-[2] overflow-hidden select-none rounded-lg border border-transparent tad-300"
:class="{ '!border-[#FF4E4F]': !isPassing && isClickPass }"
>
<ArtDragVerify
ref="dragVerify"
v-model:value="isPassing"
:text="$t('login.sliderText')"
textColor="var(--art-gray-700)"
:successText="$t('login.sliderSuccessText')"
progressBarBg="var(--main-color)"
:background="isDark ? '#26272F' : '#F1F1F4'"
handlerBg="var(--default-box-color)"
/>
</div>
<p
class="absolute top-0 z-[1] px-px mt-2 text-xs text-[#f56c6c] tad-300"
:class="{ 'translate-y-10': !isPassing && isClickPass }"
>
{{ $t('login.placeholder.slider') }}
</p>
</div>
<div class="flex-cb mt-2 text-sm">
<ElCheckbox v-model="formData.rememberPassword">{{
$t('login.rememberPwd')
}}</ElCheckbox>
<RouterLink class="text-theme" :to="{ name: 'ForgetPassword' }">{{
$t('login.forgetPwd')
}}</RouterLink>
</div>
<div style="margin-top: 30px">
<ElButton
class="w-full custom-height"
type="primary"
@click="handleSubmit"
:loading="loading"
v-ripple
>
{{ $t('login.btnText') }}
</ElButton>
</div>
<div class="mt-5 text-sm text-gray-600">
<span>{{ $t('login.noAccount') }}</span>
<RouterLink class="text-theme" :to="{ name: 'Register' }">{{
$t('login.register')
}}</RouterLink>
</div>
</ElForm>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import AppConfig from '@/config'
import { useUserStore } from '@/store/modules/user'
import { useI18n } from 'vue-i18n'
import { HttpError } from '@/utils/http/error'
import { fetchLogin } from '@/api/auth'
import { ElNotification, type FormInstance, type FormRules } from 'element-plus'
import { useSettingStore } from '@/store/modules/setting'
defineOptions({ name: 'Login' })
const settingStore = useSettingStore()
const { isDark } = storeToRefs(settingStore)
const { t, locale } = useI18n()
const formKey = ref(0)
// 监听语言切换,重置表单
watch(locale, () => {
formKey.value++
})
type AccountKey = 'super' | 'admin' | 'user'
export interface Account {
key: AccountKey
label: string
userName: string
password: string
roles: string[]
}
const accounts = computed<Account[]>(() => [
{
key: 'super',
label: t('login.roles.super'),
userName: 'Super',
password: '123456',
roles: ['R_SUPER']
},
{
key: 'admin',
label: t('login.roles.admin'),
userName: 'Admin',
password: '123456',
roles: ['R_ADMIN']
},
{
key: 'user',
label: t('login.roles.user'),
userName: 'User',
password: '123456',
roles: ['R_USER']
}
])
const dragVerify = ref()
const userStore = useUserStore()
const router = useRouter()
const route = useRoute()
const isPassing = ref(false)
const isClickPass = ref(false)
const systemName = AppConfig.systemInfo.name
const formRef = ref<FormInstance>()
const formData = reactive({
account: '',
username: '',
password: '',
rememberPassword: true
})
const rules = computed<FormRules>(() => ({
username: [{ required: true, message: t('login.placeholder.username'), trigger: 'blur' }],
password: [{ required: true, message: t('login.placeholder.password'), trigger: 'blur' }]
}))
const loading = ref(false)
onMounted(() => {
setupAccount('super')
})
// 设置账号
const setupAccount = (key: AccountKey) => {
const selectedAccount = accounts.value.find((account: Account) => account.key === key)
formData.account = key
formData.username = selectedAccount?.userName ?? ''
formData.password = selectedAccount?.password ?? ''
}
// 登录
const handleSubmit = async () => {
if (!formRef.value) return
try {
// 表单验证
const valid = await formRef.value.validate()
if (!valid) return
// 拖拽验证
if (!isPassing.value) {
isClickPass.value = true
return
}
loading.value = true
// 登录请求
const { username, password } = formData
const { token, refreshToken } = await fetchLogin({
userName: username,
password
})
// 验证token
if (!token) {
throw new Error('Login failed - no token received')
}
// 存储 token 和登录状态
userStore.setToken(token, refreshToken)
userStore.setLoginStatus(true)
// 登录成功处理
showLoginSuccessNotice()
// 获取 redirect 参数,如果存在则跳转到指定页面,否则跳转到首页
const redirect = route.query.redirect as string
router.push(redirect || '/')
} catch (error) {
// 处理 HttpError
if (error instanceof HttpError) {
// console.log(error.code)
} else {
// 处理非 HttpError
// ElMessage.error('登录失败,请稍后重试')
console.error('[Login] Unexpected error:', error)
}
} finally {
loading.value = false
resetDragVerify()
}
}
// 重置拖拽验证
const resetDragVerify = () => {
dragVerify.value.reset()
}
// 登录成功提示
const showLoginSuccessNotice = () => {
setTimeout(() => {
ElNotification({
title: t('login.success.title'),
type: 'success',
duration: 2500,
zIndex: 10000,
message: `${t('login.success.message')}, ${systemName}!`
})
}, 1000)
}
</script>
<style scoped>
@import './style.css';
</style>
<style lang="scss" scoped>
:deep(.el-select__wrapper) {
height: 40px !important;
}
</style>

View File

@@ -0,0 +1,38 @@
@reference '@styles/core/tailwind.css';
/* 授权页右侧区域 */
.auth-right-wrap {
@apply absolute inset-0 w-[440px] h-[650px] py-[5px] m-auto overflow-hidden
max-sm:px-7 max-sm:w-full
animate-[slideInRight_0.6s_cubic-bezier(0.25,0.46,0.45,0.94)_forwards]
max-md:animate-none;
.form {
@apply h-full py-[40px];
}
.title {
@apply text-g-900 text-4xl font-semibold max-md:text-3xl max-sm:pt-10;
}
.sub-title {
@apply mt-[10px] text-g-600 text-sm;
}
.custom-height {
@apply !h-[40px];
}
}
/* 滑入动画 */
@keyframes slideInRight {
from {
opacity: 0;
transform: translateX(30px);
}
to {
opacity: 1;
transform: translateX(0);
}
}

View File

@@ -0,0 +1,240 @@
<!-- 注册页面 -->
<template>
<div class="flex w-full h-screen">
<LoginLeftView />
<div class="relative flex-1">
<AuthTopBar />
<div class="auth-right-wrap">
<div class="form">
<h3 class="title">{{ $t('register.title') }}</h3>
<p class="sub-title">{{ $t('register.subTitle') }}</p>
<ElForm
class="mt-7.5"
ref="formRef"
:model="formData"
:rules="rules"
label-position="top"
:key="formKey"
>
<ElFormItem prop="username">
<ElInput
class="custom-height"
v-model.trim="formData.username"
:placeholder="$t('register.placeholder.username')"
/>
</ElFormItem>
<ElFormItem prop="password">
<ElInput
class="custom-height"
v-model.trim="formData.password"
:placeholder="$t('register.placeholder.password')"
type="password"
autocomplete="off"
show-password
/>
</ElFormItem>
<ElFormItem prop="confirmPassword">
<ElInput
class="custom-height"
v-model.trim="formData.confirmPassword"
:placeholder="$t('register.placeholder.confirmPassword')"
type="password"
autocomplete="off"
@keyup.enter="register"
show-password
/>
</ElFormItem>
<ElFormItem prop="agreement">
<ElCheckbox v-model="formData.agreement">
{{ $t('register.agreeText') }}
<RouterLink
style="color: var(--theme-color); text-decoration: none"
to="/privacy-policy"
>{{ $t('register.privacyPolicy') }}</RouterLink
>
</ElCheckbox>
</ElFormItem>
<div style="margin-top: 15px">
<ElButton
class="w-full custom-height"
type="primary"
@click="register"
:loading="loading"
v-ripple
>
{{ $t('register.submitBtnText') }}
</ElButton>
</div>
<div class="mt-5 text-sm text-g-600">
<span>{{ $t('register.hasAccount') }}</span>
<RouterLink class="text-theme" :to="{ name: 'Login' }">{{
$t('register.toLogin')
}}</RouterLink>
</div>
</ElForm>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import type { FormInstance, FormRules } from 'element-plus'
defineOptions({ name: 'Register' })
interface RegisterForm {
username: string
password: string
confirmPassword: string
agreement: boolean
}
const USERNAME_MIN_LENGTH = 3
const USERNAME_MAX_LENGTH = 20
const PASSWORD_MIN_LENGTH = 6
const REDIRECT_DELAY = 1000
const { t, locale } = useI18n()
const router = useRouter()
const formRef = ref<FormInstance>()
const loading = ref(false)
const formKey = ref(0)
// 监听语言切换,重置表单
watch(locale, () => {
formKey.value++
})
const formData = reactive<RegisterForm>({
username: '',
password: '',
confirmPassword: '',
agreement: false
})
/**
* 验证密码
* 当密码输入后,如果确认密码已填写,则触发确认密码的验证
*/
const validatePassword = (_rule: any, value: string, callback: (error?: Error) => void) => {
if (!value) {
callback(new Error(t('register.placeholder.password')))
return
}
if (formData.confirmPassword) {
formRef.value?.validateField('confirmPassword')
}
callback()
}
/**
* 验证确认密码
* 检查确认密码是否与密码一致
*/
const validateConfirmPassword = (
_rule: any,
value: string,
callback: (error?: Error) => void
) => {
if (!value) {
callback(new Error(t('register.rule.confirmPasswordRequired')))
return
}
if (value !== formData.password) {
callback(new Error(t('register.rule.passwordMismatch')))
return
}
callback()
}
/**
* 验证用户协议
* 确保用户已勾选同意协议
*/
const validateAgreement = (_rule: any, value: boolean, callback: (error?: Error) => void) => {
if (!value) {
callback(new Error(t('register.rule.agreementRequired')))
return
}
callback()
}
const rules = computed<FormRules<RegisterForm>>(() => ({
username: [
{ required: true, message: t('register.placeholder.username'), trigger: 'blur' },
{
min: USERNAME_MIN_LENGTH,
max: USERNAME_MAX_LENGTH,
message: t('register.rule.usernameLength'),
trigger: 'blur'
}
],
password: [
{ required: true, validator: validatePassword, trigger: 'blur' },
{ min: PASSWORD_MIN_LENGTH, message: t('register.rule.passwordLength'), trigger: 'blur' }
],
confirmPassword: [{ required: true, validator: validateConfirmPassword, trigger: 'blur' }],
agreement: [{ validator: validateAgreement, trigger: 'change' }]
}))
/**
* 注册用户
* 验证表单后提交注册请求
*/
const register = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
loading.value = true
// TODO: 替换为真实 API 调用
// const params = {
// username: formData.username,
// password: formData.password
// }
// const res = await AuthService.register(params)
// if (res.code === ApiStatus.success) {
// ElMessage.success('注册成功')
// toLogin()
// }
// 模拟注册请求
setTimeout(() => {
loading.value = false
ElMessage.success('注册成功')
toLogin()
}, REDIRECT_DELAY)
} catch (error) {
console.error('表单验证失败:', error)
loading.value = false
}
}
/**
* 跳转到登录页面
*/
const toLogin = () => {
setTimeout(() => {
router.push({ name: 'Login' })
}, REDIRECT_DELAY)
}
</script>
<style scoped>
@import '../login/style.css';
</style>

View File

@@ -0,0 +1,41 @@
<!-- 工作台页面 -->
<template>
<div>
<CardList></CardList>
<ElRow :gutter="20">
<ElCol :sm="24" :md="12" :lg="10">
<ActiveUser />
</ElCol>
<ElCol :sm="24" :md="12" :lg="14">
<SalesOverview />
</ElCol>
</ElRow>
<ElRow :gutter="20">
<ElCol :sm="24" :md="24" :lg="12">
<NewUser />
</ElCol>
<ElCol :sm="24" :md="12" :lg="6">
<Dynamic />
</ElCol>
<ElCol :sm="24" :md="12" :lg="6">
<TodoList />
</ElCol>
</ElRow>
<AboutProject />
</div>
</template>
<script setup lang="ts">
import CardList from './modules/card-list.vue'
import ActiveUser from './modules/active-user.vue'
import SalesOverview from './modules/sales-overview.vue'
import NewUser from './modules/new-user.vue'
import Dynamic from './modules/dynamic-stats.vue'
import TodoList from './modules/todo-list.vue'
import AboutProject from './modules/about-project.vue'
defineOptions({ name: 'Console' })
</script>

View File

@@ -0,0 +1,44 @@
<template>
<div class="art-card p-5 flex-b mb-5 max-sm:mb-4">
<div>
<h2 class="text-2xl font-medium">关于项目</h2>
<p class="text-g-700 mt-1">{{ systemName }} 是一款兼具设计美学与高效开发的后台系统</p>
<p class="text-g-700 mt-1">使用了 Vue3TypeScriptViteElement Plus 等前沿技术</p>
<div class="flex flex-wrap gap-3.5 max-w-150 mt-9">
<div
class="w-60 flex-cb h-12.5 px-3.5 border border-g-300 c-p rounded-lg text-sm bg-g-100 duration-300 hover:-translate-y-1 max-sm:w-full"
v-for="link in linkList"
:key="link.label"
@click="goPage(link.url)"
>
<span class="text-g-700">{{ link.label }}</span>
<ArtSvgIcon icon="ri:arrow-right-s-line" class="text-lg text-g-600" />
</div>
</div>
</div>
<img class="w-75 max-md:!hidden" src="@imgs/draw/draw1.png" alt="draw1" />
</div>
</template>
<script setup lang="ts">
import AppConfig from '@/config'
import { WEB_LINKS } from '@/utils/constants'
const systemName = AppConfig.systemInfo.name
const linkList = [
{ label: '项目官网', url: WEB_LINKS.DOCS },
{ label: '文档', url: WEB_LINKS.INTRODUCE },
{ label: 'Github', url: WEB_LINKS.GITHUB_HOME },
{ label: '哔哩哔哩', url: WEB_LINKS.BILIBILI }
]
/**
* 在新标签页中打开指定 URL
* @param url 要打开的网页地址
*/
const goPage = (url: string): void => {
window.open(url, '_blank', 'noopener,noreferrer')
}
</script>

View File

@@ -0,0 +1,47 @@
<template>
<div class="art-card h-105 p-4 box-border mb-5 max-sm:mb-4">
<ArtBarChart
class="box-border p-2"
barWidth="50%"
height="13.7rem"
:showAxisLine="false"
:data="chartData"
:xAxisData="xAxisLabels"
/>
<div class="ml-1">
<h3 class="mt-5 text-lg font-medium">用户概述</h3>
<p class="mt-1 text-sm">比上周 <span class="text-success font-medium">+23%</span></p>
<p class="mt-1 text-sm">我们为您创建了多个选项可将它们组合在一起并定制为像素完美的页面</p>
</div>
<div class="flex-b mt-2">
<div class="flex-1" v-for="(item, index) in list" :key="index">
<p class="text-2xl text-g-900">{{ item.num }}</p>
<p class="text-xs text-g-500">{{ item.name }}</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
interface UserStatItem {
name: string
num: string
}
// 最近9个月
const xAxisLabels = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月']
// 每月活跃用户数
const chartData = [160, 100, 150, 80, 190, 100, 175, 120, 160]
/**
* 用户统计数据列表
* 包含总用户量、总访问量、日访问量和周同比等关键指标
*/
const list: UserStatItem[] = [
{ name: '总用户量', num: '32k' },
{ name: '总访问量', num: '128k' },
{ name: '日访问量', num: '1.2k' },
{ name: '周同比', num: '+5%' }
]
</script>

View File

@@ -0,0 +1,74 @@
<template>
<ElRow :gutter="20" class="flex">
<ElCol v-for="(item, index) in dataList" :key="index" :sm="12" :md="6" :lg="6">
<div class="art-card relative flex flex-col justify-center h-35 px-5 mb-5 max-sm:mb-4">
<span class="text-g-700 text-sm">{{ item.des }}</span>
<ArtCountTo class="text-[26px] font-medium mt-2" :target="item.num" :duration="1300" />
<div class="flex-c mt-1">
<span class="text-xs text-g-600">较上周</span>
<span
class="ml-1 text-xs font-semibold"
:class="[item.change.indexOf('+') === -1 ? 'text-danger' : 'text-success']"
>
{{ item.change }}
</span>
</div>
<div
class="absolute top-0 bottom-0 right-5 m-auto size-12.5 rounded-xl flex-cc bg-theme/10"
>
<ArtSvgIcon :icon="item.icon" class="text-xl text-theme" />
</div>
</div>
</ElCol>
</ElRow>
</template>
<script setup lang="ts">
interface CardDataItem {
des: string
icon: string
startVal: number
duration: number
num: number
change: string
}
/**
* 卡片统计数据列表
* 展示总访问次数、在线访客数、点击量和新用户等核心数据指标
*/
const dataList = reactive<CardDataItem[]>([
{
des: '总访问次数',
icon: 'ri:pie-chart-line',
startVal: 0,
duration: 1000,
num: 9120,
change: '+20%'
},
{
des: '在线访客数',
icon: 'ri:group-line',
startVal: 0,
duration: 1000,
num: 182,
change: '+10%'
},
{
des: '点击量',
icon: 'ri:fire-line',
startVal: 0,
duration: 1000,
num: 9520,
change: '-12%'
},
{
des: '新用户',
icon: 'ri:progress-2-line',
startVal: 0,
duration: 1000,
num: 156,
change: '+30%'
}
])
</script>

View File

@@ -0,0 +1,79 @@
<template>
<div class="art-card h-128 p-5 mb-5 max-sm:mb-4">
<div class="art-card-header">
<div class="title">
<h4>动态</h4>
<p>新增<span class="text-success">+6</span></p>
</div>
</div>
<div class="h-9/10 mt-2 overflow-hidden">
<ElScrollbar>
<div
class="h-17.5 leading-17.5 border-b border-g-300 text-sm overflow-hidden last:border-b-0"
v-for="(item, index) in list"
:key="index"
>
<span class="text-g-800 font-medium">{{ item.username }}</span>
<span class="mx-2 text-g-600">{{ item.type }}</span>
<span class="text-theme">{{ item.target }}</span>
</div>
</ElScrollbar>
</div>
</div>
</template>
<script setup lang="ts">
interface DynamicItem {
username: string
type: string
target: string
}
/**
* 用户动态列表
* 记录用户的关注、发文、提问、兑换等各类活动
*/
const list = reactive<DynamicItem[]>([
{
username: '中小鱼',
type: '关注了',
target: '誶誶淰'
},
{
username: '何小荷',
type: '发表文章',
target: 'Vue3 + Typescript + Vite 项目实战笔记'
},
{
username: '中小鱼',
type: '关注了',
target: '誶誶淰'
},
{
username: '何小荷',
type: '发表文章',
target: 'Vue3 + Typescript + Vite 项目实战笔记'
},
{
username: '誶誶淰',
type: '提出问题',
target: '主题可以配置吗'
},
{
username: '发呆草',
type: '兑换了物品',
target: '《奇特的一生》'
},
{
username: '甜筒',
type: '关闭了问题',
target: '发呆草'
},
{
username: '冷月呆呆',
type: '兑换了物品',
target: '《高效人士的七个习惯》'
}
])
</script>

View File

@@ -0,0 +1,169 @@
<template>
<div class="art-card p-5 h-128 overflow-hidden mb-5 max-sm:mb-4">
<div class="art-card-header">
<div class="title">
<h4>新用户</h4>
<p>这个月增长<span class="text-success">+20%</span></p>
</div>
<ElRadioGroup v-model="radio2">
<ElRadioButton value="本月" label="本月"></ElRadioButton>
<ElRadioButton value="上月" label="上月"></ElRadioButton>
<ElRadioButton value="今年" label="今年"></ElRadioButton>
</ElRadioGroup>
</div>
<ArtTable
class="w-full"
:data="tableData"
style="width: 100%"
size="large"
:border="false"
:stripe="false"
:header-cell-style="{ background: 'transparent' }"
>
<template #default>
<ElTableColumn label="头像" prop="avatar" width="150px">
<template #default="scope">
<div style="display: flex; align-items: center">
<img class="size-9 rounded-lg" :src="scope.row.avatar" alt="avatar" />
<span class="ml-2">{{ scope.row.username }}</span>
</div>
</template>
</ElTableColumn>
<ElTableColumn label="地区" prop="province" />
<ElTableColumn label="性别" prop="avatar">
<template #default="scope">
<div style="display: flex; align-items: center">
<span style="margin-left: 10px">{{ scope.row.sex === 1 ? '男' : '女' }}</span>
</div>
</template>
</ElTableColumn>
<ElTableColumn label="进度" width="240">
<template #default="scope">
<ElProgress
:percentage="scope.row.pro"
:color="scope.row.color"
:stroke-width="4"
:aria-label="`${scope.row.username}的完成进度: ${scope.row.pro}%`"
/>
</template>
</ElTableColumn>
</template>
</ArtTable>
</div>
</template>
<script setup lang="ts">
import avatar1 from '@/assets/images/avatar/avatar1.webp'
import avatar2 from '@/assets/images/avatar/avatar2.webp'
import avatar3 from '@/assets/images/avatar/avatar3.webp'
import avatar4 from '@/assets/images/avatar/avatar4.webp'
import avatar5 from '@/assets/images/avatar/avatar5.webp'
import avatar6 from '@/assets/images/avatar/avatar6.webp'
interface UserTableItem {
username: string
province: string
sex: 0 | 1
age: number
percentage: number
pro: number
color: string
avatar: string
}
const ANIMATION_DELAY = 100
const radio2 = ref('本月')
/**
* 新用户表格数据
* 包含用户基本信息和完成进度
*/
const tableData = reactive<UserTableItem[]>([
{
username: '中小鱼',
province: '北京',
sex: 0,
age: 22,
percentage: 60,
pro: 0,
color: 'var(--art-primary)',
avatar: avatar1
},
{
username: '何小荷',
province: '深圳',
sex: 1,
age: 21,
percentage: 20,
pro: 0,
color: 'var(--art-secondary)',
avatar: avatar2
},
{
username: '誶誶淰',
province: '上海',
sex: 1,
age: 23,
percentage: 60,
pro: 0,
color: 'var(--art-warning)',
avatar: avatar3
},
{
username: '发呆草',
province: '长沙',
sex: 0,
age: 28,
percentage: 50,
pro: 0,
color: 'var(--art-info)',
avatar: avatar4
},
{
username: '甜筒',
province: '浙江',
sex: 1,
age: 26,
percentage: 70,
pro: 0,
color: 'var(--art-error)',
avatar: avatar5
},
{
username: '冷月呆呆',
province: '湖北',
sex: 1,
age: 25,
percentage: 90,
pro: 0,
color: 'var(--art-success)',
avatar: avatar6
}
])
/**
* 添加进度条动画效果
* 延迟后将进度值从 0 更新到目标百分比,触发动画
*/
const addAnimation = (): void => {
setTimeout(() => {
tableData.forEach((item) => {
item.pro = item.percentage
})
}, ANIMATION_DELAY)
}
onMounted(() => {
addAnimation()
})
</script>
<style lang="scss" scoped>
.art-card {
:deep(.el-radio-button__original-radio:checked + .el-radio-button__inner) {
color: var(--el-color-primary) !important;
background: transparent !important;
}
}
</style>

View File

@@ -0,0 +1,43 @@
<template>
<div class="art-card h-105 p-5 mb-5 max-sm:mb-4">
<div class="art-card-header">
<div class="title">
<h4>访问量</h4>
<p>今年增长<span class="text-success">+15%</span></p>
</div>
</div>
<ArtLineChart
height="calc(100% - 56px)"
:data="data"
:xAxisData="xAxisData"
:showAreaColor="true"
:showAxisLine="false"
/>
</div>
</template>
<script setup lang="ts">
/**
* 全年访问量数据
* 记录每月的访问量统计
*/
const data = [50, 25, 40, 20, 70, 35, 65, 30, 35, 20, 40, 44]
/**
* X 轴月份标签
*/
const xAxisData = [
'1月',
'2月',
'3月',
'4月',
'5月',
'6月',
'7月',
'8月',
'9月',
'10月',
'11月',
'12月'
]
</script>

View File

@@ -0,0 +1,71 @@
<template>
<div class="art-card h-128 p-5 mb-5 max-sm:mb-4">
<div class="art-card-header">
<div class="title">
<h4>代办事项</h4>
<p>待处理<span class="text-danger">3</span></p>
</div>
</div>
<div class="h-[calc(100%-40px)] overflow-auto">
<ElScrollbar>
<div
class="flex-cb h-17.5 border-b border-g-300 text-sm last:border-b-0"
v-for="(item, index) in list"
:key="index"
>
<div>
<p class="text-sm">{{ item.username }}</p>
<p class="text-g-500 mt-1">{{ item.date }}</p>
</div>
<ElCheckbox v-model="item.complate" />
</div>
</ElScrollbar>
</div>
</div>
</template>
<script setup lang="ts">
interface TodoItem {
username: string
date: string
complate: boolean
}
/**
* 待办事项列表
* 记录每日工作任务及完成状态
*/
const list = reactive<TodoItem[]>([
{
username: '查看今天工作内容',
date: '上午 09:30',
complate: true
},
{
username: '回复邮件',
date: '上午 10:30',
complate: true
},
{
username: '工作汇报整理',
date: '上午 11:00',
complate: true
},
{
username: '产品需求会议',
date: '下午 02:00',
complate: false
},
{
username: '整理会议内容',
date: '下午 03:30',
complate: false
},
{
username: '明天工作计划',
date: '下午 06:30',
complate: false
}
])
</script>

View File

@@ -0,0 +1,16 @@
<!-- 403页面 -->
<template>
<ArtException
:data="{
title: '403',
desc: $t('exceptionPage.403'),
btnText: $t('exceptionPage.gohome'),
imgUrl
}"
/>
</template>
<script setup lang="ts">
import imgUrl from '@imgs/svg/403.svg'
defineOptions({ name: 'Exception403' })
</script>

View File

@@ -0,0 +1,16 @@
<!-- 404页面 -->
<template>
<ArtException
:data="{
title: '404',
desc: $t('exceptionPage.404'),
btnText: $t('exceptionPage.gohome'),
imgUrl
}"
/>
</template>
<script setup lang="ts">
import imgUrl from '@imgs/svg/404.svg'
defineOptions({ name: 'Exception404' })
</script>

View File

@@ -0,0 +1,16 @@
<!-- 500页面 -->
<template>
<ArtException
:data="{
title: '500',
desc: $t('exceptionPage.500'),
btnText: $t('exceptionPage.gohome'),
imgUrl
}"
/>
</template>
<script setup lang="ts">
import imgUrl from '@imgs/svg/500.svg'
defineOptions({ name: 'Exception500' })
</script>

29
src/views/index/index.vue Normal file
View File

@@ -0,0 +1,29 @@
<!-- 布局容器 -->
<template>
<div class="app-layout">
<aside id="app-sidebar">
<ArtSidebarMenu />
</aside>
<main id="app-main">
<div id="app-header">
<ArtHeaderBar />
</div>
<div id="app-content">
<ArtPageContent />
</div>
</main>
<div id="app-global">
<ArtGlobalComponent />
</div>
</div>
</template>
<script setup lang="ts">
defineOptions({ name: 'AppLayout' })
</script>
<style lang="scss" scoped>
@use './style';
</style>

View File

@@ -0,0 +1,93 @@
.app-layout {
display: flex;
width: 100%;
min-height: 100vh;
background: var(--default-bg-color);
#app-sidebar {
flex-shrink: 0;
}
#app-main {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
height: 100vh;
overflow: auto;
#app-header {
position: sticky;
top: 0;
z-index: 50;
flex-shrink: 0;
width: 100%;
}
#app-content {
flex: 1;
:deep(.layout-content) {
box-sizing: border-box;
width: calc(100% - 40px);
margin: auto;
// 子页面默认 style
.page-content {
position: relative;
box-sizing: border-box;
padding: 20px;
overflow: hidden;
background: var(--default-box-color);
border-radius: calc(var(--custom-radius) / 2 + 2px) !important;
}
}
}
}
}
@media only screen and (width <= 1180px) {
.app-layout {
#app-main {
height: 100dvh;
}
}
}
@media only screen and (width <= 800px) {
.app-layout {
position: relative;
#app-sidebar {
position: fixed;
top: 0;
left: 0;
z-index: 300;
height: 100vh;
}
#app-main {
width: 100%;
height: auto;
overflow: visible;
#app-content {
:deep(.layout-content) {
width: calc(100% - 40px);
}
}
}
}
}
@media only screen and (width <= 640px) {
.app-layout {
#app-main {
#app-content {
:deep(.layout-content) {
width: calc(100% - 30px);
}
}
}
}
}

View File

@@ -0,0 +1,42 @@
<template>
<div class="box-border w-full h-full" v-loading="isLoading">
<iframe
ref="iframeRef"
:src="iframeUrl"
frameborder="0"
class="w-full h-full min-h-[calc(100vh-120px)] border-none"
@load="handleIframeLoad"
></iframe>
</div>
</template>
<script setup lang="ts">
import { IframeRouteManager } from '@/router/core'
defineOptions({ name: 'IframeView' })
const route = useRoute()
const isLoading = ref(true)
const iframeUrl = ref('')
const iframeRef = ref<HTMLIFrameElement | null>(null)
/**
* 初始化 iframe URL
* 从路由配置中获取对应的外部链接地址
*/
onMounted(() => {
const iframeRoute = IframeRouteManager.getInstance().findByPath(route.path)
if (iframeRoute?.meta) {
iframeUrl.value = iframeRoute.meta.link || ''
}
})
/**
* 处理 iframe 加载完成事件
* 隐藏加载状态
*/
const handleIframeLoad = (): void => {
isLoading.value = false
}
</script>

View File

@@ -0,0 +1,28 @@
<template>
<ArtResultPage
type="fail"
title="提交失败"
message="请核对并修改以下信息后,再重新提交。"
iconCode="ri:close-fill"
>
<template #content>
<p>您提交的内容有如下错误</p>
<p>
<ArtSvgIcon icon="ri:close-circle-line" class="text-red-500 mr-1" />
<span>您的账户已被冻结</span>
</p>
<p>
<ArtSvgIcon icon="ri:close-circle-line" class="text-red-500 mr-1" />
<span>您的账户还不具备申请资格</span>
</p>
</template>
<template #buttons>
<ElButton type="primary" v-ripple>返回修改</ElButton>
<ElButton v-ripple>查看</ElButton>
</template>
</ArtResultPage>
</template>
<script setup lang="ts">
defineOptions({ name: 'ResultFail' })
</script>

View File

@@ -0,0 +1,21 @@
<template>
<ArtResultPage
type="success"
title="提交成功"
message="提交结果页用于反馈一系列操作任务的处理结果,如果仅是简单操作,使用 Message 全局提示反馈即可。灰色区域可以显示一些补充的信息。"
iconCode="ri:check-fill"
>
<template #content>
<p>已提交申请等待部门审核</p>
</template>
<template #buttons>
<ElButton type="primary" v-ripple>返回修改</ElButton>
<ElButton v-ripple>查看</ElButton>
<ElButton v-ripple>打印</ElButton>
</template>
</ArtResultPage>
</template>
<script setup lang="ts">
defineOptions({ name: 'ResultSuccess' })
</script>

View File

@@ -0,0 +1,479 @@
<!-- 菜单管理页面 -->
<template>
<div class="menu-page art-full-height">
<!-- 搜索栏 -->
<ArtSearchBar
v-model="formFilters"
:items="formItems"
:showExpand="false"
@reset="handleReset"
@search="handleSearch"
/>
<ElCard class="art-table-card" shadow="never">
<!-- 表格头部 -->
<ArtTableHeader
:showZebra="false"
:loading="loading"
v-model:columns="columnChecks"
@refresh="handleRefresh"
>
<template #left>
<ElButton v-auth="'add'" @click="handleAddMenu" v-ripple> 添加菜单 </ElButton>
<ElButton @click="toggleExpand" v-ripple>
{{ isExpanded ? '收起' : '展开' }}
</ElButton>
</template>
</ArtTableHeader>
<ArtTable
ref="tableRef"
rowKey="path"
:loading="loading"
:columns="columns"
:data="filteredTableData"
:stripe="false"
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
:default-expand-all="false"
/>
<!-- 菜单弹窗 -->
<MenuDialog
v-model:visible="dialogVisible"
:type="dialogType"
:editData="editData"
:lockType="lockMenuType"
@submit="handleSubmit"
/>
</ElCard>
</div>
</template>
<script setup lang="ts">
import { formatMenuTitle } from '@/utils/router'
import ArtButtonTable from '@/components/core/forms/art-button-table/index.vue'
import { useTableColumns } from '@/hooks/core/useTableColumns'
import type { AppRouteRecord } from '@/types/router'
import MenuDialog from './modules/menu-dialog.vue'
import { fetchGetMenuList } from '@/api/system-manage'
import { ElTag, ElMessageBox } from 'element-plus'
defineOptions({ name: 'Menus' })
// 状态管理
const loading = ref(false)
const isExpanded = ref(false)
const tableRef = ref()
// 弹窗相关
const dialogVisible = ref(false)
const dialogType = ref<'menu' | 'button'>('menu')
const editData = ref<AppRouteRecord | any>(null)
const lockMenuType = ref(false)
// 搜索相关
const initialSearchState = {
name: '',
route: ''
}
const formFilters = reactive({ ...initialSearchState })
const appliedFilters = reactive({ ...initialSearchState })
const formItems = computed(() => [
{
label: '菜单名称',
key: 'name',
type: 'input',
props: { clearable: true }
},
{
label: '路由地址',
key: 'route',
type: 'input',
props: { clearable: true }
}
])
onMounted(() => {
getMenuList()
})
/**
* 获取菜单列表数据
*/
const getMenuList = async (): Promise<void> => {
loading.value = true
try {
const list = await fetchGetMenuList()
tableData.value = list
} catch (error) {
throw error instanceof Error ? error : new Error('获取菜单失败')
} finally {
loading.value = false
}
}
/**
* 获取菜单类型标签颜色
* @param row 菜单行数据
* @returns 标签颜色类型
*/
const getMenuTypeTag = (
row: AppRouteRecord
): 'primary' | 'success' | 'warning' | 'info' | 'danger' => {
if (row.meta?.isAuthButton) return 'danger'
if (row.children?.length) return 'info'
if (row.meta?.link && row.meta?.isIframe) return 'success'
if (row.path) return 'primary'
if (row.meta?.link) return 'warning'
return 'info'
}
/**
* 获取菜单类型文本
* @param row 菜单行数据
* @returns 菜单类型文本
*/
const getMenuTypeText = (row: AppRouteRecord): string => {
if (row.meta?.isAuthButton) return '按钮'
if (row.children?.length) return '目录'
if (row.meta?.link && row.meta?.isIframe) return '内嵌'
if (row.path) return '菜单'
if (row.meta?.link) return '外链'
return '未知'
}
// 表格列配置
const { columnChecks, columns } = useTableColumns(() => [
{
prop: 'meta.title',
label: '菜单名称',
minWidth: 120,
formatter: (row: AppRouteRecord) => formatMenuTitle(row.meta?.title)
},
{
prop: 'type',
label: '菜单类型',
formatter: (row: AppRouteRecord) => {
return h(ElTag, { type: getMenuTypeTag(row) }, () => getMenuTypeText(row))
}
},
{
prop: 'path',
label: '路由',
formatter: (row: AppRouteRecord) => {
if (row.meta?.isAuthButton) return ''
return row.meta?.link || row.path || ''
}
},
{
prop: 'meta.authList',
label: '权限标识',
formatter: (row: AppRouteRecord) => {
if (row.meta?.isAuthButton) {
return row.meta?.authMark || ''
}
if (!row.meta?.authList?.length) return ''
return `${row.meta.authList.length} 个权限标识`
}
},
{
prop: 'date',
label: '编辑时间',
formatter: () => '2022-3-12 12:00:00'
},
{
prop: 'status',
label: '状态',
formatter: () => h(ElTag, { type: 'success' }, () => '启用')
},
{
prop: 'operation',
label: '操作',
width: 180,
align: 'right',
formatter: (row: AppRouteRecord) => {
const buttonStyle = { style: 'text-align: right' }
if (row.meta?.isAuthButton) {
return h('div', buttonStyle, [
h(ArtButtonTable, {
type: 'edit',
onClick: () => handleEditAuth(row)
}),
h(ArtButtonTable, {
type: 'delete',
onClick: () => handleDeleteAuth()
})
])
}
return h('div', buttonStyle, [
h(ArtButtonTable, {
type: 'add',
onClick: () => handleAddAuth(),
title: '新增权限'
}),
h(ArtButtonTable, {
type: 'edit',
onClick: () => handleEditMenu(row)
}),
h(ArtButtonTable, {
type: 'delete',
onClick: () => handleDeleteMenu()
})
])
}
}
])
// 数据相关
const tableData = ref<AppRouteRecord[]>([])
/**
* 重置搜索条件
*/
const handleReset = (): void => {
Object.assign(formFilters, { ...initialSearchState })
Object.assign(appliedFilters, { ...initialSearchState })
getMenuList()
}
/**
* 执行搜索
*/
const handleSearch = (): void => {
Object.assign(appliedFilters, { ...formFilters })
getMenuList()
}
/**
* 刷新菜单列表
*/
const handleRefresh = (): void => {
getMenuList()
}
/**
* 深度克隆对象
* @param obj 要克隆的对象
* @returns 克隆后的对象
*/
const deepClone = <T,>(obj: T): T => {
if (obj === null || typeof obj !== 'object') return obj
if (obj instanceof Date) return new Date(obj) as T
if (Array.isArray(obj)) return obj.map((item) => deepClone(item)) as T
const cloned = {} as T
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
cloned[key] = deepClone(obj[key])
}
}
return cloned
}
/**
* 将权限列表转换为子节点
* @param items 菜单项数组
* @returns 转换后的菜单项数组
*/
const convertAuthListToChildren = (items: AppRouteRecord[]): AppRouteRecord[] => {
return items.map((item) => {
const clonedItem = deepClone(item)
if (clonedItem.children?.length) {
clonedItem.children = convertAuthListToChildren(clonedItem.children)
}
if (item.meta?.authList?.length) {
const authChildren: AppRouteRecord[] = item.meta.authList.map(
(auth: { title: string; authMark: string }) => ({
path: `${item.path}_auth_${auth.authMark}`,
name: `${String(item.name)}_auth_${auth.authMark}`,
meta: {
title: auth.title,
authMark: auth.authMark,
isAuthButton: true,
parentPath: item.path
}
})
)
clonedItem.children = clonedItem.children?.length
? [...clonedItem.children, ...authChildren]
: authChildren
}
return clonedItem
})
}
/**
* 搜索菜单
* @param items 菜单项数组
* @returns 搜索结果数组
*/
const searchMenu = (items: AppRouteRecord[]): AppRouteRecord[] => {
const results: AppRouteRecord[] = []
for (const item of items) {
const searchName = appliedFilters.name?.toLowerCase().trim() || ''
const searchRoute = appliedFilters.route?.toLowerCase().trim() || ''
const menuTitle = formatMenuTitle(item.meta?.title || '').toLowerCase()
const menuPath = (item.path || '').toLowerCase()
const nameMatch = !searchName || menuTitle.includes(searchName)
const routeMatch = !searchRoute || menuPath.includes(searchRoute)
if (item.children?.length) {
const matchedChildren = searchMenu(item.children)
if (matchedChildren.length > 0) {
const clonedItem = deepClone(item)
clonedItem.children = matchedChildren
results.push(clonedItem)
continue
}
}
if (nameMatch && routeMatch) {
results.push(deepClone(item))
}
}
return results
}
// 过滤后的表格数据
const filteredTableData = computed(() => {
const searchedData = searchMenu(tableData.value)
return convertAuthListToChildren(searchedData)
})
/**
* 添加菜单
*/
const handleAddMenu = (): void => {
dialogType.value = 'menu'
editData.value = null
lockMenuType.value = true
dialogVisible.value = true
}
/**
* 添加权限按钮
*/
const handleAddAuth = (): void => {
dialogType.value = 'menu'
editData.value = null
lockMenuType.value = false
dialogVisible.value = true
}
/**
* 编辑菜单
* @param row 菜单行数据
*/
const handleEditMenu = (row: AppRouteRecord): void => {
dialogType.value = 'menu'
editData.value = row
lockMenuType.value = true
dialogVisible.value = true
}
/**
* 编辑权限按钮
* @param row 权限行数据
*/
const handleEditAuth = (row: AppRouteRecord): void => {
dialogType.value = 'button'
editData.value = {
title: row.meta?.title,
authMark: row.meta?.authMark
}
lockMenuType.value = false
dialogVisible.value = true
}
/**
* 菜单表单数据类型
*/
interface MenuFormData {
name: string
path: string
component?: string
icon?: string
roles?: string[]
sort?: number
[key: string]: any
}
/**
* 提交表单数据
* @param formData 表单数据
*/
const handleSubmit = (formData: MenuFormData): void => {
console.log('提交数据:', formData)
// TODO: 调用API保存数据
getMenuList()
}
/**
* 删除菜单
*/
const handleDeleteMenu = async (): Promise<void> => {
try {
await ElMessageBox.confirm('确定要删除该菜单吗?删除后无法恢复', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
ElMessage.success('删除成功')
getMenuList()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('删除失败')
}
}
}
/**
* 删除权限按钮
*/
const handleDeleteAuth = async (): Promise<void> => {
try {
await ElMessageBox.confirm('确定要删除该权限吗?删除后无法恢复', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
ElMessage.success('删除成功')
getMenuList()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('删除失败')
}
}
}
/**
* 切换展开/收起所有菜单
*/
const toggleExpand = (): void => {
isExpanded.value = !isExpanded.value
nextTick(() => {
if (tableRef.value?.elTableRef && filteredTableData.value) {
const processRows = (rows: AppRouteRecord[]) => {
rows.forEach((row) => {
if (row.children?.length) {
tableRef.value.elTableRef.toggleRowExpansion(row, isExpanded.value)
processRows(row.children)
}
})
}
processRows(filteredTableData.value)
}
})
}
</script>

View File

@@ -0,0 +1,384 @@
<template>
<ElDialog
:title="dialogTitle"
:model-value="visible"
@update:model-value="handleCancel"
width="860px"
align-center
class="menu-dialog"
@closed="handleClosed"
>
<ArtForm
ref="formRef"
v-model="form"
:items="formItems"
:rules="rules"
:span="width > 640 ? 12 : 24"
:gutter="20"
label-width="100px"
:show-reset="false"
:show-submit="false"
>
<template #menuType>
<ElRadioGroup v-model="form.menuType" :disabled="disableMenuType">
<ElRadioButton value="menu" label="menu">菜单</ElRadioButton>
<ElRadioButton value="button" label="button">按钮</ElRadioButton>
</ElRadioGroup>
</template>
</ArtForm>
<template #footer>
<span class="dialog-footer">
<ElButton @click="handleCancel"> </ElButton>
<ElButton type="primary" @click="handleSubmit"> </ElButton>
</span>
</template>
</ElDialog>
</template>
<script setup lang="ts">
import type { FormRules } from 'element-plus'
import { ElIcon, ElTooltip } from 'element-plus'
import { QuestionFilled } from '@element-plus/icons-vue'
import { formatMenuTitle } from '@/utils/router'
import type { AppRouteRecord } from '@/types/router'
import type { FormItem } from '@/components/core/forms/art-form/index.vue'
import ArtForm from '@/components/core/forms/art-form/index.vue'
import { useWindowSize } from '@vueuse/core'
const { width } = useWindowSize()
/**
* 创建带 tooltip 的表单标签
* @param label 标签文本
* @param tooltip 提示文本
* @returns 渲染函数
*/
const createLabelTooltip = (label: string, tooltip: string) => {
return () =>
h('span', { class: 'flex items-center' }, [
h('span', label),
h(
ElTooltip,
{
content: tooltip,
placement: 'top'
},
() => h(ElIcon, { class: 'ml-0.5 cursor-help' }, () => h(QuestionFilled))
)
])
}
interface MenuFormData {
id: number
name: string
path: string
label: string
component: string
icon: string
isEnable: boolean
sort: number
isMenu: boolean
keepAlive: boolean
isHide: boolean
isHideTab: boolean
link: string
isIframe: boolean
showBadge: boolean
showTextBadge: string
fixedTab: boolean
activePath: string
roles: string[]
isFullPage: boolean
authName: string
authLabel: string
authIcon: string
authSort: number
}
interface Props {
visible: boolean
editData?: AppRouteRecord | any
type?: 'menu' | 'button'
lockType?: boolean
}
interface Emits {
(e: 'update:visible', value: boolean): void
(e: 'submit', data: MenuFormData): void
}
const props = withDefaults(defineProps<Props>(), {
visible: false,
type: 'menu',
lockType: false
})
const emit = defineEmits<Emits>()
const formRef = ref()
const isEdit = ref(false)
const form = reactive<MenuFormData & { menuType: 'menu' | 'button' }>({
menuType: 'menu',
id: 0,
name: '',
path: '',
label: '',
component: '',
icon: '',
isEnable: true,
sort: 1,
isMenu: true,
keepAlive: true,
isHide: false,
isHideTab: false,
link: '',
isIframe: false,
showBadge: false,
showTextBadge: '',
fixedTab: false,
activePath: '',
roles: [],
isFullPage: false,
authName: '',
authLabel: '',
authIcon: '',
authSort: 1
})
const rules = reactive<FormRules>({
name: [
{ required: true, message: '请输入菜单名称', trigger: 'blur' },
{ min: 2, max: 20, message: '长度在 2 到 20 个字符', trigger: 'blur' }
],
path: [{ required: true, message: '请输入路由地址', trigger: 'blur' }],
label: [{ required: true, message: '输入权限标识', trigger: 'blur' }],
authName: [{ required: true, message: '请输入权限名称', trigger: 'blur' }],
authLabel: [{ required: true, message: '请输入权限标识', trigger: 'blur' }]
})
/**
* 表单项配置
*/
const formItems = computed<FormItem[]>(() => {
const baseItems: FormItem[] = [{ label: '菜单类型', key: 'menuType', span: 24 }]
// Switch 组件的 span小屏幕 12大屏幕 6
const switchSpan = width.value < 640 ? 12 : 6
if (form.menuType === 'menu') {
return [
...baseItems,
{ label: '菜单名称', key: 'name', type: 'input', props: { placeholder: '菜单名称' } },
{
label: createLabelTooltip(
'路由地址',
'一级菜单:以 / 开头的绝对路径(如 /dashboard\n二级及以下相对路径如 console、user'
),
key: 'path',
type: 'input',
props: { placeholder: '如:/dashboard 或 console' }
},
{ label: '权限标识', key: 'label', type: 'input', props: { placeholder: '如User' } },
{
label: createLabelTooltip(
'组件路径',
'一级父级菜单:填写 /index/index\n具体页面填写组件路径如 /system/user\n目录菜单留空'
),
key: 'component',
type: 'input',
props: { placeholder: '如:/system/user 或留空' }
},
{ label: '图标', key: 'icon', type: 'input', props: { placeholder: '如ri:user-line' } },
{
label: createLabelTooltip(
'角色权限',
'仅用于前端权限模式:配置角色标识(如 R_SUPER、R_ADMIN\n后端权限模式无需配置'
),
key: 'roles',
type: 'inputtag',
props: { placeholder: '输入角色标识后按回车R_SUPER' }
},
{
label: '菜单排序',
key: 'sort',
type: 'number',
props: { min: 1, controlsPosition: 'right', style: { width: '100%' } }
},
{
label: '外部链接',
key: 'link',
type: 'input',
props: { placeholder: '如https://www.example.com' }
},
{
label: '文本徽章',
key: 'showTextBadge',
type: 'input',
props: { placeholder: '如New、Hot' }
},
{
label: createLabelTooltip(
'激活路径',
'用于详情页等隐藏菜单,指定高亮显示的父级菜单路径\n例如用户详情页高亮显示"用户管理"菜单'
),
key: 'activePath',
type: 'input',
props: { placeholder: '如:/system/user' }
},
{ label: '是否启用', key: 'isEnable', type: 'switch', span: switchSpan },
{ label: '页面缓存', key: 'keepAlive', type: 'switch', span: switchSpan },
{ label: '隐藏菜单', key: 'isHide', type: 'switch', span: switchSpan },
{ label: '是否内嵌', key: 'isIframe', type: 'switch', span: switchSpan },
{ label: '显示徽章', key: 'showBadge', type: 'switch', span: switchSpan },
{ label: '固定标签', key: 'fixedTab', type: 'switch', span: switchSpan },
{ label: '标签隐藏', key: 'isHideTab', type: 'switch', span: switchSpan },
{ label: '全屏页面', key: 'isFullPage', type: 'switch', span: switchSpan }
]
} else {
return [
...baseItems,
{
label: '权限名称',
key: 'authName',
type: 'input',
props: { placeholder: '如:新增、编辑、删除' }
},
{
label: '权限标识',
key: 'authLabel',
type: 'input',
props: { placeholder: '如add、edit、delete' }
},
{
label: '权限排序',
key: 'authSort',
type: 'number',
props: { min: 1, controlsPosition: 'right', style: { width: '100%' } }
}
]
}
})
const dialogTitle = computed(() => {
const type = form.menuType === 'menu' ? '菜单' : '按钮'
return isEdit.value ? `编辑${type}` : `新建${type}`
})
/**
* 是否禁用菜单类型切换
*/
const disableMenuType = computed(() => {
if (isEdit.value) return true
if (!isEdit.value && form.menuType === 'menu' && props.lockType) return true
return false
})
/**
* 重置表单数据
*/
const resetForm = (): void => {
formRef.value?.reset()
form.menuType = 'menu'
}
/**
* 加载表单数据(编辑模式)
*/
const loadFormData = (): void => {
if (!props.editData) return
isEdit.value = true
if (form.menuType === 'menu') {
const row = props.editData
form.id = row.id || 0
form.name = formatMenuTitle(row.meta?.title || '')
form.path = row.path || ''
form.label = row.name || ''
form.component = row.component || ''
form.icon = row.meta?.icon || ''
form.sort = row.meta?.sort || 1
form.isMenu = row.meta?.isMenu ?? true
form.keepAlive = row.meta?.keepAlive ?? false
form.isHide = row.meta?.isHide ?? false
form.isHideTab = row.meta?.isHideTab ?? false
form.isEnable = row.meta?.isEnable ?? true
form.link = row.meta?.link || ''
form.isIframe = row.meta?.isIframe ?? false
form.showBadge = row.meta?.showBadge ?? false
form.showTextBadge = row.meta?.showTextBadge || ''
form.fixedTab = row.meta?.fixedTab ?? false
form.activePath = row.meta?.activePath || ''
form.roles = row.meta?.roles || []
form.isFullPage = row.meta?.isFullPage ?? false
} else {
const row = props.editData
form.authName = row.title || ''
form.authLabel = row.authMark || ''
form.authIcon = row.icon || ''
form.authSort = row.sort || 1
}
}
/**
* 提交表单
*/
const handleSubmit = async (): Promise<void> => {
if (!formRef.value) return
try {
await formRef.value.validate()
emit('submit', { ...form })
ElMessage.success(`${isEdit.value ? '编辑' : '新增'}成功`)
handleCancel()
} catch {
ElMessage.error('表单校验失败,请检查输入')
}
}
/**
* 取消操作
*/
const handleCancel = (): void => {
emit('update:visible', false)
}
/**
* 对话框关闭后的回调
*/
const handleClosed = (): void => {
resetForm()
isEdit.value = false
}
/**
* 监听对话框显示状态
*/
watch(
() => props.visible,
(newVal) => {
if (newVal) {
form.menuType = props.type
nextTick(() => {
if (props.editData) {
loadFormData()
}
})
}
}
)
/**
* 监听菜单类型变化
*/
watch(
() => props.type,
(newType) => {
if (props.visible) {
form.menuType = newType
}
}
)
</script>

View File

@@ -0,0 +1,242 @@
<!-- 角色管理页面 -->
<template>
<div class="art-full-height">
<RoleSearch
v-show="showSearchBar"
v-model="searchForm"
@search="handleSearch"
@reset="resetSearchParams"
></RoleSearch>
<ElCard
class="art-table-card"
shadow="never"
:style="{ 'margin-top': showSearchBar ? '12px' : '0' }"
>
<ArtTableHeader
v-model:columns="columnChecks"
v-model:showSearchBar="showSearchBar"
:loading="loading"
@refresh="refreshData"
>
<template #left>
<ElSpace wrap>
<ElButton @click="showDialog('add')" v-ripple>新增角色</ElButton>
</ElSpace>
</template>
</ArtTableHeader>
<!-- 表格 -->
<ArtTable
:loading="loading"
:data="data"
:columns="columns"
:pagination="pagination"
@pagination:size-change="handleSizeChange"
@pagination:current-change="handleCurrentChange"
>
</ArtTable>
</ElCard>
<!-- 角色编辑弹窗 -->
<RoleEditDialog
v-model="dialogVisible"
:dialog-type="dialogType"
:role-data="currentRoleData"
@success="refreshData"
/>
<!-- 菜单权限弹窗 -->
<RolePermissionDialog
v-model="permissionDialog"
:role-data="currentRoleData"
@success="refreshData"
/>
</div>
</template>
<script setup lang="ts">
import { ButtonMoreItem } from '@/components/core/forms/art-button-more/index.vue'
import { useTable } from '@/hooks/core/useTable'
import { fetchGetRoleList } from '@/api/system-manage'
import ArtButtonMore from '@/components/core/forms/art-button-more/index.vue'
import RoleSearch from './modules/role-search.vue'
import RoleEditDialog from './modules/role-edit-dialog.vue'
import RolePermissionDialog from './modules/role-permission-dialog.vue'
import { ElTag, ElMessageBox } from 'element-plus'
defineOptions({ name: 'Role' })
type RoleListItem = Api.SystemManage.RoleListItem
// 搜索表单
const searchForm = ref({
roleName: undefined,
roleCode: undefined,
description: undefined,
enabled: undefined,
daterange: undefined
})
const showSearchBar = ref(false)
const dialogVisible = ref(false)
const permissionDialog = ref(false)
const currentRoleData = ref<RoleListItem | undefined>(undefined)
const {
columns,
columnChecks,
data,
loading,
pagination,
getData,
searchParams,
resetSearchParams,
handleSizeChange,
handleCurrentChange,
refreshData
} = useTable({
// 核心配置
core: {
apiFn: fetchGetRoleList,
apiParams: {
current: 1,
size: 20
},
// 排除 apiParams 中的属性
excludeParams: ['daterange'],
columnsFactory: () => [
{
prop: 'roleId',
label: '角色ID',
width: 100
},
{
prop: 'roleName',
label: '角色名称',
minWidth: 120
},
{
prop: 'roleCode',
label: '角色编码',
minWidth: 120
},
{
prop: 'description',
label: '角色描述',
minWidth: 150,
showOverflowTooltip: true
},
{
prop: 'enabled',
label: '角色状态',
width: 100,
formatter: (row) => {
const statusConfig = row.enabled
? { type: 'success', text: '启用' }
: { type: 'warning', text: '禁用' }
return h(
ElTag,
{ type: statusConfig.type as 'success' | 'warning' },
() => statusConfig.text
)
}
},
{
prop: 'createTime',
label: '创建日期',
width: 180,
sortable: true
},
{
prop: 'operation',
label: '操作',
width: 80,
fixed: 'right',
formatter: (row) =>
h('div', [
h(ArtButtonMore, {
list: [
{
key: 'permission',
label: '菜单权限',
icon: 'ri:user-3-line'
},
{
key: 'edit',
label: '编辑角色',
icon: 'ri:edit-2-line'
},
{
key: 'delete',
label: '删除角色',
icon: 'ri:delete-bin-4-line',
color: '#f56c6c'
}
],
onClick: (item: ButtonMoreItem) => buttonMoreClick(item, row)
})
])
}
]
}
})
const dialogType = ref<'add' | 'edit'>('add')
const showDialog = (type: 'add' | 'edit', row?: RoleListItem) => {
dialogVisible.value = true
dialogType.value = type
currentRoleData.value = row
}
/**
* 搜索处理
* @param params 搜索参数
*/
const handleSearch = (params: Record<string, any>) => {
// 处理日期区间参数,把 daterange 转换为 startTime 和 endTime
const { daterange, ...filtersParams } = params
const [startTime, endTime] = Array.isArray(daterange) ? daterange : [null, null]
// 搜索参数赋值
Object.assign(searchParams, { ...filtersParams, startTime, endTime })
getData()
}
const buttonMoreClick = (item: ButtonMoreItem, row: RoleListItem) => {
switch (item.key) {
case 'permission':
showPermissionDialog(row)
break
case 'edit':
showDialog('edit', row)
break
case 'delete':
deleteRole(row)
break
}
}
const showPermissionDialog = (row?: RoleListItem) => {
permissionDialog.value = true
currentRoleData.value = row
}
const deleteRole = (row: RoleListItem) => {
ElMessageBox.confirm(`确定删除角色"${row.roleName}"吗?此操作不可恢复!`, '删除确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
.then(() => {
// TODO: 调用删除接口
ElMessage.success('删除成功')
refreshData()
})
.catch(() => {
ElMessage.info('已取消删除')
})
}
</script>

View File

@@ -0,0 +1,162 @@
<template>
<ElDialog
v-model="visible"
:title="dialogType === 'add' ? '新增角色' : '编辑角色'"
width="30%"
align-center
@close="handleClose"
>
<ElForm ref="formRef" :model="form" :rules="rules" label-width="120px">
<ElFormItem label="角色名称" prop="roleName">
<ElInput v-model="form.roleName" placeholder="请输入角色名称" />
</ElFormItem>
<ElFormItem label="角色编码" prop="roleCode">
<ElInput v-model="form.roleCode" placeholder="请输入角色编码" />
</ElFormItem>
<ElFormItem label="描述" prop="description">
<ElInput
v-model="form.description"
type="textarea"
:rows="3"
placeholder="请输入角色描述"
/>
</ElFormItem>
<ElFormItem label="启用">
<ElSwitch v-model="form.enabled" />
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="handleClose">取消</ElButton>
<ElButton type="primary" @click="handleSubmit">提交</ElButton>
</template>
</ElDialog>
</template>
<script setup lang="ts">
import type { FormInstance, FormRules } from 'element-plus'
type RoleListItem = Api.SystemManage.RoleListItem
interface Props {
modelValue: boolean
dialogType: 'add' | 'edit'
roleData?: RoleListItem
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'success'): void
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
dialogType: 'add',
roleData: undefined
})
const emit = defineEmits<Emits>()
const formRef = ref<FormInstance>()
/**
* 弹窗显示状态双向绑定
*/
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
/**
* 表单验证规则
*/
const rules = reactive<FormRules>({
roleName: [
{ required: true, message: '请输入角色名称', trigger: 'blur' },
{ min: 2, max: 20, message: '长度在 2 到 20 个字符', trigger: 'blur' }
],
roleCode: [
{ required: true, message: '请输入角色编码', trigger: 'blur' },
{ min: 2, max: 50, message: '长度在 2 到 50 个字符', trigger: 'blur' }
],
description: [{ required: true, message: '请输入角色描述', trigger: 'blur' }]
})
/**
* 表单数据
*/
const form = reactive<RoleListItem>({
roleId: 0,
roleName: '',
roleCode: '',
description: '',
createTime: '',
enabled: true
})
/**
* 监听弹窗打开,初始化表单数据
*/
watch(
() => props.modelValue,
(newVal) => {
if (newVal) initForm()
}
)
/**
* 监听角色数据变化,更新表单
*/
watch(
() => props.roleData,
(newData) => {
if (newData && props.modelValue) initForm()
},
{ deep: true }
)
/**
* 初始化表单数据
* 根据弹窗类型填充表单或重置表单
*/
const initForm = () => {
if (props.dialogType === 'edit' && props.roleData) {
Object.assign(form, props.roleData)
} else {
Object.assign(form, {
roleId: 0,
roleName: '',
roleCode: '',
description: '',
createTime: '',
enabled: true
})
}
}
/**
* 关闭弹窗并重置表单
*/
const handleClose = () => {
visible.value = false
formRef.value?.resetFields()
}
/**
* 提交表单
* 验证通过后调用接口保存数据
*/
const handleSubmit = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
// TODO: 调用新增/编辑接口
const message = props.dialogType === 'add' ? '新增成功' : '修改成功'
ElMessage.success(message)
emit('success')
handleClose()
} catch (error) {
console.log('表单验证失败:', error)
}
}
</script>

View File

@@ -0,0 +1,254 @@
<template>
<ElDialog
v-model="visible"
title="菜单权限"
width="520px"
align-center
class="el-dialog-border"
@close="handleClose"
>
<ElScrollbar height="70vh">
<ElTree
ref="treeRef"
:data="processedMenuList"
show-checkbox
node-key="name"
:default-expand-all="isExpandAll"
:default-checked-keys="[1, 2, 3]"
:props="defaultProps"
@check="handleTreeCheck"
>
<template #default="{ data }">
<div style="display: flex; align-items: center">
<span v-if="data.isAuth">
{{ data.label }}
</span>
<span v-else>{{ defaultProps.label(data) }}</span>
</div>
</template>
</ElTree>
</ElScrollbar>
<template #footer>
<ElButton @click="outputSelectedData" style="margin-left: 8px">获取选中数据</ElButton>
<ElButton @click="toggleExpandAll">{{ isExpandAll ? '全部收起' : '全部展开' }}</ElButton>
<ElButton @click="toggleSelectAll" style="margin-left: 8px">{{
isSelectAll ? '取消全选' : '全部选择'
}}</ElButton>
<ElButton type="primary" @click="savePermission">保存</ElButton>
</template>
</ElDialog>
</template>
<script setup lang="ts">
import { useMenuStore } from '@/store/modules/menu'
import { formatMenuTitle } from '@/utils/router'
type RoleListItem = Api.SystemManage.RoleListItem
interface Props {
modelValue: boolean
roleData?: RoleListItem
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'success'): void
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
roleData: undefined
})
const emit = defineEmits<Emits>()
const { menuList } = storeToRefs(useMenuStore())
const treeRef = ref()
const isExpandAll = ref(true)
const isSelectAll = ref(false)
/**
* 弹窗显示状态双向绑定
*/
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
/**
* 菜单节点类型
*/
interface MenuNode {
id?: string | number
name?: string
label?: string
meta?: {
title?: string
authList?: Array<{
authMark: string
title: string
checked?: boolean
}>
}
children?: MenuNode[]
[key: string]: any
}
/**
* 处理菜单数据,将 authList 转换为树形子节点
* 递归处理菜单树,将权限列表展开为可选择的子节点
*/
const processedMenuList = computed(() => {
const processNode = (node: MenuNode): MenuNode => {
const processed = { ...node }
// 如果有 authList将其转换为子节点
if (node.meta?.authList?.length) {
const authNodes = node.meta.authList.map((auth) => ({
id: `${node.id}_${auth.authMark}`,
name: `${node.name}_${auth.authMark}`,
label: auth.title,
authMark: auth.authMark,
isAuth: true,
checked: auth.checked || false
}))
processed.children = processed.children ? [...processed.children, ...authNodes] : authNodes
}
// 递归处理子节点
if (processed.children) {
processed.children = processed.children.map(processNode)
}
return processed
}
return (menuList.value as any[]).map(processNode)
})
/**
* 树形组件配置
*/
const defaultProps = {
children: 'children',
label: (data: any) => formatMenuTitle(data.meta?.title) || data.label || ''
}
/**
* 监听弹窗打开,初始化权限数据
*/
watch(
() => props.modelValue,
(newVal) => {
if (newVal && props.roleData) {
// TODO: 根据角色加载对应的权限数据
console.log('设置权限:', props.roleData)
}
}
)
/**
* 关闭弹窗并清空选中状态
*/
const handleClose = () => {
visible.value = false
treeRef.value?.setCheckedKeys([])
}
/**
* 保存权限配置
*/
const savePermission = () => {
// TODO: 调用保存权限接口
ElMessage.success('权限保存成功')
emit('success')
handleClose()
}
/**
* 切换全部展开/收起状态
*/
const toggleExpandAll = () => {
const tree = treeRef.value
if (!tree) return
const nodes = tree.store.nodesMap
// 这里保留 any因为 Element Plus 的内部节点类型较复杂
Object.values(nodes).forEach((node: any) => {
node.expanded = !isExpandAll.value
})
isExpandAll.value = !isExpandAll.value
}
/**
* 切换全选/取消全选状态
*/
const toggleSelectAll = () => {
const tree = treeRef.value
if (!tree) return
if (!isSelectAll.value) {
const allKeys = getAllNodeKeys(processedMenuList.value)
tree.setCheckedKeys(allKeys)
} else {
tree.setCheckedKeys([])
}
isSelectAll.value = !isSelectAll.value
}
/**
* 递归获取所有节点的 key
* @param nodes 节点列表
* @returns 所有节点的 key 数组
*/
const getAllNodeKeys = (nodes: MenuNode[]): string[] => {
const keys: string[] = []
const traverse = (nodeList: MenuNode[]): void => {
nodeList.forEach((node) => {
if (node.name) keys.push(node.name)
if (node.children?.length) traverse(node.children)
})
}
traverse(nodes)
return keys
}
/**
* 处理树节点选中状态变化
* 同步更新全选按钮状态
*/
const handleTreeCheck = () => {
const tree = treeRef.value
if (!tree) return
const checkedKeys = tree.getCheckedKeys()
const allKeys = getAllNodeKeys(processedMenuList.value)
isSelectAll.value = checkedKeys.length === allKeys.length && allKeys.length > 0
}
/**
* 输出选中的权限数据到控制台
* 用于调试和查看当前选中的权限配置
*/
const outputSelectedData = () => {
const tree = treeRef.value
if (!tree) return
const selectedData = {
checkedKeys: tree.getCheckedKeys(),
halfCheckedKeys: tree.getHalfCheckedKeys(),
checkedNodes: tree.getCheckedNodes(),
halfCheckedNodes: tree.getHalfCheckedNodes(),
totalChecked: tree.getCheckedKeys().length,
totalHalfChecked: tree.getHalfCheckedKeys().length
}
console.log('=== 选中的权限数据 ===', selectedData)
ElMessage.success(`已输出选中数据到控制台,共选中 ${selectedData.totalChecked} 个节点`)
}
</script>

View File

@@ -0,0 +1,121 @@
<template>
<ArtSearchBar
ref="searchBarRef"
v-model="formData"
:items="formItems"
:rules="rules"
@reset="handleReset"
@search="handleSearch"
>
</ArtSearchBar>
</template>
<script setup lang="ts">
interface Props {
modelValue: Record<string, any>
}
interface Emits {
(e: 'update:modelValue', value: Record<string, any>): void
(e: 'search', params: Record<string, any>): void
(e: 'reset'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const searchBarRef = ref()
/**
* 表单数据双向绑定
*/
const formData = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
/**
* 表单校验规则
*/
const rules = {}
/**
* 角色状态选项
*/
const statusOptions = ref([
{ label: '启用', value: true },
{ label: '禁用', value: false }
])
/**
* 搜索表单配置项
*/
const formItems = computed(() => [
{
label: '角色名称',
key: 'roleName',
type: 'input',
placeholder: '请输入角色名称',
clearable: true
},
{
label: '角色编码',
key: 'roleCode',
type: 'input',
placeholder: '请输入角色编码',
clearable: true
},
{
label: '角色描述',
key: 'description',
type: 'input',
placeholder: '请输入角色描述',
clearable: true
},
{
label: '角色状态',
key: 'enabled',
type: 'select',
props: {
placeholder: '请选择状态',
options: statusOptions.value,
clearable: true
}
},
{
label: '创建日期',
key: 'daterange',
type: 'datetime',
props: {
style: { width: '100%' },
placeholder: '请选择日期范围',
type: 'daterange',
rangeSeparator: '至',
startPlaceholder: '开始日期',
endPlaceholder: '结束日期',
valueFormat: 'YYYY-MM-DD',
shortcuts: [
{ text: '今日', value: [new Date(), new Date()] },
{ text: '最近一周', value: [new Date(Date.now() - 604800000), new Date()] },
{ text: '最近一个月', value: [new Date(Date.now() - 2592000000), new Date()] }
]
}
}
])
/**
* 处理重置事件
*/
const handleReset = () => {
emit('reset')
}
/**
* 处理搜索事件
* 验证表单后触发搜索
*/
const handleSearch = async () => {
await searchBarRef.value.validate()
emit('search', formData.value)
}
</script>

View File

@@ -0,0 +1,247 @@
<!-- 个人中心页面 -->
<template>
<div class="w-full h-full p-0 bg-transparent border-none shadow-none">
<div class="relative flex-b mt-2.5 max-md:block max-md:mt-1">
<div class="w-112 mr-5 max-md:w-full max-md:mr-0">
<div class="art-card-sm relative p-9 pb-6 overflow-hidden text-center">
<img class="absolute top-0 left-0 w-full h-50 object-cover" src="@imgs/user/bg.webp" />
<img
class="relative z-10 w-20 h-20 mt-30 mx-auto object-cover border-2 border-white rounded-full"
src="@imgs/user/avatar.webp"
/>
<h2 class="mt-5 text-xl font-normal">{{ userInfo.userName }}</h2>
<p class="mt-5 text-sm">专注于用户体验跟视觉设计</p>
<div class="w-75 mx-auto mt-7.5 text-left">
<div class="mt-2.5">
<ArtSvgIcon icon="ri:mail-line" class="text-g-700" />
<span class="ml-2 text-sm">jdkjjfnndf@mall.com</span>
</div>
<div class="mt-2.5">
<ArtSvgIcon icon="ri:user-3-line" class="text-g-700" />
<span class="ml-2 text-sm">交互专家</span>
</div>
<div class="mt-2.5">
<ArtSvgIcon icon="ri:map-pin-line" class="text-g-700" />
<span class="ml-2 text-sm">广东省深圳市</span>
</div>
<div class="mt-2.5">
<ArtSvgIcon icon="ri:dribbble-fill" class="text-g-700" />
<span class="ml-2 text-sm">字节跳动某某平台部UED</span>
</div>
</div>
<div class="mt-10">
<h3 class="text-sm font-medium">标签</h3>
<div class="flex flex-wrap justify-center mt-3.5">
<div
v-for="item in lableList"
:key="item"
class="py-1 px-1.5 mr-2.5 mb-2.5 text-xs border border-g-300 rounded"
>
{{ item }}
</div>
</div>
</div>
</div>
</div>
<div class="flex-1 overflow-hidden max-md:w-full max-md:mt-3.5">
<div class="art-card-sm">
<h1 class="p-4 text-xl font-normal border-b border-g-300">基本设置</h1>
<ElForm
:model="form"
class="box-border p-5 [&>.el-row_.el-form-item]:w-[calc(50%-10px)] [&>.el-row_.el-input]:w-full [&>.el-row_.el-select]:w-full"
ref="ruleFormRef"
:rules="rules"
label-width="86px"
label-position="top"
>
<ElRow>
<ElFormItem label="姓名" prop="realName">
<ElInput v-model="form.realName" :disabled="!isEdit" />
</ElFormItem>
<ElFormItem label="性别" prop="sex" class="ml-5">
<ElSelect v-model="form.sex" placeholder="Select" :disabled="!isEdit">
<ElOption
v-for="item in options"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</ElSelect>
</ElFormItem>
</ElRow>
<ElRow>
<ElFormItem label="昵称" prop="nikeName">
<ElInput v-model="form.nikeName" :disabled="!isEdit" />
</ElFormItem>
<ElFormItem label="邮箱" prop="email" class="ml-5">
<ElInput v-model="form.email" :disabled="!isEdit" />
</ElFormItem>
</ElRow>
<ElRow>
<ElFormItem label="手机" prop="mobile">
<ElInput v-model="form.mobile" :disabled="!isEdit" />
</ElFormItem>
<ElFormItem label="地址" prop="address" class="ml-5">
<ElInput v-model="form.address" :disabled="!isEdit" />
</ElFormItem>
</ElRow>
<ElFormItem label="个人介绍" prop="des" class="h-32">
<ElInput type="textarea" :rows="4" v-model="form.des" :disabled="!isEdit" />
</ElFormItem>
<div class="flex-c justify-end [&_.el-button]:!w-27.5">
<ElButton type="primary" class="w-22.5" v-ripple @click="edit">
{{ isEdit ? '保存' : '编辑' }}
</ElButton>
</div>
</ElForm>
</div>
<div class="art-card-sm my-5">
<h1 class="p-4 text-xl font-normal border-b border-g-300">更改密码</h1>
<ElForm :model="pwdForm" class="box-border p-5" label-width="86px" label-position="top">
<ElFormItem label="当前密码" prop="password">
<ElInput
v-model="pwdForm.password"
type="password"
:disabled="!isEditPwd"
show-password
/>
</ElFormItem>
<ElFormItem label="新密码" prop="newPassword">
<ElInput
v-model="pwdForm.newPassword"
type="password"
:disabled="!isEditPwd"
show-password
/>
</ElFormItem>
<ElFormItem label="确认新密码" prop="confirmPassword">
<ElInput
v-model="pwdForm.confirmPassword"
type="password"
:disabled="!isEditPwd"
show-password
/>
</ElFormItem>
<div class="flex-c justify-end [&_.el-button]:!w-27.5">
<ElButton type="primary" class="w-22.5" v-ripple @click="editPwd">
{{ isEditPwd ? '保存' : '编辑' }}
</ElButton>
</div>
</ElForm>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useUserStore } from '@/store/modules/user'
import type { FormInstance, FormRules } from 'element-plus'
defineOptions({ name: 'UserCenter' })
const userStore = useUserStore()
const userInfo = computed(() => userStore.getUserInfo)
const isEdit = ref(false)
const isEditPwd = ref(false)
const date = ref('')
const ruleFormRef = ref<FormInstance>()
/**
* 用户信息表单
*/
const form = reactive({
realName: 'John Snow',
nikeName: '皮卡丘',
email: '59301283@mall.com',
mobile: '18888888888',
address: '广东省深圳市宝安区西乡街道101栋201',
sex: '2',
des: 'Art Design Pro 是一款兼具设计美学与高效开发的后台系统.'
})
/**
* 密码修改表单
*/
const pwdForm = reactive({
password: '123456',
newPassword: '123456',
confirmPassword: '123456'
})
/**
* 表单验证规则
*/
const rules = reactive<FormRules>({
realName: [
{ required: true, message: '请输入姓名', trigger: 'blur' },
{ min: 2, max: 50, message: '长度在 2 到 50 个字符', trigger: 'blur' }
],
nikeName: [
{ required: true, message: '请输入昵称', trigger: 'blur' },
{ min: 2, max: 50, message: '长度在 2 到 50 个字符', trigger: 'blur' }
],
email: [{ required: true, message: '请输入邮箱', trigger: 'blur' }],
mobile: [{ required: true, message: '请输入手机号码', trigger: 'blur' }],
address: [{ required: true, message: '请输入地址', trigger: 'blur' }],
sex: [{ required: true, message: '请选择性别', trigger: 'blur' }]
})
/**
* 性别选项
*/
const options = [
{ value: '1', label: '男' },
{ value: '2', label: '女' }
]
/**
* 用户标签列表
*/
const lableList: Array<string> = ['专注设计', '很有想法', '辣~', '大长腿', '川妹子', '海纳百川']
onMounted(() => {
getDate()
})
/**
* 根据当前时间获取问候语
*/
const getDate = () => {
const h = new Date().getHours()
if (h >= 6 && h < 9) date.value = '早上好'
else if (h >= 9 && h < 11) date.value = '上午好'
else if (h >= 11 && h < 13) date.value = '中午好'
else if (h >= 13 && h < 18) date.value = '下午好'
else if (h >= 18 && h < 24) date.value = '晚上好'
else date.value = '很晚了,早点睡'
}
/**
* 切换用户信息编辑状态
*/
const edit = () => {
isEdit.value = !isEdit.value
}
/**
* 切换密码编辑状态
*/
const editPwd = () => {
isEditPwd.value = !isEditPwd.value
}
</script>

View File

@@ -0,0 +1,261 @@
<!-- 用户管理页面 -->
<!-- art-full-height 自动计算出页面剩余高度 -->
<!-- art-table-card 一个符合系统样式的 class同时自动撑满剩余高度 -->
<!-- 更多 useTable 使用示例请移步至 功能示例 下面的高级表格示例或者查看官方文档 -->
<!-- useTable 文档https://www.artd.pro/docs/zh/guide/hooks/use-table.html -->
<template>
<div class="user-page art-full-height">
<!-- 搜索栏 -->
<UserSearch v-model="searchForm" @search="handleSearch" @reset="resetSearchParams"></UserSearch>
<ElCard class="art-table-card" shadow="never">
<!-- 表格头部 -->
<ArtTableHeader v-model:columns="columnChecks" :loading="loading" @refresh="refreshData">
<template #left>
<ElSpace wrap>
<ElButton @click="showDialog('add')" v-ripple>新增用户</ElButton>
</ElSpace>
</template>
</ArtTableHeader>
<!-- 表格 -->
<ArtTable
:loading="loading"
:data="data"
:columns="columns"
:pagination="pagination"
@selection-change="handleSelectionChange"
@pagination:size-change="handleSizeChange"
@pagination:current-change="handleCurrentChange"
>
</ArtTable>
<!-- 用户弹窗 -->
<UserDialog
v-model:visible="dialogVisible"
:type="dialogType"
:user-data="currentUserData"
@submit="handleDialogSubmit"
/>
</ElCard>
</div>
</template>
<script setup lang="ts">
import ArtButtonTable from '@/components/core/forms/art-button-table/index.vue'
import { ACCOUNT_TABLE_DATA } from '@/mock/temp/formData'
import { useTable } from '@/hooks/core/useTable'
import { fetchGetUserList } from '@/api/system-manage'
import UserSearch from './modules/user-search.vue'
import UserDialog from './modules/user-dialog.vue'
import { ElTag, ElMessageBox, ElImage } from 'element-plus'
import { DialogType } from '@/types'
defineOptions({ name: 'User' })
type UserListItem = Api.SystemManage.UserListItem
// 弹窗相关
const dialogType = ref<DialogType>('add')
const dialogVisible = ref(false)
const currentUserData = ref<Partial<UserListItem>>({})
// 选中行
const selectedRows = ref<UserListItem[]>([])
// 搜索表单
const searchForm = ref({
userName: undefined,
userGender: undefined,
userPhone: undefined,
userEmail: undefined,
status: '1'
})
// 用户状态配置
const USER_STATUS_CONFIG = {
'1': { type: 'success' as const, text: '在线' },
'2': { type: 'info' as const, text: '离线' },
'3': { type: 'warning' as const, text: '异常' },
'4': { type: 'danger' as const, text: '注销' }
} as const
/**
* 获取用户状态配置
*/
const getUserStatusConfig = (status: string) => {
return (
USER_STATUS_CONFIG[status as keyof typeof USER_STATUS_CONFIG] || {
type: 'info' as const,
text: '未知'
}
)
}
const {
columns,
columnChecks,
data,
loading,
pagination,
getData,
searchParams,
resetSearchParams,
handleSizeChange,
handleCurrentChange,
refreshData
} = useTable({
// 核心配置
core: {
apiFn: fetchGetUserList,
apiParams: {
current: 1,
size: 20,
...searchForm.value
},
// 自定义分页字段映射,未设置时将使用全局配置 tableConfig.ts 中的 paginationKey
// paginationKey: {
// current: 'pageNum',
// size: 'pageSize'
// },
columnsFactory: () => [
{ type: 'selection' }, // 勾选列
{ type: 'index', width: 60, label: '序号' }, // 序号
{
prop: 'userInfo',
label: '用户名',
width: 280,
// visible: false, // 默认是否显示列
formatter: (row) => {
return h('div', { class: 'user flex-c' }, [
h(ElImage, {
class: 'size-9.5 rounded-md',
src: row.avatar,
previewSrcList: [row.avatar],
// 图片预览是否插入至 body 元素上,用于解决表格内部图片预览样式异常
previewTeleported: true
}),
h('div', { class: 'ml-2' }, [
h('p', { class: 'user-name' }, row.userName),
h('p', { class: 'email' }, row.userEmail)
])
])
}
},
{
prop: 'userGender',
label: '性别',
sortable: true,
formatter: (row) => row.userGender
},
{ prop: 'userPhone', label: '手机号' },
{
prop: 'status',
label: '状态',
formatter: (row) => {
const statusConfig = getUserStatusConfig(row.status)
return h(ElTag, { type: statusConfig.type }, () => statusConfig.text)
}
},
{
prop: 'createTime',
label: '创建日期',
sortable: true
},
{
prop: 'operation',
label: '操作',
width: 120,
fixed: 'right', // 固定列
formatter: (row) =>
h('div', [
h(ArtButtonTable, {
type: 'edit',
onClick: () => showDialog('edit', row)
}),
h(ArtButtonTable, {
type: 'delete',
onClick: () => deleteUser(row)
})
])
}
]
},
// 数据处理
transform: {
// 数据转换器 - 替换头像
dataTransformer: (records) => {
// 类型守卫检查
if (!Array.isArray(records)) {
console.warn('数据转换器: 期望数组类型,实际收到:', typeof records)
return []
}
// 使用本地头像替换接口返回的头像
return records.map((item, index: number) => {
return {
...item,
avatar: ACCOUNT_TABLE_DATA[index % ACCOUNT_TABLE_DATA.length].avatar
}
})
}
}
})
/**
* 搜索处理
* @param params 参数
*/
const handleSearch = (params: Record<string, any>) => {
console.log(params)
// 搜索参数赋值
Object.assign(searchParams, params)
getData()
}
/**
* 显示用户弹窗
*/
const showDialog = (type: DialogType, row?: UserListItem): void => {
console.log('打开弹窗:', { type, row })
dialogType.value = type
currentUserData.value = row || {}
nextTick(() => {
dialogVisible.value = true
})
}
/**
* 删除用户
*/
const deleteUser = (row: UserListItem): void => {
console.log('删除用户:', row)
ElMessageBox.confirm(`确定要注销该用户吗?`, '注销用户', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'error'
}).then(() => {
ElMessage.success('注销成功')
})
}
/**
* 处理弹窗提交事件
*/
const handleDialogSubmit = async () => {
try {
dialogVisible.value = false
currentUserData.value = {}
} catch (error) {
console.error('提交失败:', error)
}
}
/**
* 处理表格行选择变化
*/
const handleSelectionChange = (selection: UserListItem[]): void => {
selectedRows.value = selection
console.log('选中行数据:', selectedRows.value)
}
</script>

View File

@@ -0,0 +1,143 @@
<template>
<ElDialog
v-model="dialogVisible"
:title="dialogType === 'add' ? '添加用户' : '编辑用户'"
width="30%"
align-center
>
<ElForm ref="formRef" :model="formData" :rules="rules" label-width="80px">
<ElFormItem label="用户名" prop="username">
<ElInput v-model="formData.username" placeholder="请输入用户名" />
</ElFormItem>
<ElFormItem label="手机号" prop="phone">
<ElInput v-model="formData.phone" placeholder="请输入手机号" />
</ElFormItem>
<ElFormItem label="性别" prop="gender">
<ElSelect v-model="formData.gender">
<ElOption label="男" value="男" />
<ElOption label="女" value="女" />
</ElSelect>
</ElFormItem>
<ElFormItem label="角色" prop="role">
<ElSelect v-model="formData.role" multiple>
<ElOption
v-for="role in roleList"
:key="role.roleCode"
:value="role.roleCode"
:label="role.roleName"
/>
</ElSelect>
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="dialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="handleSubmit">提交</ElButton>
</div>
</template>
</ElDialog>
</template>
<script setup lang="ts">
import { ROLE_LIST_DATA } from '@/mock/temp/formData'
import type { FormInstance, FormRules } from 'element-plus'
interface Props {
visible: boolean
type: string
userData?: Partial<Api.SystemManage.UserListItem>
}
interface Emits {
(e: 'update:visible', value: boolean): void
(e: 'submit'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
// 角色列表数据
const roleList = ref(ROLE_LIST_DATA)
// 对话框显示控制
const dialogVisible = computed({
get: () => props.visible,
set: (value) => emit('update:visible', value)
})
const dialogType = computed(() => props.type)
// 表单实例
const formRef = ref<FormInstance>()
// 表单数据
const formData = reactive({
username: '',
phone: '',
gender: '男',
role: [] as string[]
})
// 表单验证规则
const rules: FormRules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 2, max: 20, message: '长度在 2 到 20 个字符', trigger: 'blur' }
],
phone: [
{ required: true, message: '请输入手机号', trigger: 'blur' },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号格式', trigger: 'blur' }
],
gender: [{ required: true, message: '请选择性别', trigger: 'blur' }],
role: [{ required: true, message: '请选择角色', trigger: 'blur' }]
}
/**
* 初始化表单数据
* 根据对话框类型(新增/编辑)填充表单
*/
const initFormData = () => {
const isEdit = props.type === 'edit' && props.userData
const row = props.userData
Object.assign(formData, {
username: isEdit && row ? row.userName || '' : '',
phone: isEdit && row ? row.userPhone || '' : '',
gender: isEdit && row ? row.userGender || '男' : '男',
role: isEdit && row ? (Array.isArray(row.userRoles) ? row.userRoles : []) : []
})
}
/**
* 监听对话框状态变化
* 当对话框打开时初始化表单数据并清除验证状态
*/
watch(
() => [props.visible, props.type, props.userData],
([visible]) => {
if (visible) {
initFormData()
nextTick(() => {
formRef.value?.clearValidate()
})
}
},
{ immediate: true }
)
/**
* 提交表单
* 验证通过后触发提交事件
*/
const handleSubmit = async () => {
if (!formRef.value) return
await formRef.value.validate((valid) => {
if (valid) {
ElMessage.success(dialogType.value === 'add' ? '添加成功' : '更新成功')
dialogVisible.value = false
emit('submit')
}
})
}
</script>

View File

@@ -0,0 +1,112 @@
<template>
<ArtSearchBar
ref="searchBarRef"
v-model="formData"
:items="formItems"
:rules="rules"
@reset="handleReset"
@search="handleSearch"
>
</ArtSearchBar>
</template>
<script setup lang="ts">
interface Props {
modelValue: Record<string, any>
}
interface Emits {
(e: 'update:modelValue', value: Record<string, any>): void
(e: 'search', params: Record<string, any>): void
(e: 'reset'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
// 表单数据双向绑定
const searchBarRef = ref()
const formData = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
// 校验规则
const rules = {
// userName: [{ required: true, message: '请输入用户名', trigger: 'blur' }]
}
// 动态 options
const statusOptions = ref<{ label: string; value: string; disabled?: boolean }[]>([])
// 模拟接口返回状态数据
function fetchStatusOptions(): Promise<typeof statusOptions.value> {
return new Promise((resolve) => {
setTimeout(() => {
resolve([
{ label: '在线', value: '1' },
{ label: '离线', value: '2' },
{ label: '异常', value: '3' },
{ label: '注销', value: '4' }
])
}, 1000)
})
}
onMounted(async () => {
statusOptions.value = await fetchStatusOptions()
})
// 表单配置
const formItems = computed(() => [
{
label: '用户名',
key: 'userName',
type: 'input',
placeholder: '请输入用户名',
clearable: true
},
{
label: '手机号',
key: 'userPhone',
type: 'input',
props: { placeholder: '请输入手机号', maxlength: '11' }
},
{
label: '邮箱',
key: 'userEmail',
type: 'input',
props: { placeholder: '请输入邮箱' }
},
{
label: '状态',
key: 'status',
type: 'select',
props: {
placeholder: '请选择状态',
options: statusOptions.value
}
},
{
label: '性别',
key: 'userGender',
type: 'radiogroup',
props: {
options: [
{ label: '男', value: '1' },
{ label: '女', value: '2' }
]
}
}
])
// 事件
function handleReset() {
console.log('重置表单')
emit('reset')
}
async function handleSearch() {
await searchBarRef.value.validate()
emit('search', formData.value)
console.log('表单数据', formData.value)
}
</script>