This commit is contained in:
郝先瑞
2024-05-13 10:17:34 +08:00
14 changed files with 399 additions and 171 deletions

View File

@@ -1,6 +1,3 @@
## 开发环境
NODE_ENV='development'
# 应用端口
VITE_APP_PORT = 3000

View File

@@ -1,6 +1,3 @@
## 生产环境
NODE_ENV='production'
# 代理前缀
VITE_APP_BASE_API = '/prod-api'

View File

@@ -1,7 +1,7 @@
<div align="center">
<img src="https://img.shields.io/badge/Vue-3.4.21-brightgreen.svg"/>
<img src="https://img.shields.io/badge/Vite-5.1.5-green.svg"/>
<img src="https://img.shields.io/badge/Element Plus-2.6.0-blue.svg"/>
<img src="https://img.shields.io/badge/Vue-3.4.26-brightgreen.svg"/>
<img src="https://img.shields.io/badge/Vite-5.2.11-green.svg"/>
<img src="https://img.shields.io/badge/Element Plus-2.7.2-blue.svg"/>
<img src="https://img.shields.io/badge/license-MIT-green.svg"/>
<a href="https://gitee.com/youlaiorg" target="_blank">
<img src="https://img.shields.io/badge/Author-Youlai Open Source Organization-orange.svg"/>
@@ -37,7 +37,7 @@
- **Essential Infrastructure**: Dynamic routing, button permissions, internationalization, code style, Git commit conventions, and common component encapsulation.
- **Continuous Updates**: Continuously updated for 3 years since 2021, keeping up with the latest technologies and tools.
- **Continuous Updates**: Since 2021, the project has maintained an open-source status with continuous updates, integrating new tools and dependencies in real time, and has accumulated a broad user base.
## Project Preview
@@ -85,7 +85,7 @@ pnpm run dev
```bash
# Build the project
pnpm run build:prod
pnpm run build
# Upload files to the remote server
Copy the files generated in the `dist` directory to the `/usr/share/nginx/html` directory.

View File

@@ -1,7 +1,7 @@
<div align="center">
<img src="https://img.shields.io/badge/Vue-3.4.26-brightgreen.svg"/>
<img src="https://img.shields.io/badge/Vite-5.2.11-green.svg"/>
<img src="https://img.shields.io/badge/Element Plus-2.7.0-blue.svg"/>
<img src="https://img.shields.io/badge/Element Plus-2.7.2-blue.svg"/>
<img src="https://img.shields.io/badge/license-MIT-green.svg"/>
<a href="https://gitee.com/youlaiorg" target="_blank">
<img src="https://img.shields.io/badge/Author-有来开源组织-orange.svg"/>
@@ -30,7 +30,7 @@
- **基础设施**动态路由、按钮权限、国际化、代码规范、Git 提交规范、常用组件封装。
- **持续更新**2021年至今持续更新3年及时跟进最新的技术和工具。
- **持续更新**2021年起,该项目持续开源更新,实时更新工具和依赖,积累了广泛的用户群体。
@@ -82,7 +82,7 @@ pnpm run dev
```bash
# 项目打包
pnpm run build:prod
pnpm run build
# 上传文件至远程服务器
将打包生成在 `dist` 目录下的文件拷贝至 `/usr/share/nginx/html` 目录

View File

@@ -4,14 +4,17 @@
"private": true,
"type": "module",
"scripts": {
"preinstall": "npx only-allow pnpm",
"dev": "vite serve --mode development",
"build:prod": "vite build --mode production && vue-tsc --noEmit",
"prepare": "husky",
"dev": "vite",
"build": "vue-tsc --noEmit & vite build",
"preview": "vite preview",
"build-only": "vite build",
"type-check": "vue-tsc --noEmit",
"lint:eslint": "eslint --fix --ext .ts,.js,.vue ./src ",
"lint:prettier": "prettier --write \"**/*.{js,cjs,ts,json,tsx,css,less,scss,vue,html,md}\"",
"lint:stylelint": "stylelint \"**/*.{css,scss,vue}\" --fix",
"lint:lint-staged": "lint-staged",
"preinstall": "npx only-allow pnpm",
"prepare": "husky",
"commit": "git-cz"
},
"config": {

View File

@@ -1,76 +1,107 @@
<template>
<el-card shadow="never" class="table-container">
<template #header>
<!-- 表格左上方工具栏 -->
<template v-for="item in contentConfig.toolbar" :key="item">
<template v-if="typeof item === 'string'">
<!-- 刷新 -->
<template v-if="item === 'refresh'">
<el-button
type="info"
icon="refresh"
@click="handleToolbar(item)"
/>
<div class="flex-x-between mb-[10px]">
<div>
<!-- 表格左上方工具栏 -->
<template v-for="item in contentConfig.toolbar" :key="item">
<template v-if="typeof item === 'string'">
<!-- 新增 -->
<template v-if="item === 'add'">
<el-button
v-hasPerm="[`${contentConfig.pageName}:${item}`]"
type="success"
icon="plus"
@click="handleToolbar(item)"
>
新增
</el-button>
</template>
<!-- 删除 -->
<template v-else-if="item === 'delete'">
<el-button
v-hasPerm="[`${contentConfig.pageName}:${item}`]"
type="danger"
icon="delete"
:disabled="removeIds.length === 0"
@click="handleToolbar(item)"
>
删除
</el-button>
</template>
<!-- 导出 -->
<template v-else-if="item === 'export'">
<el-button
v-hasPerm="[`${contentConfig.pageName}:${item}`]"
type="primary"
icon="download"
@click="handleToolbar(item)"
>
导出
</el-button>
</template>
</template>
<!-- 新增 -->
<template v-else-if="item === 'add'">
<el-button
v-hasPerm="[`${contentConfig.pageName}:${item}`]"
type="success"
icon="plus"
@click="handleToolbar(item)"
>
新增
</el-button>
</template>
<!-- 删除 -->
<template v-else-if="item === 'delete'">
<el-button
v-hasPerm="[`${contentConfig.pageName}:${item}`]"
type="danger"
icon="delete"
:disabled="removeIds.length === 0"
@click="handleToolbar(item)"
>
删除
</el-button>
</template>
<!-- 导出 -->
<template v-else-if="item === 'export'">
<el-button
v-hasPerm="[`${contentConfig.pageName}:${item}`]"
type="primary"
icon="download"
@click="handleToolbar(item)"
>
导出
</el-button>
<!-- 其他 -->
<template v-else-if="typeof item === 'object'">
<template v-if="item.auth">
<el-button
v-hasPerm="[`${contentConfig.pageName}:${item.auth}`]"
:icon="item.icon"
type="default"
@click="handleToolbar(item.name)"
>
{{ item.text }}
</el-button>
</template>
<template v-else>
<el-button
:icon="item.icon"
type="default"
@click="handleToolbar(item.name)"
>
{{ item.text }}
</el-button>
</template>
</template>
</template>
<!-- 其他 -->
<template v-else-if="typeof item === 'object'">
<template v-if="item.auth">
<el-button
v-hasPerm="[`${contentConfig.pageName}:${item.auth}`]"
:icon="item.icon"
type="default"
@click="handleToolbar(item.name)"
>
{{ item.text }}
</el-button>
</div>
<!-- 表格右上方工具栏 -->
<div>
<el-icon class="cursor-pointer" @click="handleToolbar('refresh')">
<i-ep-refresh />
</el-icon>
<!-- 列设置 -->
<el-popover placement="bottom" trigger="click">
<template #reference>
<el-icon class="cursor-pointer ml-2">
<i-ep-setting />
</el-icon>
</template>
<template v-else>
<el-button
:icon="item.icon"
type="default"
@click="handleToolbar(item.name)"
>
{{ item.text }}
</el-button>
</template>
</template>
</template>
</template>
<el-checkbox
v-model="columnSetting.checkAll"
:indeterminate="columnSetting.isIndeterminate"
@change="handleCheckAllChange"
>
全选
</el-checkbox>
<el-checkbox-group
v-model="columnSetting.checkedCols"
@change="handleCheckedColumnsChange"
>
<div v-for="col in contentConfig.cols" :key="col.label">
<el-checkbox
v-if="col.label"
:value="col.label"
:label="col.label"
/>
</div>
</el-checkbox-group>
</el-popover>
</div>
</div>
<!-- 列表 -->
<el-table
v-loading="loading"
@@ -78,9 +109,38 @@
:data="pageData"
@selection-change="handleSelectionChange"
>
<template v-for="col in contentConfig.cols" :key="col.prop">
<template v-for="col in displayedColumns" :key="col.prop">
<!-- 显示图片 -->
<template v-if="col.show && col.templet === 'image'">
<el-table-column v-bind="col">
<template #default="scope">
<template v-if="Array.isArray(scope.row[col.prop])">
<template
v-for="(item, index) in scope.row[col.prop]"
:key="item"
>
<el-image
:src="item"
:preview-src-list="scope.row[col.prop]"
:initial-index="index"
:preview-teleported="true"
:style="`width: ${col.imageWidth ?? 40}px; height: ${col.imageHeight ?? 40}px`"
/>
</template>
</template>
<template v-else>
<el-image
:src="scope.row[col.prop]"
:preview-src-list="[scope.row[col.prop]]"
:preview-teleported="true"
:style="`width: ${col.imageWidth ?? 40}px; height: ${col.imageHeight ?? 40}px`"
/>
</template>
</template>
</el-table-column>
</template>
<!-- 列操作栏 -->
<template v-if="col.templet === 'tool'">
<template v-else-if="col.show && col.templet === 'tool'">
<el-table-column v-bind="col">
<template #default="scope">
<template v-for="item in col.operat" :key="item">
@@ -151,7 +211,7 @@
</el-table-column>
</template>
<!-- 自定义 -->
<template v-else-if="col.templet === 'custom'">
<template v-else-if="col.show && col.templet === 'custom'">
<el-table-column v-bind="col">
<template #default="scope">
<slot
@@ -164,7 +224,7 @@
</template>
<!-- 其他 -->
<template v-else>
<el-table-column v-bind="col" />
<el-table-column v-bind="col" v-if="col.show" />
</template>
</template>
</el-table>
@@ -182,7 +242,7 @@
<script setup lang="ts">
import { ref, reactive } from "vue";
import Pagination from "@/components/Pagination/index.vue";
import type { TableProps } from "element-plus";
import type { TableProps, CheckboxValueType } from "element-plus";
// 对象类型
export type IObject = Record<string, any>;
@@ -369,6 +429,53 @@ function exportPageData(queryParams: IObject = {}) {
ElMessage.error("未配置exportAction");
}
}
// 列设置类型声明
interface IColumnSetting {
checkAll: boolean;
isIndeterminate: boolean;
checkedCols: string[];
}
// 列设置
const columnSetting = ref<IColumnSetting>({
checkAll: true,
isIndeterminate: false,
checkedCols: [],
});
// 创建一个响应式副本,用于存储最后显示的列配置
const displayedColumns = ref<IObject>(props.contentConfig.cols);
// 全选/取消全选
const handleCheckAllChange = (checkAll: CheckboxValueType) => {
columnSetting.value.checkedCols = checkAll
? props.contentConfig.cols.map((col) => col.label)
: [];
columnSetting.value.isIndeterminate = false;
displayedColumns.value = displayedColumns.value.map((col: IObject) => ({
...col,
show: checkAll,
}));
};
// 选中列变化
const handleCheckedColumnsChange = (values: CheckboxValueType[]) => {
const showColumnsLength = props.contentConfig.cols.length;
const checkedCount = values.length;
columnSetting.value.checkAll = checkedCount === showColumnsLength;
columnSetting.value.isIndeterminate =
checkedCount > 0 && checkedCount < showColumnsLength;
displayedColumns.value = displayedColumns.value.map((col: IObject) => ({
...col,
show: values.includes(col.label),
}));
};
// 初始化全选状态
handleCheckAllChange(columnSetting.value.checkAll);
</script>
<style lang="scss" scoped></style>

View File

@@ -1,72 +1,164 @@
<template>
<div class="search-container" v-hasPerm="[`${searchConfig.pageName}:query`]">
<el-form ref="queryFormRef" :model="queryParams" :inline="true">
<template v-for="item in searchConfig.formItems" :key="item.prop">
<el-form-item :label="item.label" :prop="item.prop">
<!-- Input 输入框 -->
<template v-if="item.type === 'input'">
<template v-if="item.attrs?.type === 'number'">
<el-input
v-model.number="queryParams[item.prop]"
v-bind="item.attrs"
@keyup.enter="handleQuery"
/>
</template>
<template v-else>
<el-input
v-model="queryParams[item.prop]"
v-bind="item.attrs"
@keyup.enter="handleQuery"
/>
</template>
</template>
<!-- Select 选择器 -->
<template v-else-if="item.type === 'select'">
<el-select v-model="queryParams[item.prop]" v-bind="item.attrs">
<template v-for="option in item.options" :key="option.value">
<el-option :label="option.label" :value="option.value" />
<el-form ref="queryFormRef" :model="queryParams">
<el-row :gutter="20">
<template
v-if="isExpand || searchConfig.formItems.length <= showNumber"
>
<el-col
v-bind="colSpans"
v-for="item in searchConfig.formItems"
:key="item.prop"
>
<el-form-item :label="item.label" :prop="item.prop">
<!-- Input 输入框 -->
<template v-if="item.type === 'input'">
<template v-if="item.attrs?.type === 'number'">
<el-input
v-model.number="queryParams[item.prop]"
v-bind="item.attrs"
@keyup.enter="handleQuery"
/>
</template>
<template v-else>
<el-input
v-model="queryParams[item.prop]"
v-bind="item.attrs"
@keyup.enter="handleQuery"
/>
</template>
</template>
</el-select>
</template>
<!-- TreeSelect 树形选择 -->
<template v-else-if="item.type === 'tree-select'">
<el-tree-select
v-model="queryParams[item.prop]"
v-bind="item.attrs"
/>
</template>
<!-- DatePicker 日期选择器 -->
<template v-else-if="item.type === 'date-picker'">
<el-date-picker
v-model="queryParams[item.prop]"
v-bind="item.attrs"
/>
</template>
<!-- Input 输入框 -->
<template v-else>
<template v-if="item.attrs?.type === 'number'">
<el-input
v-model.number="queryParams[item.prop]"
v-bind="item.attrs"
@keyup.enter="handleQuery"
/>
</template>
<template v-else>
<el-input
v-model="queryParams[item.prop]"
v-bind="item.attrs"
@keyup.enter="handleQuery"
/>
</template>
</template>
</el-form-item>
</template>
<el-form-item>
<el-button type="primary" icon="search" @click="handleQuery">
搜索
</el-button>
<el-button icon="refresh" @click="handleReset">重置</el-button>
</el-form-item>
<!-- Select 选择器 -->
<template v-else-if="item.type === 'select'">
<el-select v-model="queryParams[item.prop]" v-bind="item.attrs">
<template v-for="option in item.options" :key="option.value">
<el-option :label="option.label" :value="option.value" />
</template>
</el-select>
</template>
<!-- TreeSelect 树形选择 -->
<template v-else-if="item.type === 'tree-select'">
<el-tree-select
v-model="queryParams[item.prop]"
v-bind="item.attrs"
/>
</template>
<!-- DatePicker 日期选择器 -->
<template v-else-if="item.type === 'date-picker'">
<el-date-picker
v-model="queryParams[item.prop]"
v-bind="item.attrs"
/>
</template>
<!-- Input 输入框 -->
<template v-else>
<template v-if="item.attrs?.type === 'number'">
<el-input
v-model.number="queryParams[item.prop]"
v-bind="item.attrs"
@keyup.enter="handleQuery"
/>
</template>
<template v-else>
<el-input
v-model="queryParams[item.prop]"
v-bind="item.attrs"
@keyup.enter="handleQuery"
/>
</template>
</template>
</el-form-item>
</el-col>
</template>
<template v-else>
<el-col
v-bind="colSpans"
v-for="item in searchConfig.formItems.slice(0, showNumber)"
:key="item.prop"
>
<el-form-item :label="item.label" :prop="item.prop">
<!-- Input 输入框 -->
<template v-if="item.type === 'input'">
<template v-if="item.attrs?.type === 'number'">
<el-input
v-model.number="queryParams[item.prop]"
v-bind="item.attrs"
@keyup.enter="handleQuery"
/>
</template>
<template v-else>
<el-input
v-model="queryParams[item.prop]"
v-bind="item.attrs"
@keyup.enter="handleQuery"
/>
</template>
</template>
<!-- Select 选择器 -->
<template v-else-if="item.type === 'select'">
<el-select v-model="queryParams[item.prop]" v-bind="item.attrs">
<template v-for="option in item.options" :key="option.value">
<el-option :label="option.label" :value="option.value" />
</template>
</el-select>
</template>
<!-- TreeSelect 树形选择 -->
<template v-else-if="item.type === 'tree-select'">
<el-tree-select
v-model="queryParams[item.prop]"
v-bind="item.attrs"
/>
</template>
<!-- DatePicker 日期选择器 -->
<template v-else-if="item.type === 'date-picker'">
<el-date-picker
v-model="queryParams[item.prop]"
v-bind="item.attrs"
/>
</template>
<!-- Input 输入框 -->
<template v-else>
<template v-if="item.attrs?.type === 'number'">
<el-input
v-model.number="queryParams[item.prop]"
v-bind="item.attrs"
@keyup.enter="handleQuery"
/>
</template>
<template v-else>
<el-input
v-model="queryParams[item.prop]"
v-bind="item.attrs"
@keyup.enter="handleQuery"
/>
</template>
</template>
</el-form-item>
</el-col>
</template>
<div class="flex flex-auto items-start justify-end pr-5">
<el-form-item>
<el-button type="primary" icon="search" @click="handleQuery">
搜索
</el-button>
<el-button icon="refresh" @click="handleReset">重置</el-button>
<el-link
v-if="isExpandable && searchConfig.formItems.length > showNumber"
@click="isExpand = !isExpand"
class="ml-2"
type="primary"
:underline="false"
>
{{ isExpand ? "收起" : "展开" }}
<i-ep-arrow-up v-if="isExpand" />
<i-ep-arrow-down v-else />
</el-link>
</el-form-item>
</div>
</el-row>
</el-form>
</div>
</template>
@@ -96,11 +188,38 @@ export interface ISearchConfig {
// 可选项(适用于select组件)
options?: { label: string; value: any }[];
}>;
// 是否开启展开和收缩
isExpandable?: boolean;
// 默认展示的表单项数量
showNumber?: number;
}
interface IProps {
searchConfig: ISearchConfig;
}
const props = defineProps<IProps>();
// 是否可展开/收缩
const isExpandable = ref(props.searchConfig.isExpandable);
// 是否展开
const isExpand = ref(false);
// 表单项展示数量,若可展开,超出展示数量的表单项隐藏
const showNumber = computed(() => {
if (isExpandable.value === true) {
return props.searchConfig.showNumber ?? 3;
} else {
return props.searchConfig.formItems.length;
}
});
// 表单项栅格列数配置
const colSpans = {
xs: 24,
sm: 12,
md: 8,
lg: 6,
xl: 4,
};
// 自定义事件
const emit = defineEmits<{
queryClick: [queryParams: IObject];

View File

@@ -3,14 +3,15 @@
v-model="settingsVisible"
size="300"
:title="$t('settings.project')"
:lockScroll="false"
>
<el-divider>{{ $t("settings.theme") }}</el-divider>
<div class="flex-center">
<el-switch
v-model="isDark"
:active-icon="Moon"
:inactive-icon="Sunny"
active-icon="Moon"
inactive-icon="Sunny"
@change="changeTheme"
/>
</div>
@@ -56,7 +57,6 @@
<script setup lang="ts">
import { useSettingsStore, usePermissionStore, useAppStore } from "@/store";
import { Sunny, Moon } from "@element-plus/icons-vue";
import { LayoutEnum } from "@/enums/LayoutEnum";
import { ThemeEnum } from "@/enums/ThemeEnum";

View File

@@ -33,6 +33,7 @@ declare module "vue" {
ElForm: (typeof import("element-plus/es"))["ElForm"];
ElFormItem: (typeof import("element-plus/es"))["ElFormItem"];
ElIcon: (typeof import("element-plus/es"))["ElIcon"];
ElImage: (typeof import("element-plus/es"))["ElImage"];
ElInput: (typeof import("element-plus/es"))["ElInput"];
ElInputNumber: (typeof import("element-plus/es"))["ElInputNumber"];
ElMenu: (typeof import("element-plus/es"))["ElMenu"];

View File

@@ -10,6 +10,8 @@ declare module "vue-router" {
icon?: string;
/** 菜单是否隐藏 */
hidden?: boolean;
/** 只有一个子路由是否始终显示 */
alwaysShow?: boolean;
/** 是否固定页签 */
affix?: boolean;
/** 是否缓存页面 */

View File

@@ -36,6 +36,7 @@ const contentConfig: IContentConfig<UserQuery> = {
{ type: "selection", width: 50, align: "center" },
{ label: "编号", align: "center", prop: "id", width: 100 },
{ label: "用户名", align: "center", prop: "username" },
{ label: "头像", align: "center", prop: "avatar", templet: "image" },
{ label: "用户昵称", align: "center", prop: "nickname", width: 120 },
{ label: "性别", align: "center", prop: "genderLabel", width: 100 },
{ label: "部门", align: "center", prop: "deptName", width: 120 },

View File

@@ -11,7 +11,7 @@ const searchConfig: ISearchConfig = {
placeholder: "用户名/昵称/手机号",
clearable: true,
style: {
width: "200px",
width: "80%",
},
},
},
@@ -42,7 +42,7 @@ const searchConfig: ISearchConfig = {
"render-after-expand": false,
clearable: true,
style: {
width: "150px",
width: "80%",
},
},
},
@@ -54,7 +54,7 @@ const searchConfig: ISearchConfig = {
placeholder: "全部",
clearable: true,
style: {
width: "100px",
width: "60%",
},
},
options: [
@@ -73,11 +73,12 @@ const searchConfig: ISearchConfig = {
"end-placeholder": "截止时间",
"value-format": "YYYY-MM-DD",
style: {
width: "240px",
width: "60%",
},
},
},
],
isExpandable: true,
};
export default searchConfig;

View File

@@ -5,8 +5,8 @@
<el-switch
v-model="isDark"
inline-prompt
:active-icon="Moon"
:inactive-icon="Sunny"
active-icon="Moon"
inactive-icon="Sunny"
@change="toggleTheme"
/>
<lang-select class="ml-2 cursor-pointer" />
@@ -117,7 +117,7 @@
import { useSettingsStore, useUserStore } from "@/store";
import AuthAPI from "@/api/auth";
import { LoginData } from "@/api/auth/model";
import { Sunny, Moon } from "@element-plus/icons-vue";
import type { FormInstance } from "element-plus";
import { LocationQuery, LocationQueryValue, useRoute } from "vue-router";
import router from "@/router";
import defaultSettings from "@/settings";
@@ -136,7 +136,7 @@ const icpVisible = ref(true);
const loading = ref(false); // 按钮loading
const isCapslock = ref(false); // 是否大写锁定
const captchaBase64 = ref(); // 验证码图片Base64字符串
const loginFormRef = ref(ElForm); // 登录表单ref
const loginFormRef = ref<FormInstance>(); // 登录表单ref
const { height } = useWindowSize();
const loginData = ref<LoginData>({
@@ -186,7 +186,7 @@ function getCaptcha() {
/** 登录 */
const route = useRoute();
function handleLogin() {
loginFormRef.value.validate((valid: boolean) => {
loginFormRef.value?.validate((valid: boolean) => {
if (valid) {
loading.value = true;
userStore
@@ -285,4 +285,3 @@ html.dark .login-container {
}
}
</style>
@/api/auth/model

View File

@@ -445,8 +445,9 @@ function resetPassword(row: { [key: string]: any }) {
cancelButtonText: "取消",
}
).then(({ value }) => {
if (!value) {
ElMessage.warning("请输入新密码");
if (!value || value.length < 6) {
// 检查密码是否为空或少于6位
ElMessage.warning("密码至少需要6位字符请重新输入");
return false;
}
UserAPI.updatePassword(row.id, value).then(() => {