初始化本地仓库
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
<!-- 留言管理页面 -->
|
||||
<template>
|
||||
<div>
|
||||
<h1 class="text-4xl font-medium mt-5">留言墙</h1>
|
||||
<p class="mt-3.5 text-g-600">每一份留言都记录了您的想法,也为我们提供了珍贵的回忆</p>
|
||||
|
||||
<ul
|
||||
class="mt-10 grid grid-cols-5 gap-5 max-2xl:grid-cols-4 max-xl:grid-cols-3 max-lg:grid-cols-2 max-sm:grid-cols-1 mb-5"
|
||||
>
|
||||
<li
|
||||
class="relative p-4 c-p aspect-16/12 duration-300 hover:-translate-y-1.5"
|
||||
:style="{ background: item.color }"
|
||||
v-for="item in commentsWithColors"
|
||||
:key="item.id"
|
||||
@click="openDrawer(item)"
|
||||
>
|
||||
<p class="text-g-600 text-sm">{{ item.date }}</p>
|
||||
<p class="mt-4 text-sm text-gray-800">{{ item.content }}</p>
|
||||
<div class="absolute bottom-4 left-0 px-4 flex-cb w-full">
|
||||
<div class="flex-c">
|
||||
<div class="flex-c mr-5 text-xs text-g-600">
|
||||
<ArtSvgIcon icon="ri:heart-line" class="mr-1 text-base" />
|
||||
<span>{{ item.collection }}</span>
|
||||
</div>
|
||||
<div class="flex-c mr-5 text-xs text-g-600">
|
||||
<ArtSvgIcon icon="ri:message-3-line" class="mr-1 text-base" />
|
||||
<span>{{ item.comment }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-sm text-gray-700">{{ item.userName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<ElDrawer
|
||||
lDrawer
|
||||
v-model="showDrawer"
|
||||
:lock-scroll="false"
|
||||
:size="360"
|
||||
modal-class="comment-modal"
|
||||
>
|
||||
<template #header>
|
||||
<h4>详情</h4>
|
||||
</template>
|
||||
<template #default>
|
||||
<div class="drawer-default">
|
||||
<div class="relative p-4 aspect-16/12" :style="{ background: clickItem.color }">
|
||||
<p class="text-g-500 text-sm">{{ clickItem.date }}</p>
|
||||
<p class="mt-4 text-sm text-gray-800">{{ clickItem.content }}</p>
|
||||
<div class="absolute bottom-4 left-0 px-4 flex-cb w-full">
|
||||
<div class="flex-c">
|
||||
<div class="flex-c mr-5 text-xs text-g-600">
|
||||
<ArtSvgIcon icon="ri:heart-line" class="mr-1 text-base" />
|
||||
<span>{{ clickItem.collection }}</span>
|
||||
</div>
|
||||
<div class="flex-c mr-5 text-xs text-g-600">
|
||||
<ArtSvgIcon icon="ri:message-3-line" class="mr-1 text-base" />
|
||||
<span>{{ clickItem.comment }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-sm text-gray-700">{{ clickItem.userName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 评论组件 -->
|
||||
<CommentWidget />
|
||||
</div>
|
||||
</template>
|
||||
</ElDrawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { commentList } from "@/apps/demo/mock/temp/commentList";
|
||||
|
||||
defineOptions({ name: "ArticleComment" });
|
||||
|
||||
interface CommentItem {
|
||||
id: number;
|
||||
date: string;
|
||||
content: string;
|
||||
collection: number;
|
||||
comment: number;
|
||||
userName: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
const COLOR_LIST = [
|
||||
"#D8F8FF",
|
||||
"#FDDFD9",
|
||||
"#FCE6F0",
|
||||
"#D3F8F0",
|
||||
"#FFEABC",
|
||||
"#F5E1FF",
|
||||
"#E1E6FE",
|
||||
];
|
||||
|
||||
const showDrawer = ref(false);
|
||||
const clickItem = ref<CommentItem>({
|
||||
id: 1,
|
||||
date: "2024-9-3",
|
||||
content: "加油!学好Node 自己写个小Demo",
|
||||
collection: 5,
|
||||
comment: 8,
|
||||
userName: "匿名",
|
||||
color: COLOR_LIST[0],
|
||||
});
|
||||
|
||||
/**
|
||||
* 为评论列表分配随机颜色
|
||||
*/
|
||||
const commentsWithColors = computed(() => {
|
||||
let lastColorIndex = -1;
|
||||
|
||||
return commentList.map((item) => {
|
||||
let newIndex: number;
|
||||
|
||||
do {
|
||||
newIndex = Math.floor(Math.random() * COLOR_LIST.length);
|
||||
} while (newIndex === lastColorIndex && COLOR_LIST.length > 1);
|
||||
|
||||
lastColorIndex = newIndex;
|
||||
|
||||
return {
|
||||
...item,
|
||||
color: COLOR_LIST[newIndex],
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 打开评论详情抽屉
|
||||
*/
|
||||
const openDrawer = (item: CommentItem) => {
|
||||
showDrawer.value = true;
|
||||
clickItem.value = item;
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,136 @@
|
||||
<!-- 文章详情页面 -->
|
||||
<template>
|
||||
<div class="article-detail page-content">
|
||||
<div class="max-w-200 m-auto mt-15">
|
||||
<h1 class="text-3xl font-semibold">{{ articleTitle }}</h1>
|
||||
<div class="markdown-body mt-12.5" v-highlight v-html="articleHtml"></div>
|
||||
</div>
|
||||
<ArtBackToTop />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import "@/apps/demo/assets/styles/core/md.scss";
|
||||
import "@/apps/demo/assets/styles/custom/one-dark-pro.scss";
|
||||
import { useCommon } from "@/hooks/core/useCommon";
|
||||
import axios from "axios";
|
||||
|
||||
defineOptions({ name: "ArticleDetail" });
|
||||
|
||||
interface ArticleResponse {
|
||||
code: number;
|
||||
data: {
|
||||
title: string;
|
||||
html_content: string;
|
||||
};
|
||||
}
|
||||
|
||||
const route = useRoute();
|
||||
const articleId = computed(() => Number(route.params.id));
|
||||
const articleTitle = ref("");
|
||||
const articleHtml = shallowRef("");
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
const getArticleDetail = async () => {
|
||||
if (!articleId.value) return;
|
||||
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const { data } = await axios.get<ArticleResponse>(
|
||||
"https://www.qiniu.lingchen.kim/blog_detail.json"
|
||||
);
|
||||
|
||||
if (data.code === 200) {
|
||||
articleTitle.value = data.data.title;
|
||||
articleHtml.value = data.data.html_content;
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = "文章加载失败";
|
||||
console.error("获取文章详情失败:", err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const { scrollToTop } = useCommon();
|
||||
|
||||
onMounted(() => {
|
||||
scrollToTop();
|
||||
getArticleDetail();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.article-detail {
|
||||
:deep(.markdown-body) {
|
||||
margin-top: 60px;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
border: 1px solid var(--art-gray-200);
|
||||
}
|
||||
|
||||
pre {
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
.copy-button {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50px;
|
||||
width: 1px;
|
||||
height: 100%;
|
||||
content: "";
|
||||
background: #0a0a0e;
|
||||
}
|
||||
}
|
||||
|
||||
.code-wrapper {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.line-number {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 2;
|
||||
box-sizing: border-box;
|
||||
display: inline-block;
|
||||
width: 50px;
|
||||
margin-right: 10px;
|
||||
font-size: 14px;
|
||||
color: #9e9e9e;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.copy-button {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
font-size: 20px;
|
||||
line-height: 40px;
|
||||
color: #999;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
background-color: #000;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
opacity: 0;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,201 @@
|
||||
<!-- 文章列表页面 -->
|
||||
<template>
|
||||
<div class="page-content !mb-5">
|
||||
<ElRow justify="space-between" :gutter="10">
|
||||
<ElCol :lg="6" :md="6" :sm="14" :xs="16">
|
||||
<ElInput
|
||||
v-model="searchVal"
|
||||
:prefix-icon="Search"
|
||||
clearable
|
||||
placeholder="输入文章标题查询"
|
||||
@keyup.enter="searchArticle"
|
||||
/>
|
||||
</ElCol>
|
||||
<ElCol :lg="12" :md="12" :sm="0" :xs="0">
|
||||
<div class="custom-segmented">
|
||||
<ElSegmented
|
||||
v-model="yearVal"
|
||||
:options="YEAR_OPTIONS"
|
||||
@change="searchArticleByYear"
|
||||
/>
|
||||
</div>
|
||||
</ElCol>
|
||||
<ElCol :lg="6" :md="6" :sm="10" :xs="6" style="display: flex; justify-content: end">
|
||||
<ElButton @click="toAddArticle" v-auth="'add'">新增文章</ElButton>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
|
||||
<div class="mt-5">
|
||||
<div
|
||||
class="grid grid-cols-5 gap-5 max-2xl:grid-cols-4 max-xl:grid-cols-3 max-lg:grid-cols-2 max-sm:grid-cols-1"
|
||||
>
|
||||
<div
|
||||
class="group c-p overflow-hidden border border-g-300/60 rounded-custom-sm"
|
||||
v-for="item in articleList"
|
||||
:key="item.id"
|
||||
@click="toDetail(item)"
|
||||
>
|
||||
<div class="relative aspect-[16/9.5]">
|
||||
<ElImage
|
||||
class="flex align-center justify-center w-full h-full object-cover bg-gray-200"
|
||||
:src="item.home_img"
|
||||
lazy
|
||||
fit="cover"
|
||||
>
|
||||
</ElImage>
|
||||
|
||||
<span
|
||||
class="absolute top-1 right-1 bg-black/50 rounded text-xs px-1 py-0.5 text-white"
|
||||
>{{ item.type_name }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="px-2 py-1">
|
||||
<h2 class="text-base text-g-800 font-medium">{{ item.title }}</h2>
|
||||
<div class="flex-b w-full h-6 mt-1">
|
||||
<div class="flex-c text-g-500">
|
||||
<ArtSvgIcon icon="ri:time-line" class="mr-1 text-sm" />
|
||||
<span class="text-sm">{{
|
||||
useDateFormat(item.create_time, "YYYY-MM-DD")
|
||||
}}</span>
|
||||
<div class="w-px h-3 bg-g-400 mx-3.5"></div>
|
||||
<ArtSvgIcon icon="ri:eye-line" class="mr-1 text-sm" />
|
||||
<span class="text-sm">{{ item.count }}</span>
|
||||
</div>
|
||||
<ElButton
|
||||
class="opacity-0 group-hover:opacity-100"
|
||||
v-auth="'edit'"
|
||||
size="small"
|
||||
@click.stop="toEdit(item)"
|
||||
>编辑</ElButton
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 16vh" v-if="showEmpty">
|
||||
<ElEmpty :description="`未找到相关数据 ${EmojiText[0]}`" />
|
||||
</div>
|
||||
|
||||
<div style="display: flex; justify-content: center; margin-top: 20px">
|
||||
<ElPagination
|
||||
size="default"
|
||||
background
|
||||
v-model:current-page="currentPage"
|
||||
:page-size="pageSize"
|
||||
:pager-count="9"
|
||||
layout="prev, pager, next, total,jumper"
|
||||
:total="total"
|
||||
:hide-on-single-page="true"
|
||||
@current-change="handleCurrentChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Search } from "@element-plus/icons-vue";
|
||||
import { router } from "@/router";
|
||||
import { useDateFormat } from "@vueuse/core";
|
||||
import EmojiText from "@/utils/ui/emojo";
|
||||
import { ArticleList } from "@/apps/demo/mock/temp/articleList";
|
||||
import { useCommon } from "@/hooks/core/useCommon";
|
||||
|
||||
defineOptions({ name: "ArticleList" });
|
||||
|
||||
interface Article {
|
||||
id: number;
|
||||
home_img: string;
|
||||
type_name: string;
|
||||
title: string;
|
||||
create_time: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface GetArticleListOptions {
|
||||
backTop?: boolean;
|
||||
}
|
||||
|
||||
const YEAR_OPTIONS = ["All", "2024", "2023", "2022", "2021", "2020", "2019"];
|
||||
const PAGE_SIZE = 40;
|
||||
|
||||
const yearVal = ref("All");
|
||||
const searchVal = ref("");
|
||||
const articleList = ref<Article[]>([]);
|
||||
const currentPage = ref(1);
|
||||
const pageSize = ref(PAGE_SIZE);
|
||||
const total = ref(0);
|
||||
const isLoading = ref(true);
|
||||
|
||||
const showEmpty = computed(() => articleList.value.length === 0 && !isLoading.value);
|
||||
|
||||
const getArticleList = async ({ backTop = false }: GetArticleListOptions = {}) => {
|
||||
isLoading.value = true;
|
||||
|
||||
try {
|
||||
if (searchVal.value) {
|
||||
yearVal.value = "All";
|
||||
}
|
||||
|
||||
// TODO: 替换为真实 API 调用
|
||||
// const params = {
|
||||
// page: currentPage.value,
|
||||
// size: pageSize.value,
|
||||
// searchVal: searchVal.value,
|
||||
// year: yearVal.value === 'All' ? '' : yearVal.value
|
||||
// }
|
||||
// const res = await ArticleService.getArticleList(params)
|
||||
|
||||
articleList.value = ArticleList as Article[];
|
||||
|
||||
if (backTop) {
|
||||
useCommon().scrollToTop();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取文章列表失败:", error);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const searchArticle = () => {
|
||||
currentPage.value = 1;
|
||||
getArticleList({ backTop: true });
|
||||
};
|
||||
|
||||
const searchArticleByYear = () => {
|
||||
currentPage.value = 1;
|
||||
getArticleList({ backTop: true });
|
||||
};
|
||||
|
||||
const handleCurrentChange = (val: number) => {
|
||||
currentPage.value = val;
|
||||
getArticleList({ backTop: true });
|
||||
};
|
||||
|
||||
const toDetail = (item: Article) => {
|
||||
router.push({ name: "ArticleDetail", params: { id: item.id } });
|
||||
};
|
||||
|
||||
const toEdit = (item: Article) => {
|
||||
router.push({ name: "ArticlePublish", query: { id: item.id } });
|
||||
};
|
||||
|
||||
const toAddArticle = () => {
|
||||
router.push({ name: "ArticlePublish" });
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
getArticleList();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.custom-segmented .el-segmented {
|
||||
height: 40px;
|
||||
padding: 6px;
|
||||
|
||||
--el-border-radius-base: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,339 @@
|
||||
<!-- 文章发布页面 -->
|
||||
<template>
|
||||
<div>
|
||||
<div>
|
||||
<div class="max-w-250 mx-auto my-5">
|
||||
<!-- 文章标题、类型 -->
|
||||
<ElRow :gutter="10">
|
||||
<ElCol :span="18">
|
||||
<ElInput
|
||||
v-model.trim="articleName"
|
||||
placeholder="请输入文章标题(最多100个字符)"
|
||||
maxlength="100"
|
||||
/>
|
||||
</ElCol>
|
||||
<ElCol :span="6">
|
||||
<ElSelect v-model="articleType" placeholder="请选择文章类型" filterable>
|
||||
<ElOption
|
||||
v-for="item in articleTypes"
|
||||
:key="item.id"
|
||||
:label="item.name"
|
||||
:value="item.id"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
|
||||
<!-- 富文本编辑器 -->
|
||||
<ArtWangEditor class="mt-2.5" v-model="editorHtml" />
|
||||
|
||||
<div class="p-5 mt-5 art-card-xs">
|
||||
<h2 class="mb-5 text-xl font-medium">发布设置</h2>
|
||||
<!-- 图片上传 -->
|
||||
<ElForm>
|
||||
<ElFormItem label="封面">
|
||||
<div class="mt-2.5">
|
||||
<ElUpload
|
||||
:action="uploadImageUrl"
|
||||
:headers="uploadHeaders"
|
||||
:show-file-list="false"
|
||||
:on-success="onSuccess"
|
||||
:on-error="onError"
|
||||
:before-upload="beforeUpload"
|
||||
>
|
||||
<div
|
||||
v-if="!cover"
|
||||
class="flex-cc flex-col w-65 h-40 border border-dashed border-[#d9d9d9] rounded-md"
|
||||
>
|
||||
<ElIcon class="!text-xl !text-g-600"><Plus /></ElIcon>
|
||||
<div class="mt-2 text-sm text-g-600">点击上传封面</div>
|
||||
</div>
|
||||
<img v-else :src="cover" class="block w-65 h-40 object-cover" />
|
||||
</ElUpload>
|
||||
<div class="mt-2 text-xs text-g-700">建议尺寸 16:9,jpg/png 格式</div>
|
||||
</div>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="可见">
|
||||
<ElSwitch v-model="visible" />
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<ElButton type="primary" @click="submit" class="w-25">
|
||||
{{ pageMode === PageModeEnum.Edit ? '保存' : '发布' }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- <div class="box-border w-70 p-5 border border-[#e3e3e3] rounded-lg">
|
||||
<div v-for="(item, index) in outlineList" :key="index">
|
||||
<p :class="['h-7.5 text-xs leading-7.5 c-p', item.level === 3 && 'pl-2.5']">{{ item.text }}</p>
|
||||
</div>
|
||||
</div> -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import { ApiStatus } from '@/utils/http/status'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import EmojiText from '@/utils/ui/emojo'
|
||||
import { PageModeEnum } from '@/enums/formEnum'
|
||||
import axios from 'axios'
|
||||
import { useCommon } from '@/hooks/core/useCommon'
|
||||
|
||||
defineOptions({ name: 'ArticlePublish' })
|
||||
|
||||
interface ArticleType {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
interface UploadResponse {
|
||||
data: {
|
||||
url: string
|
||||
}
|
||||
}
|
||||
|
||||
interface ArticleDetailResponse {
|
||||
code: number
|
||||
data: {
|
||||
title: string
|
||||
blog_class: string
|
||||
html_content: string
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_IMAGE_SIZE = 2 // MB
|
||||
const EMPTY_EDITOR_CONTENT = '<p><br></p>'
|
||||
|
||||
const route = useRoute()
|
||||
const userStore = useUserStore()
|
||||
const { accessToken } = userStore
|
||||
|
||||
const uploadImageUrl = `${import.meta.env.VITE_API_URL}/api/common/upload`
|
||||
const uploadHeaders = { Authorization: accessToken }
|
||||
|
||||
const pageMode = ref<PageModeEnum>(PageModeEnum.Add)
|
||||
const articleName = ref('')
|
||||
const articleType = ref<number>()
|
||||
const articleTypes = ref<ArticleType[]>([])
|
||||
const editorHtml = ref('')
|
||||
const createDate = ref('')
|
||||
const cover = ref('')
|
||||
const visible = ref(true)
|
||||
|
||||
/**
|
||||
* 初始化页面模式(新增或编辑)
|
||||
*/
|
||||
const initPageMode = () => {
|
||||
const { id } = route.query
|
||||
pageMode.value = id ? PageModeEnum.Edit : PageModeEnum.Add
|
||||
|
||||
if (pageMode.value === PageModeEnum.Edit) {
|
||||
getArticleDetail()
|
||||
} else {
|
||||
createDate.value = formatDate(useNow().value)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文章分类列表
|
||||
*/
|
||||
const getArticleTypes = async () => {
|
||||
try {
|
||||
const { data } = await axios.get('https://www.qiniu.lingchen.kim/classify.json')
|
||||
if (data.code === 200) {
|
||||
articleTypes.value = data.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取文章分类失败:', error)
|
||||
ElMessage.error('获取文章分类失败')
|
||||
}
|
||||
|
||||
// TODO: 替换为真实 API 调用
|
||||
// const res = await ArticleService.getArticleTypes({})
|
||||
// if (res.code === ApiStatus.success) {
|
||||
// articleTypes.value = res.data
|
||||
// }
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文章详情(编辑模式)
|
||||
*/
|
||||
const getArticleDetail = async () => {
|
||||
try {
|
||||
const { data } = await axios.get<ArticleDetailResponse>(
|
||||
'https://www.qiniu.lingchen.kim/blog_list.json'
|
||||
)
|
||||
|
||||
if (data.code === ApiStatus.success) {
|
||||
const { title, blog_class, html_content } = data.data
|
||||
articleName.value = title
|
||||
articleType.value = Number(blog_class)
|
||||
editorHtml.value = html_content
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取文章详情失败:', error)
|
||||
ElMessage.error('获取文章详情失败')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化日期为 YYYY-MM-DD 格式
|
||||
*/
|
||||
const formatDate = (date: string | Date): string => {
|
||||
return useDateFormat(date, 'YYYY-MM-DD').value
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证文章表单数据
|
||||
*/
|
||||
const validateArticle = (): boolean => {
|
||||
if (!articleName.value.trim()) {
|
||||
ElMessage.error('请输入文章标题')
|
||||
return false
|
||||
}
|
||||
|
||||
if (!articleType.value) {
|
||||
ElMessage.error('请选择文章类型')
|
||||
return false
|
||||
}
|
||||
|
||||
if (!editorHtml.value || editorHtml.value === EMPTY_EDITOR_CONTENT) {
|
||||
ElMessage.error('请输入文章内容')
|
||||
return false
|
||||
}
|
||||
|
||||
if (!cover.value) {
|
||||
ElMessage.error('请上传封面图片')
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理代码块中的多余空格
|
||||
*/
|
||||
const cleanCodeContent = (content: string): string => {
|
||||
return content.replace(/(\s*)<\/code>/g, '</code>')
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增文章
|
||||
*/
|
||||
const addArticle = async () => {
|
||||
if (!validateArticle()) return
|
||||
|
||||
try {
|
||||
const cleanedContent = cleanCodeContent(editorHtml.value)
|
||||
|
||||
// TODO: 替换为真实 API 调用
|
||||
// const params = {
|
||||
// title: articleName.value,
|
||||
// type: articleType.value,
|
||||
// content: cleanedContent,
|
||||
// cover: cover.value,
|
||||
// visible: visible.value
|
||||
// }
|
||||
// const res = await ArticleService.addArticle(params)
|
||||
// if (res.code === ApiStatus.success) {
|
||||
// ElMessage.success('文章发布成功')
|
||||
// router.push({ name: 'ArticleList' })
|
||||
// }
|
||||
|
||||
console.log('新增文章:', { cleanedContent })
|
||||
} catch (error) {
|
||||
console.error('发布文章失败:', error)
|
||||
ElMessage.error('发布文章失败')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑文章
|
||||
*/
|
||||
const editArticle = async () => {
|
||||
if (!validateArticle()) return
|
||||
|
||||
try {
|
||||
const cleanedContent = cleanCodeContent(editorHtml.value)
|
||||
|
||||
// TODO: 替换为真实 API 调用
|
||||
// const params = {
|
||||
// id: route.query.id,
|
||||
// title: articleName.value,
|
||||
// type: articleType.value,
|
||||
// content: cleanedContent,
|
||||
// cover: cover.value,
|
||||
// visible: visible.value
|
||||
// }
|
||||
// const res = await ArticleService.editArticle(params)
|
||||
// if (res.code === ApiStatus.success) {
|
||||
// ElMessage.success('文章保存成功')
|
||||
// router.push({ name: 'ArticleList' })
|
||||
// }
|
||||
|
||||
console.log('编辑文章:', { cleanedContent })
|
||||
} catch (error) {
|
||||
console.error('保存文章失败:', error)
|
||||
ElMessage.error('保存文章失败')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交表单(新增或编辑)
|
||||
*/
|
||||
const submit = () => {
|
||||
if (pageMode.value === PageModeEnum.Edit) {
|
||||
editArticle()
|
||||
} else {
|
||||
addArticle()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片上传成功回调
|
||||
*/
|
||||
const onSuccess = (response: UploadResponse) => {
|
||||
cover.value = response.data.url
|
||||
ElMessage.success(`图片上传成功 ${EmojiText[200]}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片上传失败回调
|
||||
*/
|
||||
const onError = () => {
|
||||
ElMessage.error(`图片上传失败 ${EmojiText[500]}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传前的文件校验
|
||||
*/
|
||||
const beforeUpload = (file: File): boolean => {
|
||||
const isImage = file.type.startsWith('image/')
|
||||
const isLt2M = file.size / 1024 / 1024 < MAX_IMAGE_SIZE
|
||||
|
||||
if (!isImage) {
|
||||
ElMessage.error('只能上传图片文件')
|
||||
return false
|
||||
}
|
||||
|
||||
if (!isLt2M) {
|
||||
ElMessage.error(`图片大小不能超过 ${MAX_IMAGE_SIZE}MB`)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const { scrollToTop } = useCommon()
|
||||
|
||||
onMounted(() => {
|
||||
scrollToTop()
|
||||
getArticleTypes()
|
||||
initPageMode()
|
||||
})
|
||||
</script>
|
||||
@@ -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>
|
||||
@@ -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 '@/apps/demo/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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,53 @@
|
||||
<!-- 更新日志页面 -->
|
||||
<template>
|
||||
<div class="mx-auto pt-5 mb-5">
|
||||
<!-- 标题 -->
|
||||
<h3 class="text-2xl font-medium text-g-900 mb-8">更新日志</h3>
|
||||
|
||||
<!-- 日志列表 -->
|
||||
<div class="space-y-5">
|
||||
<div
|
||||
v-for="item in upgradeLogList"
|
||||
:key="item.version"
|
||||
class="art-card-sm rounded-lg p-6 transition-shadow max-md:p-4"
|
||||
>
|
||||
<!-- 版本和日期 -->
|
||||
<div class="flex-cb gap-3 mb-4 flex-wrap">
|
||||
<span class="px-3 py-1 bg-theme/10 text-theme text-sm font-medium rounded-full">
|
||||
{{ item.version }}
|
||||
</span>
|
||||
<span class="text-sm text-g-500">{{ item.date }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 标题 -->
|
||||
<h4 class="text-lg font-medium text-g-900 mb-3">{{ item.title }}</h4>
|
||||
|
||||
<!-- 详情列表 -->
|
||||
<ul v-if="item.detail?.length" class="space-y-2 mb-4">
|
||||
<li
|
||||
v-for="(detail, index) in item.detail"
|
||||
:key="index"
|
||||
class="flex-c gap-2 text-sm text-g-600"
|
||||
>
|
||||
<span class="mt-0.5">•</span>
|
||||
<span class="text-g-700">{{ detail }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- 备注 -->
|
||||
<div v-if="item.remark" class="text-sm text-g-800 bg-g-300/60 rounded p-3 mb-3">
|
||||
{{ item.remark }}
|
||||
</div>
|
||||
|
||||
<!-- 标签 -->
|
||||
<ElTag v-if="item.requireReLogin" type="warning" size="small">需要重新登录</ElTag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { upgradeLogList } from '@/apps/demo/mock/upgrade/changeLog'
|
||||
|
||||
defineOptions({ name: 'ChangeLog' })
|
||||
</script>
|
||||
@@ -0,0 +1,50 @@
|
||||
<!-- 分析页页面 -->
|
||||
<template>
|
||||
<div>
|
||||
<ElRow :gutter="20">
|
||||
<ElCol :xl="14" :lg="15" :xs="24">
|
||||
<TodaySales />
|
||||
</ElCol>
|
||||
<ElCol :xl="10" :lg="9" :xs="24">
|
||||
<VisitorInsights />
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
|
||||
<ElRow :gutter="20">
|
||||
<ElCol :xl="10" :lg="10" :xs="24">
|
||||
<TotalRevenue />
|
||||
</ElCol>
|
||||
<ElCol :xl="7" :lg="7" :xs="24">
|
||||
<CustomerSatisfaction />
|
||||
</ElCol>
|
||||
<ElCol :xl="7" :lg="7" :xs="24">
|
||||
<TargetVsReality />
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
|
||||
<ElRow :gutter="20">
|
||||
<ElCol :xl="10" :lg="10" :xs="24">
|
||||
<TopProducts />
|
||||
</ElCol>
|
||||
<ElCol :xl="7" :lg="7" :xs="24">
|
||||
<SalesMappingByCountry />
|
||||
</ElCol>
|
||||
<ElCol :xl="7" :lg="7" :xs="24">
|
||||
<VolumeServiceLevel />
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import TodaySales from './modules/today-sales.vue'
|
||||
import VisitorInsights from './modules/visitor-insights.vue'
|
||||
import TotalRevenue from './modules/total-revenue.vue'
|
||||
import CustomerSatisfaction from './modules/customer-satisfaction.vue'
|
||||
import TargetVsReality from './modules/target-vs-reality.vue'
|
||||
import TopProducts from './modules/top-products.vue'
|
||||
import SalesMappingByCountry from './modules/sales-mapping-by-country.vue'
|
||||
import VolumeServiceLevel from './modules/volume-service-level.vue'
|
||||
|
||||
defineOptions({ name: 'Analysis' })
|
||||
</script>
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<div class="art-card h-100 p-5 mb-5 max-sm:mb-4">
|
||||
<div class="art-card-header">
|
||||
<div class="title">
|
||||
<h4>客户满意度</h4>
|
||||
</div>
|
||||
</div>
|
||||
<ArtLineChart
|
||||
height="calc(100% - 30px)"
|
||||
:data="chartData"
|
||||
:xAxisData="xAxisData"
|
||||
:showLegend="true"
|
||||
:showAxisLabel="true"
|
||||
:showAxisLine="false"
|
||||
:showSplitLine="true"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { LineDataItem } from '@/types/component/chart'
|
||||
|
||||
const AREA_STYLE_CONFIG = {
|
||||
startOpacity: 0.08,
|
||||
endOpacity: 0
|
||||
} as const
|
||||
|
||||
/**
|
||||
* X 轴数据配置
|
||||
* 表示一周的天数(周一到周日)
|
||||
*/
|
||||
const xAxisData = ['1', '2', '3', '4', '5', '6', '7']
|
||||
|
||||
/**
|
||||
* 客户满意度图表数据
|
||||
* 对比上个月和本月的满意度趋势
|
||||
*/
|
||||
const chartData = computed<LineDataItem[]>(() => [
|
||||
{
|
||||
name: '上个月',
|
||||
data: [65, 72, 68, 75, 82, 78, 85],
|
||||
areaStyle: AREA_STYLE_CONFIG
|
||||
},
|
||||
{
|
||||
name: '本月',
|
||||
data: [78, 85, 82, 88, 92, 89, 95],
|
||||
areaStyle: AREA_STYLE_CONFIG
|
||||
}
|
||||
])
|
||||
</script>
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<div class="art-card h-82 p-5 mb-5 max-sm:mb-4">
|
||||
<div class="art-card-header">
|
||||
<div class="title">
|
||||
<h4>全国销售分布</h4>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ArtRingChart
|
||||
:data="[
|
||||
{ value: 30, name: '北京' },
|
||||
{ value: 25, name: '上海' },
|
||||
{ value: 45, name: '广州' }
|
||||
]"
|
||||
:color="['#4C87F3', '#93F1B4', '#8BD8FC']"
|
||||
:radius="['46%', '60%']"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<div class="art-card h-100 p-5 mb-5 max-sm:mb-4">
|
||||
<div class="art-card-header">
|
||||
<div class="title">
|
||||
<h4>目标与实际</h4>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ArtBarChart
|
||||
class="p-5"
|
||||
height="12rem"
|
||||
:data="revenueData"
|
||||
:xAxisData="weekDays"
|
||||
:showAxisLine="false"
|
||||
barWidth="28%"
|
||||
/>
|
||||
|
||||
<div class="px-5 mt-4">
|
||||
<div v-for="item in totalItems" :key="item.label" class="flex-c mb-5 last:mb-0">
|
||||
<div class="flex-c justify-start w-3/5 text-sm">
|
||||
<div class="w-10 h-10 mr-3 text-lg rounded-md flex-cc" :class="item.iconClass">
|
||||
<ArtSvgIcon :icon="item.icon" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-start">
|
||||
<span class="text-base text-g-800">{{ item.label }}</span>
|
||||
<span class="mt-1 text-xs text-g-500">{{ item.subLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-lg font-normal" :class="item.valueClass">{{ item.value }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface RevenueDataItem {
|
||||
name: string
|
||||
data: number[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 一周的日期标签
|
||||
*/
|
||||
const weekDays = ref(['周一', '周二', '周三', '周四', '周五', '周六', '周日'])
|
||||
|
||||
/**
|
||||
* 目标与实际销售数据
|
||||
* 展示一周内的线上销售情况
|
||||
*/
|
||||
const revenueData = ref<RevenueDataItem[]>([
|
||||
{
|
||||
name: '线上销售',
|
||||
data: [12, 13, 5, 15, 10, 15, 18]
|
||||
}
|
||||
])
|
||||
|
||||
/**
|
||||
* 统计项数据
|
||||
*/
|
||||
const totalItems = [
|
||||
{
|
||||
icon: 'ri:shopping-bag-line',
|
||||
iconClass: 'text-theme bg-theme/12',
|
||||
label: '实际销售额',
|
||||
subLabel: '全球',
|
||||
value: '8,823',
|
||||
valueClass: 'text-theme'
|
||||
},
|
||||
{
|
||||
icon: 'ri:money-dollar-circle-line',
|
||||
iconClass: 'text-theme bg-theme/12',
|
||||
label: '目标销售额',
|
||||
subLabel: '商业',
|
||||
value: '12,122',
|
||||
valueClass: 'text-theme'
|
||||
}
|
||||
]
|
||||
</script>
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<div class="art-card h-82 p-5 mb-5 overflow-hidden max-lg:h-auto max-sm:mb-4">
|
||||
<div class="art-card-header pr-0">
|
||||
<div class="title">
|
||||
<h4>今日销售</h4>
|
||||
<p>销售总结</p>
|
||||
</div>
|
||||
<div class="flex-cc h-7.5 min-w-17 border border-g-300 rounded-lg text-g-500 c-p">
|
||||
<ArtSvgIcon icon="ri:arrow-up-line" class="text-base mr-1.5" />
|
||||
<span class="text-xs">导出</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2">
|
||||
<ElRow :gutter="20">
|
||||
<ElCol :span="6" :xs="24" v-for="(item, index) in salesData" :key="index">
|
||||
<div
|
||||
class="flex px-5 flex-col justify-center h-55 border border-g-300/85 rounded-xl max-lg:mb-4 max-sm:flex-row max-sm:justify-between max-sm:items-center max-sm:h-40"
|
||||
>
|
||||
<div class="size-12 rounded-lg flex-cc bg-theme/10">
|
||||
<ArtSvgIcon :icon="item.icon" class="text-xl text-theme" />
|
||||
</div>
|
||||
|
||||
<div class="max-sm:ml-4 mt-3.5 max-sm:mt-0 max-sm:text-end">
|
||||
<ArtCountTo class="text-2xl font-medium" :target="item.value" :duration="1500" />
|
||||
<p class="mt-2 text-base text-g-600 max-sm:mt-1">{{ item.label }}</p>
|
||||
<small class="text-g-500 mt-1 max-sm:mt-0.5">
|
||||
较昨天
|
||||
<span
|
||||
class="font-medium"
|
||||
:class="[item.change.indexOf('+') === -1 ? 'text-danger' : 'text-success']"
|
||||
>{{ item.change }}</span
|
||||
>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface SalesDataItem {
|
||||
label: string
|
||||
value: number
|
||||
change: string
|
||||
icon: string
|
||||
class: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 今日销售数据统计
|
||||
* 包含销售额、订单量、产品销量和新客户数等关键指标
|
||||
*/
|
||||
const salesData = ref<SalesDataItem[]>([
|
||||
{
|
||||
label: '总销售额',
|
||||
value: 999,
|
||||
change: '+10%',
|
||||
icon: 'ri:bar-chart-box-ai-line',
|
||||
class: 'bg-theme'
|
||||
},
|
||||
{
|
||||
label: '总订单量',
|
||||
value: 300,
|
||||
change: '+15%',
|
||||
icon: 'ri:bar-chart-grouped-line',
|
||||
class: 'bg-warning'
|
||||
},
|
||||
{
|
||||
label: '产品销售量',
|
||||
value: 56,
|
||||
change: '-5%',
|
||||
icon: 'ri:bar-chart-2-line',
|
||||
class: 'bg-error'
|
||||
},
|
||||
{
|
||||
label: '新客户数',
|
||||
value: 68,
|
||||
change: '+8%',
|
||||
icon: 'ri:user-add-line',
|
||||
class: 'bg-success'
|
||||
}
|
||||
])
|
||||
</script>
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
<template>
|
||||
<div class="art-card h-82 p-5 mb-5 overflow-hidden max-sm:mb-4">
|
||||
<div class="art-card-header">
|
||||
<div class="title">
|
||||
<h4>热门产品</h4>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-auto h-full">
|
||||
<ArtTable
|
||||
:data="products"
|
||||
style="width: 100%"
|
||||
size="large"
|
||||
:border="false"
|
||||
:stripe="false"
|
||||
:header-cell-style="{ background: 'transparent' }"
|
||||
>
|
||||
<ElTableColumn prop="name" label="产品名称" width="200" />
|
||||
<ElTableColumn prop="popularity" label="销量">
|
||||
<template #default="scope">
|
||||
<ElProgress
|
||||
:percentage="scope.row.popularity"
|
||||
:color="getColor(scope.row.popularity)"
|
||||
:stroke-width="5"
|
||||
:show-text="false"
|
||||
/>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="sales" label="销量" width="80">
|
||||
<template #default="scope">
|
||||
<span
|
||||
:style="{
|
||||
color: getColor(scope.row.popularity),
|
||||
backgroundColor: `rgba(${hexToRgb(getColor(scope.row.popularity))}, 0.08)`,
|
||||
border: '1px solid',
|
||||
padding: '3px 6px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px'
|
||||
}"
|
||||
>{{ scope.row.sales }}</span
|
||||
>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ArtTable>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { hexToRgb } from '@/utils/ui'
|
||||
|
||||
interface Product {
|
||||
name: string
|
||||
popularity: number
|
||||
sales: string
|
||||
}
|
||||
|
||||
const COLOR_THRESHOLDS = {
|
||||
LOW: 25,
|
||||
MEDIUM: 50,
|
||||
HIGH: 75
|
||||
} as const
|
||||
|
||||
const POPULARITY_COLORS = {
|
||||
LOW: '#00E096',
|
||||
MEDIUM: '#0095FF',
|
||||
HIGH: '#884CFF',
|
||||
VERY_HIGH: '#FE8F0E'
|
||||
} as const
|
||||
|
||||
/**
|
||||
* 热门产品列表数据
|
||||
* 包含产品名称、热度和销量信息
|
||||
*/
|
||||
const products = computed<Product[]>(() => [
|
||||
{ name: '智能手机', popularity: 10, sales: '100' },
|
||||
{ name: '笔记本电脑', popularity: 29, sales: '100' },
|
||||
{ name: '平板电脑', popularity: 65, sales: '100' },
|
||||
{ name: '智能手表', popularity: 32, sales: '100' },
|
||||
{ name: '无线耳机', popularity: 78, sales: '100' },
|
||||
{ name: '智能音箱', popularity: 41, sales: '100' }
|
||||
])
|
||||
|
||||
/**
|
||||
* 根据热度百分比获取对应的颜色
|
||||
* @param percentage 热度百分比 (0-100)
|
||||
* @returns 对应的颜色值
|
||||
*/
|
||||
const getColor = (percentage: number): string => {
|
||||
if (percentage < COLOR_THRESHOLDS.LOW) return POPULARITY_COLORS.LOW
|
||||
if (percentage < COLOR_THRESHOLDS.MEDIUM) return POPULARITY_COLORS.MEDIUM
|
||||
if (percentage < COLOR_THRESHOLDS.HIGH) return POPULARITY_COLORS.HIGH
|
||||
return POPULARITY_COLORS.VERY_HIGH
|
||||
}
|
||||
</script>
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
<template>
|
||||
<div class="art-card h-100 p-5 mb-5 max-sm:mb-4">
|
||||
<div class="art-card-header">
|
||||
<div class="title">
|
||||
<h4>总收入</h4>
|
||||
</div>
|
||||
</div>
|
||||
<ArtBarChart
|
||||
height="calc(100% - 30px)"
|
||||
:data="revenueData"
|
||||
:xAxisData="weekDays"
|
||||
:showLegend="true"
|
||||
:showAxisLine="false"
|
||||
barWidth="18%"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface RevenueDataItem {
|
||||
name: string
|
||||
data: number[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 一周的日期标签
|
||||
*/
|
||||
const weekDays = ref(['周一', '周二', '周三', '周四', '周五', '周六', '周日'])
|
||||
|
||||
/**
|
||||
* 总收入数据
|
||||
* 对比线上和线下销售的一周收入情况
|
||||
*/
|
||||
const revenueData = ref<RevenueDataItem[]>([
|
||||
{
|
||||
name: '线上销售',
|
||||
data: [12, 13, 5, 15, 10, 15, 18]
|
||||
},
|
||||
{
|
||||
name: '线下销售',
|
||||
data: [10, 11, 20, 5, 11, 13, 10]
|
||||
}
|
||||
])
|
||||
</script>
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<div class="art-card h-82 p-5 mb-5 max-sm:mb-4">
|
||||
<div class="art-card-header">
|
||||
<div class="title">
|
||||
<h4>访客洞察</h4>
|
||||
</div>
|
||||
</div>
|
||||
<ArtLineChart
|
||||
height="calc(100% - 30px)"
|
||||
:data="chartData"
|
||||
:xAxisData="xAxisData"
|
||||
:showLegend="true"
|
||||
:showAxisLabel="true"
|
||||
:showAxisLine="false"
|
||||
:showSplitLine="true"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { LineDataItem } from '@/types/component/chart'
|
||||
|
||||
/**
|
||||
* X 轴数据配置
|
||||
* 表示一年的月份(1-12月)
|
||||
*/
|
||||
const xAxisData = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12']
|
||||
|
||||
/**
|
||||
* 访客洞察图表数据
|
||||
* 对比老客户和新客户的全年访问趋势
|
||||
*/
|
||||
const chartData = computed<LineDataItem[]>(() => [
|
||||
{
|
||||
name: '老客户',
|
||||
data: [280, 350, 300, 250, 230, 210, 240, 280, 320, 350, 300, 200]
|
||||
},
|
||||
{
|
||||
name: '新客户',
|
||||
data: [260, 200, 150, 130, 180, 270, 340, 380, 300, 220, 170, 130]
|
||||
}
|
||||
])
|
||||
</script>
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<div class="art-card h-82 p-5 mb-5 max-sm:mb-4">
|
||||
<div class="art-card-header">
|
||||
<div class="title">
|
||||
<h4>业务量与服务水平</h4>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ArtBarChart
|
||||
class="mt-6"
|
||||
height="14.3rem"
|
||||
:data="volumeServiceData"
|
||||
:xAxisData="serviceCategories"
|
||||
:showLegend="true"
|
||||
:showAxisLine="false"
|
||||
:stack="true"
|
||||
barWidth="22%"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface VolumeServiceDataItem {
|
||||
name: string
|
||||
data: number[]
|
||||
stack: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 服务类别数据
|
||||
* 不同产品的分类标签
|
||||
*/
|
||||
const serviceCategories = ref(['产品A', '产品B', '产品C', '产品D', '产品E'])
|
||||
|
||||
/**
|
||||
* 业务量与服务量数据
|
||||
* 展示各产品的业务量和服务量对比,使用堆叠柱状图展示
|
||||
*/
|
||||
const volumeServiceData = ref<VolumeServiceDataItem[]>([
|
||||
{
|
||||
name: '业务量',
|
||||
data: [20, 25, 30, 35, 40],
|
||||
stack: 'total'
|
||||
},
|
||||
{
|
||||
name: '服务量',
|
||||
data: [30, 35, 40, 45, 50],
|
||||
stack: 'total'
|
||||
}
|
||||
])
|
||||
</script>
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
@@ -0,0 +1,77 @@
|
||||
<!-- 电子商务页面 -->
|
||||
<template>
|
||||
<div class="ecommerce">
|
||||
<ElRow :gutter="20">
|
||||
<ElCol :sm="24" :md="24" :lg="16">
|
||||
<Banner />
|
||||
</ElCol>
|
||||
<ElCol :sm="12" :md="12" :lg="4">
|
||||
<TotalOrderVolume />
|
||||
</ElCol>
|
||||
<ElCol :sm="12" :md="12" :lg="4">
|
||||
<TotalProducts />
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
|
||||
<ElRow :gutter="20">
|
||||
<ElCol :sm="12" :md="12" :lg="8">
|
||||
<SalesTrend />
|
||||
</ElCol>
|
||||
<ElCol :sm="12" :md="12" :lg="8">
|
||||
<SalesClassification />
|
||||
</ElCol>
|
||||
<ElCol :sm="24" :md="24" :lg="8">
|
||||
<ElRow :gutter="20">
|
||||
<ElCol :sm="24" :md="12" :lg="12">
|
||||
<ProductSales />
|
||||
</ElCol>
|
||||
<ElCol :sm="24" :md="12" :lg="12">
|
||||
<SalesGrowth />
|
||||
</ElCol>
|
||||
<ElCol :span="24" class="no-margin-bottom">
|
||||
<CartConversionRate />
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
|
||||
<ElRow :gutter="20">
|
||||
<ElCol :sm="24" :md="12" :lg="8">
|
||||
<HotCommodity />
|
||||
</ElCol>
|
||||
<ElCol :sm="24" :md="12" :lg="8">
|
||||
<AnnualSales />
|
||||
</ElCol>
|
||||
<ElCol :sm="24" :md="24" :lg="8">
|
||||
<TransactionList />
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
|
||||
<ElRow :gutter="20">
|
||||
<ElCol :md="24" :lg="8">
|
||||
<RecentTransaction />
|
||||
</ElCol>
|
||||
<ElCol :md="24" :lg="16" class="no-margin-bottom">
|
||||
<HotProductsList />
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Banner from './modules/banner.vue'
|
||||
import TotalOrderVolume from './modules/total-order-volume.vue'
|
||||
import TotalProducts from './modules/total-products.vue'
|
||||
import SalesTrend from './modules/sales-trend.vue'
|
||||
import SalesClassification from './modules/sales-classification.vue'
|
||||
import TransactionList from './modules/transaction-list.vue'
|
||||
import HotCommodity from './modules/hot-commodity.vue'
|
||||
import RecentTransaction from './modules/recent-transaction.vue'
|
||||
import AnnualSales from './modules/annual-sales.vue'
|
||||
import ProductSales from './modules/product-sales.vue'
|
||||
import SalesGrowth from './modules/sales-growth.vue'
|
||||
import CartConversionRate from './modules/cart-conversion-rate.vue'
|
||||
import HotProductsList from './modules/hot-products-list.vue'
|
||||
|
||||
defineOptions({ name: 'Ecommerce' })
|
||||
</script>
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<div class="art-card p-5 h-[28.2rem] mb-5 max-sm:mb-4">
|
||||
<div class="art-card-header">
|
||||
<div class="title">
|
||||
<h4>年度销售额</h4>
|
||||
<p>按季度统计</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ArtBarChart
|
||||
:showAxisLabel="false"
|
||||
:showAxisLine="false"
|
||||
:showSplitLine="false"
|
||||
:data="[50, 80, 50, 90, 60, 70, 50]"
|
||||
barWidth="26px"
|
||||
height="calc(100% - 155px)"
|
||||
/>
|
||||
<div class="flex justify-around mt-10">
|
||||
<div class="flex-c">
|
||||
<div class="flex-cc size-10.5 mr-2.5 text-theme bg-theme/10 rounded-lg">
|
||||
<ArtSvgIcon icon="ri:money-cny-circle-line" class="text-xl" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-lg">¥200,858</p>
|
||||
<span class="text-sm">线上销售</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-c">
|
||||
<div class="flex-cc size-10.5 mr-2.5 text-theme bg-theme/10 rounded-lg">
|
||||
<ArtSvgIcon icon="ri:heart-3-line" class="text-xl" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-lg">¥102,927</p>
|
||||
<span class="text-sm">线下销售</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<ArtBasicBanner
|
||||
class="justify-center !h-53 mb-5 max-sm:!pt-8 max-sm:!h-48 max-sm:mb-4"
|
||||
:title="`欢迎回来 ${userInfo.userName}`"
|
||||
boxStyle="!bg-theme/10"
|
||||
titleColor="var(--art-gray-900)"
|
||||
:decoration="false"
|
||||
:meteorConfig="{
|
||||
enabled: true,
|
||||
count: 10
|
||||
}"
|
||||
:buttonConfig="{
|
||||
show: false,
|
||||
text: ''
|
||||
}"
|
||||
:imageConfig="{
|
||||
src: bannerCover,
|
||||
width: '18rem',
|
||||
bottom: '-7.5rem'
|
||||
}"
|
||||
@click="handleBannerClick"
|
||||
>
|
||||
<div class="flex mt-6">
|
||||
<div class="mr-8 pr-8 border-r border-g-400 dark:border-g-300/60">
|
||||
<p class="text-3xl">
|
||||
<ArtCountTo
|
||||
class="number box-title"
|
||||
:target="2340"
|
||||
:duration="1500"
|
||||
prefix="¥"
|
||||
separator=","
|
||||
/>
|
||||
<ArtSvgIcon icon="ri:arrow-right-up-line" class="text-xl text-success relative -top-2" />
|
||||
</p>
|
||||
<p class="mt-1 text-sm text-g-700">今日销售额</p>
|
||||
</div>
|
||||
<div class="mr-8">
|
||||
<p class="text-3xl">
|
||||
<ArtCountTo class="number box-title" :target="35" :duration="1500" suffix="%" />
|
||||
<ArtSvgIcon icon="ri:arrow-right-up-line" class="text-xl text-success relative -top-2" />
|
||||
</p>
|
||||
<p class="mt-1 text-sm text-g-700">较昨日</p>
|
||||
</div>
|
||||
</div>
|
||||
</ArtBasicBanner>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import bannerCover from '@imgs/login/lf_icon2.webp'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
/**
|
||||
* 获取当前用户信息
|
||||
*/
|
||||
const userInfo = computed(() => userStore.getUserInfo)
|
||||
|
||||
/**
|
||||
* 处理横幅点击事件
|
||||
*/
|
||||
const handleBannerClick = (): void => {
|
||||
// TODO: 添加横幅点击处理逻辑
|
||||
}
|
||||
</script>
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<ArtLineChartCard
|
||||
class="mb-5 max-sm:mb-4"
|
||||
:value="2545"
|
||||
label="购物车转化率"
|
||||
:percentage="1.2"
|
||||
:height="13.5"
|
||||
:chartData="[120, 132, 101, 134, 90, 230, 210]"
|
||||
:showAreaColor="true"
|
||||
/>
|
||||
</template>
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<div class="art-card p-5 h-[28.2rem] mb-5 max-sm:mb-4">
|
||||
<div class="art-card-header">
|
||||
<div class="title">
|
||||
<h4>热销商品</h4>
|
||||
<p>本周销售排行</p>
|
||||
</div>
|
||||
</div>
|
||||
<ArtLineChart
|
||||
:showAxisLabel="false"
|
||||
:showAxisLine="false"
|
||||
:showSplitLine="false"
|
||||
:showAreaColor="true"
|
||||
:data="[8, 40, 82, 35, 90, 52, 35]"
|
||||
height="9rem"
|
||||
/>
|
||||
<div class="mt-10 space-y-5">
|
||||
<div v-for="item in weeklyList" :key="item.title" class="flex-c">
|
||||
<div class="size-10.5 flex-cc rounded-lg" :class="item.iconBgClass">
|
||||
<ArtSvgIcon :icon="item.icon" class="text-xl" />
|
||||
</div>
|
||||
<div class="ml-2.5">
|
||||
<p class="text-sm font-medium text-g-800">{{ item.title }}</p>
|
||||
<span class="text-sm text-g-600">{{ item.subtitle }}</span>
|
||||
</div>
|
||||
<div class="ml-auto px-3 py-1.5 text-sm text-center rounded" :class="item.valueBgClass">
|
||||
<span>+{{ item.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface WeeklyItem {
|
||||
icon: string
|
||||
title: string
|
||||
subtitle: string
|
||||
value: string
|
||||
iconBgClass: string
|
||||
valueBgClass: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 本周热销商品列表
|
||||
* 展示销量排名前三的商品信息
|
||||
*/
|
||||
const weeklyList: WeeklyItem[] = [
|
||||
{
|
||||
icon: 'ri:money-cny-circle-line',
|
||||
title: '智能手表Pro',
|
||||
subtitle: '电子产品',
|
||||
value: '1,286件',
|
||||
iconBgClass: 'bg-theme/12 text-theme',
|
||||
valueBgClass: 'bg-theme/12 text-theme'
|
||||
},
|
||||
{
|
||||
icon: 'ri:money-cny-circle-line',
|
||||
title: '时尚连衣裙',
|
||||
subtitle: '女装服饰',
|
||||
value: '892件',
|
||||
iconBgClass: 'bg-success/12 text-success',
|
||||
valueBgClass: 'bg-success/12 text-success'
|
||||
},
|
||||
{
|
||||
icon: 'ri:money-cny-circle-line',
|
||||
title: '厨房小家电',
|
||||
subtitle: '家居用品',
|
||||
value: '756件',
|
||||
iconBgClass: 'bg-error/12 text-error',
|
||||
valueBgClass: 'bg-error/12 text-error'
|
||||
}
|
||||
]
|
||||
</script>
|
||||
+195
@@ -0,0 +1,195 @@
|
||||
<template>
|
||||
<div class="art-card p-5 h-[27.8rem] mb-5 overflow-hidden max-sm:mb-4">
|
||||
<div class="art-card-header">
|
||||
<div class="title">
|
||||
<h4>热销产品</h4>
|
||||
<p>本月销售情况</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ElScrollbar style="height: 21.55rem" class="w-full">
|
||||
<ArtTable
|
||||
:data="tableData"
|
||||
style="margin-top: 0 !important"
|
||||
:border="false"
|
||||
:stripe="false"
|
||||
:header-cell-style="{ background: 'transparent' }"
|
||||
>
|
||||
<template #default>
|
||||
<ElTableColumn label="产品" prop="product" width="220px">
|
||||
<template #default="scope">
|
||||
<div class="flex-c">
|
||||
<img class="size-12.5 object-cover rounded-md" :src="scope.row.image" />
|
||||
<div class="flex flex-col ml-3">
|
||||
<div class="font-medium">{{ scope.row.name }}</div>
|
||||
<div class="text-xs text-slate-500">{{ scope.row.category }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="价格" prop="price">
|
||||
<template #default="scope">
|
||||
<span class="font-semibold">¥{{ scope.row.price.toLocaleString() }}</span>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="库存" prop="stock">
|
||||
<template #default="scope">
|
||||
<div
|
||||
class="inline-block px-2 py-1 text-xs font-medium rounded"
|
||||
:class="getStockClass(scope.row.stock)"
|
||||
>
|
||||
{{ getStockStatus(scope.row.stock) }}
|
||||
</div>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="销量" prop="sales" />
|
||||
<ElTableColumn label="销售趋势" width="240">
|
||||
<template #default="scope">
|
||||
<ElProgress :percentage="scope.row.pro" :color="scope.row.color" :stroke-width="4" />
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</template>
|
||||
</ArtTable>
|
||||
</ElScrollbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import product1 from '@/assets/images/3d/icon1.webp'
|
||||
import product2 from '@/assets/images/3d/icon2.webp'
|
||||
import product3 from '@/assets/images/3d/icon3.webp'
|
||||
import product4 from '@/assets/images/3d/icon4.webp'
|
||||
import product5 from '@/assets/images/3d/icon5.webp'
|
||||
import product6 from '@/assets/images/3d/icon6.webp'
|
||||
|
||||
interface ProductItem {
|
||||
name: string
|
||||
category: string
|
||||
price: number
|
||||
stock: number
|
||||
sales: number
|
||||
percentage: number
|
||||
pro: number
|
||||
color: string
|
||||
image: string
|
||||
}
|
||||
|
||||
const ANIMATION_DELAY = 100
|
||||
const STOCK_THRESHOLD = {
|
||||
LOW: 20,
|
||||
MEDIUM: 50
|
||||
} as const
|
||||
|
||||
/**
|
||||
* 热销产品表格数据
|
||||
* 包含产品信息、库存、销量和销售趋势
|
||||
*/
|
||||
const tableData = reactive<ProductItem[]>([
|
||||
{
|
||||
name: '智能手表 Pro',
|
||||
category: '电子设备',
|
||||
price: 1299,
|
||||
stock: 156,
|
||||
sales: 423,
|
||||
percentage: 75,
|
||||
pro: 0,
|
||||
color: 'var(--art-primary)',
|
||||
image: product1
|
||||
},
|
||||
{
|
||||
name: '无线蓝牙耳机',
|
||||
category: '音频设备',
|
||||
price: 499,
|
||||
stock: 89,
|
||||
sales: 652,
|
||||
percentage: 85,
|
||||
pro: 0,
|
||||
color: 'var(--art-success)',
|
||||
image: product2
|
||||
},
|
||||
{
|
||||
name: '机械键盘',
|
||||
category: '电脑配件',
|
||||
price: 399,
|
||||
stock: 12,
|
||||
sales: 238,
|
||||
percentage: 45,
|
||||
pro: 0,
|
||||
color: 'var(--art-warning)',
|
||||
image: product3
|
||||
},
|
||||
{
|
||||
name: '超薄笔记本电脑',
|
||||
category: '电子设备',
|
||||
price: 5999,
|
||||
stock: 0,
|
||||
sales: 126,
|
||||
percentage: 30,
|
||||
pro: 0,
|
||||
color: 'var(--art-error)',
|
||||
image: product4
|
||||
},
|
||||
{
|
||||
name: '智能音箱',
|
||||
category: '智能家居',
|
||||
price: 799,
|
||||
stock: 45,
|
||||
sales: 321,
|
||||
percentage: 60,
|
||||
pro: 0,
|
||||
color: 'var(--art-info)',
|
||||
image: product5
|
||||
},
|
||||
{
|
||||
name: '游戏手柄',
|
||||
category: '游戏配件',
|
||||
price: 299,
|
||||
stock: 78,
|
||||
sales: 489,
|
||||
percentage: 70,
|
||||
pro: 0,
|
||||
color: 'var(--art-secondary)',
|
||||
image: product6
|
||||
}
|
||||
])
|
||||
|
||||
/**
|
||||
* 根据库存数量获取状态文本
|
||||
* @param stock 库存数量
|
||||
* @returns 库存状态文本
|
||||
*/
|
||||
const getStockStatus = (stock: number): string => {
|
||||
if (stock === 0) return '缺货'
|
||||
if (stock < STOCK_THRESHOLD.LOW) return '低库存'
|
||||
if (stock < STOCK_THRESHOLD.MEDIUM) return '适中'
|
||||
return '充足'
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据库存数量获取状态样式类名
|
||||
* @param stock 库存数量
|
||||
* @returns CSS 类名
|
||||
*/
|
||||
const getStockClass = (stock: number): string => {
|
||||
if (stock === 0) return 'text-danger bg-danger/12'
|
||||
if (stock < STOCK_THRESHOLD.LOW) return 'text-warning bg-warning/12'
|
||||
if (stock < STOCK_THRESHOLD.MEDIUM) return 'text-info bg-info/12'
|
||||
return 'text-success bg-success/12'
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加进度条动画效果
|
||||
* 延迟后将进度值从 0 更新到目标百分比,触发动画
|
||||
*/
|
||||
const addAnimation = (): void => {
|
||||
setTimeout(() => {
|
||||
tableData.forEach((item) => {
|
||||
item.pro = item.percentage
|
||||
})
|
||||
}, ANIMATION_DELAY)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
addAnimation()
|
||||
})
|
||||
</script>
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<div class="art-card p-5 pt-3.5 mb-5 h-46 max-sm:mb-4">
|
||||
<div class="art-card-header">
|
||||
<div class="title">
|
||||
<h4 class="text-2xl">
|
||||
<span>14.5k</span>
|
||||
<ArtSvgIcon icon="ri:arrow-right-up-line" class="text-success text-lg ml-1" />
|
||||
</h4>
|
||||
<p>销售量</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ArtBarChart
|
||||
:showAxisLabel="false"
|
||||
:showAxisLine="false"
|
||||
:showSplitLine="false"
|
||||
:data="[50, 80, 50, 90, 60, 70, 50]"
|
||||
barWidth="16px"
|
||||
height="5.6rem"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<ArtTimelineListCard
|
||||
:list="timelineData"
|
||||
title="最近交易"
|
||||
subtitle="今日订单动态"
|
||||
class="h-[27.8rem] mb-5 max-sm:mb-4"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface TimelineItem {
|
||||
time: string
|
||||
status: string
|
||||
content: string
|
||||
code?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 最近交易时间线数据
|
||||
* 记录今日订单处理、商品上架、支付等关键活动
|
||||
*/
|
||||
const timelineData: TimelineItem[] = [
|
||||
{
|
||||
time: '上午 09:30',
|
||||
status: 'rgb(73, 190, 255)',
|
||||
content: '收到订单 #38291 支付 ¥385.90'
|
||||
},
|
||||
{
|
||||
time: '上午 10:00',
|
||||
status: 'rgb(54, 158, 255)',
|
||||
content: '新商品上架',
|
||||
code: 'SKU-3467'
|
||||
},
|
||||
{
|
||||
time: '上午 12:00',
|
||||
status: 'rgb(103, 232, 207)',
|
||||
content: '向供应商支付了 ¥6495.00'
|
||||
},
|
||||
{
|
||||
time: '下午 14:30',
|
||||
status: 'rgb(255, 193, 7)',
|
||||
content: '促销活动开始',
|
||||
code: 'PROMO-2023'
|
||||
},
|
||||
{
|
||||
time: '下午 15:45',
|
||||
status: 'rgb(255, 105, 105)',
|
||||
content: '订单取消提醒',
|
||||
code: 'ORD-9876'
|
||||
},
|
||||
{
|
||||
time: '下午 17:00',
|
||||
status: 'rgb(103, 232, 207)',
|
||||
content: '完成日销售报表'
|
||||
}
|
||||
]
|
||||
</script>
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
<template>
|
||||
<div class="art-card p-5 mb-5 h-105 max-sm:mb-4">
|
||||
<div class="art-card-header">
|
||||
<div class="title">
|
||||
<h4>销售分类</h4>
|
||||
<p>按产品类别</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ArtRingChart
|
||||
:data="[
|
||||
{ value: 30, name: '电子产品' },
|
||||
{ value: 55, name: '服装鞋包' },
|
||||
{ value: 36, name: '家居用品' }
|
||||
]"
|
||||
:color="['#4C87F3', '#EDF2FF', '#8BD8FC']"
|
||||
:radius="['70%', '80%']"
|
||||
height="16.5rem"
|
||||
:showLabel="false"
|
||||
:borderRadius="0"
|
||||
centerText="¥300,458"
|
||||
/>
|
||||
<div class="flex justify-around">
|
||||
<div class="flex-c">
|
||||
<div class="flex-cc size-10.5 mr-2.5 text-theme bg-theme/10 rounded-lg">
|
||||
<ArtSvgIcon icon="ri:money-cny-circle-line" class="text-xl" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-lg">¥500,458</p>
|
||||
<span class="text-sm">总收入</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-c">
|
||||
<div class="flex-cc size-10.5 mr-2.5 text-theme bg-theme/10 rounded-lg">
|
||||
<ArtSvgIcon icon="ri:heart-3-line" class="text-xl" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-lg">¥130,580</p>
|
||||
<span class="text-sm">净利润</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<div class="art-card p-5 pt-3.5 mb-5 h-46 max-sm:mb-4">
|
||||
<div class="art-card-header">
|
||||
<div class="title">
|
||||
<h4 class="text-2xl">
|
||||
<span>12%</span>
|
||||
<ArtSvgIcon icon="ri:arrow-right-up-line" class="text-success text-lg ml-1" />
|
||||
</h4>
|
||||
<p>增长</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ArtLineChart
|
||||
:showAreaColor="true"
|
||||
:showAxisLabel="false"
|
||||
:showAxisLine="false"
|
||||
:showSplitLine="false"
|
||||
:data="[50, 85, 65, 95, 75, 130, 180]"
|
||||
barWidth="16px"
|
||||
height="5rem"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<div class="art-card p-5 mb-5 h-105 max-sm:mb-4">
|
||||
<div class="art-card-header">
|
||||
<div class="title">
|
||||
<h4>销售趋势</h4>
|
||||
<p>月度销售对比</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ArtDualBarCompareChart
|
||||
:positiveData="[50, 80, 120, 90, 60]"
|
||||
:negativeData="[30, 60, 90, 70, 40]"
|
||||
:xAxisData="['一月', '二月', '三月', '四月', '五月']"
|
||||
height="19rem"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<div class="art-card h-53 p-5 pt-3.5 mb-5 max-sm:mb-4">
|
||||
<div class="art-card-header">
|
||||
<div class="title">
|
||||
<h4 class="text-2xl font-medium">205,216</h4>
|
||||
<p>这个月增长</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ArtRingChart
|
||||
:data="[
|
||||
{ value: 30, name: '已完成' },
|
||||
{ value: 25, name: '处理中' },
|
||||
{ value: 45, name: '待发货' }
|
||||
]"
|
||||
:color="['#4C87F3', '#93F1B4', '#8BD8FC']"
|
||||
:radius="['56%', '76%']"
|
||||
height="7rem"
|
||||
:showLabel="false"
|
||||
:borderRadius="0"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<div class="art-card h-53 p-5 pt-3.5 mb-5 max-sm:mb-4">
|
||||
<div class="art-card-header">
|
||||
<div class="title">
|
||||
<h4 class="text-2xl font-medium">55,231</h4>
|
||||
<p>商品总数</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ArtBarChart
|
||||
:showAxisLabel="false"
|
||||
:showAxisLine="false"
|
||||
:showSplitLine="false"
|
||||
:data="[50, 80, 40, 90, 60, 70]"
|
||||
height="7rem"
|
||||
barWidth="18px"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<ArtDataListCard
|
||||
class="mb-5 max-sm:mb-4"
|
||||
:maxCount="4"
|
||||
:list="dataList"
|
||||
title="最近活动"
|
||||
subtitle="订单处理状态"
|
||||
:showMoreButton="true"
|
||||
@more="handleMore"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface TransactionItem {
|
||||
title: string
|
||||
status: string
|
||||
time: string
|
||||
class: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 最近活动数据列表
|
||||
* 展示订单处理、退款申请、投诉处理等业务活动状态
|
||||
*/
|
||||
const dataList: TransactionItem[] = [
|
||||
{
|
||||
title: '新订单 #38291',
|
||||
status: '待处理',
|
||||
time: '5分钟',
|
||||
class: 'bg-theme/12 text-theme',
|
||||
icon: 'ri:shopping-bag-4-line'
|
||||
},
|
||||
{
|
||||
title: '退款申请 #12845',
|
||||
status: '处理中',
|
||||
time: '10分钟',
|
||||
class: 'bg-secondary/12 text-secondary',
|
||||
icon: 'ri:profile-line'
|
||||
},
|
||||
{
|
||||
title: '客户投诉处理',
|
||||
status: '待处理',
|
||||
time: '15分钟',
|
||||
class: 'bg-warning/12 text-warning',
|
||||
icon: 'ri:customer-service-2-line'
|
||||
},
|
||||
{
|
||||
title: '库存不足提醒',
|
||||
status: '紧急',
|
||||
time: '20分钟',
|
||||
class: 'bg-danger/12 text-danger',
|
||||
icon: 'ri:box-1-line'
|
||||
},
|
||||
{
|
||||
title: '订单 #29384 已发货',
|
||||
status: '已完成',
|
||||
time: '20分钟',
|
||||
class: 'bg-success/12 text-success',
|
||||
icon: 'ri:shopping-bag-3-line'
|
||||
}
|
||||
]
|
||||
|
||||
/**
|
||||
* 处理查看更多按钮点击事件
|
||||
*/
|
||||
const handleMore = (): void => {
|
||||
// TODO: 添加查看更多逻辑
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,886 @@
|
||||
<!-- 表单示例 -->
|
||||
<template>
|
||||
<div class="pb-5">
|
||||
<h2 class="mb-1 text-lg font-medium">表单组件示例</h2>
|
||||
|
||||
<ElCard shadow="never" class="art-card-xs">
|
||||
<ArtForm
|
||||
ref="formRef"
|
||||
v-model="formData"
|
||||
:items="formItems"
|
||||
:rules="formRules"
|
||||
:defaultExpanded="true"
|
||||
:labelWidth="labelWidth"
|
||||
:labelPosition="labelPosition"
|
||||
:span="span"
|
||||
:gutter="gutter"
|
||||
@reset="handleReset"
|
||||
@submit="handleSubmit"
|
||||
>
|
||||
<template #slots>
|
||||
<ElInput v-model="formData.slots" placeholder="我是插槽渲染出来的组件" />
|
||||
</template>
|
||||
</ArtForm>
|
||||
</ElCard>
|
||||
|
||||
<div class="art-card p-5 !rounded-lg mt-5">
|
||||
<pre><code>{{ formData }}</code></pre>
|
||||
</div>
|
||||
|
||||
<div class="mt-3.5">
|
||||
<h3 class="mb-2 text-base font-medium">动态表单操作</h3>
|
||||
<ElSpace wrap class="mb-3">
|
||||
<ElButton @click="getLevelOptions"> 获取用户等级数据 </ElButton>
|
||||
<ElButton @click="addFormItem"> 新增表单项 </ElButton>
|
||||
<ElButton @click="updateFormItem"> 修改表单项 </ElButton>
|
||||
<ElButton @click="deleteFormItem"> 删除表单项 </ElButton>
|
||||
<ElButton @click="batchAddFormItems"> 批量新增 </ElButton>
|
||||
<ElButton @click="resetDynamicItems"> 重置动态项 </ElButton>
|
||||
</ElSpace>
|
||||
|
||||
<h3 class="mb-2 text-base font-medium">其他操作</h3>
|
||||
<ElSpace wrap>
|
||||
<ElButton @click="validateForm"> 校验表单 </ElButton>
|
||||
<ElButton @click="resetForm"> 重置 </ElButton>
|
||||
<ElButton v-if="showUserName" @click="updateUserName"> 修改用户名 </ElButton>
|
||||
<ElButton v-if="showUserName" @click="deleteUserName"> 删除用户名 </ElButton>
|
||||
<ElButton @click="labelWidth = 120"> 修改 label 宽度 </ElButton>
|
||||
<ElButton @click="span = 8"> 设置一行显示的组件数 </ElButton>
|
||||
<ElButton @click="gutter = 50"> 修改 gutter </ElButton>
|
||||
<ElButton @click="labelPosition = 'left'"> label 左对齐 </ElButton>
|
||||
<ElButton @click="labelPosition = 'right'"> label 右对齐 </ElButton>
|
||||
<ElButton @click="labelPosition = 'top'"> label 顶部对齐 </ElButton>
|
||||
</ElSpace>
|
||||
</div>
|
||||
|
||||
<!-- 图片预览对话框 -->
|
||||
<ElDialog v-model="dialogVisible">
|
||||
<img w-full :src="dialogImageUrl" alt="Preview Image" class="w-full h-auto" />
|
||||
</ElDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ArtWangEditor from '@/components/core/forms/art-wang-editor/index.vue'
|
||||
import { SearchFormItem } from '@/components/core/forms/art-search-bar/index.vue'
|
||||
import { ElMessage, ElUpload, ElButton, ElIcon, ElInput } from 'element-plus'
|
||||
import type { UploadFile, UploadFiles, UploadUserFile } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: Record<string, any>): void
|
||||
(e: 'search', params: Record<string, any>): void
|
||||
(e: 'reset'): void
|
||||
}
|
||||
|
||||
interface OptionItem {
|
||||
label: string
|
||||
value: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
interface FormData {
|
||||
name?: string
|
||||
phone?: string
|
||||
level?: string
|
||||
address?: string
|
||||
slots?: string
|
||||
date?: string
|
||||
daterange?: string[]
|
||||
cascader?: string[]
|
||||
checkboxgroup?: string[]
|
||||
userGender?: string
|
||||
iconSelector?: string
|
||||
status?: boolean
|
||||
systemName?: string
|
||||
fileUpload: UploadUserFile[]
|
||||
imageUpload: UploadUserFile[]
|
||||
multipleFiles: UploadUserFile[]
|
||||
richTextContent: string
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const FETCH_DELAY = 500
|
||||
|
||||
const formRef = ref()
|
||||
const dialogVisible = ref(false)
|
||||
const dialogImageUrl = ref('')
|
||||
|
||||
/**
|
||||
* 表单数据
|
||||
*/
|
||||
const formData = ref<FormData>({
|
||||
name: undefined,
|
||||
phone: undefined,
|
||||
level: undefined,
|
||||
address: undefined,
|
||||
slots: undefined,
|
||||
date: undefined,
|
||||
daterange: undefined,
|
||||
cascader: undefined,
|
||||
checkboxgroup: undefined,
|
||||
userGender: undefined,
|
||||
iconSelector: undefined,
|
||||
status: undefined,
|
||||
systemName: undefined,
|
||||
fileUpload: [],
|
||||
imageUpload: [],
|
||||
multipleFiles: [],
|
||||
richTextContent: ''
|
||||
})
|
||||
|
||||
/**
|
||||
* 表单校验规则
|
||||
*/
|
||||
const formRules = {
|
||||
name: [{ required: true, message: '请输入用户名', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const labelWidth = ref(100)
|
||||
const labelPosition = ref<'right' | 'left' | 'top'>('right')
|
||||
const span = ref(6)
|
||||
const gutter = ref(12)
|
||||
|
||||
const levelOptions = ref<OptionItem[]>([])
|
||||
|
||||
/**
|
||||
* 用户等级选项
|
||||
*/
|
||||
const LEVEL_OPTIONS: OptionItem[] = [
|
||||
{ label: '普通用户', value: 'normal' },
|
||||
{ label: 'VIP用户', value: 'vip' },
|
||||
{ label: '高级VIP', value: 'svip' },
|
||||
{ label: '企业用户', value: 'enterprise', disabled: true }
|
||||
]
|
||||
|
||||
/**
|
||||
* 性别选项
|
||||
*/
|
||||
const GENDER_OPTIONS: OptionItem[] = [
|
||||
{ label: '男', value: '1' },
|
||||
{ label: '女', value: '2' }
|
||||
]
|
||||
|
||||
/**
|
||||
* 日期快捷选项
|
||||
*/
|
||||
const DATE_SHORTCUTS = [
|
||||
{ text: '今日', value: new Date() },
|
||||
{ text: '昨日', value: () => new Date(Date.now() - 86400000) },
|
||||
{ text: '一周前', value: () => new Date(Date.now() - 604800000) }
|
||||
]
|
||||
|
||||
/**
|
||||
* 模拟接口获取用户等级数据
|
||||
* @returns 用户等级选项列表
|
||||
*/
|
||||
const fetchLevelOptions = (): Promise<OptionItem[]> => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve(LEVEL_OPTIONS)
|
||||
}, FETCH_DELAY)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户等级数据
|
||||
*/
|
||||
const getLevelOptions = async (): Promise<void> => {
|
||||
levelOptions.value = await fetchLevelOptions()
|
||||
if (levelOptions.value.length) {
|
||||
ElMessage.success('成功获取到数据')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 表单项配置类型
|
||||
*/
|
||||
interface FormItemConfig {
|
||||
label: string
|
||||
key: string
|
||||
type: string
|
||||
placeholder?: string
|
||||
props?: Record<string, any>
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建表单项的工厂函数
|
||||
*/
|
||||
const createFormItem = (config: FormItemConfig) => config
|
||||
|
||||
// 基础表单项配置
|
||||
const baseFormItems = {
|
||||
username: createFormItem({
|
||||
label: '用户名',
|
||||
key: 'name',
|
||||
type: 'input',
|
||||
placeholder: '请输入用户名',
|
||||
clearable: true
|
||||
}),
|
||||
phone: createFormItem({
|
||||
label: '手机号',
|
||||
key: 'phone',
|
||||
type: 'input',
|
||||
props: { placeholder: '请输入手机号', maxlength: '11' }
|
||||
}),
|
||||
level: createFormItem({
|
||||
label: '用户等级',
|
||||
key: 'level',
|
||||
type: 'select',
|
||||
props: {
|
||||
placeholder: '请选择等级',
|
||||
options: LEVEL_OPTIONS
|
||||
}
|
||||
}),
|
||||
address: createFormItem({
|
||||
label: '地址',
|
||||
key: 'address',
|
||||
type: 'input',
|
||||
placeholder: '请输入地址'
|
||||
}),
|
||||
date: createFormItem({
|
||||
label: '日期',
|
||||
key: 'date',
|
||||
type: 'datetime',
|
||||
props: {
|
||||
style: { width: '100%' },
|
||||
placeholder: '请选择日期',
|
||||
type: 'date',
|
||||
valueFormat: 'YYYY-MM-DD',
|
||||
shortcuts: DATE_SHORTCUTS
|
||||
}
|
||||
}),
|
||||
gender: createFormItem({
|
||||
label: '性别',
|
||||
key: 'userGender',
|
||||
type: 'radiogroup',
|
||||
props: {
|
||||
options: GENDER_OPTIONS
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const userItem = ref<SearchFormItem>({
|
||||
label: '用户名',
|
||||
key: 'name',
|
||||
type: 'input',
|
||||
props: {
|
||||
placeholder: '请输入用户名',
|
||||
clearable: true
|
||||
}
|
||||
})
|
||||
|
||||
// 控制用户名字段是否显示
|
||||
const showUserName = ref(true)
|
||||
|
||||
// 动态表单项列表
|
||||
const dynamicFormItems = ref<SearchFormItem[]>([])
|
||||
|
||||
// 动态表单项计数器(用于生成唯一 key)
|
||||
let dynamicItemCounter = 0
|
||||
|
||||
// 级联选择器数据
|
||||
const cascaderOptions = [
|
||||
{
|
||||
value: 'guide',
|
||||
label: '指南',
|
||||
children: [
|
||||
{
|
||||
value: 'disciplines',
|
||||
label: '规范',
|
||||
children: [
|
||||
{ value: 'consistency', label: '一致性' },
|
||||
{ value: 'feedback', label: '反馈' },
|
||||
{ value: 'efficiency', label: '效率' },
|
||||
{ value: 'controllability', label: '可控性' }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
value: 'components',
|
||||
label: '组件',
|
||||
children: [
|
||||
{
|
||||
value: 'basic',
|
||||
label: '基础组件',
|
||||
children: [
|
||||
{ value: 'button', label: '按钮' },
|
||||
{ value: 'form', label: '表单' },
|
||||
{ value: 'table', label: '表格' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
// 树选择器数据
|
||||
const treeSelectData = [
|
||||
{
|
||||
value: '1',
|
||||
label: '一级 1',
|
||||
children: [
|
||||
{
|
||||
value: '1-1',
|
||||
label: '二级 1-1',
|
||||
children: [{ value: '1-1-1', label: '三级 1-1-1' }]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
value: '2',
|
||||
label: '一级 2',
|
||||
children: [
|
||||
{
|
||||
value: '2-1',
|
||||
label: '二级 2-1',
|
||||
children: [{ value: '2-1-1', label: '三级 2-1-1' }]
|
||||
},
|
||||
{
|
||||
value: '2-2',
|
||||
label: '二级 2-2',
|
||||
children: [{ value: '2-2-1', label: '三级 2-2-1' }]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
// 复选框选项
|
||||
const checkboxOptions = [
|
||||
{ label: '选项1', value: 'option1' },
|
||||
{ label: '选项2', value: 'option2' },
|
||||
{ label: '选项3', value: 'option3' },
|
||||
{ label: '选项4', value: 'option4' },
|
||||
{ label: '选项5(disabled)', value: 'option5', disabled: true }
|
||||
]
|
||||
|
||||
// 表单配置
|
||||
const formItems = computed(() => [
|
||||
...(showUserName.value ? [userItem.value] : []),
|
||||
// 动态表单项
|
||||
...dynamicFormItems.value,
|
||||
{
|
||||
...baseFormItems.phone
|
||||
},
|
||||
{
|
||||
...baseFormItems.level,
|
||||
props: { placeholder: '请选择等级', options: levelOptions.value }
|
||||
},
|
||||
baseFormItems.address,
|
||||
baseFormItems.date,
|
||||
// 日期时间
|
||||
{
|
||||
label: '日期时间',
|
||||
key: 'datetime',
|
||||
type: 'datetime',
|
||||
props: {
|
||||
style: { width: '100%' },
|
||||
placeholder: '请选择日期时间',
|
||||
type: 'datetime',
|
||||
valueFormat: 'YYYY-MM-DD HH:mm:ss'
|
||||
}
|
||||
},
|
||||
{
|
||||
label: '日期范围',
|
||||
key: 'daterange',
|
||||
type: 'datetime',
|
||||
props: {
|
||||
type: 'daterange',
|
||||
valueFormat: 'YYYY-MM-DD',
|
||||
rangeSeparator: '至',
|
||||
startPlaceholder: '开始日期',
|
||||
endPlaceholder: '结束日期'
|
||||
}
|
||||
},
|
||||
// 日期时间范围
|
||||
{
|
||||
label: '日期时间范围',
|
||||
key: 'datetimerange',
|
||||
type: 'datetime',
|
||||
props: {
|
||||
type: 'datetimerange',
|
||||
valueFormat: 'YYYY-MM-DD HH:mm:ss',
|
||||
rangeSeparator: '至',
|
||||
startPlaceholder: '开始日期时间',
|
||||
endPlaceholder: '结束日期时间'
|
||||
}
|
||||
},
|
||||
// 时间选择
|
||||
{
|
||||
label: '时间选择',
|
||||
key: 'timeselect',
|
||||
type: 'timeselect',
|
||||
props: {
|
||||
placeholder: '请选择时间',
|
||||
type: 'time',
|
||||
valueFormat: 'HH:mm:ss'
|
||||
}
|
||||
},
|
||||
// 时间选择器
|
||||
{
|
||||
label: '时间选择器',
|
||||
key: 'timepicker',
|
||||
type: 'timepicker',
|
||||
props: {
|
||||
style: { width: '100%' },
|
||||
placeholder: '请选择时间',
|
||||
type: 'time',
|
||||
valueFormat: 'HH:mm:ss'
|
||||
}
|
||||
},
|
||||
// 级联选择
|
||||
{
|
||||
label: '级联选择',
|
||||
key: 'cascader',
|
||||
type: 'cascader',
|
||||
props: {
|
||||
placeholder: '请选择级联选择器',
|
||||
clearable: true,
|
||||
style: { width: '100%' },
|
||||
collapseTags: true,
|
||||
maxCollapseTags: 1,
|
||||
props: { multiple: true },
|
||||
options: cascaderOptions
|
||||
}
|
||||
},
|
||||
// 树型选择器
|
||||
{
|
||||
label: '树型选择器',
|
||||
key: 'treeSelect',
|
||||
type: 'treeselect',
|
||||
props: {
|
||||
showCheckbox: true,
|
||||
multiple: true,
|
||||
clearable: true,
|
||||
data: treeSelectData
|
||||
}
|
||||
},
|
||||
{ label: '插槽', key: 'slots', type: 'input', placeholder: '请输入邮箱' },
|
||||
{
|
||||
label: '渲染组件',
|
||||
key: 'iconSelector',
|
||||
render: () => h(ElInput, { placeholder: '渲染自定义 input' })
|
||||
},
|
||||
{
|
||||
label: '自定义组件',
|
||||
key: 'customComponent',
|
||||
render: () =>
|
||||
h(
|
||||
'div',
|
||||
{
|
||||
style:
|
||||
'color: var(--art-gray-600); border: 1px solid var(--default-border-dashed); padding: 0px 15px; border-radius: 6px'
|
||||
},
|
||||
'我是一个自定义组件'
|
||||
)
|
||||
},
|
||||
{
|
||||
label: '复选框',
|
||||
key: 'checkboxgroup',
|
||||
type: 'checkboxgroup',
|
||||
span: 12,
|
||||
props: {
|
||||
options: checkboxOptions
|
||||
}
|
||||
},
|
||||
{
|
||||
...baseFormItems.gender
|
||||
},
|
||||
|
||||
{
|
||||
label: '是否启用',
|
||||
key: 'isEnabled',
|
||||
type: 'switch',
|
||||
props: {
|
||||
placeholder: '请选择是否启用'
|
||||
}
|
||||
},
|
||||
{
|
||||
label: '年龄',
|
||||
key: 'age',
|
||||
type: 'number',
|
||||
slots: {
|
||||
suffix: () => h('span', { style: 'color: #909399; font-size: 12px' }, '岁')
|
||||
}
|
||||
},
|
||||
{
|
||||
label: '网站地址',
|
||||
key: 'website',
|
||||
type: 'input',
|
||||
placeholder: '请输入网站名称',
|
||||
slots: {
|
||||
prepend: () => h('span', 'https://'),
|
||||
append: () => h('span', '.com')
|
||||
}
|
||||
},
|
||||
{
|
||||
label: '事件演示',
|
||||
key: 'event',
|
||||
type: 'input',
|
||||
props: {
|
||||
placeholder: '输入内容触发事件,控制台查看',
|
||||
clearable: true,
|
||||
prefixIcon: 'Search',
|
||||
// prefix: () => h('span', {}, '123'),
|
||||
// 事件必须以 on 开头,然后驼峰式命名拼接 ElementPlus 事件名
|
||||
onInput(val: string) {
|
||||
console.log('输入事件', val)
|
||||
},
|
||||
onClear() {
|
||||
console.log('清空事件')
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
label: '多行输入',
|
||||
key: 'remark',
|
||||
type: 'input',
|
||||
props: {
|
||||
placeholder: '请输入备注',
|
||||
type: 'textarea',
|
||||
rows: 2
|
||||
}
|
||||
},
|
||||
{
|
||||
label: '评分',
|
||||
key: 'rate',
|
||||
type: 'rate',
|
||||
props: {
|
||||
size: 'large',
|
||||
placeholder: '请选择评分'
|
||||
}
|
||||
},
|
||||
{
|
||||
label: '禁用',
|
||||
key: 'diaabled',
|
||||
type: 'input',
|
||||
placeholder: '我被禁用了',
|
||||
disabled: true // 禁用
|
||||
},
|
||||
{
|
||||
label: '滑块',
|
||||
key: 'slider',
|
||||
type: 'slider'
|
||||
// props: {
|
||||
// step: 10,
|
||||
// showStops: true
|
||||
// }
|
||||
},
|
||||
|
||||
{
|
||||
label: '隐藏',
|
||||
key: 'email',
|
||||
type: 'input',
|
||||
hidden: true
|
||||
},
|
||||
// 根据条件隐藏
|
||||
{
|
||||
label: '根据条件隐藏',
|
||||
key: 'systemName',
|
||||
type: 'input',
|
||||
hidden: formData.value.systemName === 'mac',
|
||||
placeholder: '输入 mac 组件隐藏'
|
||||
},
|
||||
{
|
||||
label: '栅格布局',
|
||||
key: 'sg1',
|
||||
type: 'input',
|
||||
span: 12,
|
||||
placeholder: '示例:栅格 span=12 占容器一半宽度,span=24 占满容器'
|
||||
},
|
||||
// 文件上传示例 - 使用 render 函数渲染
|
||||
{
|
||||
label: '文件上传',
|
||||
key: 'multipleFiles',
|
||||
span: 12,
|
||||
render: () =>
|
||||
h(
|
||||
ElUpload,
|
||||
{
|
||||
multiple: true,
|
||||
limit: 5,
|
||||
action: '#',
|
||||
autoUpload: false,
|
||||
showFileList: true,
|
||||
// accept: '.pdf,.doc,.docx,.txt',
|
||||
beforeUpload: (file: File) => {
|
||||
console.log('准备上传文件:', file.name)
|
||||
return true
|
||||
},
|
||||
onChange: (file: UploadFile, fileList: UploadFiles) => {
|
||||
console.log('多文件变化:', file, fileList)
|
||||
formData.value.multipleFiles = fileList as UploadUserFile[]
|
||||
},
|
||||
onRemove: (file: UploadFile, fileList: UploadFiles) => {
|
||||
console.log('删除文件:', file, fileList)
|
||||
formData.value.multipleFiles = fileList as UploadUserFile[]
|
||||
},
|
||||
onExceed: (files: File[], fileList: UploadUserFile[]) => {
|
||||
ElMessage.warning(
|
||||
`最多只能上传 5 个文件,当前选择了 ${files.length + fileList.length} 个文件`
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
default: () => [h(ElButton, { type: 'primary' }, () => '点击上传')]
|
||||
}
|
||||
)
|
||||
},
|
||||
// 图片上传示例 - 使用 render 函数渲染
|
||||
{
|
||||
label: '图片上传',
|
||||
key: 'imageUpload',
|
||||
span: 12,
|
||||
render: () =>
|
||||
h(
|
||||
ElUpload,
|
||||
{
|
||||
accept: '.jpg,.jpeg,.png,.gif,.webp',
|
||||
limit: 4,
|
||||
action: '#',
|
||||
autoUpload: false,
|
||||
showFileList: true,
|
||||
listType: 'picture-card',
|
||||
beforeUpload: (file: File) => {
|
||||
const isImage = file.type.startsWith('image/')
|
||||
const isLt2M = file.size / 1024 / 1024 < 2
|
||||
if (!isImage) {
|
||||
ElMessage.error('只能上传图片文件!')
|
||||
return false
|
||||
}
|
||||
if (!isLt2M) {
|
||||
ElMessage.error('图片大小不能超过 2MB!')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
},
|
||||
onChange: (file: UploadFile, fileList: UploadFiles) => {
|
||||
console.log('图片变化:', file, fileList)
|
||||
formData.value.imageUpload = fileList as UploadUserFile[]
|
||||
},
|
||||
onRemove: (file: UploadFile, fileList: UploadFiles) => {
|
||||
console.log('删除图片:', file, fileList)
|
||||
formData.value.imageUpload = fileList as UploadUserFile[]
|
||||
},
|
||||
onPreview: (file: UploadFile) => {
|
||||
dialogImageUrl.value = file.url || ''
|
||||
dialogVisible.value = true
|
||||
}
|
||||
},
|
||||
{
|
||||
default: () => [h(ElIcon, { type: 'primary' }, () => h(Plus))]
|
||||
}
|
||||
)
|
||||
},
|
||||
// 富文本编辑器示例 - 使用 render 函数渲染
|
||||
{
|
||||
label: '富文本编辑器',
|
||||
key: 'richTextContent',
|
||||
span: 24,
|
||||
render: () =>
|
||||
h(ArtWangEditor, {
|
||||
modelValue: formData.value.richTextContent,
|
||||
height: '300px',
|
||||
placeholder: '请输入富文本内容...',
|
||||
'onUpdate:modelValue': (value: string) => {
|
||||
formData.value.richTextContent = value
|
||||
console.log('富文本内容变化:', value)
|
||||
},
|
||||
toolbarKeys: [
|
||||
'headerSelect',
|
||||
'bold',
|
||||
'italic',
|
||||
'underline',
|
||||
'|',
|
||||
'bulletedList',
|
||||
'numberedList',
|
||||
'|',
|
||||
'insertLink',
|
||||
'insertImage',
|
||||
'|',
|
||||
'undo',
|
||||
'redo'
|
||||
]
|
||||
})
|
||||
}
|
||||
])
|
||||
|
||||
/**
|
||||
* 处理表单重置事件
|
||||
*/
|
||||
const handleReset = (): void => {
|
||||
console.log('重置表单')
|
||||
emit('reset')
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理表单提交事件
|
||||
*/
|
||||
const handleSubmit = async (): Promise<void> => {
|
||||
await formRef.value.validate()
|
||||
emit('search', formData.value)
|
||||
console.log('表单数据', formData.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验表单
|
||||
*/
|
||||
const validateForm = () => formRef.value.validate()
|
||||
|
||||
/**
|
||||
* 重置表单
|
||||
*/
|
||||
const resetForm = () => formRef.value.reset()
|
||||
|
||||
/**
|
||||
* 更新用户名字段配置
|
||||
*/
|
||||
const updateUserName = (): void => {
|
||||
userItem.value = {
|
||||
...userItem.value,
|
||||
label: '昵称',
|
||||
props: {
|
||||
placeholder: '请输入昵称'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除用户名字段
|
||||
*/
|
||||
const deleteUserName = (): void => {
|
||||
showUserName.value = false
|
||||
formData.value.name = undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增表单项
|
||||
*/
|
||||
const addFormItem = (): void => {
|
||||
dynamicItemCounter++
|
||||
const newItem: SearchFormItem = {
|
||||
label: `动态字段${dynamicItemCounter}`,
|
||||
key: `dynamic_${dynamicItemCounter}`,
|
||||
type: 'input',
|
||||
props: {
|
||||
placeholder: `请输入动态字段${dynamicItemCounter}`,
|
||||
clearable: true
|
||||
}
|
||||
}
|
||||
dynamicFormItems.value.push(newItem)
|
||||
ElMessage.success(`已新增表单项:${newItem.label}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改表单项(修改最后一个动态表单项)
|
||||
*/
|
||||
const updateFormItem = (): void => {
|
||||
if (dynamicFormItems.value.length === 0) {
|
||||
ElMessage.warning('没有可修改的动态表单项,请先新增')
|
||||
return
|
||||
}
|
||||
|
||||
const lastIndex = dynamicFormItems.value.length - 1
|
||||
const lastItem = dynamicFormItems.value[lastIndex]
|
||||
|
||||
// 修改最后一个表单项的配置
|
||||
dynamicFormItems.value[lastIndex] = {
|
||||
...lastItem,
|
||||
label: `已修改`,
|
||||
type: 'select',
|
||||
props: {
|
||||
placeholder: '修改为下拉选择',
|
||||
options: [
|
||||
{ label: '选项A', value: 'a' },
|
||||
{ label: '选项B', value: 'b' },
|
||||
{ label: '选项C', value: 'c' }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
ElMessage.success(`已修改表单项:${lastItem.label}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除表单项(删除最后一个动态表单项)
|
||||
*/
|
||||
const deleteFormItem = (): void => {
|
||||
if (dynamicFormItems.value.length === 0) {
|
||||
ElMessage.warning('没有可删除的动态表单项')
|
||||
return
|
||||
}
|
||||
|
||||
const deletedItem = dynamicFormItems.value.pop()
|
||||
if (deletedItem) {
|
||||
// 清除对应的表单数据
|
||||
delete formData.value[deletedItem.key as keyof FormData]
|
||||
ElMessage.success(`已删除表单项:${deletedItem.label}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量新增表单项
|
||||
*/
|
||||
const batchAddFormItems = (): void => {
|
||||
const batchItems: SearchFormItem[] = [
|
||||
{
|
||||
label: '公司名称',
|
||||
key: `company_${++dynamicItemCounter}`,
|
||||
type: 'input',
|
||||
props: {
|
||||
placeholder: '请输入公司名称',
|
||||
clearable: true
|
||||
}
|
||||
},
|
||||
{
|
||||
label: '部门',
|
||||
key: `department_${++dynamicItemCounter}`,
|
||||
type: 'select',
|
||||
props: {
|
||||
placeholder: '请选择部门',
|
||||
options: [
|
||||
{ label: '技术部', value: 'tech' },
|
||||
{ label: '产品部', value: 'product' },
|
||||
{ label: '运营部', value: 'operation' }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
label: '入职日期',
|
||||
key: `joinDate_${++dynamicItemCounter}`,
|
||||
type: 'datetime',
|
||||
props: {
|
||||
style: { width: '100%' },
|
||||
placeholder: '请选择入职日期',
|
||||
type: 'date',
|
||||
valueFormat: 'YYYY-MM-DD'
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
dynamicFormItems.value.push(...batchItems)
|
||||
ElMessage.success(`已批量新增 ${batchItems.length} 个表单项`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置动态表单项
|
||||
*/
|
||||
const resetDynamicItems = (): void => {
|
||||
if (dynamicFormItems.value.length === 0) {
|
||||
ElMessage.info('当前没有动态表单项')
|
||||
return
|
||||
}
|
||||
|
||||
// 清除所有动态表单项的数据
|
||||
dynamicFormItems.value.forEach((item) => {
|
||||
delete formData.value[item.key as keyof FormData]
|
||||
})
|
||||
|
||||
dynamicFormItems.value = []
|
||||
dynamicItemCounter = 0
|
||||
ElMessage.success('已重置所有动态表单项')
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,841 @@
|
||||
<!-- 表格搜索栏示例 -->
|
||||
<template>
|
||||
<div class="pb-5">
|
||||
<h2 class="mb-1 text-lg font-medium">基础示例(默认收起)</h2>
|
||||
<ArtSearchBar
|
||||
ref="searchBarBasicRef"
|
||||
v-model="formDataBasic"
|
||||
:items="formItemsBasic"
|
||||
@reset="handleBasicReset"
|
||||
@search="handleBasicSearch"
|
||||
>
|
||||
</ArtSearchBar>
|
||||
|
||||
<h2 class="mb-1 mt-3.5 text-lg font-medium">完整示例(默认展开)</h2>
|
||||
<ArtSearchBar
|
||||
ref="searchBarAdvancedRef"
|
||||
v-model="formDataAdvanced"
|
||||
:items="formItemsAdvanced"
|
||||
:rules="rulesAdvanced"
|
||||
:defaultExpanded="true"
|
||||
:labelWidth="labelWidthAdvanced"
|
||||
:labelPosition="labelPositionAdvanced"
|
||||
:span="spanAdvanced"
|
||||
:gutter="gutterAdvanced"
|
||||
@reset="handleAdvancedReset"
|
||||
@search="handleAdvancedSearch"
|
||||
>
|
||||
<template #slots>
|
||||
<ElInput v-model="formDataAdvanced.slots" placeholder="我是插槽渲染出来的组件" />
|
||||
</template>
|
||||
</ArtSearchBar>
|
||||
|
||||
<div class="art-card p-5 !rounded-lg mt-5">
|
||||
<pre><code>{{ formDataAdvanced }}</code></pre>
|
||||
</div>
|
||||
|
||||
<div class="mt-3.5">
|
||||
<h3 class="mb-2 text-base font-medium">动态表单操作</h3>
|
||||
<ElSpace wrap class="mb-3">
|
||||
<ElButton @click="getLevelOptions"> 获取用户等级数据 </ElButton>
|
||||
<ElButton @click="addFormItem"> 新增表单项 </ElButton>
|
||||
<ElButton @click="updateFormItem"> 修改表单项 </ElButton>
|
||||
<ElButton @click="deleteFormItem"> 删除表单项 </ElButton>
|
||||
<ElButton @click="batchAddFormItems"> 批量新增 </ElButton>
|
||||
<ElButton @click="resetDynamicItems"> 重置动态项 </ElButton>
|
||||
</ElSpace>
|
||||
|
||||
<h3 class="mb-2 text-base font-medium">其他操作</h3>
|
||||
<ElSpace wrap>
|
||||
<ElButton @click="advancedValidate"> 校验表单 </ElButton>
|
||||
<ElButton @click="advancedReset"> 重置 </ElButton>
|
||||
<ElButton v-if="showUserName" @click="updateUserName"> 修改用户名 </ElButton>
|
||||
<ElButton v-if="showUserName" @click="deleteUserName"> 删除用户名 </ElButton>
|
||||
<ElButton @click="labelWidthAdvanced = 120"> 修改 label 宽度 </ElButton>
|
||||
<ElButton @click="spanAdvanced = 8"> 设置一行显示的组件数 </ElButton>
|
||||
<ElButton @click="gutterAdvanced = 50"> 修改 gutter </ElButton>
|
||||
<ElButton @click="labelPositionAdvanced = 'left'"> label 左对齐 </ElButton>
|
||||
<ElButton @click="labelPositionAdvanced = 'right'"> label 右对齐 </ElButton>
|
||||
<ElButton @click="labelPositionAdvanced = 'top'"> label 顶部对齐 </ElButton>
|
||||
</ElSpace>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ElInput } from 'element-plus'
|
||||
import { SearchFormItem } from '@/components/core/forms/art-search-bar/index.vue'
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: Record<string, any>): void
|
||||
(e: 'search', params: Record<string, any>): void
|
||||
(e: 'reset'): void
|
||||
}
|
||||
|
||||
interface OptionItem {
|
||||
label: string
|
||||
value: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
interface BasicFormData {
|
||||
name?: string
|
||||
phone?: string
|
||||
level?: string
|
||||
address?: string
|
||||
date?: string
|
||||
daterange?: string[]
|
||||
status?: boolean
|
||||
}
|
||||
|
||||
interface AdvancedFormData extends BasicFormData {
|
||||
slots?: string
|
||||
cascader?: string[]
|
||||
checkboxgroup?: string[]
|
||||
userGender?: string
|
||||
iconSelector?: string
|
||||
systemName?: string
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const FETCH_DELAY = 500
|
||||
|
||||
const searchBarBasicRef = ref()
|
||||
const searchBarAdvancedRef = ref()
|
||||
|
||||
/**
|
||||
* 基础示例表单数据
|
||||
*/
|
||||
const formDataBasic = ref<BasicFormData>({
|
||||
name: undefined,
|
||||
phone: undefined,
|
||||
level: undefined,
|
||||
address: undefined,
|
||||
date: undefined,
|
||||
daterange: undefined,
|
||||
status: undefined
|
||||
})
|
||||
|
||||
/**
|
||||
* 完整示例表单数据
|
||||
*/
|
||||
const formDataAdvanced = ref<AdvancedFormData>({
|
||||
name: undefined,
|
||||
phone: undefined,
|
||||
level: undefined,
|
||||
address: undefined,
|
||||
slots: undefined,
|
||||
date: undefined,
|
||||
daterange: undefined,
|
||||
cascader: undefined,
|
||||
checkboxgroup: undefined,
|
||||
userGender: undefined,
|
||||
iconSelector: undefined,
|
||||
status: undefined,
|
||||
systemName: undefined
|
||||
})
|
||||
|
||||
/**
|
||||
* 完整示例校验规则
|
||||
*/
|
||||
const rulesAdvanced = {
|
||||
name: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||
phone: [
|
||||
{ required: true, message: '请输入手机号', trigger: 'blur' },
|
||||
{ min: 11, max: 11, message: '请输入11位手机号', trigger: 'blur' },
|
||||
{ pattern: /^1[3456789]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' }
|
||||
],
|
||||
level: [{ required: true, message: '请选择等级', trigger: 'change' }],
|
||||
address: [{ required: true, message: '请输入地址', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const labelWidthAdvanced = ref(100)
|
||||
const labelPositionAdvanced = ref<'right' | 'left' | 'top'>('right')
|
||||
const spanAdvanced = ref(6)
|
||||
const gutterAdvanced = ref(12)
|
||||
|
||||
const levelOptions = ref<OptionItem[]>([])
|
||||
|
||||
/**
|
||||
* 用户等级选项
|
||||
*/
|
||||
const LEVEL_OPTIONS: OptionItem[] = [
|
||||
{ label: '普通用户', value: 'normal' },
|
||||
{ label: 'VIP用户', value: 'vip' },
|
||||
{ label: '高级VIP', value: 'svip' },
|
||||
{ label: '企业用户', value: 'enterprise', disabled: true }
|
||||
]
|
||||
|
||||
/**
|
||||
* 性别选项
|
||||
*/
|
||||
const GENDER_OPTIONS: OptionItem[] = [
|
||||
{ label: '男', value: '1' },
|
||||
{ label: '女', value: '2' }
|
||||
]
|
||||
|
||||
/**
|
||||
* 日期快捷选项
|
||||
*/
|
||||
const DATE_SHORTCUTS = [
|
||||
{ text: '今日', value: new Date() },
|
||||
{ text: '昨日', value: () => new Date(Date.now() - 86400000) },
|
||||
{ text: '一周前', value: () => new Date(Date.now() - 604800000) }
|
||||
]
|
||||
|
||||
/**
|
||||
* 模拟接口获取用户等级数据
|
||||
* @returns 用户等级选项列表
|
||||
*/
|
||||
const fetchLevelOptions = (): Promise<OptionItem[]> => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve(LEVEL_OPTIONS)
|
||||
}, FETCH_DELAY)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户等级数据
|
||||
*/
|
||||
const getLevelOptions = async (): Promise<void> => {
|
||||
levelOptions.value = await fetchLevelOptions()
|
||||
if (levelOptions.value.length) {
|
||||
ElMessage.success('成功获取到数据')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 表单项配置类型
|
||||
*/
|
||||
interface FormItemConfig {
|
||||
label: string
|
||||
key: string
|
||||
type: string
|
||||
placeholder?: string
|
||||
clearable?: boolean
|
||||
props?: Record<string, any>
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建表单项的工厂函数
|
||||
*/
|
||||
const createFormItem = (config: FormItemConfig) => config
|
||||
|
||||
// 基础表单项配置
|
||||
const baseFormItems = {
|
||||
username: createFormItem({
|
||||
label: '用户名',
|
||||
key: 'name',
|
||||
type: 'input',
|
||||
placeholder: '请输入用户名',
|
||||
clearable: true
|
||||
}),
|
||||
phone: createFormItem({
|
||||
label: '手机号',
|
||||
key: 'phone',
|
||||
type: 'input',
|
||||
props: { placeholder: '请输入手机号', maxlength: '11' }
|
||||
}),
|
||||
level: createFormItem({
|
||||
label: '用户等级',
|
||||
key: 'level',
|
||||
type: 'select',
|
||||
props: {
|
||||
placeholder: '请选择等级',
|
||||
options: LEVEL_OPTIONS
|
||||
}
|
||||
}),
|
||||
address: createFormItem({
|
||||
label: '地址',
|
||||
key: 'address',
|
||||
type: 'input',
|
||||
placeholder: '请输入地址'
|
||||
}),
|
||||
date: createFormItem({
|
||||
label: '日期',
|
||||
key: 'date',
|
||||
type: 'datetime',
|
||||
props: {
|
||||
style: { width: '100%' },
|
||||
placeholder: '请选择日期',
|
||||
type: 'date',
|
||||
valueFormat: 'YYYY-MM-DD',
|
||||
shortcuts: DATE_SHORTCUTS
|
||||
}
|
||||
}),
|
||||
gender: createFormItem({
|
||||
label: '性别',
|
||||
key: 'userGender',
|
||||
type: 'radiogroup',
|
||||
props: {
|
||||
options: GENDER_OPTIONS
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 表单配置
|
||||
const formItemsBasic = computed(() => [
|
||||
baseFormItems.username,
|
||||
{
|
||||
label: '密码',
|
||||
key: 'password',
|
||||
type: 'input',
|
||||
props: {
|
||||
type: 'password',
|
||||
placeholder: '请输入密码',
|
||||
clearable: true
|
||||
}
|
||||
},
|
||||
baseFormItems.phone,
|
||||
baseFormItems.level,
|
||||
baseFormItems.address,
|
||||
baseFormItems.date,
|
||||
baseFormItems.gender
|
||||
])
|
||||
|
||||
const userItem = ref<SearchFormItem>({
|
||||
label: '用户名',
|
||||
key: 'name',
|
||||
type: 'input',
|
||||
props: {
|
||||
placeholder: '请输入用户名',
|
||||
clearable: true
|
||||
}
|
||||
})
|
||||
|
||||
// 控制用户名字段是否显示
|
||||
const showUserName = ref(true)
|
||||
|
||||
// 动态表单项列表
|
||||
const dynamicFormItems = ref<SearchFormItem[]>([])
|
||||
|
||||
// 动态表单项计数器(用于生成唯一 key)
|
||||
let dynamicItemCounter = 0
|
||||
|
||||
// 级联选择器数据
|
||||
const cascaderOptions = [
|
||||
{
|
||||
value: 'guide',
|
||||
label: '指南',
|
||||
children: [
|
||||
{
|
||||
value: 'disciplines',
|
||||
label: '规范',
|
||||
children: [
|
||||
{ value: 'consistency', label: '一致性' },
|
||||
{ value: 'feedback', label: '反馈' },
|
||||
{ value: 'efficiency', label: '效率' },
|
||||
{ value: 'controllability', label: '可控性' }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
value: 'components',
|
||||
label: '组件',
|
||||
children: [
|
||||
{
|
||||
value: 'basic',
|
||||
label: '基础组件',
|
||||
children: [
|
||||
{ value: 'button', label: '按钮' },
|
||||
{ value: 'form', label: '表单' },
|
||||
{ value: 'table', label: '表格' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
// 树选择器数据
|
||||
const treeSelectData = [
|
||||
{
|
||||
value: '1',
|
||||
label: '一级 1',
|
||||
children: [
|
||||
{
|
||||
value: '1-1',
|
||||
label: '二级 1-1',
|
||||
children: [{ value: '1-1-1', label: '三级 1-1-1' }]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
value: '2',
|
||||
label: '一级 2',
|
||||
children: [
|
||||
{
|
||||
value: '2-1',
|
||||
label: '二级 2-1',
|
||||
children: [{ value: '2-1-1', label: '三级 2-1-1' }]
|
||||
},
|
||||
{
|
||||
value: '2-2',
|
||||
label: '二级 2-2',
|
||||
children: [{ value: '2-2-1', label: '三级 2-2-1' }]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
// 复选框选项
|
||||
const checkboxOptions = [
|
||||
{ label: '选项1', value: 'option1' },
|
||||
{ label: '选项2', value: 'option2' },
|
||||
{ label: '选项3', value: 'option3' },
|
||||
{ label: '选项4', value: 'option4' },
|
||||
{ label: '选项5(disabled)', value: 'option5', disabled: true }
|
||||
]
|
||||
|
||||
// 完整示例表单配置
|
||||
const formItemsAdvanced = computed(() => [
|
||||
...(showUserName.value ? [userItem.value] : []),
|
||||
// 动态表单项
|
||||
...dynamicFormItems.value,
|
||||
{
|
||||
...baseFormItems.phone
|
||||
},
|
||||
{
|
||||
...baseFormItems.level,
|
||||
props: { placeholder: '请选择等级', options: levelOptions.value }
|
||||
},
|
||||
baseFormItems.address,
|
||||
baseFormItems.date,
|
||||
// 日期时间
|
||||
{
|
||||
label: '日期时间',
|
||||
key: 'datetime',
|
||||
type: 'datetime',
|
||||
props: {
|
||||
style: { width: '100%' },
|
||||
placeholder: '请选择日期时间',
|
||||
type: 'datetime',
|
||||
valueFormat: 'YYYY-MM-DD HH:mm:ss'
|
||||
}
|
||||
},
|
||||
{
|
||||
label: '日期范围',
|
||||
key: 'daterange',
|
||||
type: 'datetime',
|
||||
props: {
|
||||
type: 'daterange',
|
||||
valueFormat: 'YYYY-MM-DD',
|
||||
rangeSeparator: '至',
|
||||
startPlaceholder: '开始日期',
|
||||
endPlaceholder: '结束日期'
|
||||
}
|
||||
},
|
||||
// 日期时间范围
|
||||
{
|
||||
label: '日期时间范围',
|
||||
key: 'datetimerange',
|
||||
type: 'datetime',
|
||||
props: {
|
||||
type: 'datetimerange',
|
||||
valueFormat: 'YYYY-MM-DD HH:mm:ss',
|
||||
rangeSeparator: '至',
|
||||
startPlaceholder: '开始日期时间',
|
||||
endPlaceholder: '结束日期时间'
|
||||
}
|
||||
},
|
||||
// 时间选择
|
||||
{
|
||||
label: '时间选择',
|
||||
key: 'timeselect',
|
||||
type: 'timeselect',
|
||||
props: {
|
||||
placeholder: '请选择时间',
|
||||
type: 'time',
|
||||
valueFormat: 'HH:mm:ss'
|
||||
}
|
||||
},
|
||||
// 时间选择器
|
||||
{
|
||||
label: '时间选择器',
|
||||
key: 'timepicker',
|
||||
type: 'timepicker',
|
||||
props: {
|
||||
style: { width: '100%' },
|
||||
placeholder: '请选择时间',
|
||||
type: 'time',
|
||||
valueFormat: 'HH:mm:ss'
|
||||
}
|
||||
},
|
||||
// 级联选择
|
||||
{
|
||||
label: '级联选择',
|
||||
key: 'cascader',
|
||||
type: 'cascader',
|
||||
props: {
|
||||
placeholder: '请选择级联选择器',
|
||||
clearable: true,
|
||||
style: { width: '100%' },
|
||||
collapseTags: true,
|
||||
maxCollapseTags: 1,
|
||||
props: { multiple: true },
|
||||
options: cascaderOptions
|
||||
}
|
||||
},
|
||||
// 树型选择器
|
||||
{
|
||||
label: '树型选择器',
|
||||
key: 'treeSelect',
|
||||
type: 'treeselect',
|
||||
props: {
|
||||
showCheckbox: true,
|
||||
multiple: true,
|
||||
clearable: true,
|
||||
data: treeSelectData
|
||||
}
|
||||
},
|
||||
{ label: '插槽', key: 'slots', type: 'input', placeholder: '请输入邮箱' },
|
||||
{
|
||||
label: '渲染组件',
|
||||
key: 'iconSelector',
|
||||
render: () => h(ElInput, { placeholder: '渲染自定义 input' })
|
||||
},
|
||||
{
|
||||
label: '自定义组件',
|
||||
key: 'customComponent',
|
||||
render: () =>
|
||||
h(
|
||||
'div',
|
||||
{
|
||||
style:
|
||||
'color: var(--art-gray-600); border: 1px solid var(--default-border-dashed); padding: 0px 15px; border-radius: 6px'
|
||||
},
|
||||
'我是一个自定义组件'
|
||||
)
|
||||
},
|
||||
{
|
||||
label: '复选框',
|
||||
key: 'checkboxgroup',
|
||||
type: 'checkboxgroup',
|
||||
span: 12,
|
||||
props: {
|
||||
options: checkboxOptions
|
||||
}
|
||||
},
|
||||
{
|
||||
...baseFormItems.gender
|
||||
},
|
||||
|
||||
{
|
||||
label: '是否启用',
|
||||
key: 'isEnabled',
|
||||
type: 'switch',
|
||||
props: {
|
||||
placeholder: '请选择是否启用'
|
||||
}
|
||||
},
|
||||
{
|
||||
label: '年龄',
|
||||
key: 'age',
|
||||
type: 'number',
|
||||
slots: {
|
||||
suffix: () => h('span', { style: 'color: #909399; font-size: 12px' }, '岁')
|
||||
}
|
||||
},
|
||||
{
|
||||
label: '网站地址',
|
||||
key: 'website',
|
||||
type: 'input',
|
||||
placeholder: '请输入网站名称',
|
||||
slots: {
|
||||
prepend: () => h('span', 'https://'),
|
||||
append: () => h('span', '.com')
|
||||
}
|
||||
},
|
||||
{
|
||||
label: '事件演示',
|
||||
key: 'event',
|
||||
type: 'input',
|
||||
props: {
|
||||
placeholder: '输入内容触发事件,控制台查看',
|
||||
clearable: true,
|
||||
prefixIcon: 'Search',
|
||||
// prefix: () => h('span', {}, '123'),
|
||||
// 事件必须以 on 开头,然后驼峰式命名拼接 ElementPlus 事件名
|
||||
onInput(val: string) {
|
||||
console.log('输入事件', val)
|
||||
},
|
||||
onClear() {
|
||||
console.log('清空事件')
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
label: '多行输入',
|
||||
key: 'remark',
|
||||
type: 'input',
|
||||
props: {
|
||||
placeholder: '请输入备注',
|
||||
type: 'textarea',
|
||||
rows: 2
|
||||
}
|
||||
},
|
||||
{
|
||||
label: '评分',
|
||||
key: 'rate',
|
||||
type: 'rate',
|
||||
props: {
|
||||
size: 'large',
|
||||
placeholder: '请选择评分'
|
||||
}
|
||||
},
|
||||
{
|
||||
label: '禁用',
|
||||
key: 'diaabled',
|
||||
type: 'input',
|
||||
placeholder: '我被禁用了',
|
||||
disabled: true // 禁用
|
||||
},
|
||||
{
|
||||
label: '滑块',
|
||||
key: 'slider',
|
||||
type: 'slider'
|
||||
// props: {
|
||||
// step: 10,
|
||||
// showStops: true
|
||||
// }
|
||||
},
|
||||
|
||||
{
|
||||
label: '隐藏',
|
||||
key: 'email',
|
||||
type: 'input',
|
||||
hidden: true
|
||||
},
|
||||
// 根据条件隐藏
|
||||
{
|
||||
label: '根据条件隐藏',
|
||||
key: 'systemName',
|
||||
type: 'input',
|
||||
hidden: formDataAdvanced.value.systemName === 'mac',
|
||||
placeholder: '输入 mac 组件隐藏'
|
||||
},
|
||||
{
|
||||
label: '栅格布局',
|
||||
key: 'sg1',
|
||||
type: 'input',
|
||||
span: 12,
|
||||
placeholder: '示例:栅格 span=12 占容器一半宽度,span=24 占满容器'
|
||||
}
|
||||
])
|
||||
|
||||
/**
|
||||
* 创建统一的表单处理函数
|
||||
* @param ref 表单引用
|
||||
* @param formData 表单数据
|
||||
* @param type 表单类型描述
|
||||
*/
|
||||
const createFormHandler = (ref: Ref<any>, formData: Record<string, any>, type: string) => ({
|
||||
reset: () => {
|
||||
console.log(`重置${type}表单`)
|
||||
emit('reset')
|
||||
},
|
||||
search: async () => {
|
||||
await ref.value.validate()
|
||||
emit('search', formData.value)
|
||||
console.log(`${type}表单数据`, formData.value)
|
||||
},
|
||||
validate: () => ref.value.validate()
|
||||
})
|
||||
|
||||
/**
|
||||
* 基础表单处理器
|
||||
*/
|
||||
const basicFormHandler = computed(() =>
|
||||
createFormHandler(searchBarBasicRef, formDataBasic, '基础')
|
||||
)
|
||||
|
||||
/**
|
||||
* 完整表单处理器
|
||||
*/
|
||||
const advancedFormHandler = computed(() =>
|
||||
createFormHandler(searchBarAdvancedRef, formDataAdvanced, '完整')
|
||||
)
|
||||
|
||||
/**
|
||||
* 处理基础表单重置事件
|
||||
*/
|
||||
const handleBasicReset = () => basicFormHandler.value.reset()
|
||||
|
||||
/**
|
||||
* 处理基础表单搜索事件
|
||||
*/
|
||||
const handleBasicSearch = () => basicFormHandler.value.search()
|
||||
|
||||
/**
|
||||
* 处理完整表单重置事件
|
||||
*/
|
||||
const handleAdvancedReset = () => advancedFormHandler.value.reset()
|
||||
|
||||
/**
|
||||
* 处理完整表单搜索事件
|
||||
*/
|
||||
const handleAdvancedSearch = () => advancedFormHandler.value.search()
|
||||
|
||||
/**
|
||||
* 校验完整表单
|
||||
*/
|
||||
const advancedValidate = () => advancedFormHandler.value.validate()
|
||||
|
||||
/**
|
||||
* 重置完整表单
|
||||
*/
|
||||
const advancedReset = () => searchBarAdvancedRef.value.reset()
|
||||
|
||||
/**
|
||||
* 更新用户名字段配置
|
||||
*/
|
||||
const updateUserName = (): void => {
|
||||
userItem.value = {
|
||||
...userItem.value,
|
||||
label: '昵称',
|
||||
props: {
|
||||
placeholder: '请输入昵称'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除用户名字段
|
||||
*/
|
||||
const deleteUserName = (): void => {
|
||||
showUserName.value = false
|
||||
formDataAdvanced.value.name = undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增表单项
|
||||
*/
|
||||
const addFormItem = (): void => {
|
||||
dynamicItemCounter++
|
||||
const newItem: SearchFormItem = {
|
||||
label: `动态字段${dynamicItemCounter}`,
|
||||
key: `dynamic_${dynamicItemCounter}`,
|
||||
type: 'input',
|
||||
props: {
|
||||
placeholder: `请输入动态字段${dynamicItemCounter}`,
|
||||
clearable: true
|
||||
}
|
||||
}
|
||||
dynamicFormItems.value.push(newItem)
|
||||
ElMessage.success(`已新增表单项:${newItem.label}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改表单项(修改最后一个动态表单项)
|
||||
*/
|
||||
const updateFormItem = (): void => {
|
||||
if (dynamicFormItems.value.length === 0) {
|
||||
ElMessage.warning('没有可修改的动态表单项,请先新增')
|
||||
return
|
||||
}
|
||||
|
||||
const lastIndex = dynamicFormItems.value.length - 1
|
||||
const lastItem = dynamicFormItems.value[lastIndex]
|
||||
|
||||
// 修改最后一个表单项的配置
|
||||
dynamicFormItems.value[lastIndex] = {
|
||||
...lastItem,
|
||||
label: `已修改`,
|
||||
type: 'select',
|
||||
props: {
|
||||
placeholder: '修改为下拉选择',
|
||||
options: [
|
||||
{ label: '选项A', value: 'a' },
|
||||
{ label: '选项B', value: 'b' },
|
||||
{ label: '选项C', value: 'c' }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
ElMessage.success(`已修改表单项:${lastItem.label}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除表单项(删除最后一个动态表单项)
|
||||
*/
|
||||
const deleteFormItem = (): void => {
|
||||
if (dynamicFormItems.value.length === 0) {
|
||||
ElMessage.warning('没有可删除的动态表单项')
|
||||
return
|
||||
}
|
||||
|
||||
const deletedItem = dynamicFormItems.value.pop()
|
||||
if (deletedItem) {
|
||||
// 清除对应的表单数据
|
||||
delete formDataAdvanced.value[deletedItem.key as keyof AdvancedFormData]
|
||||
ElMessage.success(`已删除表单项:${deletedItem.label}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量新增表单项
|
||||
*/
|
||||
const batchAddFormItems = (): void => {
|
||||
const batchItems: SearchFormItem[] = [
|
||||
{
|
||||
label: '公司名称',
|
||||
key: `company_${++dynamicItemCounter}`,
|
||||
type: 'input',
|
||||
props: {
|
||||
placeholder: '请输入公司名称',
|
||||
clearable: true
|
||||
}
|
||||
},
|
||||
{
|
||||
label: '部门',
|
||||
key: `department_${++dynamicItemCounter}`,
|
||||
type: 'select',
|
||||
props: {
|
||||
placeholder: '请选择部门',
|
||||
options: [
|
||||
{ label: '技术部', value: 'tech' },
|
||||
{ label: '产品部', value: 'product' },
|
||||
{ label: '运营部', value: 'operation' }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
label: '入职日期',
|
||||
key: `joinDate_${++dynamicItemCounter}`,
|
||||
type: 'datetime',
|
||||
props: {
|
||||
style: { width: '100%' },
|
||||
placeholder: '请选择入职日期',
|
||||
type: 'date',
|
||||
valueFormat: 'YYYY-MM-DD'
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
dynamicFormItems.value.push(...batchItems)
|
||||
ElMessage.success(`已批量新增 ${batchItems.length} 个表单项`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置动态表单项
|
||||
*/
|
||||
const resetDynamicItems = (): void => {
|
||||
if (dynamicFormItems.value.length === 0) {
|
||||
ElMessage.info('当前没有动态表单项')
|
||||
return
|
||||
}
|
||||
|
||||
// 清除所有动态表单项的数据
|
||||
dynamicFormItems.value.forEach((item) => {
|
||||
delete formDataAdvanced.value[item.key as keyof AdvancedFormData]
|
||||
})
|
||||
|
||||
dynamicFormItems.value = []
|
||||
dynamicItemCounter = 0
|
||||
ElMessage.success('已重置所有动态表单项')
|
||||
}
|
||||
</script>
|
||||
+529
@@ -0,0 +1,529 @@
|
||||
<!-- 按钮权限示例页面 -->
|
||||
<template>
|
||||
<div class="w-full py-2">
|
||||
<!-- 页面头部 -->
|
||||
<div class="mb-6">
|
||||
<h2 class="m-0 mb-2 text-xl font-medium">{{ $t('menus.examples.permission.buttonAuth') }}</h2>
|
||||
<p class="m-0 text-sm leading-[1.6] text-g-700">
|
||||
此页面演示按钮级别的权限控制,不同用户身份下可见的按钮会有所不同。权限控制基于用户角色和权限码实现。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<ElCard shadow="never" class="art-card-xs">
|
||||
<template #header>
|
||||
<div class="flex-cb font-semibold">
|
||||
<span class="flex-1">当前用户权限信息</span>
|
||||
</div>
|
||||
</template>
|
||||
<div>
|
||||
<div class="flex items-start mb-4 last:mb-0">
|
||||
<span class="min-w-20 font-semibold">用户角色:</span>
|
||||
<ElTag :type="getRoleTagType(currentUserRole)">
|
||||
{{ getRoleDisplayName(currentUserRole) }}
|
||||
</ElTag>
|
||||
</div>
|
||||
<div class="flex items-start mb-4 last:mb-0">
|
||||
<span class="min-w-20 font-semibold">权限码:</span>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<ElTag
|
||||
v-for="permission in currentUserPermissions"
|
||||
:key="permission"
|
||||
size="small"
|
||||
type="info"
|
||||
class="m-0"
|
||||
>
|
||||
{{ permission }}
|
||||
</ElTag>
|
||||
<span v-if="!currentUserPermissions.length" class="italic text-red-500"
|
||||
>无权限码</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ElCard>
|
||||
</div>
|
||||
|
||||
<!-- 基于角色的权限控制演示 -->
|
||||
<div class="mb-6 last:mb-0">
|
||||
<ElCard shadow="never" class="art-card-xs">
|
||||
<template #header>
|
||||
<div class="flex-cb font-semibold">
|
||||
<span class="flex-1">基于角色的权限控制(v-roles 指令)</span>
|
||||
</div>
|
||||
</template>
|
||||
<div>
|
||||
<p class="p-3 m-0 mb-5 text-sm leading-[1.6] text-g-700 bg-g-200 rounded">
|
||||
使用
|
||||
<code class="px- 1.5 py-0.5 font-mono text-xs text-theme bg-theme/10 rounded"
|
||||
>v-roles</code
|
||||
>
|
||||
指令控制按钮显示,只有拥有指定角色的用户才能看到对应按钮。
|
||||
</p>
|
||||
|
||||
<div class="grid grid-cols-[repeat(auto-fit,minmax(280px,1fr))] gap-5">
|
||||
<div class="flex flex-col gap-2">
|
||||
<ElButton type="primary" plain v-roles="'R_SUPER'"> 超级管理员可见 </ElButton>
|
||||
<div class="text-xs">
|
||||
<code class="block p-1 px-2 mb-1 font-mono bg-g-200 border-full-d rounded"
|
||||
>v-roles="'R_SUPER'"</code
|
||||
>
|
||||
<span class="text-g-700">只有超级管理员可见</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 管理员级别按钮 -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<ElButton type="warning" plain v-roles="['R_SUPER', 'R_ADMIN']">
|
||||
管理员可见
|
||||
</ElButton>
|
||||
<div class="text-xs">
|
||||
<code class="block p-1 px-2 mb-1 font-mono bg-g-200 border-full-d rounded"
|
||||
>v-roles="['R_SUPER', 'R_ADMIN']"</code
|
||||
>
|
||||
<span class="text-g-700">超级管理员和管理员可见</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 所有已登录用户可见 -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<ElButton type="success" plain v-roles="['R_SUPER', 'R_ADMIN', 'R_USER']">
|
||||
所有用户可见
|
||||
</ElButton>
|
||||
<div class="text-xs">
|
||||
<code class="block p-1 px-2 mb-1 font-mono bg-g-200 border-full-d rounded"
|
||||
>v-roles="['R_SUPER', 'R_ADMIN', 'R_USER']"</code
|
||||
>
|
||||
<span class="text-g-700">所有已登录用户可见</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ElCard>
|
||||
</div>
|
||||
|
||||
<!-- 后端模式:基于路由权限配置的控制演示 -->
|
||||
<div class="mb-6 last:mb-0">
|
||||
<ElCard shadow="never" class="art-card-xs">
|
||||
<template #header>
|
||||
<div class="flex-cb font-semibold">
|
||||
<span class="flex-1">后端模式权限控制(v-auth 指令)</span>
|
||||
<ElTag v-if="!isFrontendMode" type="success" size="small">当前模式</ElTag>
|
||||
<ElTag v-else type="info" size="small">非当前模式</ElTag>
|
||||
</div>
|
||||
</template>
|
||||
<div>
|
||||
<p class="p-3 m-0 mb-5 text-sm leading-[1.6] text-g-700 bg-g-200 rounded">
|
||||
<code class="px- 1.5 py-0.5 font-mono text-xs text-theme bg-theme/10 rounded"
|
||||
>v-auth</code
|
||||
>
|
||||
指令在后端模式下根据当前路由的
|
||||
<code class="px- 1.5 py-0.5 font-mono text-xs text-theme bg-theme/10 rounded"
|
||||
>meta.authList</code
|
||||
>
|
||||
配置检查权限。 权限列表来源于路由元数据,适用于权限配置由后端统一管理的场景。
|
||||
</p>
|
||||
|
||||
<div class="mb-5">
|
||||
<h4 class="m-0 mb-2 text-sm font-semibold"> 当前路由权限配置: </h4>
|
||||
<div
|
||||
class="max-h-30 p-3 overflow-y-auto font-mono text-xs break-all whitespace-pre-wrap bg-g-200 border-full-d rounded"
|
||||
>
|
||||
<code>{{ JSON.stringify(backendAuthList, null, 2) }}</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-[repeat(auto-fit,minmax(280px,1fr))] gap-5">
|
||||
<!-- 新增权限 -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<ElButton type="primary" plain v-auth="'add'"> 新增 </ElButton>
|
||||
<div class="text-xs">
|
||||
<code class="block p-1 px-2 mb-1 font-mono bg-g-200 border-full-d rounded"
|
||||
>v-auth="'add'"</code
|
||||
>
|
||||
<span class="text-g-700">检查路由 meta.authList 中是否存在 authMark: 'add'</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 编辑权限 -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<ElButton type="warning" plain v-auth="'edit'"> 编辑 </ElButton>
|
||||
<div class="text-xs">
|
||||
<code class="block p-1 px-2 mb-1 font-mono bg-g-200 border-full-d rounded"
|
||||
>v-auth="'edit'"</code
|
||||
>
|
||||
<span class="text-g-700">检查路由 meta.authList 中是否存在 authMark: 'edit'</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 删除权限 -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<ElButton type="danger" plain v-auth="'delete'"> 删除 </ElButton>
|
||||
<div class="text-xs">
|
||||
<code class="block p-1 px-2 mb-1 font-mono bg-g-200 border-full-d rounded"
|
||||
>v-auth="'delete'"</code
|
||||
>
|
||||
<span class="text-g-700">检查路由 meta.authList 中是否存在 authMark: 'delete'</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 导出权限 -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<ElButton type="info" plain v-auth="'export'"> 导出 </ElButton>
|
||||
<div class="text-xs">
|
||||
<code class="block p-1 px-2 mb-1 font-mono bg-g-200 border-full-d rounded"
|
||||
>v-auth="'export'"</code
|
||||
>
|
||||
<span class="text-g-700">检查路由 meta.authList 中是否存在 authMark: 'export'</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<ElAlert
|
||||
:type="isFrontendMode ? 'warning' : 'success'"
|
||||
:title="
|
||||
isFrontendMode
|
||||
? '注意:当前为前端模式,v-auth 指令在后端模式下才生效'
|
||||
: '当前为后端模式,v-auth 指令正常工作'
|
||||
"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ElCard>
|
||||
</div>
|
||||
|
||||
<!-- 前端模式:编程式权限控制演示 -->
|
||||
<div class="mb-6 last:mb-0">
|
||||
<ElCard shadow="never" class="art-card-xs">
|
||||
<template #header>
|
||||
<div class="flex-cb font-semibold">
|
||||
<span class="flex-1">前端模式权限控制(hasAuth 方法)</span>
|
||||
<ElTag v-if="isFrontendMode" type="success" size="small">当前模式</ElTag>
|
||||
<ElTag v-else type="info" size="small">非当前模式</ElTag>
|
||||
</div>
|
||||
</template>
|
||||
<div>
|
||||
<p class="p-3 m-0 mb-5 text-sm leading-[1.6] text-g-700 bg-g-200 rounded">
|
||||
<code class="px- 1.5 py-0.5 font-mono text-xs text-theme bg-theme/10 rounded"
|
||||
>hasAuth</code
|
||||
>
|
||||
方法在前端模式下根据用户信息中的
|
||||
<code class="px- 1.5 py-0.5 font-mono text-xs text-theme bg-theme/10 rounded"
|
||||
>buttons</code
|
||||
>
|
||||
字段检查权限。 权限列表存储在用户状态中,适用于权限完全由前端管理的场景。
|
||||
</p>
|
||||
|
||||
<div class="mb-5">
|
||||
<h4 class="m-0 mb-2 text-sm font-semibold">当前用户权限列表(buttons 字段):</h4>
|
||||
<div
|
||||
class="max-h-30 p-3 overflow-y-auto font-mono text-xs break-all whitespace-pre-wrap bg-g-200 border-full-d rounded"
|
||||
>
|
||||
<code>{{ JSON.stringify(frontendAuthList, null, 2) }}</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-[repeat(auto-fit,minmax(280px,1fr))] gap-5">
|
||||
<!-- 条件渲染按钮 -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<ElButton v-if="hasAuth('view')" type="primary"> 查看详情 </ElButton>
|
||||
<ElButton v-else type="info" disabled> 无查看权限 </ElButton>
|
||||
<div class="text-xs">
|
||||
<code class="block p-1 px-2 mb-1 font-mono bg-g-200 border-full-d rounded"
|
||||
>v-if="hasAuth('view')"</code
|
||||
>
|
||||
<span class="text-g-700">检查用户 buttons 数组是否包含 'view'</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 动态按钮状态 -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<ElButton
|
||||
:disabled="!hasAuth('publish')"
|
||||
:type="hasAuth('publish') ? 'success' : 'info'"
|
||||
@click="handlePublish"
|
||||
>
|
||||
{{ hasAuth('publish') ? '发布' : '无发布权限' }}
|
||||
</ElButton>
|
||||
<div class="text-xs">
|
||||
<code class="block p-1 px-2 mb-1 font-mono bg-g-200 border-full-d rounded"
|
||||
>:disabled="!hasAuth('publish')"</code
|
||||
>
|
||||
<span class="text-g-700">检查用户 buttons 数组是否包含 'publish'</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 批量操作按钮 -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<ElDropdown @command="handleBatchAction" :disabled="!hasBatchPermissions">
|
||||
<ElButton :type="hasBatchPermissions ? 'warning' : 'info'">
|
||||
批量操作
|
||||
<ElIcon class="el-icon--right">
|
||||
<ArrowDown />
|
||||
</ElIcon>
|
||||
</ElButton>
|
||||
<template #dropdown>
|
||||
<ElDropdownMenu>
|
||||
<ElDropdownItem command="batchEdit" :disabled="!hasAuth('edit')">
|
||||
批量编辑
|
||||
</ElDropdownItem>
|
||||
<ElDropdownItem command="batchDelete" :disabled="!hasAuth('delete')">
|
||||
批量删除
|
||||
</ElDropdownItem>
|
||||
<ElDropdownItem command="batchExport" :disabled="!hasAuth('export')">
|
||||
批量导出
|
||||
</ElDropdownItem>
|
||||
</ElDropdownMenu>
|
||||
</template>
|
||||
</ElDropdown>
|
||||
<div class="text-xs">
|
||||
<code class="block p-1 px-2 mb-1 font-mono bg-g-200 border-full-d rounded"
|
||||
>computed(() => hasAuth('edit') || hasAuth('delete'))</code
|
||||
>
|
||||
<span class="text-g-700">复合权限检查,检查多个权限的并集</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ElCard>
|
||||
</div>
|
||||
|
||||
<!-- 后端模式:编程式权限控制演示 -->
|
||||
<div class="mb-6 last:mb-0">
|
||||
<ElCard shadow="never" class="art-card-xs">
|
||||
<template #header>
|
||||
<div class="flex-cb font-semibold">
|
||||
<span class="flex-1">后端模式权限控制(hasAuth 方法)</span>
|
||||
<ElTag v-if="!isFrontendMode" type="success" size="small">当前模式</ElTag>
|
||||
<ElTag v-else type="info" size="small">非当前模式</ElTag>
|
||||
</div>
|
||||
</template>
|
||||
<div>
|
||||
<p class="p-3 m-0 mb-5 text-sm leading-[1.6] text-g-700 bg-g-200 rounded">
|
||||
<code class="px- 1.5 py-0.5 font-mono text-xs text-theme bg-theme/10 rounded"
|
||||
>hasAuth</code
|
||||
>
|
||||
方法在后端模式下根据当前路由的
|
||||
<code class="px- 1.5 py-0.5 font-mono text-xs text-theme bg-theme/10 rounded"
|
||||
>meta.authList</code
|
||||
>
|
||||
配置检查权限。与 v-auth 指令使用相同的权限来源,但提供编程式的权限检查能力。
|
||||
</p>
|
||||
|
||||
<div class="grid grid-cols-[repeat(auto-fit,minmax(280px,1fr))] gap-5">
|
||||
<!-- 动态功能切换 -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<ElSwitch
|
||||
v-model="dynamicFeatureEnabled"
|
||||
:disabled="!hasAuth('config')"
|
||||
active-text="功能开启"
|
||||
inactive-text="功能关闭"
|
||||
/>
|
||||
<div class="text-xs">
|
||||
<code class="block p-1 px-2 mb-1 font-mono bg-g-200 border-full-d rounded"
|
||||
>:disabled="!hasAuth('config')"</code
|
||||
>
|
||||
<span class="text-g-700">检查路由权限控制功能开关</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 条件显示操作区域 -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<div v-if="hasAuth('manage')" class="flex gap-2 p-3 bg-g-200 border-full-d rounded">
|
||||
<ElButton type="primary" size="small">管理操作</ElButton>
|
||||
<ElButton type="warning" size="small">高级设置</ElButton>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex-c gap-2 p-3 text-g-500 bg-g-200 border border-dashed border-g-400 rounded"
|
||||
>
|
||||
<ElIcon><Lock /></ElIcon>
|
||||
<span class="text-sm">需要管理权限才能查看此区域</span>
|
||||
</div>
|
||||
<div class="text-xs">
|
||||
<code class="block p-1 px-2 mb-1 font-mono bg-g-200 border-full-d rounded"
|
||||
>v-if="hasAuth('manage')"</code
|
||||
>
|
||||
<span class="text-g-700">基于后端路由权限的条件渲染</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 权限状态指示器 -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<ElBadge
|
||||
:value="hasAuth('add') ? '✓' : '✗'"
|
||||
:type="hasAuth('add') ? 'success' : 'danger'"
|
||||
>
|
||||
<ElButton size="small">新增权限</ElButton>
|
||||
</ElBadge>
|
||||
<ElBadge
|
||||
:value="hasAuth('edit') ? '✓' : '✗'"
|
||||
:type="hasAuth('edit') ? 'success' : 'danger'"
|
||||
>
|
||||
<ElButton size="small">编辑权限</ElButton>
|
||||
</ElBadge>
|
||||
<ElBadge
|
||||
:value="hasAuth('delete') ? '✓' : '✗'"
|
||||
:type="hasAuth('delete') ? 'success' : 'danger'"
|
||||
>
|
||||
<ElButton size="small">删除权限</ElButton>
|
||||
</ElBadge>
|
||||
</div>
|
||||
<div class="text-xs">
|
||||
<code class="block p-1 px-2 mb-1 font-mono bg-g-200 border-full-d rounded"
|
||||
>hasAuth('permission')</code
|
||||
>
|
||||
<span class="text-g-700">实时权限状态指示器</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ElCard>
|
||||
</div>
|
||||
|
||||
<!-- 权限模式对比说明 -->
|
||||
<div class="mb-6 last:mb-0">
|
||||
<ElCard shadow="never" class="art-card-xs">
|
||||
<template #header>
|
||||
<div class="flex-cb font-semibold">
|
||||
<span class="flex-1">权限控制模式对比</span>
|
||||
</div>
|
||||
</template>
|
||||
<div>
|
||||
<div class="mt-4">
|
||||
<ElTable :data="comparisonData" border>
|
||||
<ElTableColumn prop="feature" label="功能特性" width="150" />
|
||||
<ElTableColumn prop="frontend" label="前端模式" />
|
||||
<ElTableColumn prop="backend" label="后端模式" />
|
||||
</ElTable>
|
||||
</div>
|
||||
</div>
|
||||
</ElCard>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { ArrowDown, Lock } from '@element-plus/icons-vue'
|
||||
import { useAuth } from '@/hooks/core/useAuth'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { useAppMode } from '@/hooks/core/useAppMode'
|
||||
import { useRoute } from 'vue-router'
|
||||
import type { AppRouteRecord } from '@/types/router'
|
||||
|
||||
defineOptions({ name: 'PermissionButtonAuth' })
|
||||
|
||||
const { hasAuth } = useAuth()
|
||||
const { isFrontendMode } = useAppMode()
|
||||
const userStore = useUserStore()
|
||||
const route = useRoute()
|
||||
|
||||
// 动态功能状态
|
||||
const dynamicFeatureEnabled = ref(false)
|
||||
|
||||
// 当前用户角色
|
||||
const currentUserRole = computed(() => {
|
||||
return userStore.info?.roles?.[0] || ''
|
||||
})
|
||||
|
||||
// 当前用户权限码
|
||||
const currentUserPermissions = computed(() => {
|
||||
return userStore.info?.buttons || []
|
||||
})
|
||||
|
||||
// 前端模式权限列表(用户的 buttons 字段)
|
||||
const frontendAuthList = computed(() => {
|
||||
return userStore.info?.buttons || []
|
||||
})
|
||||
|
||||
// 后端模式权限列表(路由 meta.authList 配置)
|
||||
const backendAuthList = computed(() => {
|
||||
type AuthItem = NonNullable<AppRouteRecord['meta']['authList']>[number]
|
||||
return Array.isArray(route.meta.authList) ? (route.meta.authList as AuthItem[]) : []
|
||||
})
|
||||
|
||||
// 是否拥有批量操作权限
|
||||
const hasBatchPermissions = computed(() => {
|
||||
return hasAuth('edit') || hasAuth('delete') || hasAuth('export')
|
||||
})
|
||||
|
||||
// 权限对比数据
|
||||
const comparisonData = computed(() => [
|
||||
{
|
||||
feature: '权限来源',
|
||||
frontend: '用户状态中的 buttons 字段',
|
||||
backend: '路由配置中的 meta.authList'
|
||||
},
|
||||
{
|
||||
feature: 'v-auth 指令',
|
||||
frontend: '不可用(前端模式下指令无效)',
|
||||
backend: '可用(根据路由权限配置检查)'
|
||||
},
|
||||
{
|
||||
feature: 'hasAuth 方法',
|
||||
frontend: '可用(检查 buttons 数组)',
|
||||
backend: '可用(检查 meta.authList)'
|
||||
},
|
||||
{
|
||||
feature: '权限管理',
|
||||
frontend: '完全由前端控制,灵活度高',
|
||||
backend: '后端统一管理,安全性更高'
|
||||
},
|
||||
{
|
||||
feature: '适用场景',
|
||||
frontend: '快速原型、简单应用',
|
||||
backend: '企业级应用、复杂权限体系'
|
||||
}
|
||||
])
|
||||
|
||||
// 获取角色标签类型
|
||||
const getRoleTagType = (role: string): 'primary' | 'success' | 'info' | 'warning' | 'danger' => {
|
||||
const roleMap: Record<string, 'primary' | 'success' | 'info' | 'warning' | 'danger'> = {
|
||||
R_SUPER: 'warning',
|
||||
R_ADMIN: 'primary',
|
||||
R_USER: 'success'
|
||||
}
|
||||
return roleMap[role] || 'info'
|
||||
}
|
||||
|
||||
// 获取角色显示名称
|
||||
const getRoleDisplayName = (role: string) => {
|
||||
const roleMap: Record<string, string> = {
|
||||
R_SUPER: '超级管理员',
|
||||
R_ADMIN: '管理员',
|
||||
R_USER: '普通用户'
|
||||
}
|
||||
return roleMap[role] || '未知角色'
|
||||
}
|
||||
|
||||
// 处理发布操作
|
||||
const handlePublish = () => {
|
||||
if (hasAuth('publish')) {
|
||||
ElMessage.success('发布成功!')
|
||||
} else {
|
||||
ElMessage.warning('您没有发布权限')
|
||||
}
|
||||
}
|
||||
|
||||
// 处理批量操作
|
||||
const handleBatchAction = (command: string) => {
|
||||
const actions: Record<string, string> = {
|
||||
batchEdit: '批量编辑',
|
||||
batchDelete: '批量删除',
|
||||
batchExport: '批量导出'
|
||||
}
|
||||
|
||||
const permission = command.replace('batch', '').toLowerCase()
|
||||
|
||||
if (hasAuth(permission)) {
|
||||
ElMessage.success(`${actions[command]}操作执行成功!`)
|
||||
} else {
|
||||
ElMessage.warning(`您没有${actions[command]}权限`)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
+266
@@ -0,0 +1,266 @@
|
||||
<!-- 权限页面可见页面 -->
|
||||
<template>
|
||||
<div class="w-full py-2">
|
||||
<!-- 页面头部 -->
|
||||
<div class="mb-6">
|
||||
<h2 class="m-0 mb-2 text-xl font-medium">
|
||||
{{ $t("menus.examples.permission.pageVisibility") }}
|
||||
</h2>
|
||||
<p class="m-0 text-sm leading-[1.6] text-g-700">
|
||||
此页面仅对<strong class="font-semibold text-warning">超级管理员</strong
|
||||
>用户可见,演示页面级别的权限控制。 如果您能看到此页面,说明您拥有相应的访问权限。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<ElCard shadow="never" class="art-card-xs">
|
||||
<template #header>
|
||||
<div class="flex-c gap-2 font-semibold">
|
||||
<span>权限验证成功</span>
|
||||
</div>
|
||||
</template>
|
||||
<div>
|
||||
<div class="flex-c gap-5">
|
||||
<div>
|
||||
<h3 class="m-0 mb-2 text-lg font-semibold">您拥有访问此页面的权限</h3>
|
||||
<p class="my-1 text-sm text-g-700">
|
||||
当前用户:<strong class="font-semibold">{{
|
||||
currentUser.userName
|
||||
}}</strong>
|
||||
</p>
|
||||
<p class="my-1 text-sm text-g-700">
|
||||
用户角色:
|
||||
<ElTag type="warning">{{
|
||||
getRoleDisplayName(currentUser.roles?.[0] || "")
|
||||
}}</ElTag>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ElCard>
|
||||
</div>
|
||||
|
||||
<!-- 页面级权限控制说明 -->
|
||||
<div class="mb-6 last:mb-0">
|
||||
<ElCard shadow="never" class="art-card-xs">
|
||||
<template #header>
|
||||
<div class="flex-c font-semibold">
|
||||
<span>页面级权限控制说明</span>
|
||||
</div>
|
||||
</template>
|
||||
<div>
|
||||
<ElTimeline>
|
||||
<ElTimelineItem timestamp="前端控制模式" type="primary" size="large">
|
||||
<ElCard shadow="never">
|
||||
<h4 class="m-0 mb-2 text-base font-semibold">基于角色的权限控制</h4>
|
||||
<p class="m-0 mb-2 leading-[1.6] text-g-700">
|
||||
在前端控制模式下,页面访问权限由路由配置文件中的
|
||||
<code
|
||||
class="px-1.5 py-0.5 font-mono text-xs text-theme bg-theme/12 rounded"
|
||||
>meta.roles</code
|
||||
>
|
||||
字段定义,前端会根据用户接口所拥有的角色对路由和菜单进行过滤与控制
|
||||
</p>
|
||||
<pre
|
||||
class="p-4 mt-3 mb-0 overflow-x-auto font-mono text-xs leading-[1.5] bg-g-200 border-full-d rounded-md"
|
||||
><code class="">{
|
||||
path: 'page-visibility',
|
||||
name: 'PermissionPageVisibility',
|
||||
component: '/examples/permission/page-visibility',
|
||||
meta: {
|
||||
title: 'menus.permission.pageVisibility',
|
||||
roles: ['R_SUPER'], // 仅超级管理员可访问
|
||||
keepAlive: true
|
||||
}
|
||||
}</code></pre>
|
||||
<p class="m-0 mb-2 leading-[1.6] text-g-700">
|
||||
<strong>权限验证流程:</strong>
|
||||
</p>
|
||||
<ul class="pl-5 my-2">
|
||||
<li class="my-1 leading-[1.5] text-g-700">
|
||||
用户登录后,接口返回用户角色信息(如 R_SUPER、R_ADMIN、R_USER)
|
||||
</li>
|
||||
<li class="my-1 leading-[1.5] text-g-700">
|
||||
在
|
||||
<code
|
||||
class="px-1.5 py-0.5 font-mono text-xs text-theme bg-theme/12 rounded"
|
||||
>beforeEach</code
|
||||
>
|
||||
路由守卫中检查目标路由的
|
||||
<code
|
||||
class="px-1.5 py-0.5 font-mono text-xs text-theme bg-theme/12 rounded"
|
||||
>roles</code
|
||||
>
|
||||
配置
|
||||
</li>
|
||||
<li class="my-1 leading-[1.5] text-g-700">
|
||||
比较用户角色是否包含在允许访问的角色列表中
|
||||
</li>
|
||||
<li class="my-1 leading-[1.5] text-g-700">权限不足时跳转到 403 页面</li>
|
||||
</ul>
|
||||
</ElCard>
|
||||
</ElTimelineItem>
|
||||
|
||||
<ElTimelineItem timestamp="后端控制模式" type="warning" size="large">
|
||||
<ElCard shadow="never">
|
||||
<h4 class="m-0 mb-2 text-base font-semibold">基于菜单接口的权限控制</h4>
|
||||
<p class="m-0 mb-2 leading-[1.6] text-g-700">
|
||||
在后端控制模式下,页面访问权限由后端统一管理,前端通过解析后端接口返回的菜单列表来生成可访问的路由,从而实现权限控制
|
||||
</p>
|
||||
<p class="m-0 mb-2 leading-[1.6] text-g-700">
|
||||
接口地址:src/api/menuApi.ts getMenuList
|
||||
</p>
|
||||
<pre
|
||||
class="p-4 mt-3 mb-0 overflow-x-auto font-mono text-xs leading-[1.5] bg-g-200 border-full-d rounded-md"
|
||||
><code class="">
|
||||
{
|
||||
"code": 200,
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"path": "/permission",
|
||||
"name": "Permission",
|
||||
"component": "Layout",
|
||||
"meta": {
|
||||
"title": "menus.permission.title",
|
||||
"icon": ""
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": 11,
|
||||
"path": "page-visibility",
|
||||
"name": "PermissionPageVisibility",
|
||||
"component": "permission/page-visibility/index",
|
||||
"meta": {
|
||||
"title": "menus.permission.pageVisibility",
|
||||
"keepAlive": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}</code></pre>
|
||||
<p><strong>权限验证流程:</strong></p>
|
||||
<ul>
|
||||
<li>用户登录成功后获取 Token</li>
|
||||
<li>前端调用菜单接口获取用户可访问的菜单列表</li>
|
||||
<li>前端根据菜单列表动态注册路由</li>
|
||||
<li>菜单中存在的页面用户可以正常访问,不存在的页面会跳转到 404</li>
|
||||
</ul>
|
||||
</ElCard>
|
||||
</ElTimelineItem>
|
||||
|
||||
<ElTimelineItem timestamp="菜单显示控制" type="success" size="large">
|
||||
<ElCard shadow="never">
|
||||
<h4>侧边栏菜单可见性</h4>
|
||||
<p><strong>前端控制模式:</strong></p>
|
||||
<ul>
|
||||
<li>有权限的用户:菜单项正常显示,可以点击访问</li>
|
||||
<li>无权限的用户:菜单项不显示,无法通过菜单导航到页面</li>
|
||||
<li>即使通过直接输入URL尝试访问,也会被路由守卫拦截</li>
|
||||
</ul>
|
||||
<p><strong>后端控制模式:</strong></p>
|
||||
<ul>
|
||||
<li>侧边栏菜单根据后端返回的菜单列表进行渲染</li>
|
||||
<li>后端应该根据用户权限过滤,只返回用户有权限访问的菜单项</li>
|
||||
<li>前端只显示后端返回的菜单,确保用户只能看到和访问有权限的页面</li>
|
||||
</ul>
|
||||
</ElCard>
|
||||
</ElTimelineItem>
|
||||
</ElTimeline>
|
||||
</div>
|
||||
</ElCard>
|
||||
</div>
|
||||
|
||||
<!-- 权限控制最佳实践 -->
|
||||
<div class="best-practices">
|
||||
<ElCard shadow="never" class="art-card-xs">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>权限控制最佳实践</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="practices-content">
|
||||
<ElRow :gutter="24">
|
||||
<ElCol :span="12" class="mb-5!">
|
||||
<div class="flex-c">
|
||||
<div class="size-10 bg-g-200 flex-cc rounded mr-2">
|
||||
<ElIcon size="20" color="#409EFF"><Lock /></ElIcon>
|
||||
</div>
|
||||
<div>
|
||||
<h4>多层权限验证</h4>
|
||||
<p class="text-g-700 text-sm">
|
||||
在前端路由、后端接口、UI组件等多个层面实施权限控制,确保安全性。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<div class="flex-c">
|
||||
<div class="size-10 bg-g-200 flex-cc rounded mr-2">
|
||||
<ElIcon size="20" color="#67C23A"><User /></ElIcon>
|
||||
</div>
|
||||
<div>
|
||||
<h4>基于角色的访问控制</h4>
|
||||
<p class="text-g-700 text-sm">
|
||||
采用RBAC模型,通过角色分配权限,简化权限管理复杂度。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<div class="flex-c">
|
||||
<div class="size-10 bg-g-200 flex-cc rounded mr-2">
|
||||
<ElIcon size="20" color="#E6A23C"><Key /></ElIcon>
|
||||
</div>
|
||||
<div>
|
||||
<h4>细粒度权限控制</h4>
|
||||
<p class="text-g-700 text-sm">
|
||||
支持页面级、按钮级、数据级等多种粒度的权限控制。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<div class="flex-c">
|
||||
<div class="size-10 bg-g-200 flex-cc rounded mr-2">
|
||||
<ElIcon size="20" color="#F56C6C"><View /></ElIcon>
|
||||
</div>
|
||||
<div>
|
||||
<h4>安全性优先原则</h4>
|
||||
<p class="text-g-700 text-sm">
|
||||
始终遵循最小权限原则,确保用户只能访问必要的功能和数据。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</div>
|
||||
</ElCard>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
|
||||
import { Lock, User, Key, View } from "@element-plus/icons-vue";
|
||||
import { useUserStore } from "@/store/modules/user";
|
||||
|
||||
defineOptions({ name: "PermissionPageVisibility" });
|
||||
|
||||
const userStore = useUserStore();
|
||||
|
||||
// 当前用户信息
|
||||
const currentUser = computed(() => userStore.info);
|
||||
|
||||
// 获取角色显示名称
|
||||
const getRoleDisplayName = (role: string) => {
|
||||
const roleMap: Record<string, string> = {
|
||||
R_SUPER: "超级管理员",
|
||||
R_ADMIN: "管理员",
|
||||
R_USER: "普通用户",
|
||||
};
|
||||
return roleMap[role] || "未知角色";
|
||||
};
|
||||
</script>
|
||||
+220
@@ -0,0 +1,220 @@
|
||||
<!-- 切换权限页面 -->
|
||||
<template>
|
||||
<div class="py-2">
|
||||
<!-- 页面头部 -->
|
||||
<div class="mb-6">
|
||||
<h2 class="m-0 mb-2 text-xl font-medium">权限切换演示</h2>
|
||||
<p class="m-0 leading-[1.6] text-g-700">
|
||||
点击下方按钮切换不同用户身份,模拟不同用户登录系统的效果。切换后会影响整个系统的菜单显示和按钮权限。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 当前用户信息 -->
|
||||
<div class="mb-6">
|
||||
<ElCard shadow="never" class="art-card-xs">
|
||||
<template #header>
|
||||
<div class="font-semibold">
|
||||
<span>当前登录用户</span>
|
||||
</div>
|
||||
</template>
|
||||
<div>
|
||||
<div>
|
||||
<div class="flex items-start mb-3 last:mb-0">
|
||||
<span class="min-w-30 font-semibold">用户名:</span>
|
||||
<span>{{ currentUser.userName || "未登录" }}</span>
|
||||
</div>
|
||||
<div class="flex items-start mb-3 last:mb-0">
|
||||
<span class="min-w-30 font-semibold">角色:</span>
|
||||
<ElTag :type="getRoleTagType(currentUser.roles?.[0])">
|
||||
{{ getRoleDisplayName(currentUser.roles?.[0]) }}
|
||||
</ElTag>
|
||||
</div>
|
||||
<div class="flex items-start mb-3 last:mb-0">
|
||||
<span class="min-w-30 font-semibold">权限码:</span>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<ElTag
|
||||
v-for="button in currentUser.buttons"
|
||||
:key="button"
|
||||
size="small"
|
||||
type="info"
|
||||
>
|
||||
{{ button }}
|
||||
</ElTag>
|
||||
<span v-if="!currentUser.buttons?.length" class="italic text-g-500"
|
||||
>无权限码</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ElCard>
|
||||
</div>
|
||||
|
||||
<!-- 用户角色切换 -->
|
||||
<div class="mb-6">
|
||||
<ElCard shadow="never" class="art-card-xs">
|
||||
<template #header>
|
||||
<div class="font-semibold">
|
||||
<span>账号切换</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="grid grid-cols-[repeat(auto-fit,minmax(300px,1fr))] gap-4">
|
||||
<div
|
||||
v-for="account in accounts"
|
||||
:key="account.key"
|
||||
class="p-5 border border-g-400 rounded-lg tad-300"
|
||||
:class="{
|
||||
'bg-theme/12 !border-theme': currentUser.userName === account.userName,
|
||||
}"
|
||||
>
|
||||
<div class="mb-4">
|
||||
<div>
|
||||
<h4 class="m-0 mb-2 text-base font-semibold">{{ account.label }}</h4>
|
||||
<p class="m-0 mb-2 leading-[1.5] text-g-700">{{ account.description }}</p>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-xs text-g-600">用户名: {{ account.userName }}</span>
|
||||
<span class="text-xs text-g-600"
|
||||
>角色: {{ account.roles.join(", ") }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<ElButton
|
||||
v-if="currentUser.userName !== account.userName"
|
||||
type="primary"
|
||||
@click="switchRole(account)"
|
||||
:loading="switching"
|
||||
>
|
||||
切换到此角色
|
||||
</ElButton>
|
||||
<ElTag v-else type="success">当前用户</ElTag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ElCard>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { useUserStore } from "@/store/modules/user";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { fetchLogin, fetchGetUserInfo } from "@/apps/demo/api/auth";
|
||||
|
||||
defineOptions({ name: "PermissionSwitchRole" });
|
||||
|
||||
const { t } = useI18n();
|
||||
const userStore = useUserStore();
|
||||
|
||||
// 响应式数据
|
||||
const switching = ref(false);
|
||||
|
||||
// 当前用户信息
|
||||
const currentUser = computed(() => userStore.info);
|
||||
|
||||
// 账号列表 - 与登录页面保持一致
|
||||
const accounts = computed(() => [
|
||||
{
|
||||
key: "super",
|
||||
label: t("login.roles.super"),
|
||||
userName: "Super",
|
||||
password: "123456",
|
||||
roles: ["R_SUPER"],
|
||||
color: "#E6A23C",
|
||||
description: "拥有系统最高权限,可以访问所有功能模块",
|
||||
},
|
||||
{
|
||||
key: "admin",
|
||||
label: t("login.roles.admin"),
|
||||
userName: "Admin",
|
||||
password: "123456",
|
||||
roles: ["R_ADMIN"],
|
||||
color: "#409EFF",
|
||||
description: "拥有管理权限,可以管理用户和部分系统设置",
|
||||
},
|
||||
{
|
||||
key: "user",
|
||||
label: t("login.roles.user"),
|
||||
userName: "User",
|
||||
password: "123456",
|
||||
roles: ["R_USER"],
|
||||
color: "#67C23A",
|
||||
description: "普通用户权限,只能访问基础功能模块",
|
||||
},
|
||||
]);
|
||||
|
||||
// 获取角色标签类型
|
||||
const getRoleTagType = (
|
||||
role?: string
|
||||
): "info" | "warning" | "primary" | "success" | "danger" => {
|
||||
if (!role) return "info";
|
||||
const roleMap: Record<string, "info" | "warning" | "primary" | "success" | "danger"> = {
|
||||
R_SUPER: "warning",
|
||||
R_ADMIN: "primary",
|
||||
R_USER: "success",
|
||||
};
|
||||
return roleMap[role] || "info";
|
||||
};
|
||||
|
||||
// 获取角色显示名称
|
||||
const getRoleDisplayName = (role?: string): string => {
|
||||
if (!role) return "未知角色";
|
||||
const roleMap: Record<string, string> = {
|
||||
R_SUPER: "超级管理员",
|
||||
R_ADMIN: "管理员",
|
||||
R_USER: "普通用户",
|
||||
};
|
||||
return roleMap[role] || "未知角色";
|
||||
};
|
||||
|
||||
/**
|
||||
* 账号信息类型
|
||||
*/
|
||||
interface AccountInfo {
|
||||
userName: string;
|
||||
password: string;
|
||||
role?: string;
|
||||
roles?: string[];
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换角色
|
||||
* @param account 账号信息
|
||||
*/
|
||||
const switchRole = async (account: AccountInfo) => {
|
||||
try {
|
||||
switching.value = true;
|
||||
|
||||
// 模拟登录请求
|
||||
const { token, refreshToken } = await fetchLogin({
|
||||
userName: account.userName,
|
||||
password: account.password,
|
||||
});
|
||||
|
||||
// 验证token
|
||||
if (!token) {
|
||||
throw new Error("Login failed - no token received");
|
||||
}
|
||||
|
||||
// 存储token和用户信息
|
||||
userStore.setToken(token, refreshToken);
|
||||
const userInfo = await fetchGetUserInfo();
|
||||
userStore.setUserInfo(userInfo);
|
||||
|
||||
// 延迟刷新页面以应用新权限
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 100);
|
||||
} catch (error) {
|
||||
if (error !== "cancel") {
|
||||
ElMessage.error("切换用户身份失败,请稍后重试");
|
||||
console.error("[SwitchRole] Error:", error);
|
||||
}
|
||||
} finally {
|
||||
switching.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,499 @@
|
||||
<template>
|
||||
<div class="page-content mb-5">
|
||||
<div class="mb-15 text-center">
|
||||
<h1 class="my-4 text-2xl font-semibold leading-tight">WebSocket 连接示例</h1>
|
||||
<p class="m-0 text-base leading-relaxed text-g-700">
|
||||
基于 WebSocketClient 的实时通信演示,支持连接管理、消息收发和状态监控
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 连接状态和统计信息 -->
|
||||
<ElRow :gutter="20" class="mb-15">
|
||||
<ElCol :xs="24" :sm="12" :md="8">
|
||||
<ElCard class="h-full border-0" :body-style="{ padding: '20px' }" shadow="never">
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-blue-500 mb-1">{{ messageCount }}</div>
|
||||
<div class="text-sm font-medium text-gray-900 mb-1">消息统计</div>
|
||||
<div class="text-xs text-gray-500">接收到的消息数量</div>
|
||||
</div>
|
||||
</ElCard>
|
||||
</ElCol>
|
||||
<ElCol :xs="24" :sm="12" :md="8">
|
||||
<ElCard class="h-full border-0" :body-style="{ padding: '20px' }" shadow="never">
|
||||
<div class="text-center">
|
||||
<ElTag :type="connectionTagType" size="large" class="mb-2">
|
||||
{{ wsClient?.connectionStatusText || '未连接' }}
|
||||
</ElTag>
|
||||
<div class="text-sm font-medium text-gray-900">连接状态</div>
|
||||
<div class="text-xs text-gray-500">当前WebSocket连接状态</div>
|
||||
</div>
|
||||
</ElCard>
|
||||
</ElCol>
|
||||
<ElCol :xs="24" :sm="12" :md="8">
|
||||
<ElCard class="h-full border-0" :body-style="{ padding: '20px' }" shadow="never">
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-amber-500 mb-1">{{ reconnectCount }}</div>
|
||||
<div class="text-sm font-medium text-gray-900 mb-1">重连次数</div>
|
||||
<div class="text-xs text-gray-500">自动重连尝试次数</div>
|
||||
</div>
|
||||
</ElCard>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
|
||||
<!-- 连接配置和发送消息 -->
|
||||
<ElRow :gutter="20" class="mb-15">
|
||||
<ElCol :xs="24" :md="12">
|
||||
<ElCard class="h-full border-0" shadow="never">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-base font-bold">连接配置</span>
|
||||
<ElTag :type="connectionTagType" size="large">
|
||||
{{ wsClient?.connectionStatusText || '未连接' }}
|
||||
</ElTag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<ElForm :model="connectForm" label-width="100px" class="max-w-md">
|
||||
<ElFormItem label="服务器地址">
|
||||
<ElInput v-model="connectForm.url" placeholder="ws://localhost:8080/ws" clearable />
|
||||
</ElFormItem>
|
||||
<ElFormItem label="连接选项">
|
||||
<ElSpace>
|
||||
<ElCheckbox v-model="connectForm.autoReconnect">自动重连</ElCheckbox>
|
||||
<ElCheckbox v-model="connectForm.heartbeat">心跳检测</ElCheckbox>
|
||||
</ElSpace>
|
||||
</ElFormItem>
|
||||
<ElFormItem>
|
||||
<ElSpace>
|
||||
<ElButton
|
||||
type="primary"
|
||||
@click="handleConnect"
|
||||
:loading="isConnecting"
|
||||
:disabled="isConnected"
|
||||
>
|
||||
{{ isConnecting ? '连接中...' : '连接' }}
|
||||
</ElButton>
|
||||
<ElButton type="danger" @click="handleDisconnect" :disabled="!isConnected">
|
||||
断开连接
|
||||
</ElButton>
|
||||
<ElButton @click="handleReconnect" :disabled="isConnecting">重连</ElButton>
|
||||
</ElSpace>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
</ElCard>
|
||||
</ElCol>
|
||||
|
||||
<ElCol :xs="24" :md="12">
|
||||
<ElCard class="h-full border-0" shadow="never">
|
||||
<template #header>
|
||||
<span class="text-base font-bold">发送消息</span>
|
||||
</template>
|
||||
|
||||
<ElForm :model="messageForm" @submit.prevent="handleSendMessage">
|
||||
<ElFormItem label="消息类型">
|
||||
<ElSelect v-model="messageForm.type" class="w-full">
|
||||
<ElOption label="文本消息" value="text" />
|
||||
<ElOption label="JSON数据" value="json" />
|
||||
<ElOption label="心跳包" value="ping" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="消息内容">
|
||||
<ElInput
|
||||
v-model="messageForm.content"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="请输入要发送的消息内容"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem>
|
||||
<ElSpace>
|
||||
<ElButton
|
||||
type="primary"
|
||||
@click="handleSendMessage"
|
||||
:disabled="!isConnected || !messageForm.content"
|
||||
>
|
||||
发送消息
|
||||
</ElButton>
|
||||
<ElButton @click="clearMessageForm">清空</ElButton>
|
||||
</ElSpace>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
</ElCard>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
|
||||
<!-- 接收消息 - 单独占一行 -->
|
||||
<ElRow class="mb-15">
|
||||
<ElCol :span="24">
|
||||
<ElCard class="border-0" shadow="never">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-base font-bold">接收消息</span>
|
||||
<ElButton size="small" @click="clearMessages">清空记录</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="message-container">
|
||||
<div v-for="(message, index) in messageList" :key="index" class="message-item">
|
||||
<div class="message-header">
|
||||
<ElTag size="small" :type="message.type === 'received' ? 'success' : 'info'">
|
||||
{{ message.type === 'received' ? '接收' : '发送' }}
|
||||
</ElTag>
|
||||
<span class="message-time">{{ message.time }}</span>
|
||||
</div>
|
||||
<div class="message-content">{{ message.content }}</div>
|
||||
</div>
|
||||
|
||||
<ElEmpty v-if="messageList.length === 0" description="暂无消息记录" :image-size="100" />
|
||||
</div>
|
||||
</ElCard>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
|
||||
<!-- 连接日志 -->
|
||||
<ElCard class="border-0" shadow="never">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-base font-bold">连接日志</span>
|
||||
<ElButton size="small" @click="clearLogs">清空日志</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="log-container">
|
||||
<ElAlert
|
||||
v-for="(log, index) in logList"
|
||||
:key="index"
|
||||
:type="log.type"
|
||||
:closable="false"
|
||||
class="!mb-2"
|
||||
>
|
||||
<template #title>
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="text-xs opacity-70 whitespace-nowrap">{{ log.time }}</span>
|
||||
<span class="flex-1">{{ log.message }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</ElAlert>
|
||||
|
||||
<ElEmpty v-if="logList.length === 0" description="暂无日志记录" :image-size="100" />
|
||||
</div>
|
||||
</ElCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import WebSocketClient from '@/utils/socket'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
defineOptions({ name: 'WidgetsSocketChat' })
|
||||
|
||||
// WebSocket客户端实例
|
||||
const wsClient = ref<WebSocketClient | null>(null)
|
||||
|
||||
// 连接状态
|
||||
const isConnecting = ref(false)
|
||||
const isConnected = ref(false)
|
||||
const reconnectCount = ref(0)
|
||||
const messageCount = ref(0)
|
||||
|
||||
// 用于清理 watch 的函数
|
||||
let stopWatchConnection: (() => void) | null = null
|
||||
let stopWatchStatus: (() => void) | null = null
|
||||
|
||||
// 表单数据
|
||||
const connectForm = ref({
|
||||
url: 'ws://localhost:8080/ws',
|
||||
autoReconnect: true,
|
||||
heartbeat: true
|
||||
})
|
||||
|
||||
const messageForm = ref({
|
||||
type: 'text',
|
||||
content: ''
|
||||
})
|
||||
|
||||
// 消息和日志列表
|
||||
const messageList = ref<
|
||||
Array<{
|
||||
type: 'sent' | 'received'
|
||||
content: string
|
||||
time: string
|
||||
}>
|
||||
>([])
|
||||
|
||||
const logList = ref<
|
||||
Array<{
|
||||
type: 'info' | 'success' | 'warning' | 'error'
|
||||
message: string
|
||||
time: string
|
||||
}>
|
||||
>([])
|
||||
|
||||
// 计算属性
|
||||
const connectionTagType = computed(() => {
|
||||
if (isConnecting.value) return 'warning'
|
||||
if (isConnected.value) return 'success'
|
||||
return 'danger'
|
||||
})
|
||||
|
||||
/**
|
||||
* 添加日志
|
||||
*/
|
||||
const addLog = (type: 'info' | 'success' | 'warning' | 'error', message: string) => {
|
||||
logList.value.unshift({
|
||||
type,
|
||||
message,
|
||||
time: new Date().toLocaleTimeString()
|
||||
})
|
||||
|
||||
// 限制日志数量
|
||||
if (logList.value.length > 100) {
|
||||
logList.value = logList.value.slice(0, 100)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加消息记录
|
||||
*/
|
||||
const addMessage = (type: 'sent' | 'received', content: string) => {
|
||||
messageList.value.unshift({
|
||||
type,
|
||||
content,
|
||||
time: new Date().toLocaleTimeString()
|
||||
})
|
||||
|
||||
// 限制消息数量
|
||||
if (messageList.value.length > 50) {
|
||||
messageList.value = messageList.value.slice(0, 50)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理WebSocket消息
|
||||
*/
|
||||
const handleSocketMessage = (event: MessageEvent) => {
|
||||
messageCount.value++
|
||||
addMessage('received', event.data)
|
||||
addLog('success', `收到消息: ${event.data}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接WebSocket
|
||||
*/
|
||||
const handleConnect = () => {
|
||||
if (isConnecting.value || isConnected.value) {
|
||||
return
|
||||
}
|
||||
|
||||
// 清理之前的 watch
|
||||
if (stopWatchConnection) {
|
||||
stopWatchConnection()
|
||||
stopWatchConnection = null
|
||||
}
|
||||
if (stopWatchStatus) {
|
||||
stopWatchStatus()
|
||||
stopWatchStatus = null
|
||||
}
|
||||
|
||||
isConnecting.value = true
|
||||
addLog('info', `开始连接到 ${connectForm.value.url}`)
|
||||
|
||||
try {
|
||||
wsClient.value = WebSocketClient.getInstance({
|
||||
url: connectForm.value.url,
|
||||
messageHandler: handleSocketMessage,
|
||||
reconnectInterval: connectForm.value.autoReconnect ? 5000 : 0,
|
||||
heartbeatInterval: connectForm.value.heartbeat ? 10000 : 0,
|
||||
maxReconnectAttempts: 5
|
||||
})
|
||||
|
||||
wsClient.value.init()
|
||||
|
||||
// 监听连接状态变化
|
||||
stopWatchConnection = watch(
|
||||
() => wsClient.value?.isWebSocketConnected,
|
||||
(connected) => {
|
||||
isConnected.value = connected || false
|
||||
isConnecting.value = false
|
||||
|
||||
if (connected) {
|
||||
addLog('success', 'WebSocket连接成功')
|
||||
reconnectCount.value = 0
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// 监听连接状态文本变化
|
||||
stopWatchStatus = watch(
|
||||
() => wsClient.value?.connectionStatusText,
|
||||
(status) => {
|
||||
if (status && status.includes('重连中')) {
|
||||
reconnectCount.value++
|
||||
addLog('warning', `自动重连中 (第${reconnectCount.value}次)`)
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
isConnecting.value = false
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
addLog('error', `连接失败: ${errorMessage}`)
|
||||
ElMessage.error('连接失败,请检查服务器地址')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 断开连接
|
||||
*/
|
||||
const handleDisconnect = () => {
|
||||
if (wsClient.value) {
|
||||
wsClient.value.close()
|
||||
addLog('info', '手动断开WebSocket连接')
|
||||
}
|
||||
|
||||
// 清理 watch
|
||||
if (stopWatchConnection) {
|
||||
stopWatchConnection()
|
||||
stopWatchConnection = null
|
||||
}
|
||||
if (stopWatchStatus) {
|
||||
stopWatchStatus()
|
||||
stopWatchStatus = null
|
||||
}
|
||||
|
||||
isConnected.value = false
|
||||
isConnecting.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新连接
|
||||
*/
|
||||
const handleReconnect = () => {
|
||||
handleDisconnect()
|
||||
setTimeout(() => {
|
||||
handleConnect()
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息
|
||||
*/
|
||||
const handleSendMessage = () => {
|
||||
if (!isConnected.value || !wsClient.value) {
|
||||
ElMessage.warning('请先建立WebSocket连接')
|
||||
return
|
||||
}
|
||||
|
||||
let message = messageForm.value.content
|
||||
|
||||
// 根据消息类型处理内容
|
||||
switch (messageForm.value.type) {
|
||||
case 'json':
|
||||
try {
|
||||
// 验证是否为有效JSON
|
||||
JSON.parse(message)
|
||||
} catch {
|
||||
ElMessage.error('请输入有效的JSON格式数据')
|
||||
return
|
||||
}
|
||||
break
|
||||
case 'ping':
|
||||
message = 'ping'
|
||||
break
|
||||
}
|
||||
|
||||
try {
|
||||
wsClient.value.send(message)
|
||||
addMessage('sent', message)
|
||||
addLog('info', `发送消息: ${message}`)
|
||||
ElMessage.success('消息发送成功')
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
addLog('error', `发送失败: ${errorMessage}`)
|
||||
ElMessage.error('发送消息失败')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空消息表单
|
||||
*/
|
||||
const clearMessageForm = () => {
|
||||
messageForm.value.content = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空消息记录
|
||||
*/
|
||||
const clearMessages = () => {
|
||||
messageList.value = []
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空日志
|
||||
*/
|
||||
const clearLogs = () => {
|
||||
logList.value = []
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面卸载时清理连接
|
||||
*/
|
||||
onUnmounted(() => {
|
||||
handleDisconnect()
|
||||
})
|
||||
|
||||
/**
|
||||
* 监听页面可见性变化,页面隐藏时断开连接
|
||||
*/
|
||||
onMounted(() => {
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden && isConnected.value) {
|
||||
addLog('info', '页面隐藏,保持连接')
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '@styles/core/tailwind.css';
|
||||
|
||||
.message-container {
|
||||
@apply max-h-96 overflow-y-auto space-y-3;
|
||||
}
|
||||
|
||||
.message-item {
|
||||
@apply p-3 rounded-lg border border-gray-200 bg-white dark:bg-gray-800 dark:border-gray-700;
|
||||
}
|
||||
|
||||
.message-header {
|
||||
@apply flex items-center justify-between mb-2;
|
||||
}
|
||||
|
||||
.message-time {
|
||||
@apply text-xs text-gray-500;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
@apply text-sm text-gray-800 dark:text-gray-200 break-words font-mono bg-gray-50 dark:bg-gray-900 p-2 rounded;
|
||||
}
|
||||
|
||||
.log-container {
|
||||
@apply max-h-64 overflow-y-auto;
|
||||
}
|
||||
|
||||
/* 滚动条样式 */
|
||||
.message-container::-webkit-scrollbar,
|
||||
.log-container::-webkit-scrollbar {
|
||||
@apply w-4;
|
||||
}
|
||||
|
||||
.message-container::-webkit-scrollbar-track,
|
||||
.log-container::-webkit-scrollbar-track {
|
||||
@apply bg-gray-100 rounded;
|
||||
}
|
||||
|
||||
.message-container::-webkit-scrollbar-thumb,
|
||||
.log-container::-webkit-scrollbar-thumb {
|
||||
@apply bg-gray-300 rounded hover:bg-gray-400;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,63 @@
|
||||
<!-- 基础表格 -->
|
||||
<template>
|
||||
<div class="user-page art-full-height">
|
||||
<ElCard class="art-table-card" shadow="never" style="margin-top: 0">
|
||||
<!-- 表格 -->
|
||||
<ArtTable
|
||||
rowKey="id"
|
||||
:show-table-header="false"
|
||||
:loading="loading"
|
||||
:data="data"
|
||||
:columns="columns"
|
||||
:pagination="pagination"
|
||||
@pagination:size-change="handleSizeChange"
|
||||
@pagination:current-change="handleCurrentChange"
|
||||
>
|
||||
</ArtTable>
|
||||
</ElCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useTable } from '@/hooks/core/useTable'
|
||||
import { fetchGetUserList } from '@/apps/demo/api/system-manage'
|
||||
|
||||
defineOptions({ name: 'UserMixedUsageExample' })
|
||||
|
||||
const { data, columns, loading, pagination, handleSizeChange, handleCurrentChange } = useTable({
|
||||
core: {
|
||||
apiFn: fetchGetUserList,
|
||||
apiParams: {
|
||||
current: 1,
|
||||
size: 20,
|
||||
userName: '',
|
||||
userPhone: '',
|
||||
userEmail: ''
|
||||
},
|
||||
columnsFactory: () => [
|
||||
{
|
||||
prop: 'id',
|
||||
label: 'ID'
|
||||
},
|
||||
{
|
||||
prop: 'nickName',
|
||||
label: '昵称'
|
||||
},
|
||||
{
|
||||
prop: 'userGender',
|
||||
label: '性别',
|
||||
sortable: true,
|
||||
formatter: (row) => row.userGender || '未知'
|
||||
},
|
||||
{
|
||||
prop: 'userPhone',
|
||||
label: '手机号'
|
||||
},
|
||||
{
|
||||
prop: 'userEmail',
|
||||
label: '邮箱'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
</script>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,245 @@
|
||||
<!-- 左树右表示例页面 -->
|
||||
<template>
|
||||
<div class="art-full-height">
|
||||
<div class="box-border flex gap-4 h-full max-md:block max-md:gap-0 max-md:h-auto">
|
||||
<div class="flex-shrink-0 w-58 h-full max-md:w-full max-md:h-auto max-md:mb-5">
|
||||
<ElCard class="tree-card art-card-xs flex flex-col h-full mt-0" shadow="never">
|
||||
<template #header>
|
||||
<b>分类树</b>
|
||||
</template>
|
||||
<ElScrollbar>
|
||||
<ElTree
|
||||
:data="treeData"
|
||||
:props="treeProps"
|
||||
node-key="id"
|
||||
default-expand-all
|
||||
highlight-current
|
||||
@node-click="handleNodeClick"
|
||||
/>
|
||||
</ElScrollbar>
|
||||
</ElCard>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col flex-grow min-w-0">
|
||||
<UserSearch v-model="defaultFilter" />
|
||||
|
||||
<ElCard class="flex flex-col flex-1 min-h-0 art-table-card" shadow="never">
|
||||
<ArtTableHeader
|
||||
v-model:columns="columnChecks"
|
||||
:loading="loading"
|
||||
@refresh="refreshData"
|
||||
>
|
||||
<template #left>
|
||||
<ElSpace wrap>
|
||||
<ElButton
|
||||
@click="showButtons = !showButtons"
|
||||
v-ripple
|
||||
type="primary"
|
||||
plain
|
||||
>{{ showButtons ? "收起" : "展开" }}按钮组</ElButton
|
||||
>
|
||||
<ElButton v-show="showButtons" v-ripple v-for="value in 12" :key="value"
|
||||
>表格自适应</ElButton
|
||||
>
|
||||
</ElSpace>
|
||||
</template>
|
||||
</ArtTableHeader>
|
||||
|
||||
<ArtTable
|
||||
rowKey="id"
|
||||
:loading="loading"
|
||||
:data="data"
|
||||
:columns="columns"
|
||||
:pagination="pagination"
|
||||
@pagination:size-change="handleSizeChange"
|
||||
@pagination:current-change="handleCurrentChange"
|
||||
>
|
||||
</ArtTable>
|
||||
</ElCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useTable } from "@/hooks/core/useTable";
|
||||
import { UserService } from "@/api/system/user";
|
||||
import UserSearch from "@apps/demo/views/system/user/modules/user-search.vue";
|
||||
|
||||
defineOptions({ name: "TreeTable" });
|
||||
|
||||
const showButtons = ref(false);
|
||||
|
||||
// 树形数据 - 组织架构示例
|
||||
const treeData = ref([
|
||||
{
|
||||
id: 1,
|
||||
label: "技术部",
|
||||
children: [
|
||||
{
|
||||
id: 11,
|
||||
label: "前端开发组",
|
||||
children: [
|
||||
{ id: 111, label: "React 团队" },
|
||||
{ id: 112, label: "Vue 团队" },
|
||||
{ id: 113, label: "移动端团队" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
label: "后端开发组",
|
||||
children: [
|
||||
{ id: 121, label: "Java 团队" },
|
||||
{ id: 122, label: "Node.js 团队" },
|
||||
{ id: 123, label: "Python 团队" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 13,
|
||||
label: "测试组",
|
||||
children: [
|
||||
{ id: 131, label: "功能测试" },
|
||||
{ id: 132, label: "自动化测试" },
|
||||
{ id: 133, label: "性能测试" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 14,
|
||||
label: "运维组",
|
||||
children: [
|
||||
{ id: 141, label: "系统运维" },
|
||||
{ id: 142, label: "DevOps" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
label: "产品部",
|
||||
children: [
|
||||
{
|
||||
id: 21,
|
||||
label: "产品设计组",
|
||||
children: [
|
||||
{ id: 211, label: "UI 设计" },
|
||||
{ id: 212, label: "UX 设计" },
|
||||
{ id: 213, label: "交互设计" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 22,
|
||||
label: "产品运营组",
|
||||
children: [
|
||||
{ id: 221, label: "用户运营" },
|
||||
{ id: 222, label: "内容运营" },
|
||||
{ id: 223, label: "活动运营" },
|
||||
],
|
||||
},
|
||||
{ id: 23, label: "数据分析组" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
label: "市场部",
|
||||
children: [
|
||||
{ id: 31, label: "品牌推广组" },
|
||||
{ id: 32, label: "渠道拓展组" },
|
||||
{
|
||||
id: 33,
|
||||
label: "销售组",
|
||||
children: [
|
||||
{ id: 331, label: "企业客户" },
|
||||
{ id: 332, label: "个人客户" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
label: "行政部",
|
||||
children: [
|
||||
{ id: 41, label: "人力资源组" },
|
||||
{ id: 42, label: "财务组" },
|
||||
{ id: 43, label: "行政后勤组" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
label: "客服部",
|
||||
children: [
|
||||
{ id: 51, label: "售前咨询" },
|
||||
{ id: 52, label: "售后支持" },
|
||||
{ id: 53, label: "客户成功" },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const treeProps = {
|
||||
children: "children",
|
||||
label: "label",
|
||||
};
|
||||
|
||||
const handleNodeClick = (data: any) => {
|
||||
console.log("选中节点:", data);
|
||||
// 可以根据选中的节点更新右侧表格数据
|
||||
};
|
||||
|
||||
// 表单搜索初始值
|
||||
const defaultFilter = ref({
|
||||
name: undefined,
|
||||
});
|
||||
|
||||
const {
|
||||
data,
|
||||
columns,
|
||||
columnChecks,
|
||||
loading,
|
||||
pagination,
|
||||
refreshData,
|
||||
handleSizeChange,
|
||||
handleCurrentChange,
|
||||
} = useTable({
|
||||
core: {
|
||||
apiFn: fetchGetUserList,
|
||||
apiParams: {
|
||||
current: 1,
|
||||
size: 20,
|
||||
userName: "",
|
||||
userPhone: "",
|
||||
userEmail: "",
|
||||
},
|
||||
columnsFactory: () => [
|
||||
{
|
||||
prop: "id",
|
||||
label: "ID",
|
||||
},
|
||||
{
|
||||
prop: "nickName",
|
||||
label: "昵称",
|
||||
},
|
||||
{
|
||||
prop: "userGender",
|
||||
label: "性别",
|
||||
sortable: true,
|
||||
formatter: (row) => row.userGender || "未知",
|
||||
},
|
||||
{
|
||||
prop: "userPhone",
|
||||
label: "手机号",
|
||||
},
|
||||
{
|
||||
prop: "userEmail",
|
||||
label: "邮箱",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tree-card :deep(.el-card__body) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 10px 2px 10px 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,129 @@
|
||||
<!-- 标签页示例页面 -->
|
||||
<template>
|
||||
<div class="page-content">
|
||||
<h3 class="mb-5 text-xl font-normal">标签页操作</h3>
|
||||
|
||||
<!-- 修改标签标题模块 -->
|
||||
<ElCard class="mb-7.5" header="修改标签标题" shadow="never">
|
||||
<div class="flex gap-2">
|
||||
<ElInput
|
||||
v-model="newTabTitle"
|
||||
placeholder="请输入新的标签页标题"
|
||||
clearable
|
||||
class="!max-w-75"
|
||||
/>
|
||||
|
||||
<ElButton
|
||||
type="primary"
|
||||
@click="handleUpdateTabTitle"
|
||||
:disabled="!newTabTitle.trim()"
|
||||
>
|
||||
修改
|
||||
</ElButton>
|
||||
<ElButton @click="handleResetTabTitle"> 重置 </ElButton>
|
||||
</div>
|
||||
</ElCard>
|
||||
|
||||
<!-- 获取标签页模块 -->
|
||||
<ElCard class="mb-7.5" header="获取标签页信息" shadow="never">
|
||||
<div class="mb-4">
|
||||
<p class="m-0 mb-2 text-sm text-g-600">当前标签页信息:{{ currentTab }}</p>
|
||||
</div>
|
||||
<ElRow :gutter="20">
|
||||
<ElCol :span="24">
|
||||
<ElButton type="success" plain @click="handleGetCurrentTabTitle(routePath)">
|
||||
获取当前标签页信息
|
||||
</ElButton>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</ElCard>
|
||||
|
||||
<!-- 关闭标签页模块 -->
|
||||
<ElCard class="mb-7.5" header="关闭标签页" shadow="never">
|
||||
<ElRow :gutter="20">
|
||||
<ElCol :span="24">
|
||||
<ElButton type="danger" plain @click="handleCloseTab(routePath)">
|
||||
关闭当前标签
|
||||
</ElButton>
|
||||
<ElButton type="warning" plain @click="handleCloseOthersTab(routePath)">
|
||||
关闭其他标签
|
||||
</ElButton>
|
||||
<ElButton type="danger" plain @click="handleCloseAllTab">
|
||||
关闭所有标签
|
||||
</ElButton>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</ElCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useWorktabStore } from "@/store/modules/worktab";
|
||||
import { WorkTab } from "@/types";
|
||||
|
||||
defineOptions({ name: "TabsExample" });
|
||||
|
||||
const worktabStore = useWorktabStore();
|
||||
const currentTab = ref<WorkTab | null>(null);
|
||||
const newTabTitle = ref("");
|
||||
const routePath = "/demo-examples/tabs";
|
||||
|
||||
/**
|
||||
* 更新当前标签页标题
|
||||
* 验证输入内容后调用 store 方法更新标题
|
||||
*/
|
||||
const handleUpdateTabTitle = (): void => {
|
||||
const trimmedTitle = newTabTitle.value.trim();
|
||||
if (trimmedTitle) {
|
||||
worktabStore.updateTabTitle(routePath, trimmedTitle);
|
||||
ElMessage.success("标签页标题已更新");
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 重置标签页标题
|
||||
* 将标题重置为默认值并清空输入框
|
||||
*/
|
||||
const handleResetTabTitle = (): void => {
|
||||
worktabStore.resetTabTitle(routePath);
|
||||
newTabTitle.value = "";
|
||||
ElMessage.success("标签页标题已重置");
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取指定路径的标签页信息
|
||||
* @param path 标签页路径
|
||||
*/
|
||||
const handleGetCurrentTabTitle = (path: string): void => {
|
||||
const tab = worktabStore.getTabTitle(path);
|
||||
if (tab) {
|
||||
currentTab.value = tab;
|
||||
ElMessage.success("已获取标签页信息");
|
||||
} else {
|
||||
ElMessage.warning("未找到标签信息");
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 关闭指定路径的标签页
|
||||
* @param path 要关闭的标签页路径
|
||||
*/
|
||||
const handleCloseTab = (path: string): void => {
|
||||
worktabStore.removeTab(path);
|
||||
};
|
||||
|
||||
/**
|
||||
* 关闭除指定路径外的其他所有标签页
|
||||
* @param path 要保留的标签页路径
|
||||
*/
|
||||
const handleCloseOthersTab = (path: string): void => {
|
||||
worktabStore.removeOthers(path);
|
||||
};
|
||||
|
||||
/**
|
||||
* 关闭所有标签页
|
||||
*/
|
||||
const handleCloseAllTab = (): void => {
|
||||
worktabStore.removeAll();
|
||||
};
|
||||
</script>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,168 @@
|
||||
<!-- 服务器管理页面 -->
|
||||
<template>
|
||||
<div class="page-content mb-5">
|
||||
<div class="w-full">
|
||||
<div class="flex flex-wrap w-[calc(100%+20px)]">
|
||||
<div
|
||||
class="box-border w-[calc(50%-20px)] mr-5 mb-5 border border-g-300 rounded max-lg:w-full max-md:w-full"
|
||||
v-for="item in serverList"
|
||||
:key="item.name"
|
||||
>
|
||||
<div
|
||||
class="flex-cb p-5 border-b border-g-300/80 max-lg:p-2.5 max-lg:px-5 max-md:p-2.5 max-md:px-5"
|
||||
>
|
||||
<span class="text-sm font-medium">{{ item.name }}</span>
|
||||
<span class="text-sm text-g-600">{{ item.ip }}</span>
|
||||
</div>
|
||||
<div class="flex-c p-9 scale-[0.8] max-lg:p-5 max-md:block max-md:p-5 max-sm:!block">
|
||||
<div class="mx-10 max-lg:m-0 max-lg:mr-5 max-md:m-0">
|
||||
<img
|
||||
src="@imgs/safeguard/server.png"
|
||||
alt="服务器"
|
||||
class="block w-47 max-md:w-37 max-md:mx-auto"
|
||||
/>
|
||||
<div class="flex justify-center -mt-2.5 max-md:mt-2.5">
|
||||
<ElButtonGroup>
|
||||
<ElButton type="primary" size="default">开机</ElButton>
|
||||
<ElButton type="danger" size="default">关机</ElButton>
|
||||
<ElButton type="warning" size="default">重启</ElButton>
|
||||
</ElButtonGroup>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 mt-1 max-lg:mt-0 max-md:mt-7.5">
|
||||
<div class="my-3.5">
|
||||
<p class="mb-1 text-sm">CPU</p>
|
||||
<ElProgress :percentage="item.cup" :text-inside="true" :stroke-width="17" />
|
||||
</div>
|
||||
<div class="my-3.5">
|
||||
<p class="mb-1 text-sm">RAM</p>
|
||||
<ElProgress
|
||||
:percentage="item.memory"
|
||||
status="success"
|
||||
:text-inside="true"
|
||||
:stroke-width="17"
|
||||
/>
|
||||
</div>
|
||||
<div class="my-3.5">
|
||||
<p class="mb-1 text-sm">SWAP</p>
|
||||
<ElProgress
|
||||
:percentage="item.swap"
|
||||
status="warning"
|
||||
:text-inside="true"
|
||||
:stroke-width="17"
|
||||
/>
|
||||
</div>
|
||||
<div class="my-3.5">
|
||||
<p class="mb-1 text-sm">DISK</p>
|
||||
<ElProgress
|
||||
:percentage="item.disk"
|
||||
status="success"
|
||||
:text-inside="true"
|
||||
:stroke-width="17"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineOptions({ name: 'SafeguardServer' })
|
||||
|
||||
interface ServerInfo {
|
||||
name: string
|
||||
ip: string
|
||||
cup: number
|
||||
memory: number
|
||||
swap: number
|
||||
disk: number
|
||||
}
|
||||
|
||||
const UPDATE_INTERVAL = 3000 // 更新间隔时间(毫秒)
|
||||
|
||||
/**
|
||||
* 服务器列表数据
|
||||
* 包含各服务器的基本信息和资源使用情况
|
||||
*/
|
||||
const serverList = reactive<ServerInfo[]>([
|
||||
{
|
||||
name: '开发服务器',
|
||||
ip: '192.168.1.100',
|
||||
cup: 85,
|
||||
memory: 65,
|
||||
swap: 45,
|
||||
disk: 92
|
||||
},
|
||||
{
|
||||
name: '测试服务器',
|
||||
ip: '192.168.1.101',
|
||||
cup: 32,
|
||||
memory: 78,
|
||||
swap: 90,
|
||||
disk: 45
|
||||
},
|
||||
{
|
||||
name: '预发布服务器',
|
||||
ip: '192.168.1.102',
|
||||
cup: 95,
|
||||
memory: 42,
|
||||
swap: 67,
|
||||
disk: 88
|
||||
},
|
||||
{
|
||||
name: '线上服务器',
|
||||
ip: '192.168.1.103',
|
||||
cup: 58,
|
||||
memory: 93,
|
||||
swap: 25,
|
||||
disk: 73
|
||||
}
|
||||
])
|
||||
|
||||
/**
|
||||
* 生成指定范围内的随机整数
|
||||
* @param min 最小值(默认 0)
|
||||
* @param max 最大值(默认 100)
|
||||
* @returns 随机整数
|
||||
*/
|
||||
const generateRandomValue = (min = 0, max = 100): number => {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新所有服务器的资源使用数据
|
||||
* 模拟服务器资源使用情况的实时变化
|
||||
*/
|
||||
const updateServerData = (): void => {
|
||||
serverList.forEach((server) => {
|
||||
server.cup = generateRandomValue()
|
||||
server.memory = generateRandomValue()
|
||||
server.swap = generateRandomValue()
|
||||
server.disk = generateRandomValue()
|
||||
})
|
||||
}
|
||||
|
||||
let timer: number | null = null
|
||||
|
||||
/**
|
||||
* 组件挂载时启动定时器
|
||||
* 每隔指定时间自动更新服务器数据
|
||||
*/
|
||||
onMounted(() => {
|
||||
timer = window.setInterval(updateServerData, UPDATE_INTERVAL)
|
||||
})
|
||||
|
||||
/**
|
||||
* 组件卸载时清理定时器
|
||||
* 防止内存泄漏
|
||||
*/
|
||||
onUnmounted(() => {
|
||||
if (timer !== null) {
|
||||
window.clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -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 '@/apps/demo/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
@@ -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>
|
||||
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<div class="page-content">
|
||||
<h1>菜单-1</h1>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<div class="page-content">
|
||||
<h1>菜单-2-1</h1>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<div class="page-content">
|
||||
<h1>菜单-3-1</h1>
|
||||
</div>
|
||||
</template>
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<div class="page-content">
|
||||
<h1>菜单-3-2-1</h1>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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 '@/apps/demo/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
@@ -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
@@ -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
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,269 @@
|
||||
<!-- 用户管理页面 -->
|
||||
<!-- 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 "@/apps/demo/mock/temp/formData";
|
||||
import { useTable } from "@/hooks/core/useTable";
|
||||
import { fetchGetUserList } from "@/apps/demo/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
@@ -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 '@/apps/demo/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
@@ -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>
|
||||
@@ -0,0 +1,217 @@
|
||||
<template>
|
||||
<div class="pt-5">
|
||||
<h1 class="page-title">基础 & 自定义按钮+背景色</h1>
|
||||
<ElRow :gutter="20">
|
||||
<ElCol :xs="24" :sm="12" :md="12" class="mb-5">
|
||||
<ArtBasicBanner
|
||||
title="数据中心运行状态"
|
||||
subtitle="系统访问量同比增长 23%,所有服务运行稳定,数据监控正常。"
|
||||
/>
|
||||
</ElCol>
|
||||
<ElCol :xs="24" :sm="12" :md="12" class="mb-5">
|
||||
<ArtBasicBanner
|
||||
title="欢迎使用 Art Design Pro"
|
||||
subtitle="基于 Vue 3 + TypeScript + Element Plus 构建的现代化管理系统。"
|
||||
titleColor="#333"
|
||||
subtitleColor="#666"
|
||||
boxStyle="!bg-[#D4F1F7]"
|
||||
:buttonConfig="{
|
||||
show: true,
|
||||
text: '开始探索',
|
||||
color: 'var(--art-success)',
|
||||
textColor: '#fff',
|
||||
radius: '6px'
|
||||
}"
|
||||
@buttonClick="handleBannerClick"
|
||||
/>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
|
||||
<h1 class="page-title">自定义图片 & 使用 slot 自定义内容</h1>
|
||||
|
||||
<ElRow :gutter="20">
|
||||
<ElCol :xs="24" :sm="12" :md="12" class="mb-5">
|
||||
<ArtBasicBanner
|
||||
title="探索星空计划"
|
||||
subtitle="加入我们的天文观测活动,发现宇宙的奥秘"
|
||||
boxStyle="!bg-[#FF8AAB]"
|
||||
:buttonConfig="{
|
||||
show: true,
|
||||
text: '立即参与',
|
||||
color: '#FF5A89',
|
||||
textColor: '#fff'
|
||||
}"
|
||||
:imageConfig="{
|
||||
src: icon3
|
||||
}"
|
||||
/>
|
||||
</ElCol>
|
||||
<ElCol :xs="24" :sm="12" :md="12" class="mb-5">
|
||||
<ArtBasicBanner
|
||||
boxStyle="!bg-[#70B1FF]"
|
||||
:imageConfig="{
|
||||
src: icon5
|
||||
}"
|
||||
>
|
||||
<template #title>
|
||||
<h2 style="margin: 0; font-size: 1.6rem; color: #fff !important">智能组件系统</h2>
|
||||
</template>
|
||||
|
||||
<template #subtitle>
|
||||
<div style="margin-top: 12px">
|
||||
<p style="position: relative; z-index: 10; font-style: italic"
|
||||
>灵活配置,强大扩展,支持自定义插槽内容</p
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #button>
|
||||
<div style="margin-top: 12px">
|
||||
<ElButton type="primary" color="#04A1FF"> 查看文档 </ElButton>
|
||||
</div>
|
||||
</template>
|
||||
</ArtBasicBanner>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
|
||||
<h1 class="page-title">抽象配置方案(Preset 模式)</h1>
|
||||
<ElRow :gutter="20">
|
||||
<ElCol :xs="24" :sm="12" :md="12" class="mb-5">
|
||||
<ArtBasicBanner v-bind="PresetBanners.marketing" />
|
||||
</ElCol>
|
||||
<ElCol :xs="24" :sm="12" :md="12" class="mb-5">
|
||||
<ArtBasicBanner v-bind="PresetBanners.info" />
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
|
||||
<h1 class="page-title">卡片横幅</h1>
|
||||
<ElRow :gutter="20">
|
||||
<ElCol :xs="24" :sm="12" :md="12" :lg="6" class="mb-5">
|
||||
<ArtCardBanner
|
||||
title="系统运行正常"
|
||||
description="所有核心服务运行稳定,响应时间在正常范围内。"
|
||||
/>
|
||||
</ElCol>
|
||||
<ElCol :xs="24" :sm="12" :md="12" :lg="6" class="mb-5">
|
||||
<ArtCardBanner
|
||||
:image="icon2"
|
||||
title="重要消息通知"
|
||||
description="您有 3 条待处理的重要消息,请及时查看。"
|
||||
:button="{
|
||||
show: true,
|
||||
text: '查看详情',
|
||||
color: 'var(--art-warning)',
|
||||
textColor: '#fff'
|
||||
}"
|
||||
/>
|
||||
</ElCol>
|
||||
<ElCol :xs="24" :sm="12" :md="12" :lg="6" class="mb-5">
|
||||
<ArtCardBanner
|
||||
:image="icon3"
|
||||
title="数据分析报告"
|
||||
description="本周业务数据分析报告已完成,请查看关键指标。"
|
||||
:button="{
|
||||
show: true,
|
||||
text: '下载报告',
|
||||
color: 'var(--art-error)',
|
||||
textColor: '#fff'
|
||||
}"
|
||||
/>
|
||||
</ElCol>
|
||||
<ElCol :xs="24" :sm="12" :md="12" :lg="6" class="mb-5">
|
||||
<ArtCardBanner
|
||||
:image="icon4"
|
||||
title="版本更新提醒"
|
||||
description="Art Design Pro v2.1.0 已发布,包含性能优化和新功能。"
|
||||
:button="{
|
||||
show: true,
|
||||
text: '立即更新',
|
||||
color: 'var(--theme-color)',
|
||||
textColor: '#fff'
|
||||
}"
|
||||
:cancelButton="{
|
||||
show: true,
|
||||
text: '稍后提醒',
|
||||
color: '#eee',
|
||||
textColor: '#333'
|
||||
}"
|
||||
@click="handleConfirm"
|
||||
@cancel="handleCancel"
|
||||
/>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import icon2 from '@imgs/3d/icon2.webp'
|
||||
import icon3 from '@imgs/3d/icon3.webp'
|
||||
import icon4 from '@imgs/3d/icon4.webp'
|
||||
import icon5 from '@imgs/3d/icon7.webp'
|
||||
|
||||
defineOptions({ name: 'TemplateBanners' })
|
||||
|
||||
/**
|
||||
* 处理横幅点击事件
|
||||
*/
|
||||
const handleBannerClick = () => {
|
||||
console.log('banner clicked')
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理确认按钮点击事件
|
||||
*/
|
||||
const handleConfirm = () => {
|
||||
console.log('confirm clicked')
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理取消按钮点击事件
|
||||
*/
|
||||
const handleCancel = () => {
|
||||
console.log('cancel clicked')
|
||||
}
|
||||
|
||||
/**
|
||||
* 预设横幅配置
|
||||
* 提供常用的横幅样式配置,可直接通过 v-bind 使用
|
||||
*/
|
||||
const PresetBanners = {
|
||||
marketing: {
|
||||
title: '限时优惠活动',
|
||||
subtitle: '精选商品 48 小时闪购,最高享受 7 折优惠,数量有限!',
|
||||
titleColor: 'var(--art-gray-900)',
|
||||
subtitleColor: 'var(--art-gray-900)',
|
||||
boxStyle: '!bg-success/15',
|
||||
meteorConfig: { enabled: true, count: 15 },
|
||||
buttonConfig: {
|
||||
show: true,
|
||||
text: '立即抢购',
|
||||
color: 'var(--art-success)',
|
||||
textColor: '#fff'
|
||||
}
|
||||
},
|
||||
info: {
|
||||
title: '服务到期提醒',
|
||||
subtitle: '您的高级服务将在 7 天后到期,请及时续费以继续享受完整功能。',
|
||||
titleColor: 'var(--art-gray-900)',
|
||||
subtitleColor: 'var(--art-gray-900)',
|
||||
boxStyle: '!bg-theme/15',
|
||||
meteorConfig: { enabled: true, count: 15 },
|
||||
buttonConfig: {
|
||||
show: true,
|
||||
text: '立即续费',
|
||||
color: 'var(--art-secondary)',
|
||||
textColor: '#fff'
|
||||
}
|
||||
}
|
||||
} as const
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '@styles/core/tailwind.css';
|
||||
|
||||
.page-title {
|
||||
@apply my-5 text-xl font-medium first:mt-0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,288 @@
|
||||
<template>
|
||||
<div class="page-content mb-5">
|
||||
<!-- 日历主体 -->
|
||||
<ElCalendar v-model="currentDate">
|
||||
<template #date-cell="{ data }">
|
||||
<div
|
||||
class="relative flex flex-col h-full min-h-30 max-h-30 p-1 overflow-hidden c-p"
|
||||
:class="{ 'is-selected': data.isSelected }"
|
||||
@click="handleCellClick(data.day)"
|
||||
>
|
||||
<!-- 日期显示 -->
|
||||
<p class="absolute top-1 right-1 text-sm">{{ formatDate(data.day) }}</p>
|
||||
|
||||
<!-- 事件列表 -->
|
||||
<div class="flex flex-col gap-1 w-full max-h-21 pr-1 mt-6 overflow-y-auto">
|
||||
<div
|
||||
v-for="event in getEvents(data.day)"
|
||||
:key="`${event.date}-${event.content}`"
|
||||
@click.stop="handleEventClick(event)"
|
||||
>
|
||||
<div
|
||||
class="min-w-25 px-3 py-1.5 overflow-hidden text-xs/6 font-medium text-ellipsis whitespace-nowrap rounded hover:opacity-80"
|
||||
:class="[event.bgClass, event.textClass]"
|
||||
>
|
||||
{{ event.content }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ElCalendar>
|
||||
|
||||
<!-- 事件编辑弹窗 -->
|
||||
<ElDialog v-model="dialogVisible" :title="dialogTitle" width="600px" @closed="resetForm">
|
||||
<ElForm :model="eventForm" label-width="80px">
|
||||
<ElFormItem label="活动标题" required>
|
||||
<ElInput v-model="eventForm.content" placeholder="请输入活动标题" />
|
||||
</ElFormItem>
|
||||
<ElFormItem label="事件颜色">
|
||||
<ElRadioGroup v-model="eventForm.type">
|
||||
<ElRadio v-for="type in eventTypes" :key="type.value" :value="type.value">
|
||||
{{ type.label }}
|
||||
</ElRadio>
|
||||
</ElRadioGroup>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="开始日期" required>
|
||||
<ElDatePicker
|
||||
style="width: 100%"
|
||||
v-model="eventForm.date"
|
||||
type="date"
|
||||
placeholder="选择日期"
|
||||
format="YYYY-MM-DD"
|
||||
value-format="YYYY-MM-DD"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="结束日期">
|
||||
<ElDatePicker
|
||||
style="width: 100%"
|
||||
v-model="eventForm.endDate"
|
||||
type="date"
|
||||
placeholder="选择结束日期"
|
||||
format="YYYY-MM-DD"
|
||||
value-format="YYYY-MM-DD"
|
||||
:min-date="eventForm.date"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<ElButton v-if="isEditing" type="danger" @click="handleDeleteEvent"> 删除 </ElButton>
|
||||
<ElButton type="primary" @click="handleSaveEvent">
|
||||
{{ isEditing ? '更新' : '添加' }}
|
||||
</ElButton>
|
||||
</span>
|
||||
</template>
|
||||
</ElDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineOptions({ name: 'TemplateCalendar' })
|
||||
|
||||
/**
|
||||
* 日历事件类型定义
|
||||
*/
|
||||
interface CalendarEvent {
|
||||
date: string
|
||||
endDate?: string
|
||||
content: string
|
||||
type?: 'primary' | 'success' | 'warning' | 'danger'
|
||||
bgClass?: string
|
||||
textClass?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 事件类型选项
|
||||
*/
|
||||
const eventTypes = [
|
||||
{ label: '基本', value: 'primary' },
|
||||
{ label: '成功', value: 'success' },
|
||||
{ label: '警告', value: 'warning' },
|
||||
{ label: '危险', value: 'danger' }
|
||||
] as const
|
||||
|
||||
const currentDate = ref(new Date('2025-02-07'))
|
||||
const dialogVisible = ref(false)
|
||||
const dialogTitle = ref('添加事件')
|
||||
const editingEventIndex = ref<number>(-1)
|
||||
|
||||
/**
|
||||
* 事件列表数据
|
||||
*/
|
||||
const events = ref<CalendarEvent[]>([
|
||||
{ date: '2025-02-01', content: '产品需求评审', type: 'primary' },
|
||||
{
|
||||
date: '2025-02-03',
|
||||
endDate: '2025-02-05',
|
||||
content: '项目周报会议(跨日期)',
|
||||
type: 'primary'
|
||||
},
|
||||
{ date: '2025-02-10', content: '瑜伽课程', type: 'success' },
|
||||
{ date: '2025-02-15', content: '团队建设活动', type: 'primary' },
|
||||
{ date: '2025-02-20', content: '健身训练', type: 'success' },
|
||||
{ date: '2025-02-20', content: '代码评审', type: 'danger' },
|
||||
{ date: '2025-02-20', content: '团队午餐', type: 'primary' },
|
||||
{ date: '2025-02-20', content: '项目进度汇报', type: 'warning' },
|
||||
{ date: '2025-02-28', content: '月度总结会', type: 'warning' }
|
||||
])
|
||||
|
||||
/**
|
||||
* 事件表单数据
|
||||
*/
|
||||
const eventForm = ref<CalendarEvent>({
|
||||
date: '',
|
||||
endDate: '',
|
||||
content: '',
|
||||
type: 'primary'
|
||||
})
|
||||
|
||||
/**
|
||||
* 是否处于编辑模式
|
||||
*/
|
||||
const isEditing = computed(() => editingEventIndex.value >= 0)
|
||||
|
||||
/**
|
||||
* 格式化日期,只显示日
|
||||
* @param date 完整日期字符串
|
||||
* @returns 日期中的日部分
|
||||
*/
|
||||
const formatDate = (date: string) => date.split('-')[2]
|
||||
|
||||
/**
|
||||
* 获取事件类型对应的样式类名
|
||||
* @param type 事件类型
|
||||
* @returns 包含背景和文字颜色的类名对象
|
||||
*/
|
||||
const getEventClasses = (type: CalendarEvent['type'] = 'primary') => {
|
||||
const classMap = {
|
||||
primary: { bgClass: 'bg-theme/12', textClass: 'text-theme' },
|
||||
success: { bgClass: 'bg-success/12', textClass: 'text-success' },
|
||||
warning: { bgClass: 'bg-warning/12', textClass: 'text-warning' },
|
||||
danger: { bgClass: 'bg-danger/12', textClass: 'text-danger' }
|
||||
}
|
||||
return classMap[type]
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定日期的所有事件
|
||||
* 支持跨日期事件的显示
|
||||
* @param day 日期字符串
|
||||
* @returns 该日期的事件列表
|
||||
*/
|
||||
const getEvents = (day: string) => {
|
||||
return events.value
|
||||
.filter((event) => {
|
||||
const eventDate = new Date(event.date)
|
||||
const currentDate = new Date(day)
|
||||
const endDate = event.endDate ? new Date(event.endDate) : new Date(event.date)
|
||||
|
||||
return currentDate >= eventDate && currentDate <= endDate
|
||||
})
|
||||
.map((event) => {
|
||||
const { bgClass, textClass } = getEventClasses(event.type)
|
||||
return { ...event, bgClass, textClass }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置表单数据
|
||||
*/
|
||||
const resetForm = () => {
|
||||
eventForm.value = {
|
||||
date: '',
|
||||
endDate: '',
|
||||
content: '',
|
||||
type: 'primary'
|
||||
}
|
||||
editingEventIndex.value = -1
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理日历单元格点击事件
|
||||
* 打开添加事件弹窗
|
||||
* @param day 点击的日期
|
||||
*/
|
||||
const handleCellClick = (day: string) => {
|
||||
dialogTitle.value = '添加事件'
|
||||
eventForm.value = {
|
||||
date: day,
|
||||
content: '',
|
||||
type: 'primary'
|
||||
}
|
||||
editingEventIndex.value = -1
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理事件点击
|
||||
* 打开编辑事件弹窗
|
||||
* @param event 点击的事件对象
|
||||
*/
|
||||
const handleEventClick = (event: CalendarEvent) => {
|
||||
dialogTitle.value = '编辑事件'
|
||||
eventForm.value = { ...event }
|
||||
editingEventIndex.value = events.value.findIndex(
|
||||
(e) => e.date === event.date && e.content === event.content
|
||||
)
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存事件
|
||||
* 根据编辑模式决定是新增还是更新
|
||||
*/
|
||||
const handleSaveEvent = () => {
|
||||
if (!eventForm.value.content || !eventForm.value.date) return
|
||||
|
||||
if (isEditing.value) {
|
||||
events.value[editingEventIndex.value] = { ...eventForm.value }
|
||||
} else {
|
||||
events.value.push({ ...eventForm.value })
|
||||
}
|
||||
|
||||
dialogVisible.value = false
|
||||
resetForm()
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除事件
|
||||
*/
|
||||
const handleDeleteEvent = () => {
|
||||
if (isEditing.value) {
|
||||
events.value.splice(editingEventIndex.value, 1)
|
||||
dialogVisible.value = false
|
||||
resetForm()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.el-calendar) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
:deep(.el-calendar__body) {
|
||||
height: calc(100% - 70px);
|
||||
}
|
||||
|
||||
:deep(.el-calendar-table) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
:deep(.is-selected) {
|
||||
background-color: var(--el-color-warning-light-9) !important;
|
||||
}
|
||||
|
||||
:deep(.el-calendar-day) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
:deep(.el-calendar-day:hover) {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
:deep(.el-dialog__body) {
|
||||
padding-top: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,450 @@
|
||||
<template>
|
||||
<div class="py-5">
|
||||
<h1 class="page-title">统计卡片(文字)</h1>
|
||||
<ElRow :gutter="20">
|
||||
<ElCol :xs="24" :sm="12" :md="6" v-for="card in statsCards" :key="card.id" class="mb-5">
|
||||
<ArtStatsCard
|
||||
:icon="card.icon"
|
||||
:iconStyle="card.iconStyle"
|
||||
:title="card.title"
|
||||
:description="card.description"
|
||||
:showArrow="card.showArrow"
|
||||
/>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
|
||||
<h1 class="page-title">统计卡片(数字滚动)</h1>
|
||||
<ElRow :gutter="20">
|
||||
<ElCol :xs="24" :sm="12" :md="6" v-for="card in statsCards" :key="card.id" class="mb-5">
|
||||
<ArtStatsCard
|
||||
:icon="card.icon"
|
||||
:iconStyle="card.iconStyle"
|
||||
:count="card.count"
|
||||
:description="card.description"
|
||||
:decimals="0"
|
||||
:showArrow="card.showArrow"
|
||||
separator=","
|
||||
/>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
|
||||
<h1 class="page-title">统计卡片(自定义样式)</h1>
|
||||
<ElRow :gutter="20">
|
||||
<ElCol :xs="24" :sm="12" :md="6" v-for="card in statsCards" :key="card.id" class="mb-5">
|
||||
<ArtStatsCard
|
||||
:icon="card.icon"
|
||||
:iconStyle="card.customIconStyle"
|
||||
:boxStyle="card.boxStyle"
|
||||
:title="card.title"
|
||||
:description="card.description"
|
||||
:textColor="card.textColor"
|
||||
:showArrow="card.showArrow"
|
||||
/>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
|
||||
<h1 class="page-title">进度卡片</h1>
|
||||
<ElRow :gutter="20">
|
||||
<ElCol :xs="24" :sm="12" :md="6" v-for="card in progressCards" :key="card.id" class="mb-5">
|
||||
<ArtProgressCard :percentage="card.percentage" :title="card.title" :color="card.color" />
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
|
||||
<h1 class="page-title">进度卡片(icon)</h1>
|
||||
<ElRow :gutter="20">
|
||||
<ElCol :xs="24" :sm="12" :md="6" v-for="card in progressCards" :key="card.id" class="mb-5">
|
||||
<ArtProgressCard
|
||||
:percentage="card.percentage"
|
||||
:title="card.title"
|
||||
:color="card.color"
|
||||
:icon="card.icon"
|
||||
:iconStyle="card.iconStyle"
|
||||
/>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
|
||||
<h1 class="page-title">图表卡片(小图表)</h1>
|
||||
<ElRow :gutter="20">
|
||||
<ElCol :xs="24" :sm="12" :md="6" class="mb-5">
|
||||
<ArtLineChartCard
|
||||
:isMiniChart="true"
|
||||
:value="2545"
|
||||
label="新用户"
|
||||
date="过去7天"
|
||||
:percentage="1.2"
|
||||
:height="9.5"
|
||||
:chartData="[120, 132, 101, 134, 90, 230, 210]"
|
||||
/>
|
||||
</ElCol>
|
||||
<ElCol :xs="24" :sm="12" :md="6" class="mb-5">
|
||||
<ArtBarChartCard
|
||||
:isMiniChart="true"
|
||||
:value="15480"
|
||||
label="浏览量"
|
||||
date="过去 14 天"
|
||||
:percentage="-4.15"
|
||||
:height="9.5"
|
||||
:barWidth="'45%'"
|
||||
:chartData="[120, 100, 150, 140, 90, 120, 130]"
|
||||
/>
|
||||
</ElCol>
|
||||
<ElCol :xs="24" :sm="12" :md="6" class="mb-5">
|
||||
<ArtLineChartCard
|
||||
:isMiniChart="true"
|
||||
:value="2545"
|
||||
label="粉丝数"
|
||||
date="过去 30 天"
|
||||
:percentage="1.2"
|
||||
:height="9.5"
|
||||
:showAreaColor="true"
|
||||
:chartData="[150, 180, 160, 200, 180, 220, 240]"
|
||||
/>
|
||||
</ElCol>
|
||||
<ElCol :xs="24" :sm="12" :md="6" class="mb-5">
|
||||
<ArtDonutChartCard
|
||||
:value="36358"
|
||||
title="粉丝量"
|
||||
:percentage="18"
|
||||
percentageLabel="较去年"
|
||||
:data="[50, 40]"
|
||||
:height="9.5"
|
||||
currentValue="2022"
|
||||
previousValue="2021"
|
||||
:radius="['50%', '70%']"
|
||||
/>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
|
||||
<h1 class="page-title">图表卡片(大图表)</h1>
|
||||
<ElRow :gutter="20">
|
||||
<ElCol :xs="24" :sm="12" :md="6" class="mb-5">
|
||||
<ArtLineChartCard
|
||||
:value="2545"
|
||||
label="新用户"
|
||||
:percentage="1.2"
|
||||
:height="11"
|
||||
:chartData="[120, 132, 101, 134, 90, 230, 210]"
|
||||
/>
|
||||
</ElCol>
|
||||
<ElCol :xs="24" :sm="12" :md="6" class="mb-5">
|
||||
<ArtBarChartCard
|
||||
:value="15480"
|
||||
label="浏览量"
|
||||
:percentage="-4.15"
|
||||
:height="11"
|
||||
:chartData="[120, 100, 150, 140, 90, 120, 130, 110]"
|
||||
/>
|
||||
</ElCol>
|
||||
<ElCol :xs="24" :sm="12" :md="6" class="mb-5">
|
||||
<ArtLineChartCard
|
||||
:value="2545"
|
||||
label="粉丝数"
|
||||
:percentage="1.2"
|
||||
:height="11"
|
||||
:showAreaColor="true"
|
||||
:chartData="[150, 180, 160, 200, 180, 220, 240]"
|
||||
/>
|
||||
</ElCol>
|
||||
<ElCol :xs="24" :sm="12" :md="6" class="mb-5">
|
||||
<ArtDonutChartCard
|
||||
:value="36358"
|
||||
title="粉丝量"
|
||||
:percentage="-18"
|
||||
percentageLabel="较2021年"
|
||||
:data="[70, 30]"
|
||||
:height="11"
|
||||
currentValue="12月"
|
||||
previousValue="11月"
|
||||
/>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
|
||||
<h1 class="page-title">数据列表卡片</h1>
|
||||
<ElRow :gutter="20">
|
||||
<ElCol :xs="24" :sm="12" :lg="8" class="mb-5">
|
||||
<ArtDataListCard :list="dataList" title="待办事项" subtitle="今日待处理任务" />
|
||||
</ElCol>
|
||||
<ElCol :xs="24" :sm="12" :lg="8" class="mb-5">
|
||||
<ArtDataListCard
|
||||
:maxCount="4"
|
||||
:list="dataList"
|
||||
title="最近活动"
|
||||
subtitle="近期活动列表"
|
||||
:showMoreButton="true"
|
||||
@more="handleMore"
|
||||
/>
|
||||
</ElCol>
|
||||
<ElCol :xs="24" :sm="12" :lg="8" class="mb-5">
|
||||
<ArtTimelineListCard :list="timelineData" title="最近交易" subtitle="2024年12月20日" />
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
|
||||
<h1 class="page-title">图片卡片</h1>
|
||||
<ElRow :gutter="20">
|
||||
<ElCol :xs="24" :sm="12" :md="6" v-for="card in imageCards" :key="card.id" class="mb-5">
|
||||
<ArtImageCard
|
||||
:imageUrl="card.imageUrl"
|
||||
:title="card.title"
|
||||
:category="card.category"
|
||||
:readTime="card.readTime"
|
||||
:views="card.views"
|
||||
:comments="card.comments"
|
||||
:date="card.date"
|
||||
@click="handleImageCardClick(card)"
|
||||
/>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import cover1 from '@imgs/cover/img1.webp'
|
||||
import cover2 from '@imgs/cover/img2.webp'
|
||||
import cover3 from '@imgs/cover/img3.webp'
|
||||
import cover4 from '@imgs/cover/img4.webp'
|
||||
|
||||
defineOptions({ name: 'TemplateCards' })
|
||||
|
||||
/**
|
||||
* 统计卡片数据配置
|
||||
*/
|
||||
const statsCards = [
|
||||
{
|
||||
id: 1,
|
||||
title: '销售产品',
|
||||
count: 1235,
|
||||
description: '鞋子、牛仔裤、派对服装、手表',
|
||||
icon: 'ri:bar-chart-box-line',
|
||||
boxStyle: '!bg-theme/10',
|
||||
customIconStyle: '!text-theme !text-3xl',
|
||||
iconStyle: 'bg-info',
|
||||
textColor: 'var(--theme-color)',
|
||||
showArrow: false
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: '活跃用户',
|
||||
count: 5000,
|
||||
description: '日活跃用户超过5,000+',
|
||||
icon: 'ri:account-box-2-line',
|
||||
boxStyle: '!bg-warning/10',
|
||||
customIconStyle: '!text-warning !text-3xl',
|
||||
iconStyle: 'bg-warning',
|
||||
textColor: 'var(--art-warning)',
|
||||
showArrow: false
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: '总收入',
|
||||
count: 35000,
|
||||
description: '月收入超过¥350,000+',
|
||||
icon: 'ri:money-cny-box-line',
|
||||
boxStyle: '!bg-secondary/10',
|
||||
customIconStyle: '!text-secondary !text-3xl',
|
||||
iconStyle: 'bg-secondary',
|
||||
textColor: 'var(--art-secondary)',
|
||||
showArrow: false
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: '客户评价',
|
||||
count: 4800,
|
||||
description: '平均评分4.8/5',
|
||||
icon: 'ri:message-3-line',
|
||||
boxStyle: '!bg-error/10',
|
||||
customIconStyle: '!text-error !text-3xl',
|
||||
iconStyle: 'bg-error',
|
||||
textColor: 'var(--art-error)',
|
||||
showArrow: false
|
||||
}
|
||||
]
|
||||
|
||||
const progressCards = [
|
||||
{
|
||||
id: 1,
|
||||
title: '完成进度',
|
||||
percentage: 75,
|
||||
color: 'var(--art-success)',
|
||||
icon: 'ri:arrow-up-circle-line',
|
||||
iconStyle: 'bg-success/12 text-success'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: '项目进度',
|
||||
percentage: 65,
|
||||
color: 'var(--theme-color)',
|
||||
icon: 'ri:twitch-line',
|
||||
iconStyle: 'bg-theme/12 text-theme'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: '学习进度',
|
||||
percentage: 45,
|
||||
color: 'var(--art-error)',
|
||||
icon: 'ri:game-line',
|
||||
iconStyle: '!bg-error/12 text-error'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: '任务进度',
|
||||
percentage: 90,
|
||||
color: 'var(--art-secondary)',
|
||||
icon: 'ri:flag-2-line',
|
||||
iconStyle: 'bg-secondary/12 text-secondary'
|
||||
}
|
||||
]
|
||||
|
||||
const imageCards = [
|
||||
{
|
||||
id: 1,
|
||||
imageUrl: cover1,
|
||||
title: 'AI技术在医疗领域的创新应用与发展前景',
|
||||
category: '社交',
|
||||
readTime: '2分钟',
|
||||
views: 9125,
|
||||
comments: 3,
|
||||
date: '12月19日 周一'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
imageUrl: cover2,
|
||||
title: '大数据分析助力企业决策的实践案例',
|
||||
category: '技术',
|
||||
readTime: '3分钟',
|
||||
views: 7234,
|
||||
comments: 5,
|
||||
date: '12月20日 周二'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
imageUrl: cover3,
|
||||
title: '区块链技术在供应链管理中的应用',
|
||||
category: '科技',
|
||||
readTime: '4分钟',
|
||||
views: 5678,
|
||||
comments: 8,
|
||||
date: '12月21日 周三'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
imageUrl: cover4,
|
||||
title: '云计算技术发展趋势与未来展望',
|
||||
category: '云技术',
|
||||
readTime: '5分钟',
|
||||
views: 4321,
|
||||
comments: 6,
|
||||
date: '12月22日 周四'
|
||||
}
|
||||
]
|
||||
|
||||
const dataList = [
|
||||
{
|
||||
title: '新加坡之行',
|
||||
status: '进行中',
|
||||
time: '5分钟',
|
||||
class: 'bg-theme/12 text-theme',
|
||||
icon: 'ri:camera-4-line'
|
||||
},
|
||||
{
|
||||
title: '归档数据',
|
||||
status: '进行中',
|
||||
time: '10分钟',
|
||||
class: 'bg-secondary/12 text-secondary',
|
||||
icon: 'ri:bar-chart-box-line'
|
||||
},
|
||||
{
|
||||
title: '客户会议',
|
||||
status: '待处理',
|
||||
time: '15分钟',
|
||||
class: 'bg-warning/12 text-warning',
|
||||
icon: 'ri:user-3-line'
|
||||
},
|
||||
{
|
||||
title: '筛选任务团队',
|
||||
status: '进行中',
|
||||
time: '20分钟',
|
||||
class: 'bg-error/12 text-error',
|
||||
icon: 'ri:account-circle-line'
|
||||
},
|
||||
{
|
||||
title: '发送信封给小王',
|
||||
status: '已完成',
|
||||
time: '20分钟',
|
||||
class: 'bg-success/12 text-success',
|
||||
icon: 'ri:message-3-line'
|
||||
}
|
||||
]
|
||||
|
||||
const timelineData = [
|
||||
{
|
||||
time: '上午 09:30',
|
||||
status: 'rgb(73, 190, 255)',
|
||||
content: '收到 John Doe 支付的 385.90 美元'
|
||||
},
|
||||
{
|
||||
time: '上午 10:00',
|
||||
status: 'rgb(54, 158, 255)',
|
||||
content: '新销售记录',
|
||||
code: 'ML-3467'
|
||||
},
|
||||
{
|
||||
time: '上午 12:00',
|
||||
status: 'rgb(103, 232, 207)',
|
||||
content: '向 Michael 支付了 64.95 美元'
|
||||
},
|
||||
{
|
||||
time: '下午 14:30',
|
||||
status: 'rgb(255, 193, 7)',
|
||||
content: '系统维护通知',
|
||||
code: 'MT-2023'
|
||||
},
|
||||
{
|
||||
time: '下午 15:45',
|
||||
status: 'rgb(255, 105, 105)',
|
||||
content: '紧急订单取消提醒',
|
||||
code: 'OR-9876'
|
||||
},
|
||||
{
|
||||
time: '下午 17:00',
|
||||
status: 'rgb(103, 232, 207)',
|
||||
content: '完成每日销售报表'
|
||||
}
|
||||
]
|
||||
|
||||
/**
|
||||
* 处理查看更多按钮点击
|
||||
*/
|
||||
const handleMore = () => {
|
||||
ElMessage.info('查看更多')
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片卡片类型定义
|
||||
*/
|
||||
interface ImageCard {
|
||||
id: number
|
||||
imageUrl: string
|
||||
title: string
|
||||
category: string
|
||||
readTime: string
|
||||
views: number
|
||||
comments: number
|
||||
date: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理图片卡片点击事件
|
||||
* @param card 卡片数据
|
||||
*/
|
||||
const handleImageCardClick = (card: ImageCard) => {
|
||||
console.log('点击卡片:', card)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '@styles/core/tailwind.css';
|
||||
|
||||
.page-title {
|
||||
@apply my-5 text-xl font-medium first:mt-0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,355 @@
|
||||
<!--
|
||||
* 部分属性配置:
|
||||
* 图表高度: height="12rem" | height="300px"
|
||||
* 柱状图宽度: barWidth="26%"
|
||||
* 圆角大小: :borderRadius="4" | :borderRadius="[6, 6, 0, 0]"
|
||||
* 颜色配置: :colors=['#409eff', '#95E0FB']
|
||||
* 是否显示图例: :showLegend="true"
|
||||
* 图例位置: legendPosition="bottom" | "top" | "left" | "right"
|
||||
* 是否显示提示框: :showTooltip="true"
|
||||
* 堆叠: :stack="true"
|
||||
* 数据点大小: :symbolSize="7"
|
||||
* 数据点类型: symbol="circle" | "rect" | "roundRect" | "triangle" | "diamond" | "pin" | "arrow" | "none"
|
||||
-->
|
||||
<template>
|
||||
<div class="pt-5">
|
||||
<h1 class="page-title">图表</h1>
|
||||
|
||||
<ElRow :gutter="20">
|
||||
<ElCol :xs="24" :md="12" :lg="8" class="mb-5">
|
||||
<div class="art-card">
|
||||
<div class="pb-3.5">
|
||||
<span class="text-base font-medium">柱状图(单数据)</span>
|
||||
</div>
|
||||
<ArtBarChart
|
||||
:data="singleBarData"
|
||||
:xAxisData="xAxisData"
|
||||
:showLegend="true"
|
||||
legendPosition="right"
|
||||
/>
|
||||
</div>
|
||||
</ElCol>
|
||||
<ElCol :xs="24" :md="12" :lg="8" class="mb-5">
|
||||
<div class="art-card">
|
||||
<div class="pb-3.5">
|
||||
<span class="text-base font-medium">柱状图(多组数据)</span>
|
||||
</div>
|
||||
<ArtBarChart
|
||||
:data="multiBarData"
|
||||
:xAxisData="xAxisData"
|
||||
:showLegend="true"
|
||||
barWidth="26%"
|
||||
/>
|
||||
</div>
|
||||
</ElCol>
|
||||
<ElCol :xs="24" :md="12" :lg="8" class="mb-5">
|
||||
<div class="art-card">
|
||||
<div class="pb-3.5">
|
||||
<span class="text-base font-medium">柱状图(堆叠)</span>
|
||||
</div>
|
||||
<ArtBarChart
|
||||
:data="stackBarData"
|
||||
:xAxisData="stackXAxisData"
|
||||
:showLegend="true"
|
||||
:stack="true"
|
||||
barWidth="26%"
|
||||
/>
|
||||
</div>
|
||||
</ElCol>
|
||||
<ElCol :xs="24" :md="12" :lg="8" class="mb-5">
|
||||
<div class="art-card">
|
||||
<div class="pb-3.5">
|
||||
<span class="text-base font-medium">折线图</span>
|
||||
</div>
|
||||
<ArtLineChart
|
||||
:data="[58, 15, 82, 35, 120, 62, 45]"
|
||||
:xAxisData="['一月', '二月', '三月', '四月', '五月', '六月', '七月']"
|
||||
symbol="none"
|
||||
:symbolSize="7"
|
||||
/>
|
||||
</div>
|
||||
</ElCol>
|
||||
<ElCol :xs="24" :md="12" :lg="8" class="mb-5">
|
||||
<div class="art-card">
|
||||
<div class="pb-3.5">
|
||||
<span class="text-base font-medium">折线图(渐变背景)</span>
|
||||
</div>
|
||||
<ArtLineChart
|
||||
:data="[28, 45, 82, 35, 100, 32, 55]"
|
||||
:xAxisData="['一月', '二月', '三月', '四月', '五月', '六月', '七月']"
|
||||
:showAreaColor="true"
|
||||
/>
|
||||
</div>
|
||||
</ElCol>
|
||||
<ElCol :xs="24" :md="12" :lg="8" class="mb-5">
|
||||
<div class="art-card">
|
||||
<div class="pb-3.5">
|
||||
<span class="text-base font-medium">折线图(多组数据)</span>
|
||||
</div>
|
||||
<ArtLineChart
|
||||
:data="multiLineData"
|
||||
:xAxisData="['1月', '2月', '3月', '4月', '5月', '6月']"
|
||||
:showLegend="true"
|
||||
/>
|
||||
</div>
|
||||
</ElCol>
|
||||
|
||||
<ElCol :xs="24" :md="12" :lg="8" class="mb-5">
|
||||
<div class="art-card">
|
||||
<div class="pb-3.5">
|
||||
<span class="text-base font-medium">柱状图(水平)</span>
|
||||
</div>
|
||||
<ArtHBarChart
|
||||
:data="[50, 80, 120, 90, 60]"
|
||||
:xAxisData="['产品A', '产品B', '产品C', '产品D', '产品E']"
|
||||
/>
|
||||
</div>
|
||||
</ElCol>
|
||||
<ElCol :xs="24" :md="12" :lg="8" class="mb-5">
|
||||
<div class="art-card">
|
||||
<div class="pb-3.5">
|
||||
<span class="text-base font-medium">柱状图(水平)</span>
|
||||
</div>
|
||||
|
||||
<ArtHBarChart
|
||||
:data="[
|
||||
{ name: '系列1', data: [10, 20, 30] },
|
||||
{ name: '系列2', data: [15, 25, 35] }
|
||||
]"
|
||||
:xAxisData="['类目1', '类目2', '类目3']"
|
||||
:showLegend="true"
|
||||
barWidth="30%"
|
||||
/>
|
||||
</div>
|
||||
</ElCol>
|
||||
<ElCol :xs="24" :md="12" :lg="8" class="mb-5">
|
||||
<div class="art-card">
|
||||
<div class="pb-3.5">
|
||||
<span class="text-base font-medium">柱状图(水平堆叠)</span>
|
||||
</div>
|
||||
<ArtHBarChart
|
||||
:data="[
|
||||
{ name: '系列1', data: [10, 20, 30] },
|
||||
{ name: '系列2', data: [15, 25, 35] }
|
||||
]"
|
||||
:xAxisData="['类目1', '类目2', '类目3']"
|
||||
:showLegend="true"
|
||||
:stack="true"
|
||||
barWidth="30%"
|
||||
/>
|
||||
</div>
|
||||
</ElCol>
|
||||
|
||||
<ElCol :xs="24" :md="12" :lg="8" class="mb-5">
|
||||
<div class="art-card">
|
||||
<div class="pb-3.5">
|
||||
<span class="text-base font-medium">环形图</span>
|
||||
</div>
|
||||
<ArtRingChart
|
||||
:data="[
|
||||
{ value: 35, name: '分类A' },
|
||||
{ value: 28, name: '分类B' },
|
||||
{ value: 42, name: '分类C' }
|
||||
]"
|
||||
:radius="['54%', '70%']"
|
||||
legendPosition="bottom"
|
||||
/>
|
||||
</div>
|
||||
</ElCol>
|
||||
|
||||
<ElCol :xs="24" :md="12" :lg="8" class="mb-5">
|
||||
<div class="art-card">
|
||||
<div class="pb-3.5">
|
||||
<span class="text-base font-medium">环形图</span>
|
||||
</div>
|
||||
<ArtRingChart
|
||||
:data="[
|
||||
{ value: 35, name: '分类A' },
|
||||
{ value: 28, name: '分类B' },
|
||||
{ value: 42, name: '分类C' },
|
||||
{ value: 32, name: '分类D' },
|
||||
{ value: 26, name: '分类E' },
|
||||
{ value: 37, name: '分类F' }
|
||||
]"
|
||||
:radius="['54%', '70%']"
|
||||
:borderRadius="0"
|
||||
:showLegend="true"
|
||||
legendPosition="bottom"
|
||||
centerText="¥300,458"
|
||||
/>
|
||||
</div>
|
||||
</ElCol>
|
||||
<ElCol :xs="24" :md="12" :lg="8" class="mb-5">
|
||||
<div class="art-card">
|
||||
<div class="pb-3.5">
|
||||
<span class="text-base font-medium">饼图</span>
|
||||
</div>
|
||||
<ArtRingChart
|
||||
:data="[
|
||||
{ value: 30, name: '分类A' },
|
||||
{ value: 25, name: '分类B' },
|
||||
{ value: 45, name: '分类C' }
|
||||
]"
|
||||
:radius="['0%', '70%']"
|
||||
:showLegend="true"
|
||||
legendPosition="right"
|
||||
/>
|
||||
</div>
|
||||
</ElCol>
|
||||
<ElCol :xs="24" :md="12" :lg="8" class="mb-5">
|
||||
<div class="art-card">
|
||||
<div class="pb-3.5">
|
||||
<span class="text-base font-medium">散点图</span>
|
||||
</div>
|
||||
<ArtScatterChart
|
||||
:data="[
|
||||
{ value: [1, 3] },
|
||||
{ value: [2, 4] },
|
||||
{ value: [3, 5] },
|
||||
{ value: [4, 6] },
|
||||
{ value: [5, 7] },
|
||||
{ value: [6, 8] },
|
||||
{ value: [7, 7] },
|
||||
{ value: [8, 9] },
|
||||
{ value: [9, 8] },
|
||||
{ value: [10, 6] },
|
||||
{ value: [11, 7] },
|
||||
{ value: [12, 8] }
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
</ElCol>
|
||||
|
||||
<ElCol :xs="24" :md="12" :lg="8" class="mb-5">
|
||||
<div class="art-card">
|
||||
<div class="pb-3.5">
|
||||
<span class="text-base font-medium">雷达图</span>
|
||||
</div>
|
||||
<ArtRadarChart
|
||||
:indicator="[
|
||||
{ name: '销售', max: 100 },
|
||||
{ name: '管理', max: 100 },
|
||||
{ name: '技术', max: 100 },
|
||||
{ name: '客服', max: 100 },
|
||||
{ name: '开发', max: 100 }
|
||||
]"
|
||||
:data="[
|
||||
{
|
||||
name: '预算分配',
|
||||
value: [80, 70, 90, 85, 75]
|
||||
},
|
||||
{
|
||||
name: '实际开销',
|
||||
value: [70, 75, 85, 80, 70]
|
||||
}
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
</ElCol>
|
||||
<ElCol :xs="24" :md="12" :lg="8" class="mb-5">
|
||||
<div class="art-card">
|
||||
<div class="pb-3.5">
|
||||
<span class="text-base font-medium">k线图</span>
|
||||
</div>
|
||||
<ArtKLineChart
|
||||
:data="[
|
||||
{ time: '2024-01-01', open: 20, close: 23, high: 25, low: 18 },
|
||||
{ time: '2024-01-02', open: 23, close: 21, high: 24, low: 20 },
|
||||
{ time: '2024-01-03', open: 21, close: 25, high: 26, low: 21 }
|
||||
]"
|
||||
:showDataZoom="false"
|
||||
:dataZoomStart="0"
|
||||
:dataZoomEnd="100"
|
||||
/>
|
||||
</div>
|
||||
</ElCol>
|
||||
<ElCol :xs="24" :md="12" :lg="8" class="mb-5">
|
||||
<div class="art-card">
|
||||
<div class="pb-3.5">
|
||||
<span class="text-base font-medium">双向堆叠柱状图</span>
|
||||
</div>
|
||||
<ArtDualBarCompareChart
|
||||
:positiveData="[50, 28, 80, 65, 68, 70, 60, 55]"
|
||||
:negativeData="[50, 28, 40, 45, 38, 50, 42, 48]"
|
||||
:xAxisData="[
|
||||
'0-4岁',
|
||||
'5-14岁',
|
||||
'15-24岁',
|
||||
'25-34岁',
|
||||
'35-44岁',
|
||||
'45-54岁',
|
||||
'55-64岁',
|
||||
'65岁以上'
|
||||
]"
|
||||
positiveName="男性年龄分布"
|
||||
negativeName="女性年龄分布"
|
||||
/>
|
||||
</div>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { LineDataItem } from '@/types/component/chart'
|
||||
|
||||
defineOptions({ name: 'TemplateCharts' })
|
||||
|
||||
/**
|
||||
* 多组数据折线图示例数据
|
||||
*/
|
||||
const multiLineData: LineDataItem[] = [
|
||||
{
|
||||
name: '销售额',
|
||||
data: [120, 132, 101, 134, 90, 130],
|
||||
areaStyle: {
|
||||
startOpacity: 0.1,
|
||||
endOpacity: 0
|
||||
}
|
||||
},
|
||||
{
|
||||
name: '利润',
|
||||
data: [80, 92, 71, 94, 60, 80],
|
||||
areaStyle: {
|
||||
startOpacity: 0.1,
|
||||
endOpacity: 0
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
/**
|
||||
* 单数据柱状图示例数据
|
||||
*/
|
||||
const singleBarData = ref([120, 200, 150, 80, 70, 110, 130])
|
||||
const xAxisData = ref(['周一', '周二', '周三', '周四', '周五', '周六', '周日'])
|
||||
|
||||
/**
|
||||
* 多数据柱状图示例数据
|
||||
*/
|
||||
const multiBarData = ref([
|
||||
{ name: '销售额', data: [120, 200, 150, 80, 70, 110, 130] },
|
||||
{ name: '利润', data: [20, 50, 30, 15, 10, 25, 35] }
|
||||
])
|
||||
|
||||
/**
|
||||
* 堆叠柱状图示例数据
|
||||
*/
|
||||
const stackBarData = ref([
|
||||
{ name: 'Q1', data: [20, 25, 30, 35, 40], stack: 'total' },
|
||||
{ name: 'Q2', data: [30, 35, 40, 45, 50], stack: 'total' }
|
||||
])
|
||||
|
||||
const stackXAxisData = ref(['产品A', '产品B', '产品C', '产品D', '产品E'])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '@styles/core/tailwind.css';
|
||||
|
||||
.page-title {
|
||||
@apply my-5 text-xl font-medium first:mt-0;
|
||||
}
|
||||
|
||||
.art-card {
|
||||
@apply p-5;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,452 @@
|
||||
<!-- 聊天页 -->
|
||||
<template>
|
||||
<div class="page-content flex !p-0 max-md:flex-col" :style="{ height: containerMinHeight }">
|
||||
<ElRow>
|
||||
<ElCol :span="12">
|
||||
<div class="grid-content ep-bg-purple" />
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<div class="grid-content ep-bg-purple-light" />
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
<div
|
||||
class="box-border w-90 h-full p-5 border-r border-g-300 max-md:w-full max-md:h-42 max-md:border-r-0"
|
||||
>
|
||||
<div class="pb-5 max-md:!hidden">
|
||||
<div class="flex-c gap-3">
|
||||
<ElAvatar :size="50" :src="selectedPerson?.avatar" />
|
||||
<div>
|
||||
<div class="text-base font-medium">{{ selectedPerson?.name }}</div>
|
||||
<div class="mt-1 text-xs text-g-500">{{ selectedPerson?.email }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<ElInput v-model="searchQuery" placeholder="搜索联系人" prefix-icon="Search" clearable />
|
||||
</div>
|
||||
<ElDropdown trigger="click" placement="bottom-start">
|
||||
<span class="mt-5 c-p">
|
||||
排序方式
|
||||
<ElIcon class="el-icon--right">
|
||||
<arrow-down />
|
||||
</ElIcon>
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<ElDropdownMenu>
|
||||
<ElDropdownItem>按时间排序</ElDropdownItem>
|
||||
<ElDropdownItem>按名称排序</ElDropdownItem>
|
||||
<ElDropdownItem>全部标为已读</ElDropdownItem>
|
||||
</ElDropdownMenu>
|
||||
</template>
|
||||
</ElDropdown>
|
||||
</div>
|
||||
<ElScrollbar>
|
||||
<div
|
||||
v-for="item in personList"
|
||||
:key="item.id"
|
||||
class="flex-c p-3 c-p rounded-lg tad-200 hover:bg-active-color/30 mb-1"
|
||||
:class="{ 'bg-active-color': selectedPerson?.id === item.id }"
|
||||
@click="selectPerson(item)"
|
||||
>
|
||||
<div class="relative mr-3">
|
||||
<ElAvatar :size="40" :src="item.avatar">
|
||||
{{ item.name.charAt(0) }}
|
||||
</ElAvatar>
|
||||
<div
|
||||
class="absolute right-1 bottom-1 size-2 rounded-full"
|
||||
:class="item.online ? 'bg-success/100' : 'bg-error/100'"
|
||||
></div>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex-cb mb-1">
|
||||
<span class="text-sm font-medium">{{ item.name }}</span>
|
||||
<span class="text-xs text-g-600">{{ item.lastTime }}</span>
|
||||
</div>
|
||||
<div class="flex-cb">
|
||||
<span class="overflow-hidden text-xs text-g-600 text-ellipsis whitespace-nowrap">{{
|
||||
item.email
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ElScrollbar>
|
||||
</div>
|
||||
<div class="box-border flex-1 h-full max-md:h-[calc(70%-30px)]">
|
||||
<div class="flex-cb pt-4 px-4 pb-0 mb-5">
|
||||
<div>
|
||||
<span class="text-base font-medium">Art Bot</span>
|
||||
<div class="flex-c gap-1 mt-1.5">
|
||||
<div
|
||||
class="w-2 h-2 rounded-full"
|
||||
:class="isOnline ? 'bg-success/100' : 'bg-danger/100'"
|
||||
></div>
|
||||
<span class="text-xs text-g-600">{{ isOnline ? '在线' : '离线' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-c gap-2">
|
||||
<ArtIconButton icon="ri:phone-line" circle class="size-11 text-g-600" />
|
||||
<ArtIconButton icon="ri:video-on-line" circle class="size-11 text-g-600" />
|
||||
<ArtIconButton icon="ri:more-2-fill" circle class="size-11 text-g-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col h-[calc(100%-85px)]">
|
||||
<!-- 聊天消息区域 -->
|
||||
<div
|
||||
class="flex-1 py-7.5 px-4 overflow-y-auto border-t-d [&::-webkit-scrollbar]:!w-1"
|
||||
ref="messageContainer"
|
||||
>
|
||||
<template v-for="message in messages" :key="message.id">
|
||||
<div
|
||||
:class="[
|
||||
'flex gap-2 items-start w-full mb-7.5',
|
||||
message.isMe ? 'flex-row-reverse' : 'flex-row justify-start'
|
||||
]"
|
||||
>
|
||||
<ElAvatar :size="32" :src="message.avatar" class="flex-shrink-0" />
|
||||
<div
|
||||
class="flex flex-col max-w-[70%]"
|
||||
:class="message.isMe ? 'items-end' : 'items-start'"
|
||||
>
|
||||
<div
|
||||
class="flex gap-2 mb-1 text-xs"
|
||||
:class="message.isMe ? 'flex-row-reverse' : 'flex-row'"
|
||||
>
|
||||
<span class="font-medium">{{ message.sender }}</span>
|
||||
<span class="text-g-600">{{ message.time }}</span>
|
||||
</div>
|
||||
<div
|
||||
class="py-2.5 px-3.5 text-sm leading-[1.4] rounded-md"
|
||||
:class="message.isMe ? '!bg-theme/15' : '!bg-active-color'"
|
||||
>{{ message.content }}</div
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 聊天输入区域 -->
|
||||
<div class="p-4">
|
||||
<ElInput
|
||||
v-model="messageText"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="输入消息"
|
||||
resize="none"
|
||||
@keyup.enter.prevent="sendMessage"
|
||||
>
|
||||
<template #append>
|
||||
<div class="flex gap-2 py-2">
|
||||
<ElButton :icon="Paperclip" circle plain />
|
||||
<ElButton :icon="Picture" circle plain />
|
||||
<ElButton type="primary" @click="sendMessage" v-ripple>发送</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
</ElInput>
|
||||
<div class="flex-cb mt-3">
|
||||
<div class="flex-c">
|
||||
<ArtSvgIcon icon="ri:image-line" class="mr-5 c-p text-g-600 text-lg" />
|
||||
<ArtSvgIcon icon="ri:emotion-happy-line" class="mr-5 c-p text-g-600 text-lg" />
|
||||
</div>
|
||||
<ElButton type="primary" @click="sendMessage" v-ripple class="min-w-20">发送</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Picture, Paperclip, ArrowDown } from '@element-plus/icons-vue'
|
||||
import { mittBus } from '@/utils/sys'
|
||||
import meAvatar from '@/assets/images/avatar/avatar5.webp'
|
||||
import aiAvatar from '@/assets/images/avatar/avatar10.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'
|
||||
import avatar7 from '@/assets/images/avatar/avatar7.webp'
|
||||
import avatar8 from '@/assets/images/avatar/avatar8.webp'
|
||||
import avatar9 from '@/assets/images/avatar/avatar9.webp'
|
||||
import avatar10 from '@/assets/images/avatar/avatar10.webp'
|
||||
import { useAutoLayoutHeight } from '@/hooks/core/useLayoutHeight'
|
||||
|
||||
defineOptions({ name: 'TemplateChat' })
|
||||
|
||||
const { containerMinHeight } = useAutoLayoutHeight()
|
||||
|
||||
/**
|
||||
* 联系人类型定义
|
||||
*/
|
||||
interface Person {
|
||||
id: number
|
||||
name: string
|
||||
email: string
|
||||
avatar: string
|
||||
online?: boolean
|
||||
lastTime: string
|
||||
unread?: number
|
||||
}
|
||||
|
||||
const searchQuery = ref('')
|
||||
const isDrawerVisible = ref(false)
|
||||
const isOnline = ref(true)
|
||||
const selectedPerson = ref<Person | null>(null)
|
||||
const messageText = ref('')
|
||||
const messageId = ref(10)
|
||||
const userAvatar = ref(meAvatar)
|
||||
const messageContainer = ref<HTMLElement | null>(null)
|
||||
|
||||
/**
|
||||
* 联系人列表数据
|
||||
*/
|
||||
const personList = ref<Person[]>([
|
||||
{
|
||||
id: 1,
|
||||
name: '梅洛迪·梅西',
|
||||
email: 'melody@altbox.com',
|
||||
avatar: meAvatar,
|
||||
online: true,
|
||||
lastTime: '20小时前',
|
||||
unread: 0
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: '马克·史密斯',
|
||||
email: 'max@kt.com',
|
||||
avatar: avatar2,
|
||||
online: true,
|
||||
lastTime: '2周前',
|
||||
unread: 6
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: '肖恩·宾',
|
||||
email: 'sean@dellito.com',
|
||||
avatar: avatar3,
|
||||
online: false,
|
||||
lastTime: '5小时前',
|
||||
unread: 5
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: '爱丽丝·约翰逊',
|
||||
email: 'alice@domain.com',
|
||||
avatar: avatar4,
|
||||
online: true,
|
||||
lastTime: '1小时前',
|
||||
unread: 2
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: '鲍勃·布朗',
|
||||
email: 'bob@domain.com',
|
||||
avatar: avatar5,
|
||||
online: false,
|
||||
lastTime: '3天前',
|
||||
unread: 1
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: '查理·戴维斯',
|
||||
email: 'charlie@domain.com',
|
||||
avatar: avatar6,
|
||||
online: true,
|
||||
lastTime: '10分钟前',
|
||||
unread: 0
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
name: '戴安娜·普林斯',
|
||||
email: 'diana@domain.com',
|
||||
avatar: avatar7,
|
||||
online: true,
|
||||
lastTime: '15分钟前',
|
||||
unread: 3
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
name: '伊桑·亨特',
|
||||
email: 'ethan@domain.com',
|
||||
avatar: avatar8,
|
||||
online: true,
|
||||
lastTime: '5分钟前',
|
||||
unread: 0
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
name: '杰西卡·琼斯',
|
||||
email: 'jessica@domain.com',
|
||||
avatar: avatar9,
|
||||
online: false,
|
||||
lastTime: '1天前',
|
||||
unread: 4
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
name: '彼得·帕克',
|
||||
email: 'peter@domain.com',
|
||||
avatar: avatar10,
|
||||
online: true,
|
||||
lastTime: '2小时前',
|
||||
unread: 1
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
name: '克拉克·肯特',
|
||||
email: 'clark@domain.com',
|
||||
avatar: avatar3,
|
||||
online: true,
|
||||
lastTime: '30分钟前',
|
||||
unread: 2
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
name: '布鲁斯·韦恩',
|
||||
email: 'bruce@domain.com',
|
||||
avatar: avatar5,
|
||||
online: false,
|
||||
lastTime: '3天前',
|
||||
unread: 0
|
||||
},
|
||||
{
|
||||
id: 13,
|
||||
name: '韦德·威尔逊',
|
||||
email: 'wade@domain.com',
|
||||
avatar: avatar6,
|
||||
online: true,
|
||||
lastTime: '10分钟前',
|
||||
unread: 5
|
||||
}
|
||||
])
|
||||
|
||||
/**
|
||||
* 选择联系人
|
||||
* @param person 联系人对象
|
||||
*/
|
||||
const selectPerson = (person: Person) => {
|
||||
selectedPerson.value = person
|
||||
}
|
||||
|
||||
/**
|
||||
* 消息列表数据
|
||||
*/
|
||||
const messages = ref([
|
||||
{
|
||||
id: 1,
|
||||
sender: 'Art Bot',
|
||||
content: '你好!我是你的AI助手,有什么我可以帮你的吗?',
|
||||
time: '10:00',
|
||||
isMe: false,
|
||||
avatar: aiAvatar
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
sender: 'Ricky',
|
||||
content: '我想了解一下系统的使用方法。',
|
||||
time: '10:01',
|
||||
isMe: true,
|
||||
avatar: meAvatar
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
sender: 'Art Bot',
|
||||
content: '好的,我来为您介绍系统的主要功能。首先,您可以通过左侧菜单访问不同的功能模块...',
|
||||
time: '10:02',
|
||||
isMe: false,
|
||||
avatar: aiAvatar
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
sender: 'Ricky',
|
||||
content: '听起来很不错,能具体讲讲数据分析部分吗?',
|
||||
time: '10:05',
|
||||
isMe: true,
|
||||
avatar: meAvatar
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
sender: 'Art Bot',
|
||||
content: '当然可以。数据分析模块可以帮助您实时监控关键指标,并生成详细的报表...',
|
||||
time: '10:06',
|
||||
isMe: false,
|
||||
avatar: aiAvatar
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
sender: 'Ricky',
|
||||
content: '太好了,那我如何开始使用呢?',
|
||||
time: '10:08',
|
||||
isMe: true,
|
||||
avatar: meAvatar
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
sender: 'Art Bot',
|
||||
content: '您可以先创建一个项目,然后在项目中添加相关的数据源,系统会自动进行分析。',
|
||||
time: '10:09',
|
||||
isMe: false,
|
||||
avatar: aiAvatar
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
sender: 'Ricky',
|
||||
content: '明白了,谢谢你的帮助!',
|
||||
time: '10:10',
|
||||
isMe: true,
|
||||
avatar: meAvatar
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
sender: 'Art Bot',
|
||||
content: '不客气,有任何问题随时联系我。',
|
||||
time: '10:11',
|
||||
isMe: false,
|
||||
avatar: aiAvatar
|
||||
}
|
||||
])
|
||||
|
||||
/**
|
||||
* 发送消息
|
||||
* 添加新消息到消息列表并滚动到底部
|
||||
*/
|
||||
const sendMessage = () => {
|
||||
const text = messageText.value.trim()
|
||||
if (!text) return
|
||||
|
||||
messages.value.push({
|
||||
id: messageId.value++,
|
||||
sender: 'Ricky',
|
||||
content: text,
|
||||
time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
|
||||
isMe: true,
|
||||
avatar: userAvatar.value
|
||||
})
|
||||
|
||||
messageText.value = ''
|
||||
scrollToBottom()
|
||||
}
|
||||
|
||||
/**
|
||||
* 滚动到消息列表底部
|
||||
*/
|
||||
const scrollToBottom = () => {
|
||||
setTimeout(() => {
|
||||
if (messageContainer.value) {
|
||||
messageContainer.value.scrollTop = messageContainer.value.scrollHeight
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开聊天窗口
|
||||
*/
|
||||
const openChat = () => {
|
||||
isDrawerVisible.value = true
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
scrollToBottom()
|
||||
mittBus.on('openChat', openChat)
|
||||
selectedPerson.value = personList.value[0]
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<div class="page-content mb-5" element-loading-text="加载中...">
|
||||
<ArtMapChart />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineOptions({ name: 'TemplateMap' })
|
||||
|
||||
/**
|
||||
* 异步加载地图组件
|
||||
* 减少初始加载体积
|
||||
*/
|
||||
const ArtMapChart = defineAsyncComponent(
|
||||
() => import('@/components/core/charts/art-map-chart/index.vue')
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,134 @@
|
||||
<template>
|
||||
<div class="pt-24 px-20 max-md:!pt-15 max-sm:!px-5 max-md:!px-8 bg-transparent !border-none">
|
||||
<div class="mb-10 text-center">
|
||||
<h1 class="mb-2 text-4xl font-medium max-sm:text-3xl">超过 53,476 位信赖的开发者</h1>
|
||||
<h2 class="mb-2.5 text-2xl font-normal text-g-600 max-sm:text-2xl">
|
||||
以及众多科技巨头的选择
|
||||
</h2>
|
||||
<div class="gap-2 flex-cc mt-2.5 max-sm:mt-3.5 max-sm:!block">
|
||||
<p class="text-sm italic text-g-600 max-sm:text-sm">
|
||||
本项目基于 MIT 协议开源免费,当前页面为定价模板,仅作演示用途
|
||||
</p>
|
||||
<ElTag type="success" size="large" round class="max-sm:mt-3.5">免费商用</ElTag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-20 max-md:mt-0">
|
||||
<ElRow :gutter="20" justify="center">
|
||||
<ElCol v-for="plan in pricingPlans" :key="plan.type" :xs="24" :sm="12" :md="6" class="mb-5">
|
||||
<ElCard class="flex flex-col h-full rounded-xl" shadow="never">
|
||||
<div class="mb-5">
|
||||
<h3 class="mb-2.5 text-xl font-medium">{{ plan.title }}</h3>
|
||||
<p
|
||||
class="h-10 pb-5 mb-5 overflow-hidden text-sm text-g-600 text-ellipsis border-b border-g-300/80 line-clamp-2"
|
||||
>
|
||||
{{ plan.description }}
|
||||
</p>
|
||||
<div class="mt-7.5">
|
||||
<span class="text-3xl font-medium">¥{{ plan.price }}</span>
|
||||
<span class="ml-2.5 text-sm text-g-600">/一次性付款</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-grow mb-5">
|
||||
<div
|
||||
v-for="(feature, index) in plan.features"
|
||||
:key="index"
|
||||
class="flex-c mb-2.5 text-sm"
|
||||
>
|
||||
<ElIcon class="mr-2.5" :class="feature.available ? '!text-theme' : '!text-danger'">
|
||||
<Check v-if="feature.available" />
|
||||
<Close v-else />
|
||||
</ElIcon>
|
||||
<span>{{ feature.text }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-auto text-center">
|
||||
<ElButton type="primary" class="w-full h-10" v-ripple>立即购买</ElButton>
|
||||
</div>
|
||||
</ElCard>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { Check, Close } from '@element-plus/icons-vue'
|
||||
|
||||
defineOptions({ name: 'Pricing' })
|
||||
|
||||
interface Feature {
|
||||
text: string
|
||||
available: boolean
|
||||
}
|
||||
|
||||
interface PricingPlan {
|
||||
type: string
|
||||
title: string
|
||||
description: string
|
||||
price: number
|
||||
features: Feature[]
|
||||
}
|
||||
|
||||
const pricingPlans = ref<PricingPlan[]>([
|
||||
{
|
||||
type: 'single',
|
||||
title: '单次使用版',
|
||||
description: '适用于单个最终产品,最终用户无需付费。',
|
||||
price: 349,
|
||||
features: [
|
||||
{ text: '完整源代码', available: true },
|
||||
{ text: '技术文档', available: true },
|
||||
{ text: 'SaaS应用授权', available: false },
|
||||
{ text: '单个项目使用', available: true },
|
||||
{ text: '一年技术支持', available: true },
|
||||
{ text: '一年免费更新', available: true }
|
||||
]
|
||||
},
|
||||
{
|
||||
type: 'multiple',
|
||||
title: '多次使用版',
|
||||
description: '适用于无限个最终产品,最终用户无需付费。',
|
||||
price: 629,
|
||||
features: [
|
||||
{ text: '完整源代码', available: true },
|
||||
{ text: '技术文档', available: true },
|
||||
{ text: 'SaaS应用授权', available: false },
|
||||
{ text: '无限项目使用', available: true },
|
||||
{ text: '一年技术支持', available: true },
|
||||
{ text: '一年免费更新', available: true }
|
||||
]
|
||||
},
|
||||
{
|
||||
type: 'extended',
|
||||
title: '扩展授权版',
|
||||
description: '适用于单个最终产品,最终用户需要付费。',
|
||||
price: 2099,
|
||||
features: [
|
||||
{ text: '完整源代码', available: true },
|
||||
{ text: '技术文档', available: true },
|
||||
{ text: 'SaaS应用授权', available: true },
|
||||
{ text: '单个项目使用', available: true },
|
||||
{ text: '一年技术支持', available: true },
|
||||
{ text: '一年免费更新', available: true }
|
||||
]
|
||||
},
|
||||
{
|
||||
type: 'unlimited',
|
||||
title: '无限授权版',
|
||||
description: '适用于无限个最终产品,最终用户需要付费。',
|
||||
price: 3499,
|
||||
features: [
|
||||
{ text: '完整源代码', available: true },
|
||||
{ text: '技术文档', available: true },
|
||||
{ text: 'SaaS应用授权', available: true },
|
||||
{ text: '无限项目使用', available: true },
|
||||
{ text: '一年技术支持', available: true },
|
||||
{ text: '一年免费更新', available: true }
|
||||
]
|
||||
}
|
||||
])
|
||||
</script>
|
||||
@@ -0,0 +1,136 @@
|
||||
<template>
|
||||
<div class="page-content">
|
||||
<ElButton @contextmenu.prevent="showMenu"> 右键触发菜单 </ElButton>
|
||||
|
||||
<!-- 右键菜单组件 -->
|
||||
<ArtMenuRight
|
||||
ref="menuRef"
|
||||
:menu-items="menuItems"
|
||||
:menu-width="180"
|
||||
:submenu-width="140"
|
||||
:border-radius="10"
|
||||
@select="handleSelect"
|
||||
@show="onMenuShow"
|
||||
@hide="onMenuHide"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick } from 'vue'
|
||||
import ArtMenuRight from '@/components/core/others/art-menu-right/index.vue'
|
||||
import type { MenuItemType } from '@/components/core/others/art-menu-right/index.vue'
|
||||
|
||||
defineOptions({ name: 'TemplateContextMenu' })
|
||||
|
||||
const menuRef = ref<InstanceType<typeof ArtMenuRight>>()
|
||||
const lastAction = ref('')
|
||||
|
||||
/**
|
||||
* 右键菜单选项配置
|
||||
*/
|
||||
const menuItems = computed((): MenuItemType[] => [
|
||||
{
|
||||
key: 'copy',
|
||||
label: '复制',
|
||||
icon: 'ri:file-copy-line'
|
||||
},
|
||||
{
|
||||
key: 'paste',
|
||||
label: '粘贴',
|
||||
icon: 'ri:capsule-line'
|
||||
},
|
||||
{
|
||||
key: 'cut',
|
||||
label: '剪切',
|
||||
icon: 'ri:clipboard-line',
|
||||
showLine: true
|
||||
},
|
||||
{
|
||||
key: 'export',
|
||||
label: '导出选项',
|
||||
icon: 'ri:export-line',
|
||||
children: [
|
||||
{
|
||||
key: 'exportExcel',
|
||||
label: '导出 Excel',
|
||||
icon: 'ri:file-excel-2-line'
|
||||
},
|
||||
{
|
||||
key: 'exportPdf',
|
||||
label: '导出 PDF',
|
||||
icon: 'ri:file-pdf-2-line'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'edit',
|
||||
label: '编辑选项',
|
||||
icon: 'ri:edit-2-line',
|
||||
children: [
|
||||
{
|
||||
key: 'rename',
|
||||
label: '重命名'
|
||||
},
|
||||
{
|
||||
key: 'duplicate',
|
||||
label: '复制副本'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'share',
|
||||
label: '分享',
|
||||
icon: 'ri:share-forward-line',
|
||||
showLine: true
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
label: '删除',
|
||||
icon: 'ri:delete-bin-line'
|
||||
},
|
||||
{
|
||||
key: 'disabled',
|
||||
label: '禁用选项',
|
||||
icon: 'ri:close-circle-line',
|
||||
disabled: true
|
||||
}
|
||||
])
|
||||
|
||||
/**
|
||||
* 处理菜单项选择
|
||||
* @param item 选中的菜单项
|
||||
*/
|
||||
const handleSelect = (item: MenuItemType) => {
|
||||
lastAction.value = `${item.label} (${item.key})`
|
||||
ElMessage.success(`执行操作: ${item.label}`)
|
||||
console.log('选择了菜单项:', item)
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示右键菜单
|
||||
* @param e 鼠标事件
|
||||
*/
|
||||
const showMenu = (e: MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
nextTick(() => {
|
||||
menuRef.value?.show(e)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 菜单显示回调
|
||||
*/
|
||||
const onMenuShow = () => {
|
||||
console.log('菜单显示')
|
||||
}
|
||||
|
||||
/**
|
||||
* 菜单隐藏回调
|
||||
*/
|
||||
const onMenuHide = () => {
|
||||
console.log('菜单隐藏')
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,175 @@
|
||||
<template>
|
||||
<div class="page-content mb-5">
|
||||
<div class="mb-15 text-center">
|
||||
<h1 class="my-4 text-2xl font-semibold leading-tight"
|
||||
>基于 VueUse useTransition 的 Count-To 组件</h1
|
||||
>
|
||||
<p class="m-0 text-base leading-relaxed text-g-700"
|
||||
>高性能数字滚动动画组件,支持完整的动画控制和事件监听</p
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- 基础用法 -->
|
||||
<div class="mb-15">
|
||||
<h2 class="m-0 mb-6 text-xl font-medium">基础用法</h2>
|
||||
<div class="count">
|
||||
<ArtCountTo :target="1000" :duration="2000" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 带前缀后缀 -->
|
||||
<div class="mb-15">
|
||||
<h2 class="m-0 mb-6 text-xl font-medium">带前缀后缀</h2>
|
||||
<div class="count">
|
||||
<ArtCountTo :target="20000" :duration="2500" prefix="¥" suffix="元" :decimals="2" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 小数点和分隔符 -->
|
||||
<div class="mb-15">
|
||||
<h2 class="m-0 mb-6 text-xl font-medium">小数点和分隔符</h2>
|
||||
<div class="count">
|
||||
<ArtCountTo :target="2023.45" :duration="3000" :decimals="2" separator="," />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 动画效果对比 -->
|
||||
<div class="mb-15">
|
||||
<h2 class="m-0 mb-6 text-xl font-medium">动画效果对比</h2>
|
||||
<div class="grid grid-cols-[repeat(auto-fit,minmax(200px,1fr))] gap-6 mb-8">
|
||||
<div class="text-center" v-for="easing in easingTypes" :key="easing.type">
|
||||
<div class="mb-3 text-sm font-medium text-g-700">{{ easing.name }}</div>
|
||||
<div class="count">
|
||||
<ArtCountTo :target="easingTarget" :duration="3000" :easing="easing.type" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<ElButton @click="triggerEasing">触发所有动画</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 控制按钮 -->
|
||||
<div class="mb-15">
|
||||
<h2 class="m-0 mb-6 text-xl font-medium">控制按钮</h2>
|
||||
<div class="count">
|
||||
<ArtCountTo
|
||||
ref="countToRef"
|
||||
:target="controlTarget"
|
||||
:duration="2000"
|
||||
@started="handleAnimationStarted"
|
||||
@finished="handleAnimationFinished"
|
||||
@paused="handleAnimationPaused"
|
||||
@reset="handleAnimationReset"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 justify-center">
|
||||
<ElButton @click="startCount">开始</ElButton>
|
||||
<ElButton @click="pauseCount">暂停</ElButton>
|
||||
<ElButton @click="resetCount">重置</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ArtCountTo from '@/components/core/text-effect/art-count-to/index.vue'
|
||||
|
||||
defineOptions({ name: 'TemplateCountTo' })
|
||||
|
||||
const controlTarget = ref(0)
|
||||
const countToRef = ref()
|
||||
const easingTarget = ref(0)
|
||||
|
||||
/**
|
||||
* 缓动动画类型配置
|
||||
*/
|
||||
const easingTypes = [
|
||||
{ name: 'Linear', type: 'linear' },
|
||||
{ name: 'Ease Out Cubic', type: 'easeOutCubic' },
|
||||
{ name: 'Ease Out Expo', type: 'easeOutExpo' },
|
||||
{ name: 'Ease Out Sine', type: 'easeOutSine' },
|
||||
{ name: 'Ease In Out', type: 'easeInOutCubic' },
|
||||
{ name: 'Ease In Quad', type: 'easeInQuad' }
|
||||
] as const
|
||||
|
||||
/**
|
||||
* 开始计数动画
|
||||
*/
|
||||
const startCount = () => {
|
||||
const newTarget = 5000
|
||||
controlTarget.value = newTarget
|
||||
countToRef.value?.start(newTarget)
|
||||
}
|
||||
|
||||
/**
|
||||
* 暂停计数动画
|
||||
*/
|
||||
const pauseCount = () => {
|
||||
countToRef.value?.pause()
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置计数动画
|
||||
*/
|
||||
const resetCount = () => {
|
||||
countToRef.value?.reset()
|
||||
controlTarget.value = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发缓动效果演示
|
||||
* 在 0 和 1000 之间切换
|
||||
*/
|
||||
const triggerEasing = () => {
|
||||
easingTarget.value = easingTarget.value === 0 ? 1000 : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 动画开始回调
|
||||
* @param value 目标值
|
||||
*/
|
||||
const handleAnimationStarted = (value: number) => {
|
||||
console.log('动画开始,目标值:', value)
|
||||
}
|
||||
|
||||
/**
|
||||
* 动画完成回调
|
||||
* @param value 最终值
|
||||
*/
|
||||
const handleAnimationFinished = (value: number) => {
|
||||
console.log('动画完成,最终值:', value)
|
||||
}
|
||||
|
||||
/**
|
||||
* 动画暂停回调
|
||||
* @param value 当前值
|
||||
*/
|
||||
const handleAnimationPaused = (value: number) => {
|
||||
console.log('动画暂停,当前值:', value)
|
||||
}
|
||||
|
||||
/**
|
||||
* 动画重置回调
|
||||
*/
|
||||
const handleAnimationReset = () => {
|
||||
console.log('动画已重置')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '@styles/core/tailwind.css';
|
||||
|
||||
.count {
|
||||
@apply p-5
|
||||
mb-5
|
||||
text-2xl
|
||||
font-semibold
|
||||
text-center
|
||||
bg-g-100
|
||||
rounded-lg
|
||||
tabular-nums
|
||||
border border-g-300/60;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,108 @@
|
||||
<!-- https://vue-draggable-plus.pages.dev/ -->
|
||||
<template>
|
||||
<div class="page-content mb-5">
|
||||
<ElRow>
|
||||
<ElCard shadow="never" class="w-75 mr-5 mb-7.5">
|
||||
<template #header>
|
||||
<span class="text-base font-bold">基础示例</span>
|
||||
</template>
|
||||
<template #default>
|
||||
<VueDraggable ref="el" v-model="userList">
|
||||
<div
|
||||
class="p-2.5 mb-2.5 cursor-move bg-active-color rounded"
|
||||
v-for="item in userList"
|
||||
:key="item.name"
|
||||
>
|
||||
{{ item.name }}
|
||||
</div>
|
||||
</VueDraggable>
|
||||
</template>
|
||||
</ElCard>
|
||||
|
||||
<ElCard shadow="never" class="w-75 mb-7.5">
|
||||
<template #header>
|
||||
<span class="text-base font-bold">过渡动画</span>
|
||||
</template>
|
||||
<template #default>
|
||||
<VueDraggable v-model="userList" target=".sort-target" :scroll="true">
|
||||
<TransitionGroup type="transition" tag="ul" name="fade" class="sort-target">
|
||||
<li
|
||||
v-for="item in userList"
|
||||
:key="item.name"
|
||||
class="p-2.5 mb-2.5 cursor-move bg-active-color rounded"
|
||||
>
|
||||
{{ item.name }}
|
||||
</li>
|
||||
</TransitionGroup>
|
||||
</VueDraggable>
|
||||
</template>
|
||||
</ElCard>
|
||||
</ElRow>
|
||||
|
||||
<ElCard shadow="never" class="mb-7.5">
|
||||
<template #header>
|
||||
<span class="text-base font-bold">表格拖拽排序</span>
|
||||
</template>
|
||||
<template #default>
|
||||
<VueDraggable target="tbody" v-model="userList" :animation="150">
|
||||
<ArtTable :data="userList">
|
||||
<ElTableColumn label="姓名" prop="name" />
|
||||
<ElTableColumn label="角色" prop="role" />
|
||||
</ArtTable>
|
||||
</VueDraggable>
|
||||
</template>
|
||||
</ElCard>
|
||||
|
||||
<ElCard shadow="never" class="mb-7.5">
|
||||
<template #header>
|
||||
<span class="text-base font-bold">指定元素拖拽排序</span>
|
||||
</template>
|
||||
<template #default>
|
||||
<VueDraggable target="tbody" handle=".handle" v-model="userList" :animation="150">
|
||||
<ArtTable :data="userList">
|
||||
<ElTableColumn label="姓名" prop="name" />
|
||||
<ElTableColumn label="角色" prop="role" />
|
||||
<ElTableColumn label="操作" width="100">
|
||||
<ElButton size="default" class="handle"> 移动 </ElButton>
|
||||
</ElTableColumn>
|
||||
</ArtTable>
|
||||
</VueDraggable>
|
||||
</template>
|
||||
</ElCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { VueDraggable } from 'vue-draggable-plus'
|
||||
|
||||
defineOptions({ name: 'TemplateDrag' })
|
||||
|
||||
/**
|
||||
* 用户列表数据
|
||||
* 用于演示拖拽排序功能
|
||||
*/
|
||||
const userList = ref([
|
||||
{ name: '孙悟空', role: '斗战胜佛' },
|
||||
{ name: '猪八戒', role: '净坛使者' },
|
||||
{ name: '沙僧', role: '金身罗汉' },
|
||||
{ name: '唐僧', role: '旃檀功德佛' }
|
||||
])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-move,
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: all 0.5s cubic-bezier(0.55, 0, 0.1, 1);
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: scaleY(0.01) translate(30px, 0);
|
||||
}
|
||||
|
||||
.fade-leave-active {
|
||||
position: absolute;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,151 @@
|
||||
<template>
|
||||
<div class="page-content">
|
||||
<ArtExcelImport @import-success="handleImportSuccess" @import-error="handleImportError">
|
||||
<template #import-text> 上传 Excel </template>
|
||||
</ArtExcelImport>
|
||||
|
||||
<ArtExcelExport
|
||||
style="margin-left: 10px"
|
||||
:data="tableData"
|
||||
filename="用户数据-1"
|
||||
sheetName="用户列表"
|
||||
type="success"
|
||||
:headers="headers"
|
||||
auto-index
|
||||
:columns="columnConfig"
|
||||
@export-success="handleExportSuccess"
|
||||
@export-error="handleExportError"
|
||||
@export-progress="handleProgress"
|
||||
>
|
||||
导出 Excel
|
||||
</ArtExcelExport>
|
||||
|
||||
<ElButton type="danger" @click="handleClear" v-ripple>清除数据</ElButton>
|
||||
|
||||
<ArtTable :data="tableData" style="margin-top: 10px">
|
||||
<ElTableColumn
|
||||
v-for="key in Object.keys(headers)"
|
||||
:key="key"
|
||||
:prop="key"
|
||||
:label="headers[key as keyof typeof headers]"
|
||||
/>
|
||||
</ArtTable>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineOptions({ name: 'WidgetsExcel' })
|
||||
|
||||
/**
|
||||
* 表格数据类型定义
|
||||
*/
|
||||
interface TableData {
|
||||
name: string
|
||||
age: number
|
||||
city: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 表格数据
|
||||
*/
|
||||
const tableData = ref<TableData[]>([
|
||||
{ name: '李四', age: 20, city: '上海' },
|
||||
{ name: '张三', age: 25, city: '北京' },
|
||||
{ name: '王五', age: 30, city: '广州' },
|
||||
{ name: '赵六', age: 35, city: '深圳' },
|
||||
{ name: '孙七', age: 28, city: '杭州' },
|
||||
{ name: '周八', age: 32, city: '成都' },
|
||||
{ name: '吴九', age: 27, city: '武汉' },
|
||||
{ name: '郑十', age: 40, city: '南京' },
|
||||
{ name: '刘一', age: 22, city: '重庆' },
|
||||
{ name: '陈二', age: 33, city: '西安' }
|
||||
])
|
||||
|
||||
/**
|
||||
* 表头映射配置
|
||||
* 用于 Excel 导入导出时的字段映射
|
||||
*/
|
||||
const headers = {
|
||||
name: '姓名',
|
||||
age: '年龄',
|
||||
city: '城市'
|
||||
}
|
||||
|
||||
/**
|
||||
* 列配置
|
||||
* 用于 Excel 导出时的列宽和格式化
|
||||
*/
|
||||
const columnConfig = {
|
||||
name: {
|
||||
title: '姓名',
|
||||
width: 20,
|
||||
formatter: (value: unknown) => (value ? String(value) : '未知')
|
||||
},
|
||||
age: {
|
||||
title: '年龄',
|
||||
width: 10,
|
||||
formatter: (value: unknown) => (value ? `${value}岁` : '0岁')
|
||||
},
|
||||
city: {
|
||||
title: '城市',
|
||||
width: 12,
|
||||
formatter: (value: unknown) => (value ? `${value}市` : '未知')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 Excel 导入成功
|
||||
* 将导入的数据转换为表格数据格式
|
||||
* @param data 导入的原始数据
|
||||
*/
|
||||
const handleImportSuccess = (data: Array<Record<string, unknown>>) => {
|
||||
const formattedData: TableData[] = data.map((item) => ({
|
||||
name: String(item['姓名'] || ''),
|
||||
age: Number(item['年龄']) || 0,
|
||||
city: String(item['城市'] || '')
|
||||
}))
|
||||
tableData.value = formattedData
|
||||
ElMessage.success(`成功导入 ${formattedData.length} 条数据`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 Excel 导入错误
|
||||
* @param error 错误对象
|
||||
*/
|
||||
const handleImportError = (error: Error) => {
|
||||
console.error('导入失败:', error)
|
||||
ElMessage.error(`导入失败: ${error.message}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 Excel 导出成功
|
||||
*/
|
||||
const handleExportSuccess = () => {
|
||||
console.log('导出成功')
|
||||
ElMessage.success('Excel 导出成功')
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 Excel 导出错误
|
||||
* @param error 错误对象
|
||||
*/
|
||||
const handleExportError = (error: Error) => {
|
||||
ElMessage.error(`导出失败: ${error.message}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理导出进度
|
||||
* @param progress 导出进度百分比
|
||||
*/
|
||||
const handleProgress = (progress: number) => {
|
||||
console.log('导出进度:', progress)
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空表格数据
|
||||
*/
|
||||
const handleClear = () => {
|
||||
tableData.value = []
|
||||
ElMessage.info('已清空数据')
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,116 @@
|
||||
<template>
|
||||
<div class="page-content">
|
||||
<div class="mb-5">
|
||||
<ElSpace wrap>
|
||||
<ElButton :disabled="isLaunching" v-ripple @click="handleSingleLaunch"
|
||||
>✨ 放个小礼花</ElButton
|
||||
>
|
||||
<ElButton :disabled="isLaunching" v-ripple @click="handleImageLaunch(bp)"
|
||||
>🎉 打开幸运红包</ElButton
|
||||
>
|
||||
<ElButton :disabled="isLaunching" v-ripple @click="handleMultipleLaunch('')"
|
||||
>🎆 璀璨烟火秀</ElButton
|
||||
>
|
||||
<ElButton :disabled="isLaunching" v-ripple @click="handleImageLaunch(sd)"
|
||||
>❄️ 飘点小雪花</ElButton
|
||||
>
|
||||
<ElButton :disabled="isLaunching" v-ripple @click="handleMultipleLaunch(sd)"
|
||||
>❄️ 浪漫暴风雪</ElButton
|
||||
>
|
||||
</ElSpace>
|
||||
</div>
|
||||
|
||||
<ElDescriptions
|
||||
title="礼花组件说明"
|
||||
direction="vertical"
|
||||
:column="1"
|
||||
border
|
||||
style="margin-top: 50px"
|
||||
>
|
||||
<ElDescriptionsItem label="显示时机">
|
||||
礼花效果组件已全局注册,触发时机由配置文件控制。默认配置中的日期已过,不会在你使用过程中再次触发,无需担心。
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="礼花样式">
|
||||
默认显示几何图形,可以配置图片,图片需要提前在
|
||||
src/components/core/layouts/art-fireworks-effect/index.vue 文件预先定义
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="配置文件">
|
||||
在 src/config/festival.ts 文件中,可以配置节日和对应的礼花样式
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="快捷键">
|
||||
command + shift + p 或者 ctrl + shift + p
|
||||
</ElDescriptionsItem>
|
||||
</ElDescriptions>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { mittBus } from '@/utils/sys'
|
||||
import bp from '@imgs/ceremony/hb.png'
|
||||
import sd from '@imgs/ceremony/sd.png'
|
||||
|
||||
defineOptions({ name: 'WidgetsFireworks' })
|
||||
|
||||
const timerRef = ref<ReturnType<typeof setInterval> | null>(null)
|
||||
const isLaunching = ref(false)
|
||||
|
||||
/**
|
||||
* 触发连续礼花效果
|
||||
* @param count 发射次数
|
||||
* @param src 图片资源路径
|
||||
*/
|
||||
const triggerFireworks = (count: number, src: string) => {
|
||||
// 清除之前的定时器
|
||||
if (timerRef.value) {
|
||||
clearInterval(timerRef.value)
|
||||
timerRef.value = null
|
||||
}
|
||||
|
||||
isLaunching.value = true
|
||||
|
||||
let fired = 0
|
||||
timerRef.value = setInterval(() => {
|
||||
mittBus.emit('triggerFireworks', src)
|
||||
fired++
|
||||
|
||||
if (fired >= count) {
|
||||
clearInterval(timerRef.value!)
|
||||
timerRef.value = null
|
||||
isLaunching.value = false
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
/**
|
||||
* 发射单个礼花
|
||||
*/
|
||||
const handleSingleLaunch = () => {
|
||||
mittBus.emit('triggerFireworks')
|
||||
}
|
||||
|
||||
/**
|
||||
* 发射多个礼花
|
||||
* @param src 图片资源路径
|
||||
*/
|
||||
const handleMultipleLaunch = (src: string) => {
|
||||
triggerFireworks(10, src)
|
||||
}
|
||||
|
||||
/**
|
||||
* 发射带图片的礼花
|
||||
* @param src 图片资源路径
|
||||
*/
|
||||
const handleImageLaunch = (src: string) => {
|
||||
mittBus.emit('triggerFireworks', src)
|
||||
}
|
||||
|
||||
/**
|
||||
* 组件卸载时清理定时器
|
||||
*/
|
||||
onUnmounted(() => {
|
||||
if (timerRef.value) {
|
||||
clearInterval(timerRef.value)
|
||||
timerRef.value = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,110 @@
|
||||
<template>
|
||||
<div class="space-y-5 mb-5">
|
||||
<div class="text-2xl font-medium mt-5 max-sm:text-2xl max-sm:mt-3">图标</div>
|
||||
<div class="text-g-800">
|
||||
v.3.0 版本图标库升级为 iconify,可在
|
||||
<a href="https://icones.js.org/" target="_blank" class="text-theme hover:underline"
|
||||
>Iconify</a
|
||||
>
|
||||
中查找,支持多种图标库,如 Remix Icon, Solar, Tabler Icons 等。
|
||||
</div>
|
||||
<div class="text-g-800">
|
||||
为确保系统图标风格统一,项目全部采用 Remix Icon 图标库,可在
|
||||
<a
|
||||
href="https://icones.js.org/collection/ri"
|
||||
target="_blank"
|
||||
class="text-theme hover:underline"
|
||||
>Iconify</a
|
||||
>
|
||||
或<a href="https://remixicon.com/" target="_blank" class="text-theme hover:underline">
|
||||
Remix Icon 官网 </a
|
||||
>搜索使用。
|
||||
</div>
|
||||
|
||||
<!-- Iconify 图标 -->
|
||||
<div class="art-card-sm p-5">
|
||||
<div class="text-lg font-semibold mb-4">Iconify</div>
|
||||
<div class="flex items-center gap-6">
|
||||
<ArtSvgIcon icon="ri:github-fill" class="text-2xl" />
|
||||
<ArtSvgIcon icon="ri:copilot-line" class="text-2xl text-theme" />
|
||||
<ArtSvgIcon icon="ri:edge-line" class="text-2xl text-secondary" />
|
||||
<ArtSvgIcon icon="ri:planet-line" class="text-2xl text-warning" />
|
||||
<ArtSvgIcon icon="ri:windows-line" class="text-2xl text-info" />
|
||||
<ArtSvgIcon icon="ri:thumb-up-line" class="text-2xl text-danger" />
|
||||
<ArtSvgIcon icon="ri:gift-2-line" class="text-2xl text-success" />
|
||||
<ArtSvgIcon icon="ri:apple-line" class="text-2xl text-secondary" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Svg Icons -->
|
||||
<div class="art-card-sm p-5">
|
||||
<div class="text-lg font-semibold mb-4">Svg Icons</div>
|
||||
<div class="flex items-center gap-6">
|
||||
<ArtSvgIcon icon="svg-spinners:3-dots-fade" class="text-2xl text-red-400" />
|
||||
<ArtSvgIcon icon="svg-spinners:3-dots-bounce" class="text-2xl text-blue-400" />
|
||||
<ArtSvgIcon icon="svg-spinners:3-dots-move" class="text-2xl text-orange-400" />
|
||||
<ArtSvgIcon icon="svg-spinners:3-dots-rotate" class="text-2xl text-purple-400" />
|
||||
<ArtSvgIcon icon="svg-spinners:blocks-shuffle-2" class="text-2xl text-pink-300" />
|
||||
<ArtSvgIcon icon="svg-spinners:clock" class="text-2xl text-yellow-500" />
|
||||
<ArtSvgIcon icon="svg-spinners:tadpole" class="text-2xl text-orange-500" />
|
||||
<ArtSvgIcon icon="svg-spinners:blocks-wave" class="text-2xl text-blue-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Svg Icons -->
|
||||
<div class="art-card-sm p-5">
|
||||
<div class="text-lg font-semibold mb-4">Material Line Icons</div>
|
||||
<div class="flex items-center gap-6">
|
||||
<ArtSvgIcon icon="line-md:phone-call-twotone-loop" class="text-2xl text-blue-500" />
|
||||
|
||||
<ArtSvgIcon icon="line-md:switch-off" class="text-2xl text-green-500" />
|
||||
<ArtSvgIcon icon="line-md:sun-rising-filled-loop" class="text-2xl text-yellow-400" />
|
||||
<ArtSvgIcon icon="line-md:volume-high-filled" class="text-2xl text-purple-500" />
|
||||
<ArtSvgIcon icon="line-md:github-twotone" class="text-2xl text-gray-700" />
|
||||
<ArtSvgIcon icon="line-md:telegram" class="text-2xl text-sky-500" />
|
||||
<ArtSvgIcon icon="line-md:reddit-loop" class="text-2xl text-orange-400" />
|
||||
<ArtSvgIcon
|
||||
icon="line-md:coffee-half-empty-filled-loop"
|
||||
class="text-2xl text-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 使用示例 -->
|
||||
<div class="art-card-sm p-5">
|
||||
<div class="text-lg font-semibold mb-4">使用示例</div>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<div class="text-sm text-g-600 mb-2">基础使用</div>
|
||||
<div class="bg-g-200 dark:bg-g-300/30 p-4 rounded font-mono text-sm text-g-800">
|
||||
<ArtSvgIcon icon="ri:home-line" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm text-g-600 mb-2">自定义大小</div>
|
||||
<div class="bg-g-200 dark:bg-g-300/30 p-4 rounded font-mono text-sm text-g-800">
|
||||
<ArtSvgIcon icon="ri:user-line" class="text-2xl" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm text-g-600 mb-2">自定义颜色</div>
|
||||
<div class="bg-g-200 dark:bg-g-300/30 p-4 rounded font-mono text-sm text-g-800">
|
||||
<ArtSvgIcon icon="ri:heart-fill" class="text-red-500" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm text-g-600 mb-2">组合使用</div>
|
||||
<div class="bg-g-200 dark:bg-g-300/30 p-4 rounded font-mono text-sm text-g-800">
|
||||
<ArtSvgIcon icon="ri:star-fill" class="text-4xl text-yellow-500" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ArtSvgIcon from '@/components/core/base/art-svg-icon/index.vue'
|
||||
|
||||
defineOptions({ name: 'IconPage' })
|
||||
</script>
|
||||
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<div class="page-content">
|
||||
<ArtCutterImg
|
||||
v-model:imgUrl="imageUrl"
|
||||
:boxWidth="530"
|
||||
:boxHeight="300"
|
||||
:cutWidth="360"
|
||||
:cutHeight="200"
|
||||
:quality="1"
|
||||
:tool="true"
|
||||
:watermarkText="'My Watermark'"
|
||||
watermarkColor="#ff0000"
|
||||
:showPreview="true"
|
||||
:originalGraph="false"
|
||||
:title="'图片裁剪'"
|
||||
:previewTitle="'预览效果'"
|
||||
@error="handleError"
|
||||
@imageLoadComplete="handleLoadComplete"
|
||||
@imageLoadError="handleLoadError"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import lockImg from '@imgs/lock/bg_dark.webp'
|
||||
|
||||
defineOptions({ name: 'WidgetsImageCrop' })
|
||||
|
||||
/**
|
||||
* 图片 URL
|
||||
*/
|
||||
const imageUrl = ref(lockImg)
|
||||
|
||||
/**
|
||||
* 处理裁剪错误
|
||||
* @param error 错误对象
|
||||
*/
|
||||
const handleError = (error: Error) => {
|
||||
console.error('裁剪错误:', error)
|
||||
ElMessage.error('图片裁剪失败')
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理图片加载完成
|
||||
* @param result 加载结果
|
||||
*/
|
||||
const handleLoadComplete = (result: { url: string; width: number; height: number }) => {
|
||||
console.log('图片加载完成:', result)
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理图片加载错误
|
||||
* @param error 错误对象
|
||||
*/
|
||||
const handleLoadError = (error: Error) => {
|
||||
console.error('图片加载失败:', error)
|
||||
ElMessage.error('图片加载失败')
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,136 @@
|
||||
<template>
|
||||
<div class="test-page p-5">
|
||||
<h1 class="text-2xl font-bold mb-5">图片选择器组件测试</h1>
|
||||
|
||||
<!-- 单选测试 -->
|
||||
<div class="mb-8 p-5 bg-white rounded-lg shadow-sm">
|
||||
<h2 class="text-xl font-semibold mb-3 text-g-800">1. 单选模式</h2>
|
||||
<div class="flex items-center gap-5">
|
||||
<ImageSelector
|
||||
v-model="singleImage"
|
||||
:multiple="false"
|
||||
shape="circle"
|
||||
size="medium"
|
||||
@confirm="handleSingleConfirm"
|
||||
/>
|
||||
<div class="text-sm">
|
||||
<div class="font-medium">已选择:</div>
|
||||
<div class="text-g-600 mt-1 break-all">{{ singleImage || '未选择' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 多选测试 -->
|
||||
<div class="mb-8 p-5 bg-white rounded-lg shadow-sm">
|
||||
<h2 class="text-xl font-semibold mb-3 text-g-800">2. 多选模式</h2>
|
||||
<ImageSelector
|
||||
v-model="multipleImages"
|
||||
:multiple="true"
|
||||
shape="square"
|
||||
size="small"
|
||||
@confirm="handleMultipleConfirm"
|
||||
/>
|
||||
<div class="mt-3 text-sm">
|
||||
<div class="font-medium">已选择 ({{ multipleImages.length }} 张):</div>
|
||||
<div class="text-g-600 mt-1 break-all">{{ multipleImages.length > 0 ? multipleImages.join(', ') : '未选择' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 大尺寸单选 -->
|
||||
<div class="mb-8 p-5 bg-white rounded-lg shadow-sm">
|
||||
<h2 class="text-xl font-semibold mb-3 text-g-800">3. 大尺寸单选(带默认值)</h2>
|
||||
<div class="flex items-center gap-5">
|
||||
<ImageSelector
|
||||
v-model="largeImage"
|
||||
:multiple="false"
|
||||
shape="square"
|
||||
size="large"
|
||||
:default-selected="'/upload/default-avatar.webp'"
|
||||
@confirm="handleLargeConfirm"
|
||||
/>
|
||||
<div class="text-sm">
|
||||
<div class="font-medium">已选择:</div>
|
||||
<div class="text-g-600 mt-1 break-all">{{ largeImage || '未选择' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 禁用状态 -->
|
||||
<div class="mb-8 p-5 bg-white rounded-lg shadow-sm">
|
||||
<h2 class="text-xl font-semibold mb-3 text-g-800">4. 禁用状态</h2>
|
||||
<ImageSelector
|
||||
v-model="disabledImage"
|
||||
:multiple="false"
|
||||
shape="circle"
|
||||
size="medium"
|
||||
:disabled="true"
|
||||
/>
|
||||
<div class="mt-2 text-sm text-g-600">
|
||||
禁用状态下的组件不可点击
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 代码示例 -->
|
||||
<div class="mb-8 p-5 bg-white rounded-lg shadow-sm">
|
||||
<h2 class="text-xl font-semibold mb-3 text-g-800">5. 代码示例</h2>
|
||||
<ElTabs>
|
||||
<ElTabPane label="单选">
|
||||
<pre class="bg-g-100 p-3 rounded text-sm overflow-auto">{{ singleCode }}</pre>
|
||||
</ElTabPane>
|
||||
<ElTabPane label="多选">
|
||||
<pre class="bg-g-100 p-3 rounded text-sm overflow-auto">{{ multipleCode }}</pre>
|
||||
</ElTabPane>
|
||||
</ElTabs>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import ImageSelector from '@/components/form/components/image-selector/index.vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { $t } from '@/locales'
|
||||
|
||||
// 测试数据
|
||||
const singleImage = ref<string>('')
|
||||
const multipleImages = ref<string[]>([])
|
||||
const largeImage = ref<string>('/upload/default-avatar.webp')
|
||||
const disabledImage = ref<string>('/upload/default-avatar.webp')
|
||||
|
||||
// 事件处理
|
||||
const handleSingleConfirm = (image: string) => {
|
||||
ElMessage.success(`单选确认: ${image}`)
|
||||
}
|
||||
|
||||
const handleMultipleConfirm = (images: string[]) => {
|
||||
ElMessage.success(`多选确认: 选择了 ${images.length} 张图片`)
|
||||
}
|
||||
|
||||
const handleLargeConfirm = (image: string) => {
|
||||
ElMessage.success(`大尺寸确认: ${image}`)
|
||||
}
|
||||
|
||||
// 代码示例
|
||||
const singleCode = `<ImageSelector
|
||||
v-model="singleImage"
|
||||
:multiple="false"
|
||||
shape="circle"
|
||||
size="medium"
|
||||
@confirm="handleConfirm"
|
||||
/>`
|
||||
|
||||
const multipleCode = `<ImageSelector
|
||||
v-model="multipleImages"
|
||||
:multiple="true"
|
||||
shape="square"
|
||||
size="small"
|
||||
@confirm="handleConfirm"
|
||||
/>`
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.test-page {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,130 @@
|
||||
<template>
|
||||
<div class="page-content">
|
||||
<ElRow :gutter="20">
|
||||
<ElCol
|
||||
:span="6"
|
||||
:md="12"
|
||||
:sm="12"
|
||||
:xs="24"
|
||||
v-for="preset in qrcodePresets"
|
||||
:key="preset.title"
|
||||
>
|
||||
<ElCard class="mb-5" shadow="never">
|
||||
<template #header>
|
||||
<div>
|
||||
<span class="text-base font-bold">{{ preset.title }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex-cc p-5 rounded">
|
||||
<QrcodeVue :value="qrValue" v-bind="preset.config" />
|
||||
</div>
|
||||
</ElCard>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import QrcodeVue from 'qrcode.vue'
|
||||
import type { Level, RenderAs, ImageSettings } from 'qrcode.vue'
|
||||
|
||||
defineOptions({ name: 'WidgetsQrcode' })
|
||||
|
||||
/**
|
||||
* 二维码内容
|
||||
*/
|
||||
const qrValue = ref('https://www.artd.pro')
|
||||
const isShowLogo = ref(false)
|
||||
|
||||
/**
|
||||
* 预设二维码样式配置
|
||||
*/
|
||||
const qrcodePresets = [
|
||||
{
|
||||
title: '渲染成 svg 标签',
|
||||
config: {
|
||||
size: 160,
|
||||
level: 'H' as Level,
|
||||
renderAs: 'svg' as RenderAs,
|
||||
margin: 0,
|
||||
background: '#ffffff',
|
||||
foreground: '#000000'
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '渲染成 canvas 标签',
|
||||
config: {
|
||||
size: 160,
|
||||
level: 'H' as Level,
|
||||
renderAs: 'canvas' as RenderAs,
|
||||
margin: 0,
|
||||
background: '#ffffff',
|
||||
foreground: '#000000'
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '自定义颜色',
|
||||
config: {
|
||||
size: 160,
|
||||
level: 'H' as Level,
|
||||
renderAs: 'canvas' as RenderAs,
|
||||
margin: 0,
|
||||
background: '#f0f0f0',
|
||||
foreground: '#4080ff'
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '带有Logo',
|
||||
config: {
|
||||
size: 160,
|
||||
level: 'H' as Level,
|
||||
renderAs: 'canvas' as RenderAs,
|
||||
margin: 0,
|
||||
background: '#ffffff',
|
||||
foreground: '#000000',
|
||||
imageSettings: {
|
||||
src: 'https://www.artd.pro/assets/draw1-Ce1WF34i.png',
|
||||
width: 40,
|
||||
height: 40,
|
||||
excavate: true
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
/**
|
||||
* 二维码配置
|
||||
*/
|
||||
const qrcodeConfig = reactive({
|
||||
size: 160,
|
||||
level: 'H' as Level,
|
||||
renderAs: 'canvas' as RenderAs,
|
||||
margin: 0,
|
||||
background: '#ffffff',
|
||||
foreground: '#000000',
|
||||
imageSettings: {
|
||||
src: 'https://www.artd.pro/assets/draw1-Ce1WF34i.png',
|
||||
width: 40,
|
||||
height: 40,
|
||||
excavate: true
|
||||
} as ImageSettings
|
||||
})
|
||||
|
||||
/**
|
||||
* 监听是否显示 logo
|
||||
* 根据状态动态设置二维码中心的 logo 图片
|
||||
*/
|
||||
watch(isShowLogo, (val) => {
|
||||
if (!val) {
|
||||
qrcodeConfig.imageSettings = {} as ImageSettings
|
||||
} else {
|
||||
qrcodeConfig.imageSettings = {
|
||||
src: 'https://www.artd.pro/assets/draw1-Ce1WF34i.png',
|
||||
width: 40,
|
||||
height: 40,
|
||||
excavate: true
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<div class="page-content space-y-5">
|
||||
<!-- 基础用法 -->
|
||||
<ArtTextScroll
|
||||
text="Art Design Pro 是一款兼具设计美学与高效开发的后台系统 <a target='_blank' href='https://www.artd.pro/docs/'>点击我 </a>访问官方文档"
|
||||
showClose
|
||||
/>
|
||||
|
||||
<!-- 使用不同的类型 -->
|
||||
<ArtTextScroll type="success" text="这是一条成功类型的滚动公告" />
|
||||
|
||||
<ArtTextScroll type="warning" text="这是一条警告类型的滚动公告" />
|
||||
|
||||
<ArtTextScroll type="danger" text="这是一条危险类型的滚动公告" />
|
||||
|
||||
<ArtTextScroll type="info" text="这是一条信息类型的滚动公告" />
|
||||
|
||||
<!-- 可关闭 -->
|
||||
<ArtTextScroll text="这是一条可关闭的滚动公告" @close="handleClose" />
|
||||
|
||||
<!-- 自定义速度和方向 -->
|
||||
<ArtTextScroll
|
||||
type="warning"
|
||||
text="这是一条速度较慢、向右滚动的公告"
|
||||
:speed="30"
|
||||
direction="right"
|
||||
/>
|
||||
|
||||
<ArtTextScroll
|
||||
text="这是一条文字溢出才会滚动的公告,当文本内容超出容器宽度时才会开始滚动显示,否则保持静止状态"
|
||||
@close="handleClose"
|
||||
:alwaysScroll="false"
|
||||
/>
|
||||
|
||||
<ArtTextScroll type="danger" direction="up" :speed="30" text="这是一条向上滚动的公告" />
|
||||
|
||||
<ArtTextScroll type="info" direction="down" :speed="30" text="这是一条向下滚动的公告" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineOptions({ name: 'WidgetsTextScroll' })
|
||||
|
||||
/**
|
||||
* 处理关闭事件
|
||||
*/
|
||||
const handleClose = () => {
|
||||
console.log('文本滚动组件已关闭')
|
||||
ElMessage.info('已关闭')
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<div class="page-content">
|
||||
<div class="max-w-150">
|
||||
<ArtVideoPlayer
|
||||
playerId="my-video-1"
|
||||
:videoUrl="videoUrl"
|
||||
:posterUrl="posterUrl"
|
||||
:autoplay="false"
|
||||
:volume="1"
|
||||
:playbackRates="[0.5, 1, 1.5, 2]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import lockImg from '@imgs/lock/bg_dark.webp'
|
||||
|
||||
defineOptions({ name: 'WidgetsVideo' })
|
||||
|
||||
/**
|
||||
* 视频源 URL
|
||||
*/
|
||||
const videoUrl = ref(
|
||||
'//lf3-static.bytednsdoc.com/obj/eden-cn/nupenuvpxnuvo/xgplayer_doc/xgplayer-demo.mp4'
|
||||
)
|
||||
|
||||
/**
|
||||
* 视频封面图片 URL
|
||||
*/
|
||||
const posterUrl = ref(lockImg)
|
||||
</script>
|
||||
@@ -0,0 +1,537 @@
|
||||
<template>
|
||||
<div class="page-content mb-5">
|
||||
<!-- 完整工具栏编辑器 -->
|
||||
<ElCard class="editor-card" shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>🛠️ 完整工具栏编辑器</span>
|
||||
<div class="header-buttons">
|
||||
<ElButton size="small" @click="clearFullEditor">清空</ElButton>
|
||||
<ElButton size="small" @click="getFullEditorContent">获取内容</ElButton>
|
||||
<ElButton size="small" @click="setFullEditorDemo">设置示例</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<ArtWangEditor
|
||||
ref="fullEditorRef"
|
||||
v-model="fullEditorHtml"
|
||||
height="400px"
|
||||
placeholder="请输入内容,体验完整的编辑功能..."
|
||||
:exclude-keys="[]"
|
||||
/>
|
||||
</ElCard>
|
||||
|
||||
<!-- 简化工具栏编辑器 -->
|
||||
<ElCard class="editor-card" shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>✨ 简化工具栏编辑器</span>
|
||||
<div class="header-buttons">
|
||||
<ElButton size="small" @click="clearSimpleEditor">清空</ElButton>
|
||||
<ElButton size="small" @click="getSimpleEditorContent">获取内容</ElButton>
|
||||
<ElButton size="small" @click="setSimpleEditorDemo">设置示例</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<ArtWangEditor
|
||||
ref="simpleEditorRef"
|
||||
v-model="simpleEditorHtml"
|
||||
height="400px"
|
||||
placeholder="请输入内容,体验简化的编辑功能..."
|
||||
:toolbar-keys="simpleToolbarKeys"
|
||||
/>
|
||||
</ElCard>
|
||||
|
||||
<!-- 内容对比预览 -->
|
||||
<ElCard class="preview-card" shadow="never">
|
||||
<template #header>
|
||||
<span>📖 内容预览对比</span>
|
||||
</template>
|
||||
|
||||
<ElRow :gutter="20">
|
||||
<ElCol :span="12">
|
||||
<h3>完整编辑器内容</h3>
|
||||
<ElTabs v-model="fullActiveTab">
|
||||
<ElTabPane label="渲染效果" name="preview">
|
||||
<div class="content-preview" v-html="fullEditorHtml"></div>
|
||||
</ElTabPane>
|
||||
<ElTabPane label="HTML源码" name="html">
|
||||
<ElInput
|
||||
v-model="fullEditorHtml"
|
||||
type="textarea"
|
||||
:rows="8"
|
||||
placeholder="HTML源码"
|
||||
readonly
|
||||
/>
|
||||
</ElTabPane>
|
||||
</ElTabs>
|
||||
</ElCol>
|
||||
|
||||
<ElCol :span="12">
|
||||
<h3>简化编辑器内容</h3>
|
||||
<ElTabs v-model="simpleActiveTab">
|
||||
<ElTabPane label="渲染效果" name="preview">
|
||||
<div class="content-preview" v-html="simpleEditorHtml"></div>
|
||||
</ElTabPane>
|
||||
<ElTabPane label="HTML源码" name="html">
|
||||
<ElInput
|
||||
v-model="simpleEditorHtml"
|
||||
type="textarea"
|
||||
:rows="8"
|
||||
placeholder="HTML源码"
|
||||
readonly
|
||||
/>
|
||||
</ElTabPane>
|
||||
</ElTabs>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</ElCard>
|
||||
|
||||
<!-- 使用说明 -->
|
||||
<ElCard class="usage-card" shadow="never">
|
||||
<template #header>
|
||||
<span>📚 使用说明</span>
|
||||
</template>
|
||||
|
||||
<ElCollapse v-model="activeCollapse">
|
||||
<ElCollapseItem title="基础用法" name="basic">
|
||||
<pre><code class="language-vue"><template>
|
||||
<ArtWangEditor v-model="content" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
const content = ref('<p>初始内容</p>')
|
||||
</script></code></pre>
|
||||
</ElCollapseItem>
|
||||
|
||||
<ElCollapseItem title="完整工具栏配置" name="full">
|
||||
<pre><code class="language-vue"><template>
|
||||
<!-- 显示所有工具,不排除任何功能 -->
|
||||
<ArtWangEditor
|
||||
v-model="content"
|
||||
:exclude-keys="[]"
|
||||
/>
|
||||
</template></code></pre>
|
||||
</ElCollapseItem>
|
||||
|
||||
<ElCollapseItem title="简化工具栏配置" name="simple">
|
||||
<pre><code class="language-vue"><template>
|
||||
<!-- 只显示基础编辑工具 -->
|
||||
<ArtWangEditor
|
||||
v-model="content"
|
||||
:toolbar-keys="[
|
||||
'bold', 'italic', 'underline', '|',
|
||||
'bulletedList', 'numberedList', '|',
|
||||
'insertLink', 'insertImage', '|',
|
||||
'undo', 'redo'
|
||||
]"
|
||||
/>
|
||||
</template></code></pre>
|
||||
</ElCollapseItem>
|
||||
|
||||
<ElCollapseItem title="自定义配置" name="config">
|
||||
<pre><code class="language-vue"><template>
|
||||
<ArtWangEditor
|
||||
v-model="content"
|
||||
height="600px"
|
||||
placeholder="请输入您的内容..."
|
||||
:exclude-keys="['fontFamily', 'fontSize']"
|
||||
:upload-config="{
|
||||
maxFileSize: 5 * 1024 * 1024,
|
||||
maxNumberOfFiles: 5
|
||||
}"
|
||||
/>
|
||||
</template></code></pre>
|
||||
</ElCollapseItem>
|
||||
|
||||
<ElCollapseItem title="组件方法调用" name="methods">
|
||||
<pre><code class="language-vue"><template>
|
||||
<ArtWangEditor ref="editorRef" v-model="content" />
|
||||
<el-button @click="handleClear">清空</el-button>
|
||||
<el-button @click="handleFocus">聚焦</el-button>
|
||||
<el-button @click="handleGetContent">获取内容</el-button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
const editorRef = ref()
|
||||
const content = ref('')
|
||||
|
||||
const handleClear = () => {
|
||||
editorRef.value?.clear()
|
||||
}
|
||||
|
||||
const handleFocus = () => {
|
||||
editorRef.value?.focus()
|
||||
}
|
||||
|
||||
const handleGetContent = () => {
|
||||
const html = editorRef.value?.getHtml()
|
||||
console.log('编辑器内容:', html)
|
||||
}
|
||||
</script></code></pre>
|
||||
</ElCollapseItem>
|
||||
|
||||
<ElCollapseItem title="工具栏配置说明" name="toolbar-config">
|
||||
<div class="toolbar-explanation">
|
||||
<h4>完整工具栏 vs 简化工具栏</h4>
|
||||
<ElRow :gutter="16">
|
||||
<ElCol :span="12">
|
||||
<h5>✅ 完整工具栏包含:</h5>
|
||||
<ul>
|
||||
<li>文本格式:加粗、斜体、下划线、字体颜色、背景色</li>
|
||||
<li>段落格式:标题、引用、对齐方式、缩进</li>
|
||||
<li>列表:有序列表、无序列表、待办事项</li>
|
||||
<li>插入:链接、图片、表格、分割线、表情</li>
|
||||
<li>代码:代码块、行内代码</li>
|
||||
<li>操作:撤销、重做、全屏、清除格式</li>
|
||||
</ul>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<h5>⚡ 简化工具栏包含:</h5>
|
||||
<ul>
|
||||
<li>基础格式:加粗、斜体、下划线</li>
|
||||
<li>列表:有序列表、无序列表</li>
|
||||
<li>插入:链接、图片</li>
|
||||
<li>操作:撤销、重做</li>
|
||||
</ul>
|
||||
<p class="note">适用于简单的文本编辑场景,界面更清爽。</p>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</div>
|
||||
</ElCollapseItem>
|
||||
</ElCollapse>
|
||||
</ElCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineOptions({ name: 'WidgetsWangEditor' })
|
||||
|
||||
const fullEditorRef = ref()
|
||||
const simpleEditorRef = ref()
|
||||
const fullActiveTab = ref('preview')
|
||||
const simpleActiveTab = ref('preview')
|
||||
const activeCollapse = ref(['basic'])
|
||||
|
||||
/**
|
||||
* 简化工具栏配置
|
||||
* 只包含基础的编辑功能
|
||||
*/
|
||||
const simpleToolbarKeys = [
|
||||
'bold',
|
||||
'italic',
|
||||
'underline',
|
||||
'|',
|
||||
'bulletedList',
|
||||
'numberedList',
|
||||
'|',
|
||||
'insertLink',
|
||||
'insertImage',
|
||||
'|',
|
||||
'undo',
|
||||
'redo'
|
||||
]
|
||||
|
||||
// 完整编辑器内容
|
||||
const fullEditorHtml = ref(`<h1>🎨 完整工具栏编辑器示例</h1>
|
||||
<p>这个编辑器包含所有功能,您可以体验丰富的格式编辑功能。</p>
|
||||
|
||||
<h2>✨ 文本样式</h2>
|
||||
<p><strong>这是加粗的文字</strong></p>
|
||||
<p><em>这是斜体文字</em></p>
|
||||
<p><u>这是下划线文字</u></p>
|
||||
<p><span style="color: rgb(194, 79, 74);">这是彩色文字</span></p>
|
||||
|
||||
<h2>📝 列表和待办</h2>
|
||||
<ul>
|
||||
<li>无序列表项 1</li>
|
||||
<li>无序列表项 2</li>
|
||||
</ul>
|
||||
|
||||
<ol>
|
||||
<li>有序列表项 1</li>
|
||||
<li>有序列表项 2</li>
|
||||
</ol>
|
||||
|
||||
<ul class="w-e-todo">
|
||||
<li class="w-e-todo-item"><input type="checkbox" checked="true" readonly="true" disabled="disabled"><span>已完成的任务</span></li>
|
||||
<li class="w-e-todo-item"><input type="checkbox" readonly="true" disabled="disabled"><span>待完成的任务</span></li>
|
||||
</ul>
|
||||
|
||||
<h2>💬 引用和表格</h2>
|
||||
<blockquote>
|
||||
这是一段引用文字,展示引用格式的效果。
|
||||
</blockquote>
|
||||
|
||||
<table style="border-collapse: collapse; width: 100%;" border="1">
|
||||
<thead>
|
||||
<tr><th>功能</th><th>描述</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>完整工具栏</td><td>包含所有编辑功能</td></tr>
|
||||
<tr><td>自定义配置</td><td>支持灵活的工具栏配置</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2>💻 代码块</h2>
|
||||
<pre><code class="language-javascript">// 完整编辑器支持代码高亮
|
||||
function createEditor() {
|
||||
return new WangEditor({
|
||||
container: '#editor',
|
||||
toolbar: 'full' // 完整工具栏
|
||||
});
|
||||
}</code></pre>
|
||||
|
||||
<p>🔗 <a href="https://www.wangeditor.com/" target="_blank">访问官网了解更多</a></p>`)
|
||||
|
||||
// 简化编辑器内容
|
||||
const simpleEditorHtml = ref(`<h1>✨ 简化工具栏编辑器示例</h1>
|
||||
<p>这个编辑器只包含基础的编辑功能,界面更加简洁。</p>
|
||||
|
||||
<h2>基础文本格式</h2>
|
||||
<p><strong>加粗文字</strong></p>
|
||||
<p><em>斜体文字</em></p>
|
||||
<p><u>下划线文字</u></p>
|
||||
|
||||
<h2>列表功能</h2>
|
||||
<ul>
|
||||
<li>无序列表项 1</li>
|
||||
<li>无序列表项 2</li>
|
||||
</ul>
|
||||
|
||||
<ol>
|
||||
<li>有序列表项 1</li>
|
||||
<li>有序列表项 2</li>
|
||||
</ol>
|
||||
|
||||
<h2>链接和图片</h2>
|
||||
<p>支持插入 <a href="https://www.wangeditor.com/" target="_blank">链接</a> 和图片。</p>
|
||||
|
||||
<p>简化版编辑器专注于基础功能,适合简单的内容编辑需求。</p>`)
|
||||
|
||||
/**
|
||||
* 清空完整编辑器内容
|
||||
*/
|
||||
const clearFullEditor = () => {
|
||||
fullEditorRef.value?.clear()
|
||||
ElMessage.success('完整编辑器已清空')
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取完整编辑器内容
|
||||
*/
|
||||
const getFullEditorContent = () => {
|
||||
const content = fullEditorRef.value?.getHtml()
|
||||
console.log('完整编辑器内容:', content)
|
||||
ElMessage.success('完整编辑器内容已输出到控制台')
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置完整编辑器演示内容
|
||||
*/
|
||||
const setFullEditorDemo = () => {
|
||||
const demoContent = `<h2>🎉 完整编辑器演示内容</h2>
|
||||
<p>这是通过方法设置的演示内容,展示完整编辑器的强大功能。</p>
|
||||
<ul>
|
||||
<li>支持丰富的文本格式</li>
|
||||
<li>包含表格、代码块等高级功能</li>
|
||||
<li>提供完整的编辑体验</li>
|
||||
</ul>
|
||||
<table style="border-collapse: collapse; width: 100%;" border="1">
|
||||
<tr><th>特性</th><th>状态</th></tr>
|
||||
<tr><td>完整工具栏</td><td>✅ 已启用</td></tr>
|
||||
<tr><td>高级功能</td><td>✅ 已启用</td></tr>
|
||||
</table>`
|
||||
|
||||
fullEditorRef.value?.setHtml(demoContent)
|
||||
ElMessage.success('已设置完整编辑器演示内容')
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空简化编辑器内容
|
||||
*/
|
||||
const clearSimpleEditor = () => {
|
||||
simpleEditorRef.value?.clear()
|
||||
ElMessage.success('简化编辑器已清空')
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取简化编辑器内容
|
||||
*/
|
||||
const getSimpleEditorContent = () => {
|
||||
const content = simpleEditorRef.value?.getHtml()
|
||||
console.log('简化编辑器内容:', content)
|
||||
ElMessage.success('简化编辑器内容已输出到控制台')
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置简化编辑器演示内容
|
||||
*/
|
||||
const setSimpleEditorDemo = () => {
|
||||
const demoContent = `<h2>⚡ 简化编辑器演示内容</h2>
|
||||
<p>这是通过方法设置的演示内容,展示简化编辑器的核心功能。</p>
|
||||
<ul>
|
||||
<li><strong>基础格式</strong>:加粗、斜体、下划线</li>
|
||||
<li><em>列表支持</em>:有序和无序列表</li>
|
||||
<li><u>媒体插入</u>:链接和图片</li>
|
||||
</ul>
|
||||
<ol>
|
||||
<li>界面简洁清爽</li>
|
||||
<li>功能专注实用</li>
|
||||
<li>适合快速编辑</li>
|
||||
</ol>
|
||||
<p>🔗 <a href="https://example.com" target="_blank">这是一个链接示例</a></p>`
|
||||
|
||||
simpleEditorRef.value?.setHtml(demoContent)
|
||||
ElMessage.success('已设置简化编辑器演示内容')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-content {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.editor-card {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.header-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.preview-card {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.preview-card h3 {
|
||||
margin: 0 0 16px;
|
||||
font-size: 16px;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.content-preview {
|
||||
min-height: 200px;
|
||||
max-height: 300px;
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
background-color: var(--el-bg-color);
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.content-preview :deep(h1),
|
||||
.content-preview :deep(h2),
|
||||
.content-preview :deep(h3) {
|
||||
margin: 16px 0 8px;
|
||||
}
|
||||
|
||||
.content-preview :deep(p) {
|
||||
margin: 8px 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.content-preview :deep(table) {
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.content-preview :deep(table th),
|
||||
.content-preview :deep(table td) {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.content-preview :deep(pre) {
|
||||
padding: 12px;
|
||||
margin: 16px 0;
|
||||
overflow-x: auto;
|
||||
background-color: var(--el-fill-color-light);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.content-preview :deep(blockquote) {
|
||||
padding-left: 16px;
|
||||
margin: 16px 0;
|
||||
color: var(--el-text-color-regular);
|
||||
border-left: 4px solid var(--el-color-primary);
|
||||
}
|
||||
|
||||
.usage-card :deep(.el-collapse-item__content) {
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.usage-card pre {
|
||||
padding: 16px;
|
||||
margin: 0;
|
||||
overflow-x: auto;
|
||||
background-color: var(--el-fill-color-light);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.usage-card pre code {
|
||||
font-family: Consolas, Monaco, 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.toolbar-explanation h4 {
|
||||
margin: 0 0 16px;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.toolbar-explanation h5 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.toolbar-explanation ul {
|
||||
padding-left: 20px;
|
||||
margin: 8px 0 16px;
|
||||
}
|
||||
|
||||
.toolbar-explanation ul li {
|
||||
margin: 4px 0;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.toolbar-explanation .note {
|
||||
margin: 8px 0 0;
|
||||
font-size: 12px;
|
||||
font-style: italic;
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
.page-content {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
align-items: stretch !important;
|
||||
}
|
||||
|
||||
.header-buttons {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.preview-card :deep(.el-col) {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<div class="page-content mb-5">
|
||||
<!-- 基础文字水印 -->
|
||||
<ElCard class="mb-7.5" shadow="never">
|
||||
<template #header>基础文字水印</template>
|
||||
<ElWatermark content="Art Design Pro" :font="{ color: 'rgba(128, 128, 128, 0.2)' }">
|
||||
<div style="height: 200px"></div>
|
||||
</ElWatermark>
|
||||
</ElCard>
|
||||
|
||||
<!-- 多行文字水印 -->
|
||||
<ElCard class="mb-7.5" shadow="never">
|
||||
<template #header>多行文字水印</template>
|
||||
<ElWatermark
|
||||
:content="['Art Design Pro', '专注用户体验,视觉设计']"
|
||||
:font="{ fontSize: 16, color: 'rgba(128, 128, 128, 0.2)' }"
|
||||
>
|
||||
<div style="height: 200px"></div>
|
||||
</ElWatermark>
|
||||
</ElCard>
|
||||
|
||||
<!-- 图片水印 -->
|
||||
<ElCard class="mb-7.5" shadow="never">
|
||||
<template #header>图片水印</template>
|
||||
<ElWatermark :image="watermarkImage" :opacity="0.2" :width="80" :height="20">
|
||||
<div style="height: 200px"></div>
|
||||
</ElWatermark>
|
||||
</ElCard>
|
||||
|
||||
<!-- 自定义样式水印 -->
|
||||
<ElCard class="mb-7.5" shadow="never">
|
||||
<template #header>自定义样式水印</template>
|
||||
<ElWatermark
|
||||
content="Art Design Pro"
|
||||
:font="{
|
||||
fontSize: 20,
|
||||
fontFamily: 'Arial',
|
||||
color: 'rgba(255, 0, 0, 0.3)'
|
||||
}"
|
||||
:rotate="-22"
|
||||
:gap="[100, 100]"
|
||||
>
|
||||
<div style="height: 200px"></div>
|
||||
</ElWatermark>
|
||||
</ElCard>
|
||||
|
||||
<ElButton
|
||||
:type="settingStore.watermarkVisible ? 'danger' : 'primary'"
|
||||
@click="handleWatermarkVisible"
|
||||
>
|
||||
{{ settingStore.watermarkVisible ? '隐藏全局水印' : '显示全局水印' }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useSettingStore } from '@/store/modules/setting'
|
||||
|
||||
defineOptions({ name: 'Watermark' })
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
/**
|
||||
* 水印图片 URL
|
||||
*/
|
||||
const watermarkImage = ref('https://element-plus.org/images/element-plus-logo.svg')
|
||||
|
||||
/**
|
||||
* 切换全局水印显示状态
|
||||
*/
|
||||
const handleWatermarkVisible = () => {
|
||||
useSettingStore().setWatermarkVisible(!settingStore.watermarkVisible)
|
||||
ElMessage.success(settingStore.watermarkVisible ? '已显示全局水印' : '已隐藏全局水印')
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user