first commit
This commit is contained in:
62
src/views/auth/forget-password/index.vue
Normal file
62
src/views/auth/forget-password/index.vue
Normal 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>
|
||||
284
src/views/auth/login/index.vue
Normal file
284
src/views/auth/login/index.vue
Normal 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>
|
||||
38
src/views/auth/login/style.css
Normal file
38
src/views/auth/login/style.css
Normal 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);
|
||||
}
|
||||
}
|
||||
240
src/views/auth/register/index.vue
Normal file
240
src/views/auth/register/index.vue
Normal 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>
|
||||
41
src/views/dashboard/console/index.vue
Normal file
41
src/views/dashboard/console/index.vue
Normal 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>
|
||||
44
src/views/dashboard/console/modules/about-project.vue
Normal file
44
src/views/dashboard/console/modules/about-project.vue
Normal 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">使用了 Vue3、TypeScript、Vite、Element 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>
|
||||
47
src/views/dashboard/console/modules/active-user.vue
Normal file
47
src/views/dashboard/console/modules/active-user.vue
Normal 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>
|
||||
74
src/views/dashboard/console/modules/card-list.vue
Normal file
74
src/views/dashboard/console/modules/card-list.vue
Normal 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>
|
||||
79
src/views/dashboard/console/modules/dynamic-stats.vue
Normal file
79
src/views/dashboard/console/modules/dynamic-stats.vue
Normal 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>
|
||||
169
src/views/dashboard/console/modules/new-user.vue
Normal file
169
src/views/dashboard/console/modules/new-user.vue
Normal 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>
|
||||
43
src/views/dashboard/console/modules/sales-overview.vue
Normal file
43
src/views/dashboard/console/modules/sales-overview.vue
Normal 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>
|
||||
71
src/views/dashboard/console/modules/todo-list.vue
Normal file
71
src/views/dashboard/console/modules/todo-list.vue
Normal 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>
|
||||
16
src/views/exception/403/index.vue
Normal file
16
src/views/exception/403/index.vue
Normal 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>
|
||||
16
src/views/exception/404/index.vue
Normal file
16
src/views/exception/404/index.vue
Normal 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>
|
||||
16
src/views/exception/500/index.vue
Normal file
16
src/views/exception/500/index.vue
Normal 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
29
src/views/index/index.vue
Normal 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>
|
||||
93
src/views/index/style.scss
Normal file
93
src/views/index/style.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
42
src/views/outside/Iframe.vue
Normal file
42
src/views/outside/Iframe.vue
Normal 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>
|
||||
28
src/views/result/fail/index.vue
Normal file
28
src/views/result/fail/index.vue
Normal 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>
|
||||
21
src/views/result/success/index.vue
Normal file
21
src/views/result/success/index.vue
Normal 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>
|
||||
479
src/views/system/menu/index.vue
Normal file
479
src/views/system/menu/index.vue
Normal 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>
|
||||
384
src/views/system/menu/modules/menu-dialog.vue
Normal file
384
src/views/system/menu/modules/menu-dialog.vue
Normal 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>
|
||||
242
src/views/system/role/index.vue
Normal file
242
src/views/system/role/index.vue
Normal 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>
|
||||
162
src/views/system/role/modules/role-edit-dialog.vue
Normal file
162
src/views/system/role/modules/role-edit-dialog.vue
Normal 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>
|
||||
254
src/views/system/role/modules/role-permission-dialog.vue
Normal file
254
src/views/system/role/modules/role-permission-dialog.vue
Normal 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>
|
||||
121
src/views/system/role/modules/role-search.vue
Normal file
121
src/views/system/role/modules/role-search.vue
Normal 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>
|
||||
247
src/views/system/user-center/index.vue
Normal file
247
src/views/system/user-center/index.vue
Normal 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>
|
||||
261
src/views/system/user/index.vue
Normal file
261
src/views/system/user/index.vue
Normal 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>
|
||||
143
src/views/system/user/modules/user-dialog.vue
Normal file
143
src/views/system/user/modules/user-dialog.vue
Normal 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>
|
||||
112
src/views/system/user/modules/user-search.vue
Normal file
112
src/views/system/user/modules/user-search.vue
Normal 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>
|
||||
Reference in New Issue
Block a user