sv
54
src/App.vue
Normal file
@@ -0,0 +1,54 @@
|
||||
<script lang="ts" name="APP" setup>
|
||||
import { isDark } from '@/utils/is'
|
||||
import { useAppStore } from '@/store/modules/app'
|
||||
import { useDesign } from '@/hooks/web/useDesign'
|
||||
import { CACHE_KEY } from '@/hooks/web/useCache'
|
||||
import routerSearch from '@/components/RouterSearch/index.vue'
|
||||
import cache from '@/plugins/cache'
|
||||
|
||||
const { getPrefixCls } = useDesign()
|
||||
const prefixCls = getPrefixCls('app')
|
||||
const appStore = useAppStore()
|
||||
const currentSize = computed(() => appStore.getCurrentSize)
|
||||
const greyMode = computed(() => appStore.getGreyMode)
|
||||
|
||||
// 根据浏览器当前主题设置系统主题色
|
||||
const setDefaultTheme = () => {
|
||||
let isDarkTheme = cache.local.get(CACHE_KEY.IS_DARK)
|
||||
if (isDarkTheme === null) {
|
||||
isDarkTheme = isDark()
|
||||
}
|
||||
appStore.setIsDark(isDarkTheme)
|
||||
}
|
||||
setDefaultTheme()
|
||||
</script>
|
||||
<template>
|
||||
<ConfigGlobal :size="currentSize">
|
||||
<RouterView :class="greyMode ? `${prefixCls}-grey-mode` : ''" />
|
||||
<routerSearch />
|
||||
</ConfigGlobal>
|
||||
</template>
|
||||
<style lang="scss">
|
||||
$prefix-cls: #{$namespace}-app;
|
||||
|
||||
.size {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
padding: 0 !important;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
@extend .size;
|
||||
|
||||
#app {
|
||||
@extend .size;
|
||||
}
|
||||
}
|
||||
|
||||
.#{$prefix-cls}-grey-mode {
|
||||
filter: grayscale(100%);
|
||||
}
|
||||
</style>
|
||||
48
src/api/infra/config/index.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import request from '@/config/axios'
|
||||
|
||||
export interface ConfigVO {
|
||||
id: number | undefined
|
||||
category: string
|
||||
name: string
|
||||
key: string
|
||||
value: string
|
||||
type: number
|
||||
visible: boolean
|
||||
remark: string
|
||||
createTime: Date
|
||||
}
|
||||
|
||||
// 查询参数列表
|
||||
export const getConfigPage = (params: PageParam) => {
|
||||
return request.get({ url: '/admin-api/infra/config/page', params })
|
||||
}
|
||||
|
||||
// 查询参数详情
|
||||
export const getConfig = (id: number) => {
|
||||
return request.get({ url: '/admin-api/infra/config/get?id=' + id })
|
||||
}
|
||||
|
||||
// 根据参数键名查询参数值
|
||||
export const getConfigKey = (configKey: string) => {
|
||||
return request.get({ url: '/admin-api/infra/config/get-value-by-key?key=' + configKey })
|
||||
}
|
||||
|
||||
// 新增参数
|
||||
export const createConfig = (data: ConfigVO) => {
|
||||
return request.post({ url: '/admin-api/infra/config/create', data })
|
||||
}
|
||||
|
||||
// 修改参数
|
||||
export const updateConfig = (data: ConfigVO) => {
|
||||
return request.put({ url: '/admin-api/infra/config/update', data })
|
||||
}
|
||||
|
||||
// 删除参数
|
||||
export const deleteConfig = (id: number) => {
|
||||
return request.delete({ url: '/admin-api/infra/config/delete?id=' + id })
|
||||
}
|
||||
|
||||
// 导出参数
|
||||
export const exportConfig = (params) => {
|
||||
return request.download({ url: '/admin-api/infra/config/export', params })
|
||||
}
|
||||
45
src/api/infra/file/index.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import request from '@/config/axios'
|
||||
|
||||
export interface FilePageReqVO extends PageParam {
|
||||
path?: string
|
||||
type?: string
|
||||
createTime?: Date[]
|
||||
}
|
||||
|
||||
// 文件预签名地址 Response VO
|
||||
export interface FilePresignedUrlRespVO {
|
||||
// 文件配置编号
|
||||
configId: number
|
||||
// 文件上传 URL
|
||||
uploadUrl: string
|
||||
// 文件 URL
|
||||
url: string
|
||||
}
|
||||
|
||||
// 查询文件列表
|
||||
export const getFilePage = (params: FilePageReqVO) => {
|
||||
return request.get({ url: '/infra/file/page', params })
|
||||
}
|
||||
|
||||
// 删除文件
|
||||
export const deleteFile = (id: number) => {
|
||||
return request.delete({ url: '/infra/file/delete?id=' + id })
|
||||
}
|
||||
|
||||
// 获取文件预签名地址
|
||||
export const getFilePresignedUrl = (path: string) => {
|
||||
return request.get<FilePresignedUrlRespVO>({
|
||||
url: '/infra/file/presigned-url',
|
||||
params: { path }
|
||||
})
|
||||
}
|
||||
|
||||
// 创建文件
|
||||
export const createFile = (data: any) => {
|
||||
return request.post({ url: '/infra/file/create', data })
|
||||
}
|
||||
|
||||
// 上传文件
|
||||
export const updateFile = (data: any) => {
|
||||
return request.upload({ url: '/admin-api/system/file/upload', data })
|
||||
}
|
||||
73
src/api/login/index.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import request from '@/config/axios'
|
||||
import { getRefreshToken } from '@/utils/auth'
|
||||
import type { UserLoginVO } from './types'
|
||||
|
||||
export interface SmsCodeVO {
|
||||
mobile: string
|
||||
scene: number
|
||||
}
|
||||
|
||||
export interface SmsLoginVO {
|
||||
mobile: string
|
||||
code: string
|
||||
}
|
||||
|
||||
// 登录
|
||||
export const login = (data: UserLoginVO) => {
|
||||
return request.post({ url: '/admin-api/system/auth/platform/login', data })
|
||||
}
|
||||
|
||||
// 刷新访问令牌
|
||||
export const refreshToken = () => {
|
||||
return request.post({
|
||||
url: '/admin-api/system/auth/refresh-token?refreshToken=' + getRefreshToken()
|
||||
})
|
||||
}
|
||||
|
||||
// 使用租户名,获得租户编号
|
||||
export const getTenantIdByName = (name: string) => {
|
||||
return request.get({ url: '/admin-api/system/tenant/get-id-by-name?name=' + name })
|
||||
}
|
||||
|
||||
// 登出
|
||||
export const loginOut = () => {
|
||||
return request.post({ url: '/admin-api/system/auth/logout' })
|
||||
}
|
||||
|
||||
// 获取用户权限信息
|
||||
export const getInfo = (params: any) => {
|
||||
return request.get({ url: '/admin-api/system/auth/get-permission-info', params })
|
||||
}
|
||||
|
||||
//获取登录验证码
|
||||
export const sendSmsCode = (data: SmsCodeVO) => {
|
||||
return request.post({ url: '/admin-api/system/auth/send-sms-code', data })
|
||||
}
|
||||
|
||||
// 短信验证码登录
|
||||
export const smsLogin = (data: SmsLoginVO) => {
|
||||
return request.post({ url: '/admin-api/system/auth/sms-login', data })
|
||||
}
|
||||
|
||||
// 社交授权的跳转
|
||||
export const socialAuthRedirect = (type: number, redirectUri: string) => {
|
||||
return request.get({
|
||||
url: '/admin-api/system/auth/social-auth-redirect?type=' + type + '&redirectUri=' + redirectUri
|
||||
})
|
||||
}
|
||||
// 获取验证图片以及 token
|
||||
export const getCode = (data) => {
|
||||
return request.postOriginal({ url: '/admin-api/system/captcha/get', data })
|
||||
}
|
||||
|
||||
// 滑动或者点选验证
|
||||
export const reqCheck = (data) => {
|
||||
return request.postOriginal({ url: '/admin-api/system/captcha/check', data })
|
||||
}
|
||||
|
||||
// 获取应用信息
|
||||
export const getAppInfo = (instanceId: number) => {
|
||||
return request.get({
|
||||
url: '/admin-api/system/serviceInstance/getInstanceInfo?instanceId=' + instanceId
|
||||
})
|
||||
}
|
||||
41
src/api/login/oauth2/index.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import request from '@/config/axios'
|
||||
|
||||
// 获得授权信息
|
||||
export const getAuthorize = (clientId: string) => {
|
||||
return request.get({ url: '/admin-api/system/oauth2/authorize?clientId=' + clientId })
|
||||
}
|
||||
|
||||
// 发起授权
|
||||
export const authorize = (
|
||||
responseType: string,
|
||||
clientId: string,
|
||||
redirectUri: string,
|
||||
state: string,
|
||||
autoApprove: boolean,
|
||||
checkedScopes: string[],
|
||||
uncheckedScopes: string[]
|
||||
) => {
|
||||
// 构建 scopes
|
||||
const scopes = {}
|
||||
for (const scope of checkedScopes) {
|
||||
scopes[scope] = true
|
||||
}
|
||||
for (const scope of uncheckedScopes) {
|
||||
scopes[scope] = false
|
||||
}
|
||||
// 发起请求
|
||||
return request.post({
|
||||
url: '/admin-api/system/oauth2/authorize',
|
||||
headers: {
|
||||
'Content-type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
params: {
|
||||
response_type: responseType,
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
state: state,
|
||||
auto_approve: autoApprove,
|
||||
scope: JSON.stringify(scopes)
|
||||
}
|
||||
})
|
||||
}
|
||||
28
src/api/login/types.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export type UserLoginVO = {
|
||||
username: string
|
||||
password: string
|
||||
captchaVerification: string
|
||||
}
|
||||
|
||||
export type TokenType = {
|
||||
id: number // 编号
|
||||
accessToken: string // 访问令牌
|
||||
refreshToken: string // 刷新令牌
|
||||
userId: number // 用户编号
|
||||
userType: number //用户类型
|
||||
clientId: string //客户端编号
|
||||
expiresTime: number //过期时间
|
||||
}
|
||||
|
||||
export type UserVO = {
|
||||
id: number
|
||||
username: string
|
||||
nickname: string
|
||||
deptId: number
|
||||
email: string
|
||||
mobile: string
|
||||
sex: number
|
||||
avatar: string
|
||||
loginIp: string
|
||||
loginDate: string
|
||||
}
|
||||
5
src/api/system/app/index.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import request from '@/config/axios'
|
||||
|
||||
export const getSimpleAppList = async () => {
|
||||
return await request.get({ url: '/admin-api/system/serviceInstance/simple-list' })
|
||||
}
|
||||
43
src/api/system/dept/index.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import request from '@/config/axios'
|
||||
|
||||
export interface DeptVO {
|
||||
id?: number
|
||||
name: string
|
||||
parentId: number
|
||||
status: number
|
||||
sort: number
|
||||
leaderUserId: number
|
||||
phone: string
|
||||
email: string
|
||||
createTime: Date
|
||||
}
|
||||
|
||||
// 查询部门(精简)列表
|
||||
export const getSimpleDeptList = async (params: any): Promise<any[]> => {
|
||||
return await request.get({ url: '/admin-api/system/dept/list-all-simple', params })
|
||||
}
|
||||
|
||||
// 查询部门列表
|
||||
export const getDeptPage = async (params) => {
|
||||
return await request.get({ url: '/admin-api/system/dept/list', params })
|
||||
}
|
||||
|
||||
// 查询部门详情
|
||||
export const getDept = async (id: number) => {
|
||||
return await request.get({ url: '/admin-api/system/dept/get?id=' + id })
|
||||
}
|
||||
|
||||
// 新增部门
|
||||
export const createDept = async (data: DeptVO) => {
|
||||
return await request.post({ url: '/admin-api/system/dept/create', data: data })
|
||||
}
|
||||
|
||||
// 修改部门
|
||||
export const updateDept = async (params: DeptVO) => {
|
||||
return await request.put({ url: '/admin-api/system/dept/update', data: params })
|
||||
}
|
||||
|
||||
// 删除部门
|
||||
export const deleteDept = async (id: number) => {
|
||||
return await request.delete({ url: '/admin-api/system/dept/delete?id=' + id })
|
||||
}
|
||||
54
src/api/system/dict/dict.data.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import request from '@/config/axios'
|
||||
|
||||
export type DictDataVO = {
|
||||
id: number | undefined
|
||||
sort: number | undefined
|
||||
label: string
|
||||
value: string
|
||||
dictType: string
|
||||
status: number
|
||||
colorType: string
|
||||
cssClass: string
|
||||
remark: string
|
||||
createTime: Date
|
||||
}
|
||||
|
||||
// 查询字典数据(精简)列表
|
||||
export const listSimpleDictData = () => {
|
||||
return request.get({ url: '/admin-api/oa/dict-data/simple-list' })
|
||||
}
|
||||
|
||||
// 查询字典数据列表
|
||||
export const getDictDataPage = (params: PageParam) => {
|
||||
return request.get({ url: '/admin-api/oa/dict-data/page', params })
|
||||
}
|
||||
|
||||
// 查询字典数据详情
|
||||
export const getDictData = (id: number) => {
|
||||
return request.get({ url: '/admin-api/oa/dict-data/get?id=' + id })
|
||||
}
|
||||
|
||||
// 新增字典数据
|
||||
export const createDictData = (data: DictDataVO) => {
|
||||
return request.post({ url: '/admin-api/oa/dict-data/create', data })
|
||||
}
|
||||
|
||||
// 修改字典数据
|
||||
export const updateDictData = (data: DictDataVO) => {
|
||||
return request.put({ url: '/admin-api/oa/dict-data/update', data })
|
||||
}
|
||||
|
||||
// 删除字典数据
|
||||
export const deleteDictData = (id: number) => {
|
||||
return request.delete({ url: '/admin-api/oa/dict-data/delete?id=' + id })
|
||||
}
|
||||
|
||||
// 导出字典类型数据
|
||||
export const exportDictData = (params) => {
|
||||
return request.get({ url: '/admin-api/oa/dict-data/export', params })
|
||||
}
|
||||
|
||||
// 获取通用字典数据
|
||||
export const getGeneralSysDictData = (dictType: string) => {
|
||||
return request.get({ url: '/admin-api/system/dict-data/get-by-type', params: { dictType } })
|
||||
}
|
||||
44
src/api/system/dict/dict.type.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import request from '@/config/axios'
|
||||
|
||||
export type DictTypeVO = {
|
||||
id: number | undefined
|
||||
name: string
|
||||
type: string
|
||||
status: number
|
||||
remark: string
|
||||
createTime: Date
|
||||
}
|
||||
|
||||
// 查询字典(精简)列表
|
||||
export const getSimpleDictTypeList = () => {
|
||||
return request.get({ url: '/admin-api/oa/dict-type/list-all-simple' })
|
||||
}
|
||||
|
||||
// 查询字典列表
|
||||
export const getDictTypePage = (params: PageParam) => {
|
||||
return request.get({ url: '/admin-api/oa/dict-type/page', params })
|
||||
}
|
||||
|
||||
// 查询字典详情
|
||||
export const getDictType = (id: number) => {
|
||||
return request.get({ url: '/admin-api/oa/dict-type/get?id=' + id })
|
||||
}
|
||||
|
||||
// 新增字典
|
||||
export const createDictType = (data: DictTypeVO) => {
|
||||
return request.post({ url: '/admin-api/oa/dict-type/create', data })
|
||||
}
|
||||
|
||||
// 修改字典
|
||||
export const updateDictType = (data: DictTypeVO) => {
|
||||
return request.put({ url: '/admin-api/oa/dict-type/update', data })
|
||||
}
|
||||
|
||||
// 删除字典
|
||||
export const deleteDictType = (id: number) => {
|
||||
return request.delete({ url: '/admin-api/oa/dict-type/delete?id=' + id })
|
||||
}
|
||||
// 导出字典类型
|
||||
export const exportDictType = (params) => {
|
||||
return request.get({ url: '/admin-api/oa/dict-type/export', params })
|
||||
}
|
||||
54
src/api/system/menu/index.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import request from '@/config/axios'
|
||||
|
||||
export interface MenuVO {
|
||||
id: number
|
||||
name: string
|
||||
permission: string
|
||||
type: number
|
||||
sort: number
|
||||
parentId: number
|
||||
path: string
|
||||
icon: string
|
||||
component: string
|
||||
componentName?: string
|
||||
status: number
|
||||
visible: boolean
|
||||
keepAlive: boolean
|
||||
alwaysShow?: boolean
|
||||
createTime: Date
|
||||
}
|
||||
|
||||
// 获取服务列表
|
||||
export const getServiceAppList = () => {
|
||||
return request.get({ url: '/admin-api/system/service/list' })
|
||||
}
|
||||
|
||||
// 查询菜单(精简)列表
|
||||
export const getSimpleMenusList = () => {
|
||||
return request.get({ url: '/admin-api/system/menu/list-all-simple' })
|
||||
}
|
||||
|
||||
// 查询菜单列表
|
||||
export const getMenuList = (params) => {
|
||||
return request.get({ url: '/admin-api/system/menu/list', params })
|
||||
}
|
||||
|
||||
// 获取菜单详情
|
||||
export const getMenu = (id: number) => {
|
||||
return request.get({ url: '/admin-api/system/menu/get?id=' + id })
|
||||
}
|
||||
|
||||
// 新增菜单
|
||||
export const createMenu = (data: MenuVO) => {
|
||||
return request.post({ url: '/admin-api/system/menu/create', data })
|
||||
}
|
||||
|
||||
// 修改菜单
|
||||
export const updateMenu = (data: MenuVO) => {
|
||||
return request.put({ url: '/admin-api/system/menu/update', data })
|
||||
}
|
||||
|
||||
// 删除菜单
|
||||
export const deleteMenu = (id: number) => {
|
||||
return request.delete({ url: '/admin-api/system/menu/delete?id=' + id })
|
||||
}
|
||||
53
src/api/system/notify/message/index.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import request from '@/config/axios'
|
||||
|
||||
export interface NotifyMessageVO {
|
||||
id: number
|
||||
userId: number
|
||||
userType: number
|
||||
templateId: number
|
||||
templateCode: string
|
||||
templateNickname: string
|
||||
templateContent: string
|
||||
templateType: number
|
||||
templateParams: string
|
||||
readStatus: boolean
|
||||
readTime: Date
|
||||
}
|
||||
|
||||
// 查询站内信消息列表
|
||||
export const getNotifyMessagePage = async (params: any) => {
|
||||
return await request.get({ url: '/admin-api/system/notify-message/page', params })
|
||||
}
|
||||
|
||||
// 获得我的站内信分页
|
||||
export const getMyNotifyMessagePage = async (params: any) => {
|
||||
return await request.get({ url: '/admin-api/system/notify-message/my-page', params })
|
||||
}
|
||||
|
||||
// 批量标记已读
|
||||
export const updateNotifyMessageRead = async (data: any) => {
|
||||
return await request.put({
|
||||
url: '/admin-api/system/notify-message/update-read?',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
// 标记所有站内信为已读
|
||||
export const updateAllNotifyMessageRead = async (data: any) => {
|
||||
return await request.put({ url: '/admin-api/system/notify-message/update-all-read', data })
|
||||
}
|
||||
|
||||
// 获取当前用户的最新站内信列表
|
||||
export const getUnreadNotifyMessageList = async (params: any) => {
|
||||
return await request.get({ url: '/admin-api/system/notify-message/get-unread-list', params })
|
||||
}
|
||||
|
||||
// 获得当前用户的未读站内信数量
|
||||
export const getUnreadNotifyMessageCount = async (params: any) => {
|
||||
return await request.get({ url: '/admin-api/system/notify-message/get-unread-count', params })
|
||||
}
|
||||
|
||||
// 获取详情
|
||||
export const getNotifyMessageDetail = async (id: number) => {
|
||||
return await request.get({ url: '/admin-api/system/notify-message/get', params: { id } })
|
||||
}
|
||||
49
src/api/system/notify/template/index.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import request from '@/config/axios'
|
||||
|
||||
export interface NotifyTemplateVO {
|
||||
id?: number
|
||||
name: string
|
||||
nickname: string
|
||||
code: string
|
||||
content: string
|
||||
type: number
|
||||
params: string
|
||||
status: number
|
||||
remark: string
|
||||
}
|
||||
|
||||
export interface NotifySendReqVO {
|
||||
userId: number | null
|
||||
templateCode: string
|
||||
templateParams: Map<String, Object>
|
||||
}
|
||||
|
||||
// 查询站内信模板列表
|
||||
export const getNotifyTemplatePage = async (params: PageParam) => {
|
||||
return await request.get({ url: '/system/notify-template/page', params })
|
||||
}
|
||||
|
||||
// 查询站内信模板详情
|
||||
export const getNotifyTemplate = async (id: number) => {
|
||||
return await request.get({ url: '/system/notify-template/get?id=' + id })
|
||||
}
|
||||
|
||||
// 新增站内信模板
|
||||
export const createNotifyTemplate = async (data: NotifyTemplateVO) => {
|
||||
return await request.post({ url: '/system/notify-template/create', data })
|
||||
}
|
||||
|
||||
// 修改站内信模板
|
||||
export const updateNotifyTemplate = async (data: NotifyTemplateVO) => {
|
||||
return await request.put({ url: '/system/notify-template/update', data })
|
||||
}
|
||||
|
||||
// 删除站内信模板
|
||||
export const deleteNotifyTemplate = async (id: number) => {
|
||||
return await request.delete({ url: '/system/notify-template/delete?id=' + id })
|
||||
}
|
||||
|
||||
// 发送站内信
|
||||
export const sendNotify = (data: NotifySendReqVO) => {
|
||||
return request.post({ url: '/system/notify-template/send-notify', data })
|
||||
}
|
||||
42
src/api/system/permission/index.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import request from '@/config/axios'
|
||||
|
||||
export interface PermissionAssignUserRoleReqVO {
|
||||
userId: number
|
||||
roleIds: number[]
|
||||
}
|
||||
|
||||
export interface PermissionAssignRoleMenuReqVO {
|
||||
roleId: number
|
||||
menuIds: number[]
|
||||
}
|
||||
|
||||
export interface PermissionAssignRoleDataScopeReqVO {
|
||||
roleId: number
|
||||
dataScope: number
|
||||
dataScopeDeptIds: number[]
|
||||
}
|
||||
|
||||
// 查询角色拥有的菜单权限
|
||||
export const getRoleMenuList = async (roleId: number) => {
|
||||
return await request.get({ url: '/admin-api/system/permission/list-role-menus?roleId=' + roleId })
|
||||
}
|
||||
|
||||
// 赋予角色菜单权限
|
||||
export const assignRoleMenu = async (data: PermissionAssignRoleMenuReqVO) => {
|
||||
return await request.post({ url: '/admin-api/system/permission/assign-role-menu', data })
|
||||
}
|
||||
|
||||
// 赋予角色数据权限
|
||||
export const assignRoleDataScope = async (data: PermissionAssignRoleDataScopeReqVO) => {
|
||||
return await request.post({ url: '/admin-api/system/permission/assign-role-data-scope', data })
|
||||
}
|
||||
|
||||
// 查询用户拥有的角色数组
|
||||
export const getUserRoleList = async (userId: number) => {
|
||||
return await request.get({ url: '/admin-api/system/permission/list-user-roles?userId=' + userId })
|
||||
}
|
||||
|
||||
// 赋予用户角色
|
||||
export const assignUserRole = async (data: PermissionAssignUserRoleReqVO) => {
|
||||
return await request.post({ url: '/admin-api/system/permission/assign-user-role', data })
|
||||
}
|
||||
53
src/api/system/role/index.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import request from '@/config/axios'
|
||||
|
||||
export interface RoleVO {
|
||||
id: number
|
||||
name: string
|
||||
code: string
|
||||
sort: number
|
||||
status: number
|
||||
type: number
|
||||
dataScope: number
|
||||
dataScopeDeptIds: number[]
|
||||
createTime: Date
|
||||
}
|
||||
|
||||
export interface UpdateStatusReqVO {
|
||||
id: number
|
||||
status: number
|
||||
}
|
||||
|
||||
// 查询角色列表
|
||||
export const getRolePage = async (params: PageParam) => {
|
||||
return await request.get({ url: '/admin-api/system/role/page', params })
|
||||
}
|
||||
|
||||
// 查询角色(精简)列表
|
||||
export const getSimpleRoleList = async () => {
|
||||
return await request.get({ url: '/admin-api/system/role/list-all-simple' })
|
||||
}
|
||||
|
||||
// 查询角色详情
|
||||
export const getRole = async (id: number) => {
|
||||
return await request.get({ url: '/admin-api/system/role/get?id=' + id })
|
||||
}
|
||||
|
||||
// 新增角色
|
||||
export const createRole = async (data: RoleVO) => {
|
||||
return await request.post({ url: '/admin-api/system/role/create', data })
|
||||
}
|
||||
|
||||
// 修改角色
|
||||
export const updateRole = async (data: RoleVO) => {
|
||||
return await request.put({ url: '/admin-api/system/role/update', data })
|
||||
}
|
||||
|
||||
// 删除角色
|
||||
export const deleteRole = async (id: number) => {
|
||||
return await request.delete({ url: '/admin-api/system/role/delete?id=' + id })
|
||||
}
|
||||
|
||||
// 角色用户
|
||||
export const getRoleUsers = async (params) => {
|
||||
return await request.get({ url: '/admin-api/system/role/getUserByRole', params })
|
||||
}
|
||||
16
src/api/system/set/index.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import request from '@/config/axios'
|
||||
|
||||
// 通过key,查询内容
|
||||
export const getConfigByConfigKey = (params) => {
|
||||
return request.get({ url: '/admin-api/crm/config/getConfigByConfigKey', params })
|
||||
}
|
||||
|
||||
// 保存配置项
|
||||
export const updateConfig = (data) => {
|
||||
return request.put({ url: '/admin-api/crm/config/batchUpdateConfigValue', data })
|
||||
}
|
||||
|
||||
// 根据模块获取配置列表
|
||||
export const getConfigList = (params) => {
|
||||
return request.get({ url: '/admin-api/crm/config/query', params })
|
||||
}
|
||||
76
src/api/system/user/index.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import request from '@/config/axios'
|
||||
|
||||
export interface UserVO {
|
||||
id: number
|
||||
username: string
|
||||
nickname: string
|
||||
deptId: number
|
||||
postIds: string[]
|
||||
email: string
|
||||
mobile: string
|
||||
sex: number
|
||||
avatar: string
|
||||
loginIp: string
|
||||
status: number
|
||||
remark: string
|
||||
loginDate: Date
|
||||
createTime: Date
|
||||
}
|
||||
|
||||
// 查询用户管理列表
|
||||
export const getUserPage = (params: PageParam) => {
|
||||
return request.get({ url: '/admin-api/system/user/page', params })
|
||||
}
|
||||
|
||||
// 查询用户详情
|
||||
export const getUser = (id: number) => {
|
||||
return request.get({ url: '/admin-api/system/user/get?id=' + id })
|
||||
}
|
||||
|
||||
// 新增用户
|
||||
export const createUser = (data: UserVO) => {
|
||||
return request.post({ url: '/admin-api/system/user/create', data })
|
||||
}
|
||||
|
||||
// 修改用户
|
||||
export const updateUser = (data: UserVO) => {
|
||||
return request.put({ url: '/admin-api/system/user/update', data })
|
||||
}
|
||||
|
||||
// 删除用户
|
||||
export const deleteUser = (id: number) => {
|
||||
return request.delete({ url: '/admin-api/system/user/delete?id=' + id })
|
||||
}
|
||||
|
||||
// 导出用户
|
||||
export const exportUser = (params) => {
|
||||
return request.download({ url: '/admin-api/system/user/export', params })
|
||||
}
|
||||
|
||||
// 下载用户导入模板
|
||||
export const importUserTemplate = () => {
|
||||
return request.download({ url: '/admin-api/system/user/get-import-template' })
|
||||
}
|
||||
|
||||
// 用户密码重置
|
||||
export const resetUserPwd = (id: number, password: string) => {
|
||||
const data = {
|
||||
id,
|
||||
password
|
||||
}
|
||||
return request.put({ url: '/admin-api/system/user/update-password', data: data })
|
||||
}
|
||||
|
||||
// 用户状态修改
|
||||
export const updateUserStatus = (id: number, status: number) => {
|
||||
const data = {
|
||||
id,
|
||||
status
|
||||
}
|
||||
return request.put({ url: '/admin-api/system/user/update-status', data: data })
|
||||
}
|
||||
|
||||
// 获取用户精简信息列表
|
||||
export const getSimpleUserList = (): Promise<UserVO[]> => {
|
||||
return request.get({ url: '/admin-api/system/user/list-all-simple' })
|
||||
}
|
||||
77
src/api/system/user/profile.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import request from '@/config/axios'
|
||||
|
||||
export interface ProfileDept {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
export interface ProfileRole {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
export interface ProfilePost {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
export interface SocialUser {
|
||||
id: number
|
||||
type: number
|
||||
openid: string
|
||||
token: string
|
||||
rawTokenInfo: string
|
||||
nickname: string
|
||||
avatar: string
|
||||
rawUserInfo: string
|
||||
code: string
|
||||
state: string
|
||||
}
|
||||
export interface ProfileVO {
|
||||
id: number
|
||||
username: string
|
||||
nickname: string
|
||||
dept: ProfileDept
|
||||
roles: ProfileRole[]
|
||||
posts: ProfilePost[]
|
||||
socialUsers: SocialUser[]
|
||||
email: string
|
||||
mobile: string
|
||||
sex: number
|
||||
avatar: string
|
||||
status: number
|
||||
remark: string
|
||||
loginIp: string
|
||||
loginDate: Date
|
||||
createTime: Date
|
||||
}
|
||||
|
||||
export interface UserProfileUpdateReqVO {
|
||||
nickname: string
|
||||
email: string
|
||||
mobile: string
|
||||
sex: number
|
||||
}
|
||||
|
||||
// 查询用户个人信息
|
||||
export const getUserProfile = () => {
|
||||
return request.get({ url: '/admin-api/system/user/profile/get' })
|
||||
}
|
||||
|
||||
// 修改用户个人信息
|
||||
export const updateUserProfile = (data: UserProfileUpdateReqVO) => {
|
||||
return request.put({ url: '/admin-api/system/user/profile/update', data })
|
||||
}
|
||||
|
||||
// 用户密码重置
|
||||
export const updateUserPassword = (oldPassword: string, newPassword: string) => {
|
||||
return request.put({
|
||||
url: '/admin-api/system/user/profile/update-password',
|
||||
data: {
|
||||
oldPassword: oldPassword,
|
||||
newPassword: newPassword
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 用户头像上传
|
||||
export const uploadAvatar = (data) => {
|
||||
return request.upload({ url: '/admin-api/system/user/profile/update-avatar', data: data })
|
||||
}
|
||||
31
src/api/system/user/socialUser.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import request from '@/config/axios'
|
||||
|
||||
// 社交绑定,使用 code 授权码
|
||||
export const socialBind = (type, code, state) => {
|
||||
return request.post({
|
||||
url: '/admin-api/system/social-user/bind',
|
||||
data: {
|
||||
type,
|
||||
code,
|
||||
state
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 取消社交绑定
|
||||
export const socialUnbind = (type, openid) => {
|
||||
return request.delete({
|
||||
url: '/admin-api/system/social-user/unbind',
|
||||
data: {
|
||||
type,
|
||||
openid
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 社交授权的跳转
|
||||
export const socialAuthRedirect = (type, redirectUri) => {
|
||||
return request.get({
|
||||
url: '/admin-api/system/auth/social-auth-redirect?type=' + type + '&redirectUri=' + redirectUri
|
||||
})
|
||||
}
|
||||
BIN
src/assets/fonts/DISPLAY FREE TFB.ttf
Normal file
BIN
src/assets/fonts/字魂74号-飞墨手书_爱给网_aigei_com.ttf
Normal file
BIN
src/assets/imgs/avatar.gif
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
src/assets/imgs/avatar.jpg
Normal file
|
After Width: | Height: | Size: 6.1 KiB |
BIN
src/assets/imgs/login2.gif
Normal file
|
After Width: | Height: | Size: 309 KiB |
BIN
src/assets/imgs/profile.jpg
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
BIN
src/assets/imgs/shisong.jpg
Normal file
|
After Width: | Height: | Size: 192 KiB |
BIN
src/assets/imgs/wechat.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
1
src/assets/svgs/403.svg
Normal file
|
After Width: | Height: | Size: 13 KiB |
1
src/assets/svgs/404.svg
Normal file
|
After Width: | Height: | Size: 13 KiB |
1
src/assets/svgs/500.svg
Normal file
|
After Width: | Height: | Size: 19 KiB |
1
src/assets/svgs/icon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg width="128" height="128" xmlns="http://www.w3.org/2000/svg"><path d="M115.147.062a13 13 0 014.94.945c1.55.63 2.907 1.526 4.069 2.688a13.148 13.148 0 012.761 4.069c.678 1.55 1.017 3.245 1.017 5.086v102.3c0 3.681-1.187 6.733-3.56 9.155-2.373 2.422-5.352 3.633-8.937 3.633H12.992c-3.875 0-7-1.26-9.373-3.779-2.373-2.518-3.56-5.667-3.56-9.445V12.704c0-3.39 1.163-6.345 3.488-8.863C5.872 1.32 8.972.062 12.847.062h102.3zM81.434 109.047c1.744 0 3.003-.412 3.778-1.235.775-.824 1.163-1.914 1.163-3.27 0-1.26-.388-2.325-1.163-3.197-.775-.872-2.034-1.307-3.778-1.307H72.57c.097-.194.145-.485.145-.872V27.09h9.01c1.743 0 2.954-.436 3.633-1.308.678-.872 1.017-1.938 1.017-3.197 0-1.26-.34-2.325-1.017-3.197-.679-.872-1.89-1.308-3.633-1.308H46.268c-1.743 0-2.954.436-3.632 1.308-.678.872-1.018 1.938-1.018 3.197 0 1.26.34 2.325 1.018 3.197.678.872 1.889 1.308 3.632 1.308h8.138v72.075c0 .193.024.339.073.436.048.096.072.242.072.436H46.56c-1.744 0-3.003.435-3.778 1.307-.775.872-1.163 1.938-1.163 3.197 0 1.356.388 2.446 1.163 3.27.775.823 2.034 1.235 3.778 1.235h34.875z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
1
src/assets/svgs/login-bg.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="5760" height="3040"><image width="5760" height="3040" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAFoAAAAvgAQMAAAC1QKagAAAABGdBTUEAALGPC/xhBQAAACBjSFJN AAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAABlBMVEUsNEr///91v/yPAAAA AWJLR0QB/wIt3gAAAAd0SU1FB+YBBQYyN1c3BnEAAAhjSURBVHja7cExAQAAAMKg9U9tDB+gAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAACAtwFzzwABY3VrRQAAACV0RVh0ZGF0ZTpjcmVhdGUAMjAyMi0wMS0wNVQwNjo1 MDo1MyswMDowMCfNlVoAAAAldEVYdGRhdGU6bW9kaWZ5ADIwMjItMDEtMDVUMDY6NTA6NTQrMDA6 MDCTNxNoAAAAAElFTkSuQmCC"/></svg>
|
||||
|
After Width: | Height: | Size: 3.3 KiB |
1
src/assets/svgs/login-box-bg.svg
Normal file
|
After Width: | Height: | Size: 33 KiB |
1
src/assets/svgs/message.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg width="128" height="128" xmlns="http://www.w3.org/2000/svg"><path d="M0 20.967v59.59c0 11.59 8.537 20.966 19.075 20.966h28.613l1 26.477L76.8 101.523h32.125c10.538 0 19.075-9.377 19.075-20.966v-59.59C128 9.377 119.463 0 108.925 0h-89.85C8.538 0 0 9.377 0 20.967zm82.325 33.1c0-5.524 4.013-9.935 9.037-9.935 5.026 0 9.038 4.41 9.038 9.934 0 5.524-4.025 9.934-9.038 9.934-5.024 0-9.037-4.41-9.037-9.934zm-27.613 0c0-5.524 4.013-9.935 9.038-9.935s9.037 4.41 9.037 9.934c0 5.524-4.025 9.934-9.037 9.934-5.025 0-9.038-4.41-9.038-9.934zm-27.1 0c0-5.524 4.013-9.935 9.038-9.935s9.038 4.41 9.038 9.934c0 5.524-4.026 9.934-9.05 9.934-5.013 0-9.025-4.41-9.025-9.934z"/></svg>
|
||||
|
After Width: | Height: | Size: 669 B |
1
src/assets/svgs/money.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg width="128" height="128" xmlns="http://www.w3.org/2000/svg"><path d="M54.122 127.892v-28.68H7.513V87.274h46.609v-12.4H7.513v-12.86h38.003L.099 0h22.6l32.556 45.07c3.617 5.144 6.44 9.611 8.487 13.385 1.788-3.05 4.89-7.779 9.301-14.186L103.93 0h24.01L82.385 62.013h38.34v12.862h-46.41v12.4h46.41v11.937h-46.41v28.68H54.123z"/></svg>
|
||||
|
After Width: | Height: | Size: 335 B |
1
src/assets/svgs/peoples.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg width="128" height="128" xmlns="http://www.w3.org/2000/svg"><path d="M95.648 118.762c0 5.035-3.563 9.121-7.979 9.121H7.98c-4.416 0-7.979-4.086-7.979-9.121C0 100.519 15.408 83.47 31.152 76.75c-9.099-6.43-15.216-17.863-15.216-30.987v-9.128c0-20.16 14.293-36.518 31.893-36.518s31.894 16.358 31.894 36.518v9.122c0 13.137-6.123 24.556-15.216 30.993 15.738 6.726 31.141 23.769 31.141 42.012z"/><path d="M106.032 118.252h15.867c3.376 0 6.101-3.125 6.101-6.972 0-13.957-11.787-26.984-23.819-32.123 6.955-4.919 11.638-13.66 11.638-23.704v-6.985c0-15.416-10.928-27.926-24.39-27.926-1.674 0-3.306.193-4.89.561 1.936 4.713 3.018 9.974 3.018 15.526v9.121c0 13.137-3.056 23.111-11.066 30.993 14.842 4.41 27.312 23.42 27.541 41.509z"/></svg>
|
||||
|
After Width: | Height: | Size: 731 B |
1
src/assets/svgs/shopping.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg width="128" height="128" xmlns="http://www.w3.org/2000/svg"><path d="M42.913 101.36c1.642 0 3.198.332 4.667.996a12.28 12.28 0 013.89 2.772c1.123 1.184 1.987 2.582 2.592 4.193.605 1.612.908 3.318.908 5.118 0 1.8-.303 3.507-.908 5.118-.605 1.611-1.469 3.01-2.593 4.194a13.3 13.3 0 01-3.889 2.843 10.582 10.582 0 01-4.667 1.066c-1.729 0-3.306-.355-4.732-1.066a13.604 13.604 0 01-3.825-2.843c-1.123-1.185-1.988-2.583-2.593-4.194a14.437 14.437 0 01-.907-5.118c0-1.8.302-3.506.907-5.118.605-1.61 1.47-3.009 2.593-4.193a12.515 12.515 0 013.825-2.772c1.426-.664 3.003-.996 4.732-.996zm53.932.285c1.643 0 3.22.331 4.733.995a11.386 11.386 0 013.889 2.772c1.08 1.185 1.945 2.583 2.593 4.194.648 1.61.972 3.317.972 5.118 0 1.8-.324 3.506-.972 5.117-.648 1.611-1.513 3.01-2.593 4.194a12.253 12.253 0 01-3.89 2.843 11 11 0 01-4.732 1.066 10.58 10.58 0 01-4.667-1.066 12.478 12.478 0 01-3.824-2.843c-1.08-1.185-1.945-2.583-2.593-4.194a13.581 13.581 0 01-.973-5.117c0-1.801.325-3.507.973-5.118.648-1.611 1.512-3.01 2.593-4.194a11.559 11.559 0 013.824-2.772 11.212 11.212 0 014.667-.995zm21.781-80.747c2.42 0 4.3.355 5.64 1.066 1.34.71 2.29 1.587 2.852 2.63a6.427 6.427 0 01.778 3.34c-.044 1.185-.195 2.204-.454 3.057-.26.853-.8 2.606-1.62 5.26a589.268 589.268 0 01-2.788 8.743 1236.373 1236.373 0 00-3.047 9.453c-.994 3.128-1.75 5.592-2.269 7.393-1.123 3.79-2.55 6.42-4.278 7.89-1.728 1.469-3.846 2.203-6.352 2.203H39.023l1.945 12.795h65.342c4.148 0 6.223 1.943 6.223 5.828 0 1.896-.41 3.53-1.232 4.905-.821 1.374-2.442 2.061-4.862 2.061H38.505c-1.729 0-3.176-.426-4.343-1.28-1.167-.852-2.14-1.966-2.917-3.34a21.277 21.277 0 01-1.88-4.478 44.128 44.128 0 01-1.102-4.55c-.087-.568-.324-1.942-.713-4.122-.39-2.18-.865-4.904-1.426-8.174l-1.88-10.947c-.692-4.027-1.383-8.079-2.075-12.154-1.642-9.572-3.5-20.234-5.574-31.986H6.87c-1.296 0-2.377-.356-3.24-1.067a9.024 9.024 0 01-2.14-2.558 10.416 10.416 0 01-1.167-3.2C.108 8.53 0 7.488 0 6.54c0-1.896.583-3.46 1.75-4.69C2.917.615 4.494 0 6.482 0h13.095c1.728 0 3.111.284 4.148.853 1.037.569 1.858 1.28 2.463 2.132a8.548 8.548 0 011.297 2.701c.26.948.475 1.754.648 2.417.173.758.346 1.825.519 3.199.173 1.374.345 2.772.518 4.193.26 1.706.519 3.507.778 5.403h88.678z"/></svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
3
src/components/Backtop/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import Backtop from './src/Backtop.vue'
|
||||
|
||||
export { Backtop }
|
||||
15
src/components/Backtop/src/Backtop.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script lang="ts" name="BackTop" setup>
|
||||
import { ElBacktop } from 'element-plus'
|
||||
import { useDesign } from '@/hooks/web/useDesign'
|
||||
|
||||
const { getPrefixCls, variables } = useDesign()
|
||||
|
||||
const prefixCls = getPrefixCls('backtop')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElBacktop
|
||||
:class="`${prefixCls}-backtop`"
|
||||
:target="`.${variables.namespace}-layout-content-scrollbar .${variables.elNamespace}-scrollbar__wrap`"
|
||||
/>
|
||||
</template>
|
||||
3
src/components/ConfigGlobal/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import ConfigGlobal from './src/ConfigGlobal.vue'
|
||||
|
||||
export { ConfigGlobal }
|
||||
61
src/components/ConfigGlobal/src/ConfigGlobal.vue
Normal file
@@ -0,0 +1,61 @@
|
||||
<script lang="ts" name="ConfigGlobal" setup>
|
||||
import { propTypes } from '@/utils/propTypes'
|
||||
import { useLocaleStore } from '@/store/modules/locale'
|
||||
import { useAppStore } from '@/store/modules/app'
|
||||
import { setCssVar } from '@/utils'
|
||||
import { useDesign } from '@/hooks/web/useDesign'
|
||||
import { ElementPlusSize } from '@/types/elementPlus'
|
||||
import { useWindowSize } from '@vueuse/core'
|
||||
|
||||
const { variables } = useDesign()
|
||||
|
||||
const appStore = useAppStore()
|
||||
|
||||
const props = defineProps({
|
||||
size: propTypes.oneOf<ElementPlusSize>(['default', 'small', 'large']).def('default')
|
||||
})
|
||||
|
||||
provide('configGlobal', props)
|
||||
|
||||
// 初始化所有主题色
|
||||
onMounted(() => {
|
||||
appStore.setCssVarTheme()
|
||||
})
|
||||
|
||||
const { width } = useWindowSize()
|
||||
|
||||
// 监听窗口变化
|
||||
watch(
|
||||
() => width.value,
|
||||
(width: number) => {
|
||||
if (width < 768) {
|
||||
!appStore.getMobile ? appStore.setMobile(true) : undefined
|
||||
setCssVar('--left-menu-min-width', '0')
|
||||
appStore.setCollapse(true)
|
||||
appStore.getLayout !== 'classic' ? appStore.setLayout('classic') : undefined
|
||||
} else {
|
||||
appStore.getMobile ? appStore.setMobile(false) : undefined
|
||||
setCssVar('--left-menu-min-width', '64px')
|
||||
}
|
||||
},
|
||||
{
|
||||
immediate: true
|
||||
}
|
||||
)
|
||||
|
||||
// 多语言相关
|
||||
const localeStore = useLocaleStore()
|
||||
|
||||
const currentLocale = computed(() => localeStore.currentLocale)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElConfigProvider
|
||||
:locale="currentLocale.elLocale"
|
||||
:message="{ max: 1 }"
|
||||
:namespace="variables.elNamespace"
|
||||
:size="size"
|
||||
>
|
||||
<slot></slot>
|
||||
</ElConfigProvider>
|
||||
</template>
|
||||
3
src/components/ContentDetailWrap/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import ContentDetailWrap from './src/ContentDetailWrap.vue'
|
||||
|
||||
export { ContentDetailWrap }
|
||||
56
src/components/ContentDetailWrap/src/ContentDetailWrap.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<script lang="ts" name="ContentDetailWrap" setup>
|
||||
import { propTypes } from '@/utils/propTypes'
|
||||
import { useDesign } from '@/hooks/web/useDesign'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { getPrefixCls } = useDesign()
|
||||
|
||||
const prefixCls = getPrefixCls('content-detail-wrap')
|
||||
|
||||
defineProps({
|
||||
title: propTypes.string.def(''),
|
||||
message: propTypes.string.def('')
|
||||
})
|
||||
const emit = defineEmits(['back'])
|
||||
const offset = ref(85)
|
||||
const contentDetailWrap = ref()
|
||||
onMounted(() => {
|
||||
offset.value = contentDetailWrap.value.getBoundingClientRect().top
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="contentDetailWrap" :class="[`${prefixCls}-container`]">
|
||||
<Sticky :offset="offset">
|
||||
<div
|
||||
:class="[
|
||||
`${prefixCls}-header`,
|
||||
'flex border-bottom-1 h-50px items-center text-center pr-10px'
|
||||
]"
|
||||
>
|
||||
<div :class="[`${prefixCls}-header__back`, 'flex pl-10px pr-10px ']">
|
||||
<ElButton @click="emit('back')">
|
||||
<Icon class="mr-5px" icon="ep:arrow-left" />
|
||||
{{ t('common.back') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
<div :class="[`${prefixCls}-header__title`, 'flex flex-1 justify-center']">
|
||||
<slot name="title">
|
||||
<label class="text-16px font-700">{{ title }}</label>
|
||||
</slot>
|
||||
</div>
|
||||
<div :class="[`${prefixCls}-header__right`, 'flex pl-10px pr-10px']">
|
||||
<slot name="right"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</Sticky>
|
||||
<div style="padding: var(--app-content-padding)">
|
||||
<ElCard :class="[`${prefixCls}-body`, 'mb-20px']" shadow="never">
|
||||
<div>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</ElCard>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
3
src/components/ContentWrap/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import ContentWrap from './src/ContentWrap.vue'
|
||||
|
||||
export { ContentWrap }
|
||||
32
src/components/ContentWrap/src/ContentWrap.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<script lang="ts" name="ContentWrap" setup>
|
||||
import { propTypes } from '@/utils/propTypes'
|
||||
import { useDesign } from '@/hooks/web/useDesign'
|
||||
|
||||
const { getPrefixCls } = useDesign()
|
||||
|
||||
const prefixCls = getPrefixCls('content-wrap')
|
||||
|
||||
defineProps({
|
||||
title: propTypes.string.def(''),
|
||||
message: propTypes.string.def('')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElCard :class="[prefixCls, 'mb-15px']" shadow="never">
|
||||
<template v-if="title" #header>
|
||||
<div class="flex items-center">
|
||||
<span class="text-16px font-700">{{ title }}</span>
|
||||
<ElTooltip v-if="message" effect="dark" placement="right">
|
||||
<template #content>
|
||||
<div class="max-w-200px">{{ message }}</div>
|
||||
</template>
|
||||
<Icon :size="14" class="ml-5px" icon="ep:question-filled" />
|
||||
</ElTooltip>
|
||||
</div>
|
||||
</template>
|
||||
<div>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</ElCard>
|
||||
</template>
|
||||
3
src/components/CountTo/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import CountTo from './src/CountTo.vue'
|
||||
|
||||
export { CountTo }
|
||||
180
src/components/CountTo/src/CountTo.vue
Normal file
@@ -0,0 +1,180 @@
|
||||
<script lang="ts" name="CountTo" setup>
|
||||
import { PropType } from 'vue'
|
||||
import { isNumber } from '@/utils/is'
|
||||
import { propTypes } from '@/utils/propTypes'
|
||||
import { useDesign } from '@/hooks/web/useDesign'
|
||||
|
||||
const { getPrefixCls } = useDesign()
|
||||
|
||||
const prefixCls = getPrefixCls('count-to')
|
||||
|
||||
const props = defineProps({
|
||||
startVal: propTypes.number.def(0),
|
||||
endVal: propTypes.number.def(2021),
|
||||
duration: propTypes.number.def(3000),
|
||||
autoplay: propTypes.bool.def(true),
|
||||
decimals: propTypes.number.validate((value: number) => value >= 0).def(0),
|
||||
decimal: propTypes.string.def('.'),
|
||||
separator: propTypes.string.def(','),
|
||||
prefix: propTypes.string.def(''),
|
||||
suffix: propTypes.string.def(''),
|
||||
useEasing: propTypes.bool.def(true),
|
||||
easingFn: {
|
||||
type: Function as PropType<(t: number, b: number, c: number, d: number) => number>,
|
||||
default(t: number, b: number, c: number, d: number) {
|
||||
return (c * (-Math.pow(2, (-10 * t) / d) + 1) * 1024) / 1023 + b
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['mounted', 'callback'])
|
||||
|
||||
const formatNumber = (num: number | string) => {
|
||||
const { decimals, decimal, separator, suffix, prefix } = props
|
||||
num = Number(num).toFixed(decimals)
|
||||
num += ''
|
||||
const x = num.split('.')
|
||||
let x1 = x[0]
|
||||
const x2 = x.length > 1 ? decimal + x[1] : ''
|
||||
const rgx = /(\d+)(\d{3})/
|
||||
if (separator && !isNumber(separator)) {
|
||||
while (rgx.test(x1)) {
|
||||
x1 = x1.replace(rgx, '$1' + separator + '$2')
|
||||
}
|
||||
}
|
||||
return prefix + x1 + x2 + suffix
|
||||
}
|
||||
|
||||
const state = reactive<{
|
||||
localStartVal: number
|
||||
printVal: number | null
|
||||
displayValue: string
|
||||
paused: boolean
|
||||
localDuration: number | null
|
||||
startTime: number | null
|
||||
timestamp: number | null
|
||||
rAF: any
|
||||
remaining: number | null
|
||||
}>({
|
||||
localStartVal: props.startVal,
|
||||
displayValue: formatNumber(props.startVal),
|
||||
printVal: null,
|
||||
paused: false,
|
||||
localDuration: props.duration,
|
||||
startTime: null,
|
||||
timestamp: null,
|
||||
remaining: null,
|
||||
rAF: null
|
||||
})
|
||||
|
||||
const displayValue = toRef(state, 'displayValue')
|
||||
|
||||
onMounted(() => {
|
||||
if (props.autoplay) {
|
||||
start()
|
||||
}
|
||||
emit('mounted')
|
||||
})
|
||||
|
||||
const getCountDown = computed(() => {
|
||||
return props.startVal > props.endVal
|
||||
})
|
||||
|
||||
watch([() => props.startVal, () => props.endVal], () => {
|
||||
if (props.autoplay) {
|
||||
start()
|
||||
}
|
||||
})
|
||||
|
||||
const start = () => {
|
||||
const { startVal, duration } = props
|
||||
state.localStartVal = startVal
|
||||
state.startTime = null
|
||||
state.localDuration = duration
|
||||
state.paused = false
|
||||
state.rAF = requestAnimationFrame(count)
|
||||
}
|
||||
|
||||
const pauseResume = () => {
|
||||
if (state.paused) {
|
||||
resume()
|
||||
state.paused = false
|
||||
} else {
|
||||
pause()
|
||||
state.paused = true
|
||||
}
|
||||
}
|
||||
|
||||
const pause = () => {
|
||||
cancelAnimationFrame(state.rAF)
|
||||
}
|
||||
|
||||
const resume = () => {
|
||||
state.startTime = null
|
||||
state.localDuration = +(state.remaining as number)
|
||||
state.localStartVal = +(state.printVal as number)
|
||||
requestAnimationFrame(count)
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
state.startTime = null
|
||||
cancelAnimationFrame(state.rAF)
|
||||
state.displayValue = formatNumber(props.startVal)
|
||||
}
|
||||
|
||||
const count = (timestamp: number) => {
|
||||
const { useEasing, easingFn, endVal } = props
|
||||
if (!state.startTime) state.startTime = timestamp
|
||||
state.timestamp = timestamp
|
||||
const progress = timestamp - state.startTime
|
||||
state.remaining = (state.localDuration as number) - progress
|
||||
if (useEasing) {
|
||||
if (unref(getCountDown)) {
|
||||
state.printVal =
|
||||
state.localStartVal -
|
||||
easingFn(progress, 0, state.localStartVal - endVal, state.localDuration as number)
|
||||
} else {
|
||||
state.printVal = easingFn(
|
||||
progress,
|
||||
state.localStartVal,
|
||||
endVal - state.localStartVal,
|
||||
state.localDuration as number
|
||||
)
|
||||
}
|
||||
} else {
|
||||
if (unref(getCountDown)) {
|
||||
state.printVal =
|
||||
state.localStartVal -
|
||||
(state.localStartVal - endVal) * (progress / (state.localDuration as number))
|
||||
} else {
|
||||
state.printVal =
|
||||
state.localStartVal +
|
||||
(endVal - state.localStartVal) * (progress / (state.localDuration as number))
|
||||
}
|
||||
}
|
||||
if (unref(getCountDown)) {
|
||||
state.printVal = state.printVal < endVal ? endVal : state.printVal
|
||||
} else {
|
||||
state.printVal = state.printVal > endVal ? endVal : state.printVal
|
||||
}
|
||||
state.displayValue = formatNumber(state.printVal!)
|
||||
if (progress < (state.localDuration as number)) {
|
||||
state.rAF = requestAnimationFrame(count)
|
||||
} else {
|
||||
emit('callback')
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
pauseResume,
|
||||
reset,
|
||||
start,
|
||||
pause
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span :class="prefixCls">
|
||||
{{ displayValue }}
|
||||
</span>
|
||||
</template>
|
||||
2
src/components/Crontab/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import Crontab from './src/Crontab.vue'
|
||||
export { Crontab }
|
||||
1009
src/components/Crontab/src/Crontab.vue
Normal file
4
src/components/Cropper/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import CropperImage from './src/Cropper.vue'
|
||||
import CropperAvatar from './src/CropperAvatar.vue'
|
||||
|
||||
export { CropperImage, CropperAvatar }
|
||||
257
src/components/Cropper/src/CopperModal.vue
Normal file
@@ -0,0 +1,257 @@
|
||||
<template>
|
||||
<div>
|
||||
<Dialog
|
||||
v-model="dialogVisible"
|
||||
:canFullscreen="false"
|
||||
:title="t('cropper.modalTitle')"
|
||||
maxHeight="380px"
|
||||
width="800px"
|
||||
>
|
||||
<div :class="prefixCls">
|
||||
<div :class="`${prefixCls}-left`">
|
||||
<div :class="`${prefixCls}-cropper`">
|
||||
<CropperImage
|
||||
v-if="src"
|
||||
:circled="circled"
|
||||
:src="src"
|
||||
height="300px"
|
||||
@cropend="handleCropend"
|
||||
@ready="handleReady"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div :class="`${prefixCls}-toolbar`">
|
||||
<el-upload :beforeUpload="handleBeforeUpload" :fileList="[]" accept="image/*">
|
||||
<el-tooltip :content="t('cropper.selectImage')" placement="bottom">
|
||||
<XButton preIcon="ant-design:upload-outlined" type="primary" />
|
||||
</el-tooltip>
|
||||
</el-upload>
|
||||
<el-space>
|
||||
<el-tooltip :content="t('cropper.btn_reset')" placement="bottom">
|
||||
<XButton
|
||||
:disabled="!src"
|
||||
preIcon="ant-design:reload-outlined"
|
||||
size="small"
|
||||
type="primary"
|
||||
@click="handlerToolbar('reset')"
|
||||
/>
|
||||
</el-tooltip>
|
||||
<el-tooltip :content="t('cropper.btn_rotate_left')" placement="bottom">
|
||||
<XButton
|
||||
:disabled="!src"
|
||||
preIcon="ant-design:rotate-left-outlined"
|
||||
size="small"
|
||||
type="primary"
|
||||
@click="handlerToolbar('rotate', -45)"
|
||||
/>
|
||||
</el-tooltip>
|
||||
<el-tooltip :content="t('cropper.btn_rotate_right')" placement="bottom">
|
||||
<XButton
|
||||
:disabled="!src"
|
||||
preIcon="ant-design:rotate-right-outlined"
|
||||
size="small"
|
||||
type="primary"
|
||||
@click="handlerToolbar('rotate', 45)"
|
||||
/>
|
||||
</el-tooltip>
|
||||
<el-tooltip :content="t('cropper.btn_scale_x')" placement="bottom">
|
||||
<XButton
|
||||
:disabled="!src"
|
||||
preIcon="vaadin:arrows-long-h"
|
||||
size="small"
|
||||
type="primary"
|
||||
@click="handlerToolbar('scaleX')"
|
||||
/>
|
||||
</el-tooltip>
|
||||
<el-tooltip :content="t('cropper.btn_scale_y')" placement="bottom">
|
||||
<XButton
|
||||
:disabled="!src"
|
||||
preIcon="vaadin:arrows-long-v"
|
||||
size="small"
|
||||
type="primary"
|
||||
@click="handlerToolbar('scaleY')"
|
||||
/>
|
||||
</el-tooltip>
|
||||
<el-tooltip :content="t('cropper.btn_zoom_in')" placement="bottom">
|
||||
<XButton
|
||||
:disabled="!src"
|
||||
preIcon="ant-design:zoom-in-outlined"
|
||||
size="small"
|
||||
type="primary"
|
||||
@click="handlerToolbar('zoom', 0.1)"
|
||||
/>
|
||||
</el-tooltip>
|
||||
<el-tooltip :content="t('cropper.btn_zoom_out')" placement="bottom">
|
||||
<XButton
|
||||
:disabled="!src"
|
||||
preIcon="ant-design:zoom-out-outlined"
|
||||
size="small"
|
||||
type="primary"
|
||||
@click="handlerToolbar('zoom', -0.1)"
|
||||
/>
|
||||
</el-tooltip>
|
||||
</el-space>
|
||||
</div>
|
||||
</div>
|
||||
<div :class="`${prefixCls}-right`">
|
||||
<div :class="`${prefixCls}-preview`">
|
||||
<img v-if="previewSource" :alt="t('cropper.preview')" :src="previewSource" />
|
||||
</div>
|
||||
<template v-if="previewSource">
|
||||
<div :class="`${prefixCls}-group`">
|
||||
<el-avatar :src="previewSource" size="large" />
|
||||
<el-avatar :size="48" :src="previewSource" />
|
||||
<el-avatar :size="64" :src="previewSource" />
|
||||
<el-avatar :size="80" :src="previewSource" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button type="primary" @click="handleOk">{{ t('cropper.okText') }}</el-button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" name="CopperModal" setup>
|
||||
import { useDesign } from '@/hooks/web/useDesign'
|
||||
import { dataURLtoBlob } from '@/utils/filt'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { CropendResult, Cropper } from './types'
|
||||
import { propTypes } from '@/utils/propTypes'
|
||||
import { CropperImage } from '@/components/Cropper'
|
||||
|
||||
const props = defineProps({
|
||||
srcValue: propTypes.string.def(''),
|
||||
circled: propTypes.bool.def(true)
|
||||
})
|
||||
const emit = defineEmits(['uploadSuccess'])
|
||||
const { t } = useI18n()
|
||||
const { getPrefixCls } = useDesign()
|
||||
const prefixCls = getPrefixCls('cropper-am')
|
||||
|
||||
const src = ref(props.srcValue)
|
||||
const previewSource = ref('')
|
||||
const cropper = ref<Cropper>()
|
||||
const dialogVisible = ref(false)
|
||||
let filename = ''
|
||||
let scaleX = 1
|
||||
let scaleY = 1
|
||||
|
||||
// Block upload
|
||||
function handleBeforeUpload(file: File) {
|
||||
const reader = new FileReader()
|
||||
reader.readAsDataURL(file)
|
||||
src.value = ''
|
||||
previewSource.value = ''
|
||||
reader.onload = function (e) {
|
||||
src.value = (e.target?.result as string) ?? ''
|
||||
filename = file.name
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function handleCropend({ imgBase64 }: CropendResult) {
|
||||
previewSource.value = imgBase64
|
||||
}
|
||||
|
||||
function handleReady(cropperInstance: Cropper) {
|
||||
cropper.value = cropperInstance
|
||||
}
|
||||
|
||||
function handlerToolbar(event: string, arg?: number) {
|
||||
if (event === 'scaleX') {
|
||||
scaleX = arg = scaleX === -1 ? 1 : -1
|
||||
}
|
||||
if (event === 'scaleY') {
|
||||
scaleY = arg = scaleY === -1 ? 1 : -1
|
||||
}
|
||||
cropper?.value?.[event]?.(arg)
|
||||
}
|
||||
|
||||
async function handleOk() {
|
||||
const blob = dataURLtoBlob(previewSource.value)
|
||||
emit('uploadSuccess', { source: previewSource.value, data: blob, filename: filename })
|
||||
}
|
||||
|
||||
function openModal() {
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
dialogVisible.value = false
|
||||
}
|
||||
|
||||
defineExpose({ openModal, closeModal })
|
||||
</script>
|
||||
<style lang="scss">
|
||||
$prefix-cls: #{$namespace}-cropper-am;
|
||||
|
||||
.#{$prefix-cls} {
|
||||
display: flex;
|
||||
|
||||
&-left,
|
||||
&-right {
|
||||
height: 340px;
|
||||
}
|
||||
|
||||
&-left {
|
||||
width: 55%;
|
||||
}
|
||||
|
||||
&-right {
|
||||
width: 45%;
|
||||
}
|
||||
|
||||
&-cropper {
|
||||
height: 300px;
|
||||
background: #eee;
|
||||
background-image: linear-gradient(
|
||||
45deg,
|
||||
rgb(0 0 0 / 25%) 25%,
|
||||
transparent 0,
|
||||
transparent 75%,
|
||||
rgb(0 0 0 / 25%) 0
|
||||
),
|
||||
linear-gradient(
|
||||
45deg,
|
||||
rgb(0 0 0 / 25%) 25%,
|
||||
transparent 0,
|
||||
transparent 75%,
|
||||
rgb(0 0 0 / 25%) 0
|
||||
);
|
||||
background-position: 0 0, 12px 12px;
|
||||
background-size: 24px 24px;
|
||||
}
|
||||
|
||||
&-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
&-preview {
|
||||
width: 220px;
|
||||
height: 220px;
|
||||
margin: 0 auto;
|
||||
overflow: hidden;
|
||||
border: 1px solid;
|
||||
border-radius: 50%;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&-group {
|
||||
display: flex;
|
||||
padding-top: 8px;
|
||||
margin-top: 8px;
|
||||
border-top: 1px solid;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
181
src/components/Cropper/src/Cropper.vue
Normal file
@@ -0,0 +1,181 @@
|
||||
<template>
|
||||
<div :class="getClass" :style="getWrapperStyle">
|
||||
<img
|
||||
v-show="isReady"
|
||||
ref="imgElRef"
|
||||
:alt="alt"
|
||||
:crossorigin="crossorigin"
|
||||
:src="src"
|
||||
:style="getImageStyle"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" name="Cropper" setup>
|
||||
import { CSSProperties, PropType } from 'vue'
|
||||
import Cropper from 'cropperjs'
|
||||
import 'cropperjs/dist/cropper.css'
|
||||
import { useDesign } from '@/hooks/web/useDesign'
|
||||
import { propTypes } from '@/utils/propTypes'
|
||||
import { useDebounceFn } from '@vueuse/core'
|
||||
|
||||
type Options = Cropper.Options
|
||||
|
||||
const defaultOptions: Options = {
|
||||
aspectRatio: 1,
|
||||
zoomable: true,
|
||||
zoomOnTouch: true,
|
||||
zoomOnWheel: true,
|
||||
cropBoxMovable: true,
|
||||
cropBoxResizable: true,
|
||||
toggleDragModeOnDblclick: true,
|
||||
autoCrop: true,
|
||||
background: true,
|
||||
highlight: true,
|
||||
center: true,
|
||||
responsive: true,
|
||||
restore: true,
|
||||
checkCrossOrigin: true,
|
||||
checkOrientation: true,
|
||||
scalable: true,
|
||||
modal: true,
|
||||
guides: true,
|
||||
movable: true,
|
||||
rotatable: true
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
src: propTypes.string.def(''),
|
||||
alt: propTypes.string.def(''),
|
||||
circled: propTypes.bool.def(false),
|
||||
realTimePreview: propTypes.bool.def(true),
|
||||
height: propTypes.string.def('360px'),
|
||||
crossorigin: {
|
||||
type: String as PropType<'' | 'anonymous' | 'use-credentials' | undefined>,
|
||||
default: undefined
|
||||
},
|
||||
imageStyle: { type: Object as PropType<CSSProperties>, default: () => ({}) },
|
||||
options: { type: Object as PropType<Options>, default: () => ({}) }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['cropend', 'ready', 'cropendError'])
|
||||
const attrs = useAttrs()
|
||||
const imgElRef = ref<ElRef<HTMLImageElement>>()
|
||||
const cropper = ref<Nullable<Cropper>>()
|
||||
const isReady = ref(false)
|
||||
|
||||
const { getPrefixCls } = useDesign()
|
||||
const prefixCls = getPrefixCls('cropper-image')
|
||||
const debounceRealTimeCroppered = useDebounceFn(realTimeCroppered, 80)
|
||||
|
||||
const getImageStyle = computed((): CSSProperties => {
|
||||
return {
|
||||
height: props.height,
|
||||
maxWidth: '100%',
|
||||
...props.imageStyle
|
||||
}
|
||||
})
|
||||
|
||||
const getClass = computed(() => {
|
||||
return [
|
||||
prefixCls,
|
||||
attrs.class,
|
||||
{
|
||||
[`${prefixCls}--circled`]: props.circled
|
||||
}
|
||||
]
|
||||
})
|
||||
const getWrapperStyle = computed((): CSSProperties => {
|
||||
return { height: `${props.height}`.replace(/px/, '') + 'px' }
|
||||
})
|
||||
|
||||
onMounted(init)
|
||||
|
||||
onUnmounted(() => {
|
||||
cropper.value?.destroy()
|
||||
})
|
||||
|
||||
async function init() {
|
||||
const imgEl = unref(imgElRef)
|
||||
if (!imgEl) {
|
||||
return
|
||||
}
|
||||
cropper.value = new Cropper(imgEl, {
|
||||
...defaultOptions,
|
||||
ready: () => {
|
||||
isReady.value = true
|
||||
realTimeCroppered()
|
||||
emit('ready', cropper.value)
|
||||
},
|
||||
crop() {
|
||||
debounceRealTimeCroppered()
|
||||
},
|
||||
zoom() {
|
||||
debounceRealTimeCroppered()
|
||||
},
|
||||
cropmove() {
|
||||
debounceRealTimeCroppered()
|
||||
},
|
||||
...props.options
|
||||
})
|
||||
}
|
||||
|
||||
// Real-time display preview
|
||||
function realTimeCroppered() {
|
||||
props.realTimePreview && croppered()
|
||||
}
|
||||
|
||||
// event: return base64 and width and height information after cropping
|
||||
function croppered() {
|
||||
if (!cropper.value) {
|
||||
return
|
||||
}
|
||||
let imgInfo = cropper.value.getData()
|
||||
const canvas = props.circled ? getRoundedCanvas() : cropper.value.getCroppedCanvas()
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) {
|
||||
return
|
||||
}
|
||||
let fileReader: FileReader = new FileReader()
|
||||
fileReader.readAsDataURL(blob)
|
||||
fileReader.onloadend = (e) => {
|
||||
emit('cropend', {
|
||||
imgBase64: e.target?.result ?? '',
|
||||
imgInfo
|
||||
})
|
||||
}
|
||||
fileReader.onerror = () => {
|
||||
emit('cropendError')
|
||||
}
|
||||
}, 'image/png')
|
||||
}
|
||||
|
||||
// Get a circular picture canvas
|
||||
function getRoundedCanvas() {
|
||||
const sourceCanvas = cropper.value!.getCroppedCanvas()
|
||||
const canvas = document.createElement('canvas')
|
||||
const context = canvas.getContext('2d')!
|
||||
const width = sourceCanvas.width
|
||||
const height = sourceCanvas.height
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
context.imageSmoothingEnabled = true
|
||||
context.drawImage(sourceCanvas, 0, 0, width, height)
|
||||
context.globalCompositeOperation = 'destination-in'
|
||||
context.beginPath()
|
||||
context.arc(width / 2, height / 2, Math.min(width, height) / 2, 0, 2 * Math.PI, true)
|
||||
context.fill()
|
||||
return canvas
|
||||
}
|
||||
</script>
|
||||
<style lang="scss">
|
||||
$prefix-cls: #{$namespace}-cropper-image;
|
||||
|
||||
.#{$prefix-cls} {
|
||||
&--circled {
|
||||
.cropper-view-box,
|
||||
.cropper-face {
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
141
src/components/Cropper/src/CropperAvatar.vue
Normal file
@@ -0,0 +1,141 @@
|
||||
<template>
|
||||
<div class="user-info-head" @click="open()">
|
||||
<img v-if="sourceValue" :src="sourceValue" alt="avatar" class="img-circle img-lg" />
|
||||
<el-button v-if="showBtn" :class="`${prefixCls}-upload-btn`" @click="open()">
|
||||
{{ btnText ? btnText : t('cropper.selectImage') }}
|
||||
</el-button>
|
||||
<CopperModal
|
||||
ref="cropperModelRef"
|
||||
:srcValue="sourceValue"
|
||||
@upload-success="handleUploadSuccess"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" name="CropperAvatar" setup>
|
||||
import { useDesign } from '@/hooks/web/useDesign'
|
||||
|
||||
import { propTypes } from '@/utils/propTypes'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import CopperModal from './CopperModal.vue'
|
||||
|
||||
const props = defineProps({
|
||||
width: propTypes.string.def('200px'),
|
||||
value: propTypes.string.def(''),
|
||||
showBtn: propTypes.bool.def(true),
|
||||
btnText: propTypes.string.def('')
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:value', 'change'])
|
||||
const sourceValue = ref(props.value)
|
||||
const { getPrefixCls } = useDesign()
|
||||
const prefixCls = getPrefixCls('cropper-avatar')
|
||||
const message = useMessage()
|
||||
const { t } = useI18n()
|
||||
|
||||
const cropperModelRef = ref()
|
||||
|
||||
watchEffect(() => {
|
||||
sourceValue.value = props.value
|
||||
})
|
||||
|
||||
watch(
|
||||
() => sourceValue.value,
|
||||
(v: string) => {
|
||||
emit('update:value', v)
|
||||
}
|
||||
)
|
||||
|
||||
function handleUploadSuccess({ source, data, filename }) {
|
||||
sourceValue.value = source
|
||||
emit('change', { source, data, filename })
|
||||
message.success(t('cropper.uploadSuccess'))
|
||||
}
|
||||
|
||||
function open() {
|
||||
cropperModelRef.value.openModal()
|
||||
}
|
||||
|
||||
function close() {
|
||||
cropperModelRef.value.closeModal()
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
open,
|
||||
close
|
||||
})
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
$prefix-cls: #{$namespace}--cropper-avatar;
|
||||
|
||||
.#{$prefix-cls} {
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
|
||||
&-image-wrapper {
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
border: 1px solid;
|
||||
border-radius: 50%;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&-image-mask {
|
||||
opacity: 0%;
|
||||
position: absolute;
|
||||
width: inherit;
|
||||
height: inherit;
|
||||
border-radius: inherit;
|
||||
border: inherit;
|
||||
background: rgb(0 0 0 / 40%);
|
||||
cursor: pointer;
|
||||
transition: opacity 0.4s;
|
||||
|
||||
::v-deep(svg) {
|
||||
margin: auto;
|
||||
}
|
||||
}
|
||||
|
||||
&-image-mask:hover {
|
||||
opacity: 4000%;
|
||||
}
|
||||
|
||||
&-upload-btn {
|
||||
margin: 10px auto;
|
||||
}
|
||||
}
|
||||
|
||||
.user-info-head {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.img-circle {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.img-lg {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
}
|
||||
|
||||
.user-info-head:hover:after {
|
||||
content: '+';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
color: #eee;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
font-size: 24px;
|
||||
font-style: normal;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
cursor: pointer;
|
||||
line-height: 110px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
</style>
|
||||
8
src/components/Cropper/src/types.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type Cropper from 'cropperjs'
|
||||
|
||||
export interface CropendResult {
|
||||
imgBase64: string
|
||||
imgInfo: Cropper.Data
|
||||
}
|
||||
|
||||
export type { Cropper }
|
||||
3
src/components/Descriptions/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import Descriptions from './src/Descriptions.vue'
|
||||
|
||||
export { Descriptions }
|
||||
172
src/components/Descriptions/src/Descriptions.vue
Normal file
@@ -0,0 +1,172 @@
|
||||
<script lang="ts" name="Descriptions" setup>
|
||||
import { PropType } from 'vue'
|
||||
import dayjs from 'dayjs'
|
||||
import { useDesign } from '@/hooks/web/useDesign'
|
||||
import { propTypes } from '@/utils/propTypes'
|
||||
import { useAppStore } from '@/store/modules/app'
|
||||
import { DescriptionsSchema } from '@/types/descriptions'
|
||||
|
||||
const appStore = useAppStore()
|
||||
|
||||
const mobile = computed(() => appStore.getMobile)
|
||||
|
||||
const attrs = useAttrs()
|
||||
|
||||
const slots = useSlots()
|
||||
|
||||
const props = defineProps({
|
||||
title: propTypes.string.def(''),
|
||||
message: propTypes.string.def(''),
|
||||
collapse: propTypes.bool.def(true),
|
||||
columns: propTypes.number.def(1),
|
||||
labelWidth: propTypes.string.def('100px'),
|
||||
schema: {
|
||||
type: Array as PropType<DescriptionsSchema[]>,
|
||||
default: () => []
|
||||
},
|
||||
data: {
|
||||
type: Object as PropType<any>,
|
||||
default: () => ({})
|
||||
},
|
||||
defaultShow: propTypes.bool.def(true)
|
||||
})
|
||||
|
||||
const { getPrefixCls } = useDesign()
|
||||
|
||||
const prefixCls = getPrefixCls('descriptions')
|
||||
|
||||
const getBindValue = computed(() => {
|
||||
const delArr: string[] = ['title', 'message', 'collapse', 'schema', 'data', 'class']
|
||||
const obj = { ...attrs, ...props }
|
||||
for (const key in obj) {
|
||||
if (delArr.indexOf(key) !== -1) {
|
||||
delete obj[key]
|
||||
}
|
||||
}
|
||||
return obj
|
||||
})
|
||||
|
||||
const getBindItemValue = (item: DescriptionsSchema) => {
|
||||
const delArr: string[] = ['field']
|
||||
const obj = { ...item }
|
||||
for (const key in obj) {
|
||||
if (delArr.indexOf(key) !== -1) {
|
||||
delete obj[key]
|
||||
}
|
||||
}
|
||||
return obj
|
||||
}
|
||||
|
||||
// 折叠
|
||||
const show = ref(props.defaultShow)
|
||||
|
||||
const toggleClick = () => {
|
||||
if (props.collapse) {
|
||||
show.value = !unref(show)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
prefixCls,
|
||||
'bg-[var(--el-color-white)] dark:(bg-[var(--el-bg-color)] border-[var(--el-border-color)] border-1px)'
|
||||
]"
|
||||
>
|
||||
<div
|
||||
v-if="title"
|
||||
:class="[
|
||||
`${prefixCls}-header`,
|
||||
'h-40px flex justify-between items-center border-bottom-1 border-solid border-[var(--tags-view-border-color)] px-10px cursor-pointer dark:border-[var(--el-border-color)]'
|
||||
]"
|
||||
@click="toggleClick"
|
||||
>
|
||||
<div :class="[`${prefixCls}-header__title`, 'relative font-18px font-bold ml-10px']">
|
||||
<div class="flex items-center">
|
||||
{{ title }}
|
||||
<ElTooltip v-if="message" :content="message" placement="right">
|
||||
<Icon class="ml-5px" icon="ep:warning" />
|
||||
</ElTooltip>
|
||||
</div>
|
||||
</div>
|
||||
<Icon v-if="collapse" :icon="show ? 'ep:arrow-down' : 'ep:arrow-up'" />
|
||||
</div>
|
||||
|
||||
<ElCollapseTransition>
|
||||
<div v-show="show" :class="[`${prefixCls}-content`, 'p-10px']">
|
||||
<ElDescriptions
|
||||
:column="props.columns"
|
||||
:direction="mobile ? 'vertical' : 'horizontal'"
|
||||
border
|
||||
v-bind="getBindValue"
|
||||
>
|
||||
<template v-if="slots['extra']" #extra>
|
||||
<slot name="extra"></slot>
|
||||
</template>
|
||||
<ElDescriptionsItem
|
||||
v-for="item in schema"
|
||||
:key="item.field"
|
||||
min-width="80"
|
||||
:span="item.span"
|
||||
label-class-name="desc-label"
|
||||
v-bind="getBindItemValue(item)"
|
||||
>
|
||||
<template #label>
|
||||
<slot
|
||||
:name="`${item.field}-label`"
|
||||
:row="{
|
||||
label: item.label
|
||||
}"
|
||||
>{{ item.label }}
|
||||
</slot>
|
||||
</template>
|
||||
|
||||
<template #default>
|
||||
<slot v-if="item.dateFormat">
|
||||
{{
|
||||
data[item.field] !== null ? dayjs(data[item.field]).format(item.dateFormat) : ''
|
||||
}}
|
||||
</slot>
|
||||
<slot v-else-if="item.dictType">
|
||||
<DictTag :type="item.dictType" :value="data[item.field] + ''" />
|
||||
</slot>
|
||||
<slot v-else-if="item.isEditor">
|
||||
<div v-if="data[item.field]" v-dompurify-html="data[item.field]"></div>
|
||||
</slot>
|
||||
<slot v-else :name="item.field" :row="data">{{ data[item.field] }}</slot>
|
||||
</template>
|
||||
</ElDescriptionsItem>
|
||||
</ElDescriptions>
|
||||
</div>
|
||||
</ElCollapseTransition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
$prefix-cls: #{$namespace}-descriptions;
|
||||
|
||||
.#{$prefix-cls}-header {
|
||||
&__title {
|
||||
&::after {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: -10px;
|
||||
width: 4px;
|
||||
height: 70%;
|
||||
background: var(--el-color-primary);
|
||||
content: '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.#{$prefix-cls}-content {
|
||||
// :deep(.#{$elNamespace}-descriptions__cell) {
|
||||
// width: 0;
|
||||
// }
|
||||
|
||||
:deep(.desc-label) {
|
||||
width: v-bind('labelWidth');
|
||||
}
|
||||
}
|
||||
</style>
|
||||
3
src/components/Dialog/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import Dialog from './src/Dialog.vue'
|
||||
|
||||
export { Dialog }
|
||||
126
src/components/Dialog/src/Dialog.vue
Normal file
@@ -0,0 +1,126 @@
|
||||
<script lang="ts" name="Dialog" setup>
|
||||
import { propTypes } from '@/utils/propTypes'
|
||||
import { isNumber } from '@/utils/is'
|
||||
|
||||
const slots = useSlots()
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: propTypes.bool.def(false),
|
||||
title: propTypes.string.def('Dialog'),
|
||||
fullscreen: propTypes.bool.def(true),
|
||||
width: propTypes.oneOfType([String, Number]).def('40%'),
|
||||
scroll: propTypes.bool.def(false), // 是否开启滚动条。如果是的话,按照 maxHeight 设置最大高度
|
||||
maxHeight: propTypes.oneOfType([String, Number]).def('300px')
|
||||
})
|
||||
|
||||
const getBindValue = computed(() => {
|
||||
const delArr: string[] = ['fullscreen', 'title', 'maxHeight']
|
||||
const attrs = useAttrs()
|
||||
const obj = { ...attrs, ...props }
|
||||
for (const key in obj) {
|
||||
if (delArr.indexOf(key) !== -1) {
|
||||
delete obj[key]
|
||||
}
|
||||
}
|
||||
return obj
|
||||
})
|
||||
|
||||
const isFullscreen = ref(false)
|
||||
|
||||
const toggleFull = () => {
|
||||
isFullscreen.value = !unref(isFullscreen)
|
||||
}
|
||||
|
||||
const dialogHeight = ref(isNumber(props.maxHeight) ? `${props.maxHeight}px` : props.maxHeight)
|
||||
|
||||
watch(
|
||||
() => isFullscreen.value,
|
||||
async (val: boolean) => {
|
||||
// 计算最大高度
|
||||
await nextTick()
|
||||
if (val) {
|
||||
const windowHeight = document.documentElement.offsetHeight
|
||||
dialogHeight.value = `${windowHeight - 55 - 60 - (slots.footer ? 63 : 0)}px`
|
||||
} else {
|
||||
dialogHeight.value = isNumber(props.maxHeight) ? `${props.maxHeight}px` : props.maxHeight
|
||||
}
|
||||
},
|
||||
{
|
||||
immediate: true
|
||||
}
|
||||
)
|
||||
|
||||
const dialogStyle = computed(() => {
|
||||
return {
|
||||
height: unref(dialogHeight)
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElDialog
|
||||
:close-on-click-modal="false"
|
||||
:fullscreen="isFullscreen"
|
||||
:width="width"
|
||||
destroy-on-close
|
||||
draggable
|
||||
lock-scroll
|
||||
v-bind="getBindValue"
|
||||
@close="emit('close')"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex justify-between">
|
||||
<slot name="title">
|
||||
{{ title }}
|
||||
</slot>
|
||||
<Icon
|
||||
v-if="fullscreen"
|
||||
:icon="isFullscreen ? 'zmdi:fullscreen-exit' : 'zmdi:fullscreen'"
|
||||
class="mr-22px cursor-pointer is-hover mt-2px z-10"
|
||||
color="var(--el-color-info)"
|
||||
@click="toggleFull"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 情况一:如果 scroll 为 true,说明开启滚动条 -->
|
||||
<ElScrollbar v-if="scroll" :style="dialogStyle">
|
||||
<slot></slot>
|
||||
</ElScrollbar>
|
||||
<!-- 情况二:如果 scroll 为 false,说明关闭滚动条滚动条 -->
|
||||
<slot v-else></slot>
|
||||
|
||||
<template v-if="slots.footer" #footer>
|
||||
<slot name="footer"></slot>
|
||||
</template>
|
||||
</ElDialog>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.#{$elNamespace}-dialog__header {
|
||||
margin-right: 0 !important;
|
||||
border-bottom: 1px solid var(--tags-view-border-color);
|
||||
}
|
||||
|
||||
.#{$elNamespace}-dialog__footer {
|
||||
border-top: 1px solid var(--tags-view-border-color);
|
||||
}
|
||||
|
||||
.is-hover {
|
||||
&:hover {
|
||||
color: var(--el-color-primary) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.dark {
|
||||
.#{$elNamespace}-dialog__header {
|
||||
border-bottom: 1px solid var(--el-border-color);
|
||||
}
|
||||
|
||||
.#{$elNamespace}-dialog__footer {
|
||||
border-top: 1px solid var(--el-border-color);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
3
src/components/DictTag/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import DictTag from './src/DictTag.vue'
|
||||
|
||||
export { DictTag }
|
||||
60
src/components/DictTag/src/DictTag.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<script lang="tsx">
|
||||
import { defineComponent, PropType, ref } from 'vue'
|
||||
import { isHexColor } from '@/utils/color'
|
||||
import { ElTag } from 'element-plus'
|
||||
import { DictDataType, getDictOptions } from '@/utils/dict'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'DictTag',
|
||||
props: {
|
||||
type: {
|
||||
type: String as PropType<string>,
|
||||
required: true
|
||||
},
|
||||
value: {
|
||||
type: [String, Number, Boolean] as PropType<string | number | boolean>,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
setup(props) {
|
||||
const dictData = ref<DictDataType>()
|
||||
const getDictObj = (dictType: string, value: string) => {
|
||||
const dictOptions = getDictOptions(dictType)
|
||||
dictOptions.forEach((dict: DictDataType) => {
|
||||
if (dict.value === value) {
|
||||
if (dict.colorType + '' === 'primary' || dict.colorType + '' === 'default') {
|
||||
dict.colorType = ''
|
||||
}
|
||||
dictData.value = dict
|
||||
}
|
||||
})
|
||||
}
|
||||
const rederDictTag = () => {
|
||||
if (!props.type) {
|
||||
return null
|
||||
}
|
||||
// 解决自定义字典标签值为零时标签不渲染的问题
|
||||
if (props.value === undefined || props.value === null) {
|
||||
return null
|
||||
}
|
||||
getDictObj(props.type, props.value.toString())
|
||||
// 添加标签的文字颜色为白色,解决自定义背景颜色时标签文字看不清的问题
|
||||
return (
|
||||
<ElTag
|
||||
style={dictData.value?.cssClass ? 'color: #fff' : ''}
|
||||
type={dictData.value?.colorType}
|
||||
color={
|
||||
dictData.value?.cssClass && isHexColor(dictData.value?.cssClass)
|
||||
? dictData.value?.cssClass
|
||||
: ''
|
||||
}
|
||||
disableTransitions={true}
|
||||
>
|
||||
{dictData.value?.label}
|
||||
</ElTag>
|
||||
)
|
||||
}
|
||||
return () => rederDictTag()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
32
src/components/DocAlert/index.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<el-alert v-if="getEnable()" type="success" show-icon>
|
||||
<template #title>
|
||||
<div @click="goToUrl">{{ '【' + title + '】文档地址:' + url }}</div>
|
||||
</template>
|
||||
</el-alert>
|
||||
</template>
|
||||
<script setup lang="tsx" name="DocAlert">
|
||||
import { propTypes } from '@/utils/propTypes'
|
||||
|
||||
const props = defineProps({
|
||||
title: propTypes.string,
|
||||
url: propTypes.string
|
||||
})
|
||||
|
||||
/** 跳转 URL 链接 */
|
||||
const goToUrl = () => {
|
||||
window.open(props.url)
|
||||
}
|
||||
|
||||
/** 是否开启 */
|
||||
const getEnable = () => {
|
||||
return import.meta.env.VITE_APP_TENANT_ENABLE === 'true'
|
||||
}
|
||||
</script>
|
||||
<style scoped>
|
||||
.el-alert--success.is-light {
|
||||
border: 1px solid green;
|
||||
margin-bottom: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
3
src/components/Echart/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import Echart from './src/Echart.vue'
|
||||
|
||||
export { Echart }
|
||||
114
src/components/Echart/src/Echart.vue
Normal file
@@ -0,0 +1,114 @@
|
||||
<script lang="ts" name="EChart" setup>
|
||||
import type { EChartsOption } from 'echarts'
|
||||
import echarts from '@/plugins/echarts'
|
||||
import { debounce } from 'lodash-es'
|
||||
import 'echarts-wordcloud'
|
||||
import { propTypes } from '@/utils/propTypes'
|
||||
import { PropType } from 'vue'
|
||||
import { useAppStore } from '@/store/modules/app'
|
||||
import { isString } from '@/utils/is'
|
||||
import { useDesign } from '@/hooks/web/useDesign'
|
||||
|
||||
const { getPrefixCls, variables } = useDesign()
|
||||
|
||||
const prefixCls = getPrefixCls('echart')
|
||||
|
||||
const appStore = useAppStore()
|
||||
|
||||
const props = defineProps({
|
||||
options: {
|
||||
type: Object as PropType<EChartsOption>,
|
||||
required: true
|
||||
},
|
||||
width: propTypes.oneOfType([Number, String]).def(''),
|
||||
height: propTypes.oneOfType([Number, String]).def('500px')
|
||||
})
|
||||
|
||||
const isDark = computed(() => appStore.getIsDark)
|
||||
|
||||
const theme = computed(() => {
|
||||
const echartTheme: boolean | string = unref(isDark) ? true : 'auto'
|
||||
|
||||
return echartTheme
|
||||
})
|
||||
|
||||
const options = computed(() => {
|
||||
return Object.assign(props.options, {
|
||||
darkMode: unref(theme)
|
||||
})
|
||||
})
|
||||
|
||||
const elRef = ref<ElRef>()
|
||||
|
||||
let echartRef: Nullable<echarts.ECharts> = null
|
||||
|
||||
const contentEl = ref<Element>()
|
||||
|
||||
const styles = computed(() => {
|
||||
const width = isString(props.width) ? props.width : `${props.width}px`
|
||||
const height = isString(props.height) ? props.height : `${props.height}px`
|
||||
|
||||
return {
|
||||
width,
|
||||
height
|
||||
}
|
||||
})
|
||||
|
||||
const initChart = () => {
|
||||
if (unref(elRef) && props.options) {
|
||||
echartRef = echarts.init(unref(elRef) as HTMLElement)
|
||||
echartRef?.setOption(unref(options))
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => options.value,
|
||||
(options) => {
|
||||
if (echartRef) {
|
||||
echartRef?.setOption(options)
|
||||
resizeHandler()
|
||||
}
|
||||
},
|
||||
{
|
||||
deep: true
|
||||
}
|
||||
)
|
||||
|
||||
const resizeHandler = debounce(() => {
|
||||
if (echartRef) {
|
||||
echartRef.resize()
|
||||
}
|
||||
}, 100)
|
||||
|
||||
const contentResizeHandler = async (e: TransitionEvent) => {
|
||||
if (e.propertyName === 'width') {
|
||||
resizeHandler()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
initChart()
|
||||
|
||||
window.addEventListener('resize', resizeHandler)
|
||||
|
||||
contentEl.value = document.getElementsByClassName(`${variables.namespace}-layout-content`)[0]
|
||||
unref(contentEl) &&
|
||||
(unref(contentEl) as Element).addEventListener('transitionend', contentResizeHandler)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', resizeHandler)
|
||||
unref(contentEl) &&
|
||||
(unref(contentEl) as Element).removeEventListener('transitionend', contentResizeHandler)
|
||||
})
|
||||
|
||||
onActivated(() => {
|
||||
if (echartRef) {
|
||||
echartRef.resize()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="elRef" :class="[$attrs.class, prefixCls]" :style="styles"></div>
|
||||
</template>
|
||||
8
src/components/Editor/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import Editor from './src/Editor.vue'
|
||||
import { IDomEditor } from '@wangeditor/editor'
|
||||
|
||||
export interface EditorExpose {
|
||||
getEditorRef: () => Promise<IDomEditor>
|
||||
}
|
||||
|
||||
export { Editor }
|
||||
280
src/components/Editor/src/Editor.vue
Normal file
@@ -0,0 +1,280 @@
|
||||
<script lang="ts" name="Editor" setup>
|
||||
import { PropType } from 'vue'
|
||||
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
|
||||
import { i18nChangeLanguage, IDomEditor, IEditorConfig } from '@wangeditor/editor'
|
||||
import { propTypes } from '@/utils/propTypes'
|
||||
import { isNumber } from '@/utils/is'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useLocaleStore } from '@/store/modules/locale'
|
||||
import { getAccessToken, getTenantId, getAppId } from '@/utils/auth'
|
||||
|
||||
type InsertFnType = (url: string, alt: string, href: string) => void
|
||||
|
||||
const localeStore = useLocaleStore()
|
||||
|
||||
const currentLocale = computed(() => localeStore.getCurrentLocale)
|
||||
|
||||
i18nChangeLanguage(unref(currentLocale).lang)
|
||||
|
||||
const props = defineProps({
|
||||
editorId: propTypes.string.def('wangeEditor-1'),
|
||||
height: propTypes.oneOfType([Number, String]).def('40vh'),
|
||||
editorConfig: {
|
||||
type: Object as PropType<Partial<IEditorConfig>>,
|
||||
default: () => undefined
|
||||
},
|
||||
readonly: propTypes.bool.def(false),
|
||||
modelValue: propTypes.string.def(''),
|
||||
toolbarConfig: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
excludeKeys: [
|
||||
'insertVideo', // 网络视频
|
||||
'insertImage', // 网络图片
|
||||
'insertLink', // 链接
|
||||
'codeBlock', // 代码块
|
||||
'headerSelect', // 标题
|
||||
'blockquote', // 引用
|
||||
'fontFamily', // 字体
|
||||
'todo', // 代办
|
||||
'group-indent', // 缩进
|
||||
'emotion', // 表情
|
||||
'undo', // 撤销
|
||||
'redo', // 重做
|
||||
'fullScreen'
|
||||
]
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['change', 'update:modelValue'])
|
||||
|
||||
// 编辑器实例,必须用 shallowRef
|
||||
const editorRef = shallowRef<IDomEditor>()
|
||||
|
||||
const valueHtml = ref('')
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val: string) => {
|
||||
if (val === unref(valueHtml)) return
|
||||
valueHtml.value = val
|
||||
},
|
||||
{
|
||||
immediate: true
|
||||
}
|
||||
)
|
||||
|
||||
// 监听
|
||||
watch(
|
||||
() => valueHtml.value,
|
||||
(val: string) => {
|
||||
emit('update:modelValue', val)
|
||||
}
|
||||
)
|
||||
|
||||
const handleCreated = (editor: IDomEditor) => {
|
||||
editorRef.value = editor
|
||||
}
|
||||
|
||||
// 编辑器配置
|
||||
const editorConfig = computed((): IEditorConfig => {
|
||||
return Object.assign(
|
||||
{
|
||||
placeholder: '请输入内容...',
|
||||
readOnly: props.readonly,
|
||||
customAlert: (s: string, t: string) => {
|
||||
switch (t) {
|
||||
case 'success':
|
||||
ElMessage.success(s)
|
||||
break
|
||||
case 'info':
|
||||
ElMessage.info(s)
|
||||
break
|
||||
case 'warning':
|
||||
ElMessage.warning(s)
|
||||
break
|
||||
case 'error':
|
||||
ElMessage.error(s)
|
||||
break
|
||||
default:
|
||||
ElMessage.info(s)
|
||||
break
|
||||
}
|
||||
},
|
||||
autoFocus: false,
|
||||
scroll: true,
|
||||
MENU_CONF: {
|
||||
['uploadImage']: {
|
||||
server: import.meta.env.VITE_UPLOAD_URL,
|
||||
// 单个文件的最大体积限制,默认为 2M
|
||||
maxFileSize: 5 * 1024 * 1024,
|
||||
// 最多可上传几个文件,默认为 100
|
||||
maxNumberOfFiles: 10,
|
||||
// 选择文件时的类型限制,默认为 ['image/*'] 。如不想限制,则设置为 []
|
||||
allowedFileTypes: ['image/*'],
|
||||
|
||||
// 自定义上传参数,例如传递验证的 token 等。参数会被添加到 formData 中,一起上传到服务端。
|
||||
meta: { updateSupport: 0 },
|
||||
// 将 meta 拼接到 url 参数中,默认 false
|
||||
metaWithUrl: true,
|
||||
|
||||
// 自定义增加 http header
|
||||
headers: {
|
||||
Accept: '*',
|
||||
Authorization: 'Bearer ' + getAccessToken(),
|
||||
'tenant-id': getTenantId(),
|
||||
'instance-id': getAppId()
|
||||
},
|
||||
|
||||
// 跨域是否传递 cookie ,默认为 false
|
||||
withCredentials: true,
|
||||
|
||||
// 超时时间,默认为 10 秒
|
||||
timeout: 5 * 1000, // 5 秒
|
||||
|
||||
// form-data fieldName,后端接口参数名称,默认值wangeditor-uploaded-image
|
||||
fieldName: 'file',
|
||||
|
||||
// 上传之前触发
|
||||
onBeforeUpload(file: File) {
|
||||
console.log(file)
|
||||
return file
|
||||
},
|
||||
// 上传进度的回调函数
|
||||
onProgress(progress: number) {
|
||||
// progress 是 0-100 的数字
|
||||
console.log('progress', progress)
|
||||
},
|
||||
onSuccess(file: File, res: any) {
|
||||
console.log('onSuccess', file, res)
|
||||
},
|
||||
onFailed(file: File, res: any) {
|
||||
alert(res.message)
|
||||
console.log('onFailed', file, res)
|
||||
},
|
||||
onError(file: File, err: any, res: any) {
|
||||
alert(err.message)
|
||||
console.error('onError', file, err, res)
|
||||
},
|
||||
// 自定义插入图片
|
||||
customInsert(res: any, insertFn: InsertFnType) {
|
||||
insertFn(res.data, 'image', res.data)
|
||||
}
|
||||
},
|
||||
['uploadVideo']: {
|
||||
server: import.meta.env.VITE_UPLOAD_URL,
|
||||
// 单个文件的最大体积限制,默认为 2M
|
||||
maxFileSize: 100 * 1024 * 1024,
|
||||
// 最多可上传几个文件,默认为 100
|
||||
maxNumberOfFiles: 10,
|
||||
// 选择文件时的类型限制,默认为 ['image/*'] 。如不想限制,则设置为 []
|
||||
allowedFileTypes: ['video/*'],
|
||||
|
||||
// 自定义上传参数,例如传递验证的 token 等。参数会被添加到 formData 中,一起上传到服务端。
|
||||
meta: { updateSupport: 0 },
|
||||
// 将 meta 拼接到 url 参数中,默认 false
|
||||
metaWithUrl: true,
|
||||
|
||||
// 自定义增加 http header
|
||||
headers: {
|
||||
Accept: '*',
|
||||
Authorization: 'Bearer ' + getAccessToken(),
|
||||
'tenant-id': getTenantId(),
|
||||
'instance-id': getAppId()
|
||||
},
|
||||
|
||||
// 跨域是否传递 cookie ,默认为 false
|
||||
withCredentials: true,
|
||||
|
||||
// 超时时间,默认为 10 秒
|
||||
timeout: 10 * 1000, // 5 秒
|
||||
|
||||
// form-data fieldName,后端接口参数名称,默认值wangeditor-uploaded-image
|
||||
fieldName: 'file',
|
||||
|
||||
// 上传之前触发
|
||||
onBeforeUpload(file: File) {
|
||||
console.log(file)
|
||||
return file
|
||||
},
|
||||
// 上传进度的回调函数
|
||||
onProgress(progress: number) {
|
||||
// progress 是 0-100 的数字
|
||||
console.log('progress', progress)
|
||||
},
|
||||
onSuccess(file: File, res: any) {
|
||||
console.log('onSuccess', file, res)
|
||||
},
|
||||
onFailed(file: File, res: any) {
|
||||
alert(res.message)
|
||||
console.log('onFailed', file, res)
|
||||
},
|
||||
onError(file: File, err: any, res: any) {
|
||||
alert(err.message)
|
||||
console.error('onError', file, err, res)
|
||||
},
|
||||
// 自定义插入图片
|
||||
customInsert(res: any, insertFn: InsertFnType) {
|
||||
insertFn(res.data, 'video', res.data)
|
||||
}
|
||||
}
|
||||
},
|
||||
uploadImgShowBase64: true
|
||||
},
|
||||
props.editorConfig || {}
|
||||
)
|
||||
})
|
||||
|
||||
const editorStyle = computed(() => {
|
||||
return {
|
||||
height: isNumber(props.height) ? `${props.height}px` : props.height
|
||||
}
|
||||
})
|
||||
|
||||
// 回调函数
|
||||
const handleChange = (editor: IDomEditor) => {
|
||||
emit('change', editor)
|
||||
}
|
||||
|
||||
// 组件销毁时,及时销毁编辑器
|
||||
onBeforeUnmount(() => {
|
||||
const editor = unref(editorRef.value)
|
||||
if (editor === null) return
|
||||
|
||||
// 销毁,并移除 editor
|
||||
editor?.destroy()
|
||||
})
|
||||
|
||||
const getEditorRef = async (): Promise<IDomEditor> => {
|
||||
await nextTick()
|
||||
return unref(editorRef.value) as IDomEditor
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
getEditorRef
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="border-1 border-solid border-[var(--tags-view-border-color)] z-99">
|
||||
<!-- 工具栏 -->
|
||||
<Toolbar
|
||||
:editor="editorRef"
|
||||
:editorId="editorId"
|
||||
:defaultConfig="toolbarConfig"
|
||||
class="border-bottom-1 border-solid border-[var(--tags-view-border-color)]"
|
||||
/>
|
||||
<!-- 编辑器 -->
|
||||
<Editor
|
||||
v-model="valueHtml"
|
||||
:defaultConfig="editorConfig"
|
||||
:editorId="editorId"
|
||||
:style="editorStyle"
|
||||
@on-change="handleChange"
|
||||
@on-created="handleCreated"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style src="@wangeditor/editor/dist/css/style.css"></style>
|
||||
68
src/components/Editor/src/README.md
Normal file
@@ -0,0 +1,68 @@
|
||||
|
||||
**「工具栏key」**
|
||||
[
|
||||
"headerSelect",// 标题
|
||||
"blockquote", // 引用
|
||||
"bold", // 加粗
|
||||
"underline", // 下划线
|
||||
"italic", // 斜体
|
||||
// 删除线、清除格式等
|
||||
"group-more-style",
|
||||
{
|
||||
key: "group-more-style",
|
||||
title: "更多",
|
||||
iconSvg:
|
||||
'<svg viewBox="0 0 1024 1024"><path d="M204.8 505.6…0 153.6 0 76.8 76.8 0 1 0-153.6 0Z"></path></svg>',
|
||||
menuKeys: Array(5)
|
||||
},
|
||||
"color", // 文字颜色
|
||||
"bgColor", // 背景色
|
||||
"fontSize", // 字号
|
||||
"fontFamily", // 字体
|
||||
"lineHeight", // 行高
|
||||
"bulletedList", // 无序列表
|
||||
"numberedList", // 有序列表
|
||||
"todo", // 代办
|
||||
// 对齐
|
||||
"group-justify",
|
||||
{
|
||||
key: "group-justify",
|
||||
title: "对齐",
|
||||
iconSvg:
|
||||
'<svg viewBox="0 0 1024 1024"><path d="M768 793.6v1…72.8 102.4v102.4H51.2V102.4h921.6z"></path></svg>',
|
||||
menuKeys: Array(4)
|
||||
},
|
||||
// 缩进
|
||||
"group-indent",
|
||||
{
|
||||
key: "group-indent",
|
||||
title: "缩进",
|
||||
iconSvg:
|
||||
'<svg viewBox="0 0 1024 1024"><path d="M0 64h1024v1…32h1024v128H0z m0-128V320l256 192z"></path></svg>',
|
||||
menuKeys: Array(2)
|
||||
},
|
||||
"emotion",// 表情
|
||||
"insertLink",// 插入链接
|
||||
"group-image",// 上传图片
|
||||
{
|
||||
key: "group-image",
|
||||
title: "图片",
|
||||
iconSvg:
|
||||
'<svg viewBox="0 0 1024 1024"><path d="M959.877 128…l224.01-384 256 320h64l224.01-192z"></path></svg>',
|
||||
menuKeys: Array(2)
|
||||
},
|
||||
"group-video",// 上传视频
|
||||
{
|
||||
key: "group-video",
|
||||
title: "视频",
|
||||
iconSvg:
|
||||
'<svg viewBox="0 0 1024 1024"><path d="M981.184 160….904zM384 704V320l320 192-320 192z"></path></svg>',
|
||||
menuKeys: Array(2)
|
||||
},
|
||||
"insertTable",// 插入表格
|
||||
"codeBlock", // 代码块
|
||||
"divider", // 分割线
|
||||
"undo", // 撤销
|
||||
"redo", // 重做
|
||||
"fullScreen" // 全屏
|
||||
]
|
||||
3
src/components/Error/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import Error from './src/Error.vue'
|
||||
|
||||
export { Error }
|
||||
56
src/components/Error/src/Error.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<script lang="ts" name="Error" setup>
|
||||
import pageError from '@/assets/svgs/404.svg'
|
||||
import networkError from '@/assets/svgs/500.svg'
|
||||
import noPermission from '@/assets/svgs/403.svg'
|
||||
import { propTypes } from '@/utils/propTypes'
|
||||
|
||||
interface ErrorMap {
|
||||
url: string
|
||||
message: string
|
||||
buttonText: string
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const errorMap: {
|
||||
[key: string]: ErrorMap
|
||||
} = {
|
||||
'404': {
|
||||
url: pageError,
|
||||
message: t('error.pageError'),
|
||||
buttonText: t('error.returnToHome')
|
||||
},
|
||||
'500': {
|
||||
url: networkError,
|
||||
message: t('error.networkError'),
|
||||
buttonText: t('error.returnToHome')
|
||||
},
|
||||
'403': {
|
||||
url: noPermission,
|
||||
message: t('error.noPermission'),
|
||||
buttonText: t('error.returnToHome')
|
||||
}
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
type: propTypes.string.validate((v: string) => ['404', '500', '403'].includes(v)).def('404')
|
||||
})
|
||||
|
||||
const emit = defineEmits(['errorClick'])
|
||||
|
||||
const btnClick = () => {
|
||||
emit('errorClick', props.type)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex justify-center">
|
||||
<div class="text-center">
|
||||
<img :src="errorMap[type].url" alt="" width="350" />
|
||||
<div class="text-14px text-[var(--el-color-info)]">{{ errorMap[type].message }}</div>
|
||||
<div class="mt-20px">
|
||||
<ElButton type="primary" @click="btnClick">{{ errorMap[type].buttonText }}</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
15
src/components/Form/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import Form from './src/Form.vue'
|
||||
import { ElForm } from 'element-plus'
|
||||
import { FormSchema, FormSetPropsType } from '@/types/form'
|
||||
|
||||
export interface FormExpose {
|
||||
setValues: (data: Recordable) => void
|
||||
setProps: (props: Recordable) => void
|
||||
delSchema: (field: string) => void
|
||||
addSchema: (formSchema: FormSchema, index?: number) => void
|
||||
setSchema: (schemaProps: FormSetPropsType[]) => void
|
||||
formModel: Recordable
|
||||
getElFormRef: () => ComponentRef<typeof ElForm>
|
||||
}
|
||||
|
||||
export { Form }
|
||||
314
src/components/Form/src/Form.vue
Normal file
@@ -0,0 +1,314 @@
|
||||
<script lang="tsx">
|
||||
import { PropType, defineComponent, ref, computed, unref, watch, onMounted } from 'vue'
|
||||
import { ElForm, ElFormItem, ElRow, ElCol, ElTooltip } from 'element-plus'
|
||||
import { componentMap } from './componentMap'
|
||||
import { propTypes } from '@/utils/propTypes'
|
||||
import { getSlot } from '@/utils/tsxHelper'
|
||||
import {
|
||||
setTextPlaceholder,
|
||||
setGridProp,
|
||||
setComponentProps,
|
||||
setItemComponentSlots,
|
||||
initModel,
|
||||
setFormItemSlots
|
||||
} from './helper'
|
||||
import { useRenderSelect } from './components/useRenderSelect'
|
||||
import { useRenderRadio } from './components/useRenderRadio'
|
||||
import { useRenderCheckbox } from './components/useRenderCheckbox'
|
||||
import { useDesign } from '@/hooks/web/useDesign'
|
||||
import { findIndex } from '@/utils'
|
||||
import { set } from 'lodash-es'
|
||||
import { FormProps } from './types'
|
||||
import { Icon } from '@/components/Icon'
|
||||
import { FormSchema, FormSetPropsType } from '@/types/form'
|
||||
|
||||
const { getPrefixCls } = useDesign()
|
||||
|
||||
const prefixCls = getPrefixCls('form')
|
||||
|
||||
export default defineComponent({
|
||||
name: 'Form',
|
||||
props: {
|
||||
// 生成Form的布局结构数组
|
||||
schema: {
|
||||
type: Array as PropType<FormSchema[]>,
|
||||
default: () => []
|
||||
},
|
||||
// 是否需要栅格布局
|
||||
// update by 芋艿:将 true 改成 false,因为项目更常用这种方式
|
||||
isCol: propTypes.bool.def(false),
|
||||
// 是否搜索
|
||||
isSearch: propTypes.bool.def(false),
|
||||
// 表单数据对象
|
||||
model: {
|
||||
type: Object as PropType<Recordable>,
|
||||
default: () => ({})
|
||||
},
|
||||
// 是否自动设置placeholder
|
||||
autoSetPlaceholder: propTypes.bool.def(true),
|
||||
// 是否自定义内容
|
||||
isCustom: propTypes.bool.def(false),
|
||||
// 表单label宽度
|
||||
labelWidth: propTypes.oneOfType([String, Number]).def('auto'),
|
||||
// 是否 loading 数据中 add by 芋艿
|
||||
vLoading: propTypes.bool.def(false),
|
||||
inlineBlock: propTypes.bool.def(false)
|
||||
},
|
||||
emits: ['register'],
|
||||
setup(props, { slots, expose, emit }) {
|
||||
// element form 实例
|
||||
const elFormRef = ref<ComponentRef<typeof ElForm>>()
|
||||
|
||||
// useForm传入的props
|
||||
const outsideProps = ref<FormProps>({})
|
||||
|
||||
const mergeProps = ref<FormProps>({})
|
||||
|
||||
const getProps = computed(() => {
|
||||
const propsObj = { ...props }
|
||||
Object.assign(propsObj, unref(mergeProps))
|
||||
return propsObj
|
||||
})
|
||||
|
||||
// 表单数据
|
||||
const formModel = ref<Recordable>({})
|
||||
|
||||
onMounted(() => {
|
||||
emit('register', unref(elFormRef)?.$parent, unref(elFormRef))
|
||||
})
|
||||
|
||||
// 对表单赋值
|
||||
const setValues = (data: Recordable = {}) => {
|
||||
formModel.value = Object.assign(unref(formModel), data)
|
||||
}
|
||||
|
||||
const setProps = (props: FormProps = {}) => {
|
||||
mergeProps.value = Object.assign(unref(mergeProps), props)
|
||||
outsideProps.value = props
|
||||
}
|
||||
|
||||
const delSchema = (field: string) => {
|
||||
const { schema } = unref(getProps)
|
||||
|
||||
const index = findIndex(schema, (v: FormSchema) => v.field === field)
|
||||
if (index > -1) {
|
||||
schema.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
const addSchema = (formSchema: FormSchema, index?: number) => {
|
||||
const { schema } = unref(getProps)
|
||||
if (index !== void 0) {
|
||||
schema.splice(index, 0, formSchema)
|
||||
return
|
||||
}
|
||||
schema.push(formSchema)
|
||||
}
|
||||
|
||||
const setSchema = (schemaProps: FormSetPropsType[]) => {
|
||||
const { schema } = unref(getProps)
|
||||
for (const v of schema) {
|
||||
for (const item of schemaProps) {
|
||||
if (v.field === item.field) {
|
||||
set(v, item.path, item.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const getElFormRef = (): ComponentRef<typeof ElForm> => {
|
||||
return unref(elFormRef) as ComponentRef<typeof ElForm>
|
||||
}
|
||||
|
||||
expose({
|
||||
setValues,
|
||||
formModel,
|
||||
setProps,
|
||||
delSchema,
|
||||
addSchema,
|
||||
setSchema,
|
||||
getElFormRef
|
||||
})
|
||||
|
||||
// 监听表单结构化数组,重新生成formModel
|
||||
watch(
|
||||
() => unref(getProps).schema,
|
||||
(schema = []) => {
|
||||
formModel.value = initModel(schema, unref(formModel))
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
deep: true
|
||||
}
|
||||
)
|
||||
|
||||
// 渲染包裹标签,是否使用栅格布局
|
||||
const renderWrap = () => {
|
||||
const { isCol } = unref(getProps)
|
||||
const content = isCol ? (
|
||||
<ElRow gutter={20}>{renderFormItemWrap()}</ElRow>
|
||||
) : (
|
||||
renderFormItemWrap()
|
||||
)
|
||||
return content
|
||||
}
|
||||
|
||||
// 是否要渲染el-col
|
||||
const renderFormItemWrap = () => {
|
||||
// hidden属性表示隐藏,不做渲染
|
||||
const { schema = [], isCol } = unref(getProps)
|
||||
|
||||
return schema
|
||||
.filter((v) => !v.hidden)
|
||||
.map((item) => {
|
||||
// 如果是 Divider 组件,需要自己占用一行
|
||||
const isDivider = item.component === 'Divider'
|
||||
const Com = componentMap['Divider'] as ReturnType<typeof defineComponent>
|
||||
return isDivider ? (
|
||||
<Com {...{ contentPosition: 'left', ...item.componentProps }}>{item?.label}</Com>
|
||||
) : isCol ? (
|
||||
// 如果需要栅格,需要包裹 ElCol
|
||||
<ElCol {...setGridProp(item.colProps)}>{renderFormItem(item)}</ElCol>
|
||||
) : (
|
||||
renderFormItem(item)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// 渲染formItem
|
||||
const renderFormItem = (item: FormSchema) => {
|
||||
// 单独给只有options属性的组件做判断
|
||||
const notRenderOptions = ['SelectV2', 'Cascader', 'Transfer']
|
||||
const slotsMap: Recordable = {
|
||||
...setItemComponentSlots(slots, item?.componentProps?.slots, item.field)
|
||||
}
|
||||
if (item?.component !== 'SelectV2' && item?.component !== 'Cascader' && item?.options) {
|
||||
slotsMap.default = () => renderOptions(item)
|
||||
}
|
||||
|
||||
const formItemSlots: Recordable = setFormItemSlots(slots, item.field)
|
||||
// 如果有 labelMessage,自动使用插槽渲染
|
||||
if (item?.labelMessage) {
|
||||
formItemSlots.label = () => {
|
||||
return (
|
||||
<>
|
||||
<span>{item.label}</span>
|
||||
<ElTooltip placement="right" raw-content>
|
||||
{{
|
||||
content: () => <span v-html={item.labelMessage}></span>,
|
||||
default: () => (
|
||||
<Icon
|
||||
icon="ep:warning"
|
||||
size={16}
|
||||
color="var(--el-color-primary)"
|
||||
class="ml-2px relative top-1px"
|
||||
></Icon>
|
||||
)
|
||||
}}
|
||||
</ElTooltip>
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
const { isSearch } = unref(getProps)
|
||||
return (
|
||||
<ElFormItem
|
||||
class={isSearch ? 'search-form-item' : 'crud-form-item'}
|
||||
{...(item.formItemProps || {})}
|
||||
prop={item.field}
|
||||
label={item.label || ''}
|
||||
>
|
||||
{{
|
||||
...formItemSlots,
|
||||
default: () => {
|
||||
const Com = componentMap[item.component as string] as ReturnType<
|
||||
typeof defineComponent
|
||||
>
|
||||
const baseSty = isSearch ? '' : 'width: 100%;'
|
||||
const { autoSetPlaceholder } = unref(getProps)
|
||||
return slots[item.field] ? (
|
||||
getSlot(slots, item.field, formModel.value)
|
||||
) : (
|
||||
<Com
|
||||
vModel={formModel.value[item.field]}
|
||||
{...(autoSetPlaceholder && setTextPlaceholder(item))}
|
||||
{...setComponentProps(item)}
|
||||
filterable
|
||||
format={item.component == 'DatePicker' ? 'YYYY-MM-DD' : null}
|
||||
value-format={item.component == 'DatePicker' ? 'YYYY-MM-DD' : null}
|
||||
style={baseSty + item.componentProps?.style}
|
||||
// eslint-disable-next-line prettier/prettier
|
||||
{...(notRenderOptions.includes(item?.component as string) && item?.componentProps?.options
|
||||
? { options: item?.componentProps?.options || [] }
|
||||
: {})}
|
||||
>
|
||||
{{ ...slotsMap }}
|
||||
</Com>
|
||||
)
|
||||
}
|
||||
}}
|
||||
</ElFormItem>
|
||||
)
|
||||
}
|
||||
|
||||
// 渲染options
|
||||
const renderOptions = (item: FormSchema) => {
|
||||
switch (item.component) {
|
||||
case 'Select':
|
||||
case 'SelectV2':
|
||||
const { renderSelectOptions } = useRenderSelect(slots)
|
||||
return renderSelectOptions(item)
|
||||
case 'Radio':
|
||||
case 'RadioButton':
|
||||
const { renderRadioOptions } = useRenderRadio()
|
||||
return renderRadioOptions(item)
|
||||
case 'Checkbox':
|
||||
case 'CheckboxButton':
|
||||
const { renderCheckboxOptions } = useRenderCheckbox()
|
||||
return renderCheckboxOptions(item)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 过滤传入Form组件的属性
|
||||
const getFormBindValue = () => {
|
||||
// 避免在标签上出现多余的属性
|
||||
const delKeys = ['schema', 'isCol', 'autoSetPlaceholder', 'isCustom', 'model']
|
||||
const props = { ...unref(getProps) }
|
||||
for (const key in props) {
|
||||
if (delKeys.indexOf(key) !== -1) {
|
||||
delete props[key]
|
||||
}
|
||||
}
|
||||
return props
|
||||
}
|
||||
|
||||
return () => (
|
||||
<ElForm
|
||||
ref={elFormRef}
|
||||
{...getFormBindValue()}
|
||||
model={props.isCustom ? props.model : formModel}
|
||||
class={prefixCls}
|
||||
v-loading={props.vLoading}
|
||||
style={props.inlineBlock ? 'display: inline' : ''}
|
||||
>
|
||||
{{
|
||||
// 如果需要自定义,就什么都不渲染,而是提供默认插槽
|
||||
default: () => {
|
||||
const { isCustom } = unref(getProps)
|
||||
return isCustom ? getSlot(slots, 'default') : renderWrap()
|
||||
}
|
||||
}}
|
||||
</ElForm>
|
||||
)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.#{$elNamespace}-form.#{$namespace}-form .#{$elNamespace}-row {
|
||||
margin-right: 0 !important;
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
</style>
|
||||
55
src/components/Form/src/componentMap.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { Component } from 'vue'
|
||||
import {
|
||||
ElCascader,
|
||||
ElCheckboxGroup,
|
||||
ElColorPicker,
|
||||
ElDatePicker,
|
||||
ElInput,
|
||||
ElInputNumber,
|
||||
ElRadioGroup,
|
||||
ElRate,
|
||||
ElSelect,
|
||||
ElSelectV2,
|
||||
ElTreeSelect,
|
||||
ElSlider,
|
||||
ElSwitch,
|
||||
ElTimePicker,
|
||||
ElTimeSelect,
|
||||
ElTransfer,
|
||||
ElAutocomplete,
|
||||
ElDivider
|
||||
} from 'element-plus'
|
||||
import { InputPassword } from '@/components/InputPassword'
|
||||
import { Editor } from '@/components/Editor'
|
||||
import { UploadImg, UploadImgs, UploadFile } from '@/components/UploadFile'
|
||||
import { ComponentName } from '@/types/components'
|
||||
|
||||
const componentMap: Recordable<Component, ComponentName> = {
|
||||
Radio: ElRadioGroup,
|
||||
Checkbox: ElCheckboxGroup,
|
||||
CheckboxButton: ElCheckboxGroup,
|
||||
Input: ElInput,
|
||||
Autocomplete: ElAutocomplete,
|
||||
InputNumber: ElInputNumber,
|
||||
Select: ElSelect,
|
||||
Cascader: ElCascader,
|
||||
Switch: ElSwitch,
|
||||
Slider: ElSlider,
|
||||
TimePicker: ElTimePicker,
|
||||
DatePicker: ElDatePicker,
|
||||
Rate: ElRate,
|
||||
ColorPicker: ElColorPicker,
|
||||
Transfer: ElTransfer,
|
||||
Divider: ElDivider,
|
||||
TimeSelect: ElTimeSelect,
|
||||
SelectV2: ElSelectV2,
|
||||
TreeSelect: ElTreeSelect,
|
||||
RadioButton: ElRadioGroup,
|
||||
InputPassword: InputPassword,
|
||||
Editor: Editor,
|
||||
UploadImg: UploadImg,
|
||||
UploadImgs: UploadImgs,
|
||||
UploadFile: UploadFile
|
||||
}
|
||||
|
||||
export { componentMap }
|
||||
26
src/components/Form/src/components/useRenderCheckbox.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { FormSchema } from '@/types/form'
|
||||
import { ElCheckbox, ElCheckboxButton } from 'element-plus'
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
export const useRenderCheckbox = () => {
|
||||
const renderCheckboxOptions = (item: FormSchema) => {
|
||||
// 如果有别名,就取别名
|
||||
const labelAlias = item?.componentProps?.optionsAlias?.labelField || 'id'
|
||||
const valueAlias = item?.componentProps?.optionsAlias?.valueField || 'name'
|
||||
const Com = (item.component === 'Checkbox' ? ElCheckbox : ElCheckboxButton) as ReturnType<
|
||||
typeof defineComponent
|
||||
>
|
||||
return item?.options?.map((option) => {
|
||||
const { ...other } = option
|
||||
return (
|
||||
<Com {...other} label={option[valueAlias || 'value']}>
|
||||
{option[labelAlias || 'label']}
|
||||
</Com>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
renderCheckboxOptions
|
||||
}
|
||||
}
|
||||
26
src/components/Form/src/components/useRenderRadio.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { FormSchema } from '@/types/form'
|
||||
import { ElRadio, ElRadioButton } from 'element-plus'
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
export const useRenderRadio = () => {
|
||||
const renderRadioOptions = (item: FormSchema) => {
|
||||
// 如果有别名,就取别名
|
||||
const labelAlias = item?.componentProps?.optionsAlias?.labelField || 'id'
|
||||
const valueAlias = item?.componentProps?.optionsAlias?.valueField || 'name'
|
||||
const Com = (item.component === 'Radio' ? ElRadio : ElRadioButton) as ReturnType<
|
||||
typeof defineComponent
|
||||
>
|
||||
return item?.options?.map((option) => {
|
||||
const { ...other } = option
|
||||
return (
|
||||
<Com {...other} label={option[valueAlias || 'value']}>
|
||||
{option[labelAlias || 'label']}
|
||||
</Com>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
renderRadioOptions
|
||||
}
|
||||
}
|
||||
57
src/components/Form/src/components/useRenderSelect.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { FormSchema } from '@/types/form'
|
||||
import { ComponentOptions } from '@/types/components'
|
||||
import { ElOption, ElOptionGroup } from 'element-plus'
|
||||
import { getSlot } from '@/utils/tsxHelper'
|
||||
import { Slots } from 'vue'
|
||||
|
||||
export const useRenderSelect = (slots: Slots) => {
|
||||
// 渲染 select options
|
||||
const renderSelectOptions = (item: FormSchema) => {
|
||||
// 如果有别名,就取别名
|
||||
const labelAlias = item?.componentProps?.optionsAlias?.labelField
|
||||
return item?.options?.map((option) => {
|
||||
if (option?.length) {
|
||||
return (
|
||||
<ElOptionGroup label={option[labelAlias || 'label']}>
|
||||
{() => {
|
||||
return option?.map((v) => {
|
||||
return renderSelectOptionItem(item, v)
|
||||
})
|
||||
}}
|
||||
</ElOptionGroup>
|
||||
)
|
||||
} else {
|
||||
return renderSelectOptionItem(item, option)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 渲染 select option item
|
||||
const renderSelectOptionItem = (item: FormSchema, option: ComponentOptions) => {
|
||||
// 如果有别名,就取别名
|
||||
const labelAlias = item?.componentProps?.optionsAlias?.labelField || 'id'
|
||||
const valueAlias = item?.componentProps?.optionsAlias?.valueField || 'name'
|
||||
|
||||
const { label, value, ...other } = option
|
||||
|
||||
return (
|
||||
<ElOption
|
||||
{...other}
|
||||
label={labelAlias ? option[labelAlias] : label}
|
||||
value={valueAlias ? option[valueAlias] : value}
|
||||
>
|
||||
{{
|
||||
default: () =>
|
||||
// option 插槽名规则,{field}-option
|
||||
item?.componentProps?.optionsSlot
|
||||
? getSlot(slots, `${item.field}-option`, { item: option })
|
||||
: undefined
|
||||
}}
|
||||
</ElOption>
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
renderSelectOptions
|
||||
}
|
||||
}
|
||||
148
src/components/Form/src/helper.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import type { Slots } from 'vue'
|
||||
import { getSlot } from '@/utils/tsxHelper'
|
||||
import { PlaceholderModel } from './types'
|
||||
import { FormSchema } from '@/types/form'
|
||||
import { ColProps } from '@/types/components'
|
||||
|
||||
/**
|
||||
*
|
||||
* @param schema 对应组件数据
|
||||
* @returns 返回提示信息对象
|
||||
* @description 用于自动设置placeholder
|
||||
*/
|
||||
export const setTextPlaceholder = (schema: FormSchema): PlaceholderModel => {
|
||||
const { t } = useI18n()
|
||||
const textMap = ['Input', 'Autocomplete', 'InputNumber', 'InputPassword']
|
||||
const selectMap = ['Select', 'SelectV2', 'TimePicker', 'DatePicker', 'TimeSelect', 'TimeSelect']
|
||||
if (textMap.includes(schema?.component as string)) {
|
||||
return {
|
||||
placeholder: t('common.inputText') + schema.label
|
||||
}
|
||||
}
|
||||
if (selectMap.includes(schema?.component as string)) {
|
||||
// 一些范围选择器
|
||||
const twoTextMap = ['datetimerange', 'daterange', 'monthrange', 'datetimerange', 'daterange']
|
||||
if (
|
||||
twoTextMap.includes(
|
||||
(schema?.componentProps?.type || schema?.componentProps?.isRange) as string
|
||||
)
|
||||
) {
|
||||
return {
|
||||
startPlaceholder: t('common.startTimeText'),
|
||||
endPlaceholder: t('common.endTimeText'),
|
||||
rangeSeparator: '-'
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
placeholder: t('common.selectText') + schema.label
|
||||
}
|
||||
}
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param col 内置栅格
|
||||
* @returns 返回栅格属性
|
||||
* @description 合并传入进来的栅格属性
|
||||
*/
|
||||
export const setGridProp = (col: ColProps = {}): ColProps => {
|
||||
const colProps: ColProps = {
|
||||
// 如果有span,代表用户优先级更高,所以不需要默认栅格
|
||||
...(col.span
|
||||
? {}
|
||||
: {
|
||||
xs: 24,
|
||||
sm: 12,
|
||||
md: 12,
|
||||
lg: 12,
|
||||
xl: 12
|
||||
}),
|
||||
...col
|
||||
}
|
||||
return colProps
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param item 传入的组件属性
|
||||
* @returns 默认添加 clearable 属性
|
||||
*/
|
||||
export const setComponentProps = (item: FormSchema): Recordable => {
|
||||
const notNeedClearable = ['ColorPicker']
|
||||
const componentProps: Recordable = notNeedClearable.includes(item.component as string)
|
||||
? { ...item.componentProps }
|
||||
: {
|
||||
clearable: true,
|
||||
...item.componentProps
|
||||
}
|
||||
// 需要删除额外的属性
|
||||
delete componentProps?.slots
|
||||
return componentProps
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param slots 插槽
|
||||
* @param slotsProps 插槽属性
|
||||
* @param field 字段名
|
||||
*/
|
||||
export const setItemComponentSlots = (
|
||||
slots: Slots,
|
||||
slotsProps: Recordable = {},
|
||||
field: string
|
||||
): Recordable => {
|
||||
const slotObj: Recordable = {}
|
||||
for (const key in slotsProps) {
|
||||
if (slotsProps[key]) {
|
||||
// 由于组件有可能重复,需要有一个唯一的前缀
|
||||
slotObj[key] = (data: Recordable) => {
|
||||
return getSlot(slots, `${field}-${key}`, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
return slotObj
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param schema Form表单结构化数组
|
||||
* @param formModel FormModel
|
||||
* @returns FormModel
|
||||
* @description 生成对应的formModel
|
||||
*/
|
||||
export const initModel = (schema: FormSchema[], formModel: Recordable) => {
|
||||
const model: Recordable = { ...formModel }
|
||||
schema.map((v) => {
|
||||
// 如果是hidden,就删除对应的值
|
||||
if (v.hidden) {
|
||||
delete model[v.field]
|
||||
} else if (v.component && v.component !== 'Divider') {
|
||||
const hasField = Reflect.has(model, v.field)
|
||||
// 如果先前已经有值存在,则不进行重新赋值,而是采用现有的值
|
||||
model[v.field] = hasField ? model[v.field] : v.value !== void 0 ? v.value : ''
|
||||
}
|
||||
})
|
||||
return model
|
||||
}
|
||||
|
||||
/**
|
||||
* @param slots 插槽
|
||||
* @param field 字段名
|
||||
* @returns 返回FormIiem插槽
|
||||
*/
|
||||
export const setFormItemSlots = (slots: Slots, field: string): Recordable => {
|
||||
const slotObj: Recordable = {}
|
||||
if (slots[`${field}-error`]) {
|
||||
slotObj['error'] = (data: Recordable) => {
|
||||
return getSlot(slots, `${field}-error`, data)
|
||||
}
|
||||
}
|
||||
if (slots[`${field}-label`]) {
|
||||
slotObj['label'] = (data: Recordable) => {
|
||||
return getSlot(slots, `${field}-label`, data)
|
||||
}
|
||||
}
|
||||
return slotObj
|
||||
}
|
||||
17
src/components/Form/src/types.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { FormSchema } from '@/types/form'
|
||||
|
||||
export interface PlaceholderModel {
|
||||
placeholder?: string
|
||||
startPlaceholder?: string
|
||||
endPlaceholder?: string
|
||||
rangeSeparator?: string
|
||||
}
|
||||
|
||||
export type FormProps = {
|
||||
schema?: FormSchema[]
|
||||
isCol?: boolean
|
||||
model?: Recordable
|
||||
autoSetPlaceholder?: boolean
|
||||
isCustom?: boolean
|
||||
labelWidth?: string | number
|
||||
} & Recordable
|
||||
3
src/components/Highlight/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import Highlight from './src/Highlight.vue'
|
||||
|
||||
export { Highlight }
|
||||
65
src/components/Highlight/src/Highlight.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<script lang="tsx">
|
||||
import { defineComponent, PropType, computed, h, unref } from 'vue'
|
||||
import { propTypes } from '@/utils/propTypes'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'Highlight',
|
||||
props: {
|
||||
tag: propTypes.string.def('span'),
|
||||
keys: {
|
||||
type: Array as PropType<string[]>,
|
||||
default: () => []
|
||||
},
|
||||
color: propTypes.string.def('var(--el-color-primary)')
|
||||
},
|
||||
emits: ['click'],
|
||||
setup(props, { emit, slots }) {
|
||||
const keyNodes = computed(() => {
|
||||
return props.keys.map((key) => {
|
||||
return h(
|
||||
'span',
|
||||
{
|
||||
onClick: () => {
|
||||
emit('click', key)
|
||||
},
|
||||
style: {
|
||||
color: props.color,
|
||||
cursor: 'pointer'
|
||||
}
|
||||
},
|
||||
key
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
const parseText = (text: string) => {
|
||||
props.keys.forEach((key, index) => {
|
||||
const regexp = new RegExp(key, 'g')
|
||||
text = text.replace(regexp, `{{${index}}}`)
|
||||
})
|
||||
return text.split(/{{|}}/)
|
||||
}
|
||||
|
||||
const renderText = () => {
|
||||
if (!slots?.default) return null
|
||||
const node = slots?.default()[0].children
|
||||
|
||||
if (!node) {
|
||||
return slots?.default()[0]
|
||||
}
|
||||
|
||||
const textArray = parseText(node as string)
|
||||
const regexp = /^[0-9]*$/
|
||||
const nodes = textArray.map((t) => {
|
||||
if (regexp.test(t)) {
|
||||
return unref(keyNodes)[t] || t
|
||||
}
|
||||
return t
|
||||
})
|
||||
return h(props.tag, nodes)
|
||||
}
|
||||
|
||||
return () => renderText()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
3
src/components/IFrame/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import IFrame from './src/IFrame.vue'
|
||||
|
||||
export { IFrame }
|
||||
30
src/components/IFrame/src/IFrame.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<script lang="ts" name="IFrame" setup>
|
||||
import { propTypes } from '@/utils/propTypes'
|
||||
|
||||
const props = defineProps({
|
||||
src: propTypes.string.def('')
|
||||
})
|
||||
const loading = ref(true)
|
||||
const height = ref('')
|
||||
const frameRef = ref<HTMLElement | null>(null)
|
||||
const init = () => {
|
||||
height.value = document.documentElement.clientHeight - 94.5 + 'px'
|
||||
loading.value = false
|
||||
}
|
||||
onMounted(() => {
|
||||
setTimeout(() => {
|
||||
init()
|
||||
}, 300)
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<div v-loading="loading" style="height: 100%">
|
||||
<iframe
|
||||
ref="frameRef"
|
||||
:src="props.src"
|
||||
frameborder="no"
|
||||
scrolling="auto"
|
||||
style="width: 100%; height: 100%"
|
||||
></iframe>
|
||||
</div>
|
||||
</template>
|
||||
4
src/components/Icon/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import Icon from './src/Icon.vue'
|
||||
import IconSelect from './src/IconSelect.vue'
|
||||
|
||||
export { Icon, IconSelect }
|
||||
83
src/components/Icon/src/Icon.vue
Normal file
@@ -0,0 +1,83 @@
|
||||
<script lang="ts" name="Icon" setup>
|
||||
import { propTypes } from '@/utils/propTypes'
|
||||
import Iconify from '@purge-icons/generated'
|
||||
import { useDesign } from '@/hooks/web/useDesign'
|
||||
|
||||
const { getPrefixCls } = useDesign()
|
||||
|
||||
const prefixCls = getPrefixCls('icon')
|
||||
|
||||
const props = defineProps({
|
||||
// icon name
|
||||
icon: propTypes.string,
|
||||
// icon color
|
||||
color: propTypes.string,
|
||||
// icon size
|
||||
size: propTypes.number.def(16),
|
||||
// icon svg class
|
||||
svgClass: propTypes.string.def('')
|
||||
})
|
||||
|
||||
const elRef = ref<ElRef>(null)
|
||||
|
||||
const isLocal = computed(() => props.icon.startsWith('svg-icon:'))
|
||||
|
||||
const symbolId = computed(() => {
|
||||
return unref(isLocal) ? `#icon-${props.icon.split('svg-icon:')[1]}` : props.icon
|
||||
})
|
||||
|
||||
const getIconifyStyle = computed(() => {
|
||||
const { color, size } = props
|
||||
return {
|
||||
fontSize: `${size}px`,
|
||||
color
|
||||
}
|
||||
})
|
||||
|
||||
const getSvgClass = computed(() => {
|
||||
const { svgClass } = props
|
||||
return `iconify ${svgClass}`
|
||||
})
|
||||
|
||||
const updateIcon = async (icon: string) => {
|
||||
if (unref(isLocal)) return
|
||||
|
||||
const el = unref(elRef)
|
||||
if (!el) return
|
||||
|
||||
await nextTick()
|
||||
|
||||
if (!icon) return
|
||||
|
||||
const svg = Iconify.renderSVG(icon, {})
|
||||
if (svg) {
|
||||
el.textContent = ''
|
||||
el.appendChild(svg)
|
||||
} else {
|
||||
const span = document.createElement('span')
|
||||
span.className = 'iconify'
|
||||
span.dataset.icon = icon
|
||||
el.textContent = ''
|
||||
el.appendChild(span)
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.icon,
|
||||
(icon: string) => {
|
||||
updateIcon(icon)
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElIcon :class="prefixCls" :color="color" :size="size">
|
||||
<svg v-if="isLocal" :class="getSvgClass" aria-hidden="true">
|
||||
<use :xlink:href="symbolId" />
|
||||
</svg>
|
||||
|
||||
<span v-else ref="elRef" :class="$attrs.class" :style="getIconifyStyle">
|
||||
<span :class="getSvgClass" :data-icon="symbolId"></span>
|
||||
</span>
|
||||
</ElIcon>
|
||||
</template>
|
||||
227
src/components/Icon/src/IconSelect.vue
Normal file
@@ -0,0 +1,227 @@
|
||||
<script lang="ts" name="IconSelect" setup>
|
||||
import { CSSProperties } from 'vue'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
import { IconJson } from '@/components/Icon/src/data'
|
||||
|
||||
type ParameterCSSProperties = (item?: string) => CSSProperties | undefined
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
require: false,
|
||||
type: String
|
||||
}
|
||||
})
|
||||
const emit = defineEmits<{ (e: 'update:modelValue', v: string) }>()
|
||||
|
||||
const visible = ref(false)
|
||||
const inputValue = toRef(props, 'modelValue')
|
||||
const iconList = ref(IconJson)
|
||||
const icon = ref('add-location')
|
||||
const currentActiveType = ref('ep:')
|
||||
// 深拷贝图标数据,前端做搜索
|
||||
const copyIconList = cloneDeep(iconList.value)
|
||||
|
||||
const pageSize = ref(96)
|
||||
const currentPage = ref(1)
|
||||
|
||||
// 搜索条件
|
||||
const filterValue = ref('')
|
||||
|
||||
const tabsList = [
|
||||
{
|
||||
label: 'Element Plus',
|
||||
name: 'ep:'
|
||||
},
|
||||
{
|
||||
label: 'Font Awesome 4',
|
||||
name: 'fa:'
|
||||
},
|
||||
{
|
||||
label: 'Font Awesome 5 Solid',
|
||||
name: 'fa-solid:'
|
||||
}
|
||||
]
|
||||
|
||||
const pageList = computed(() => {
|
||||
if (currentPage.value === 1) {
|
||||
return copyIconList[currentActiveType.value]
|
||||
?.filter((v) => v.includes(filterValue.value))
|
||||
.slice(currentPage.value - 1, pageSize.value)
|
||||
} else {
|
||||
return copyIconList[currentActiveType.value]
|
||||
?.filter((v) => v.includes(filterValue.value))
|
||||
.slice(
|
||||
pageSize.value * (currentPage.value - 1),
|
||||
pageSize.value * (currentPage.value - 1) + pageSize.value
|
||||
)
|
||||
}
|
||||
})
|
||||
const iconCount = computed(() => {
|
||||
return copyIconList[currentActiveType.value] == undefined
|
||||
? 0
|
||||
: copyIconList[currentActiveType.value].length
|
||||
})
|
||||
|
||||
const iconItemStyle = computed((): ParameterCSSProperties => {
|
||||
return (item) => {
|
||||
if (inputValue.value === currentActiveType.value + item) {
|
||||
return {
|
||||
borderColor: 'var(--el-color-primary)',
|
||||
color: 'var(--el-color-primary)'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function handleClick({ props }) {
|
||||
currentPage.value = 1
|
||||
currentActiveType.value = props.name
|
||||
emit('update:modelValue', currentActiveType.value + iconList.value[currentActiveType.value][0])
|
||||
icon.value = iconList.value[currentActiveType.value][0]
|
||||
}
|
||||
|
||||
function onChangeIcon(item) {
|
||||
icon.value = item
|
||||
emit('update:modelValue', currentActiveType.value + item)
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
function onCurrentChange(page) {
|
||||
currentPage.value = page
|
||||
}
|
||||
|
||||
watch(
|
||||
() => {
|
||||
return props.modelValue
|
||||
},
|
||||
() => {
|
||||
if (props.modelValue && props.modelValue.indexOf(':') >= 0) {
|
||||
currentActiveType.value = props.modelValue.substring(0, props.modelValue.indexOf(':') + 1)
|
||||
icon.value = props.modelValue.substring(props.modelValue.indexOf(':') + 1)
|
||||
}
|
||||
}
|
||||
)
|
||||
watch(
|
||||
() => {
|
||||
return filterValue.value
|
||||
},
|
||||
() => {
|
||||
currentPage.value = 1
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="selector">
|
||||
<ElInput v-model="inputValue" @click="visible = !visible">
|
||||
<template #append>
|
||||
<ElPopover
|
||||
:popper-options="{
|
||||
placement: 'auto'
|
||||
}"
|
||||
:visible="visible"
|
||||
:width="350"
|
||||
popper-class="pure-popper"
|
||||
trigger="click"
|
||||
>
|
||||
<template #reference>
|
||||
<div
|
||||
class="w-40px h-32px cursor-pointer flex justify-center items-center"
|
||||
@click="visible = !visible"
|
||||
>
|
||||
<Icon :icon="currentActiveType + icon" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<ElInput v-model="filterValue" class="p-2" clearable placeholder="搜索图标" />
|
||||
<ElDivider border-style="dashed" />
|
||||
|
||||
<ElTabs v-model="currentActiveType" @tab-click="handleClick">
|
||||
<ElTabPane
|
||||
v-for="(pane, index) in tabsList"
|
||||
:key="index"
|
||||
:label="pane.label"
|
||||
:name="pane.name"
|
||||
>
|
||||
<ElDivider border-style="dashed" class="tab-divider" />
|
||||
<ElScrollbar height="220px">
|
||||
<ul class="flex flex-wrap px-2 ml-2">
|
||||
<li
|
||||
v-for="(item, key) in pageList"
|
||||
:key="key"
|
||||
:style="iconItemStyle(item)"
|
||||
:title="item"
|
||||
class="icon-item p-2 w-1/10 cursor-pointer mr-2 mt-1 flex justify-center items-center border border-solid"
|
||||
@click="onChangeIcon(item)"
|
||||
>
|
||||
<Icon :icon="currentActiveType + item" />
|
||||
</li>
|
||||
</ul>
|
||||
</ElScrollbar>
|
||||
</ElTabPane>
|
||||
</ElTabs>
|
||||
<ElDivider border-style="dashed" />
|
||||
|
||||
<ElPagination
|
||||
:current-page="currentPage"
|
||||
:page-size="pageSize"
|
||||
:total="iconCount"
|
||||
background
|
||||
class="flex items-center justify-center h-10"
|
||||
layout="prev, pager, next"
|
||||
small
|
||||
@current-change="onCurrentChange"
|
||||
/>
|
||||
</ElPopover>
|
||||
</template>
|
||||
</ElInput>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.el-divider--horizontal {
|
||||
margin: 1px auto !important;
|
||||
}
|
||||
|
||||
.tab-divider.el-divider--horizontal {
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.icon-item {
|
||||
&:hover {
|
||||
border-color: var(--el-color-primary);
|
||||
color: var(--el-color-primary);
|
||||
transition: all 0.4s;
|
||||
transform: scaleX(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-tabs__nav-next) {
|
||||
font-size: 15px;
|
||||
line-height: 32px;
|
||||
box-shadow: -5px 0 5px -6px #ccc;
|
||||
}
|
||||
|
||||
:deep(.el-tabs__nav-prev) {
|
||||
font-size: 15px;
|
||||
line-height: 32px;
|
||||
box-shadow: 5px 0 5px -6px #ccc;
|
||||
}
|
||||
|
||||
:deep(.el-input-group__append) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:deep(.el-tabs__item) {
|
||||
font-size: 12px;
|
||||
font-weight: normal;
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
}
|
||||
|
||||
:deep(.el-tabs__header),
|
||||
:deep(.el-tabs__nav-wrap) {
|
||||
margin: 0;
|
||||
position: static;
|
||||
}
|
||||
</style>
|
||||
1961
src/components/Icon/src/data.ts
Normal file
33
src/components/ImageViewer/index.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import ImageViewer from './src/ImageViewer.vue'
|
||||
import { isClient } from '@/utils/is'
|
||||
import { createVNode, render, VNode } from 'vue'
|
||||
import { ImageViewerProps } from './src/types'
|
||||
|
||||
let instance: Nullable<VNode> = null
|
||||
|
||||
export function createImageViewer(options: ImageViewerProps) {
|
||||
if (!isClient) return
|
||||
const {
|
||||
urlList,
|
||||
initialIndex = 0,
|
||||
infinite = true,
|
||||
hideOnClickModal = false,
|
||||
appendToBody = false,
|
||||
zIndex = 2000,
|
||||
show = true
|
||||
} = options
|
||||
|
||||
const propsData: Partial<ImageViewerProps> = {}
|
||||
const container = document.createElement('div')
|
||||
propsData.urlList = urlList
|
||||
propsData.initialIndex = initialIndex
|
||||
propsData.infinite = infinite
|
||||
propsData.hideOnClickModal = hideOnClickModal
|
||||
propsData.appendToBody = appendToBody
|
||||
propsData.zIndex = zIndex
|
||||
propsData.show = show
|
||||
|
||||
document.body.appendChild(container)
|
||||
instance = createVNode(ImageViewer, propsData)
|
||||
render(instance, container)
|
||||
}
|
||||
33
src/components/ImageViewer/src/ImageViewer.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<script lang="ts" name="ImageViewer" setup>
|
||||
import { PropType } from 'vue'
|
||||
import { propTypes } from '@/utils/propTypes'
|
||||
|
||||
const props = defineProps({
|
||||
urlList: {
|
||||
type: Array as PropType<string[]>,
|
||||
default: (): string[] => []
|
||||
},
|
||||
zIndex: propTypes.number.def(200),
|
||||
initialIndex: propTypes.number.def(0),
|
||||
infinite: propTypes.bool.def(true),
|
||||
hideOnClickModal: propTypes.bool.def(false),
|
||||
appendToBody: propTypes.bool.def(false),
|
||||
show: propTypes.bool.def(false)
|
||||
})
|
||||
|
||||
const getBindValue = computed(() => {
|
||||
const propsData: Recordable = { ...props }
|
||||
delete propsData.show
|
||||
return propsData
|
||||
})
|
||||
|
||||
const show = ref(props.show)
|
||||
|
||||
const close = () => {
|
||||
show.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElImageViewer v-if="show" v-bind="getBindValue" @close="close" />
|
||||
</template>
|
||||
9
src/components/ImageViewer/src/types.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export interface ImageViewerProps {
|
||||
urlList?: string[]
|
||||
zIndex?: number
|
||||
initialIndex?: number
|
||||
infinite?: boolean
|
||||
hideOnClickModal?: boolean
|
||||
appendToBody?: boolean
|
||||
show?: boolean
|
||||
}
|
||||
3
src/components/Infotip/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import Infotip from './src/Infotip.vue'
|
||||
|
||||
export { Infotip }
|
||||
52
src/components/Infotip/src/Infotip.vue
Normal file
@@ -0,0 +1,52 @@
|
||||
<script lang="ts" name="InfoTip" setup>
|
||||
import { PropType } from 'vue'
|
||||
import { useDesign } from '@/hooks/web/useDesign'
|
||||
import { propTypes } from '@/utils/propTypes'
|
||||
import { TipSchema } from '@/types/infoTip'
|
||||
|
||||
const { getPrefixCls } = useDesign()
|
||||
|
||||
const prefixCls = getPrefixCls('infotip')
|
||||
|
||||
defineProps({
|
||||
title: propTypes.string.def(''),
|
||||
schema: {
|
||||
type: Array as PropType<Array<string | TipSchema>>,
|
||||
required: true,
|
||||
default: () => []
|
||||
},
|
||||
showIndex: propTypes.bool.def(true),
|
||||
highlightColor: propTypes.string.def('var(--el-color-primary)')
|
||||
})
|
||||
|
||||
const emit = defineEmits(['click'])
|
||||
|
||||
const keyClick = (key: string) => {
|
||||
emit('click', key)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
prefixCls,
|
||||
'p-20px mb-20px border-1px border-solid border-[var(--el-color-primary)] bg-[var(--el-color-primary-light-9)]'
|
||||
]"
|
||||
>
|
||||
<div v-if="title" :class="[`${prefixCls}__header`, 'flex items-center']">
|
||||
<Icon :size="22" color="var(--el-color-primary)" icon="ep:warning-filled" />
|
||||
<span :class="[`${prefixCls}__title`, 'pl-5px text-16px font-bold']">{{ title }}</span>
|
||||
</div>
|
||||
<div :class="`${prefixCls}__content`">
|
||||
<p v-for="(item, $index) in schema" :key="$index" class="text-14px mt-15px">
|
||||
<Highlight
|
||||
:color="highlightColor"
|
||||
:keys="typeof item === 'string' ? [] : item.keys"
|
||||
@click="keyClick"
|
||||
>
|
||||
{{ showIndex ? `${$index + 1}、` : '' }}{{ typeof item === 'string' ? item : item.label }}
|
||||
</Highlight>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
3
src/components/InputPassword/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import InputPassword from './src/InputPassword.vue'
|
||||
|
||||
export { InputPassword }
|
||||
148
src/components/InputPassword/src/InputPassword.vue
Normal file
@@ -0,0 +1,148 @@
|
||||
<script lang="ts" name="InputPassword" setup>
|
||||
import { propTypes } from '@/utils/propTypes'
|
||||
import { useConfigGlobal } from '@/hooks/web/useConfigGlobal'
|
||||
import type { ZxcvbnResult } from '@zxcvbn-ts/core'
|
||||
import { zxcvbn } from '@zxcvbn-ts/core'
|
||||
import { useDesign } from '@/hooks/web/useDesign'
|
||||
|
||||
const { getPrefixCls } = useDesign()
|
||||
|
||||
const prefixCls = getPrefixCls('input-password')
|
||||
|
||||
const props = defineProps({
|
||||
// 是否显示密码强度
|
||||
strength: propTypes.bool.def(false),
|
||||
modelValue: propTypes.string.def('')
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val: string) => {
|
||||
if (val === unref(valueRef)) return
|
||||
valueRef.value = val
|
||||
}
|
||||
)
|
||||
|
||||
const { configGlobal } = useConfigGlobal()
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
// 设置input的type属性
|
||||
const textType = ref<'password' | 'text'>('password')
|
||||
|
||||
const changeTextType = () => {
|
||||
textType.value = unref(textType) === 'text' ? 'password' : 'text'
|
||||
}
|
||||
|
||||
// 输入框的值
|
||||
const valueRef = ref(props.modelValue)
|
||||
|
||||
// 监听
|
||||
watch(
|
||||
() => valueRef.value,
|
||||
(val: string) => {
|
||||
emit('update:modelValue', val)
|
||||
}
|
||||
)
|
||||
|
||||
// 获取密码强度
|
||||
const getPasswordStrength = computed(() => {
|
||||
const value = unref(valueRef)
|
||||
const zxcvbnRef = zxcvbn(unref(valueRef)) as ZxcvbnResult
|
||||
return value ? zxcvbnRef.score : -1
|
||||
})
|
||||
|
||||
const getIconName = computed(() => (unref(textType) === 'password' ? 'ep:hide' : 'ep:view'))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="[prefixCls, `${prefixCls}--${configGlobal?.size}`]">
|
||||
<ElInput v-model="valueRef" :type="textType" v-bind="$attrs">
|
||||
<template #suffix>
|
||||
<Icon :icon="getIconName" class="el-input__icon cursor-pointer" @click="changeTextType" />
|
||||
</template>
|
||||
</ElInput>
|
||||
<div
|
||||
v-if="strength"
|
||||
:class="`${prefixCls}__bar`"
|
||||
class="relative h-6px mt-10px mb-6px mr-auto ml-auto"
|
||||
>
|
||||
<div :class="`${prefixCls}__bar--fill`" :data-score="getPasswordStrength"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
$prefix-cls: #{$namespace}-input-password;
|
||||
|
||||
.#{$prefix-cls} {
|
||||
:deep(.#{$elNamespace}-input__clear) {
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
&__bar {
|
||||
background-color: var(--el-text-color-disabled);
|
||||
border-radius: var(--el-border-radius-base);
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
display: block;
|
||||
width: 20%;
|
||||
height: inherit;
|
||||
background-color: transparent;
|
||||
border-color: var(--el-color-white);
|
||||
border-style: solid;
|
||||
border-width: 0 5px 0 5px;
|
||||
content: '';
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 20%;
|
||||
}
|
||||
|
||||
&::after {
|
||||
right: 20%;
|
||||
}
|
||||
|
||||
&--fill {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: inherit;
|
||||
background-color: transparent;
|
||||
border-radius: inherit;
|
||||
transition: width 0.5s ease-in-out, background 0.25s;
|
||||
|
||||
&[data-score='0'] {
|
||||
width: 20%;
|
||||
background-color: var(--el-color-danger);
|
||||
}
|
||||
|
||||
&[data-score='1'] {
|
||||
width: 40%;
|
||||
background-color: var(--el-color-danger);
|
||||
}
|
||||
|
||||
&[data-score='2'] {
|
||||
width: 60%;
|
||||
background-color: var(--el-color-warning);
|
||||
}
|
||||
|
||||
&[data-score='3'] {
|
||||
width: 80%;
|
||||
background-color: var(--el-color-success);
|
||||
}
|
||||
|
||||
&[data-score='4'] {
|
||||
width: 100%;
|
||||
background-color: var(--el-color-success);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&--mini > &__bar {
|
||||
border-radius: var(--el-border-radius-small);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
79
src/components/Pagination/index.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<!-- 基于 ruoyi-vue3 的 Pagination 重构,核心是简化无用的属性,并使用 ts 重写 -->
|
||||
<template>
|
||||
<el-pagination
|
||||
v-show="total > 0"
|
||||
v-model:current-page="currentPage"
|
||||
v-model:page-size="pageSize"
|
||||
:background="true"
|
||||
:page-sizes="[10, 20, 50, 100, 200]"
|
||||
:pager-count="pagerCount"
|
||||
:total="total"
|
||||
class="float-right mt-15px mb-15px"
|
||||
:layout="layout"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
/>
|
||||
</template>
|
||||
<script name="Pagination" setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
// 总条目数
|
||||
total: {
|
||||
required: true,
|
||||
type: Number
|
||||
},
|
||||
// 当前页数:pageNo
|
||||
page: {
|
||||
type: Number,
|
||||
default: 1
|
||||
},
|
||||
// 每页显示条目个数:pageSize
|
||||
limit: {
|
||||
type: Number,
|
||||
default: 20
|
||||
},
|
||||
// 设置最大页码按钮数。 页码按钮的数量,当总页数超过该值时会折叠
|
||||
// 移动端页码按钮的数量端默认值 5
|
||||
pagerCount: {
|
||||
type: Number,
|
||||
default: document.body.clientWidth < 992 ? 5 : 7
|
||||
},
|
||||
layout: {
|
||||
type: String,
|
||||
default: 'total, sizes, prev, pager, next, jumper'
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:page', 'update:limit', 'pagination', 'pagination'])
|
||||
const currentPage = computed({
|
||||
get() {
|
||||
return props.page
|
||||
},
|
||||
set(val) {
|
||||
// 触发 update:page 事件,更新 limit 属性,从而更新 pageNo
|
||||
emit('update:page', val)
|
||||
}
|
||||
})
|
||||
const pageSize = computed({
|
||||
get() {
|
||||
return props.limit
|
||||
},
|
||||
set(val) {
|
||||
// 触发 update:limit 事件,更新 limit 属性,从而更新 pageSize
|
||||
emit('update:limit', val)
|
||||
}
|
||||
})
|
||||
const handleSizeChange = (val) => {
|
||||
// 如果修改后超过最大页面,强制跳转到第 1 页
|
||||
if (currentPage.value * val > props.total) {
|
||||
currentPage.value = 1
|
||||
}
|
||||
// 触发 pagination 事件,重新加载列表
|
||||
emit('pagination', { page: currentPage.value, limit: val })
|
||||
}
|
||||
const handleCurrentChange = (val) => {
|
||||
// 触发 pagination 事件,重新加载列表
|
||||
emit('pagination', { page: val, limit: pageSize.value })
|
||||
}
|
||||
</script>
|
||||
3
src/components/Qrcode/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import Qrcode from './src/Qrcode.vue'
|
||||
|
||||
export { Qrcode }
|
||||
251
src/components/Qrcode/src/Qrcode.vue
Normal file
@@ -0,0 +1,251 @@
|
||||
<script lang="ts" name="Qrcode" setup>
|
||||
import { computed, nextTick, PropType, ref, unref, watch } from 'vue'
|
||||
import QRCode, { QRCodeRenderersOptions } from 'qrcode'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
import { propTypes } from '@/utils/propTypes'
|
||||
import { useDesign } from '@/hooks/web/useDesign'
|
||||
import { isString } from '@/utils/is'
|
||||
import { QrcodeLogo } from '@/types/qrcode'
|
||||
|
||||
const props = defineProps({
|
||||
// img 或者 canvas,img不支持logo嵌套
|
||||
tag: propTypes.string.validate((v: string) => ['canvas', 'img'].includes(v)).def('canvas'),
|
||||
// 二维码内容
|
||||
text: {
|
||||
type: [String, Array] as PropType<string | Recordable[]>,
|
||||
default: null
|
||||
},
|
||||
// qrcode.js配置项
|
||||
options: {
|
||||
type: Object as PropType<QRCodeRenderersOptions>,
|
||||
default: () => ({})
|
||||
},
|
||||
// 宽度
|
||||
width: propTypes.number.def(200),
|
||||
// logo
|
||||
logo: {
|
||||
type: [String, Object] as PropType<Partial<QrcodeLogo> | string>,
|
||||
default: ''
|
||||
},
|
||||
// 是否过期
|
||||
disabled: propTypes.bool.def(false),
|
||||
// 过期提示内容
|
||||
disabledText: propTypes.string.def('')
|
||||
})
|
||||
|
||||
const emit = defineEmits(['done', 'click', 'disabled-click'])
|
||||
|
||||
const { getPrefixCls } = useDesign()
|
||||
|
||||
const prefixCls = getPrefixCls('qrcode')
|
||||
|
||||
const { toCanvas, toDataURL } = QRCode
|
||||
|
||||
const loading = ref(true)
|
||||
|
||||
const wrapRef = ref<Nullable<HTMLCanvasElement | HTMLImageElement>>(null)
|
||||
|
||||
const renderText = computed(() => String(props.text))
|
||||
|
||||
const wrapStyle = computed(() => {
|
||||
return {
|
||||
width: props.width + 'px',
|
||||
height: props.width + 'px'
|
||||
}
|
||||
})
|
||||
|
||||
const initQrcode = async () => {
|
||||
await nextTick()
|
||||
const options = cloneDeep(props.options || {})
|
||||
if (props.tag === 'canvas') {
|
||||
// 容错率,默认对内容少的二维码采用高容错率,内容多的二维码采用低容错率
|
||||
options.errorCorrectionLevel =
|
||||
options.errorCorrectionLevel || getErrorCorrectionLevel(unref(renderText))
|
||||
const _width: number = await getOriginWidth(unref(renderText), options)
|
||||
options.scale = props.width === 0 ? undefined : (props.width / _width) * 4
|
||||
const canvasRef: HTMLCanvasElement | any = await toCanvas(
|
||||
unref(wrapRef) as HTMLCanvasElement,
|
||||
unref(renderText),
|
||||
options
|
||||
)
|
||||
if (props.logo) {
|
||||
const url = await createLogoCode(canvasRef)
|
||||
emit('done', url)
|
||||
loading.value = false
|
||||
} else {
|
||||
emit('done', canvasRef.toDataURL())
|
||||
loading.value = false
|
||||
}
|
||||
} else {
|
||||
const url = await toDataURL(renderText.value, {
|
||||
errorCorrectionLevel: 'H',
|
||||
width: props.width,
|
||||
...options
|
||||
})
|
||||
;(unref(wrapRef) as HTMLImageElement).src = url
|
||||
emit('done', url)
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => renderText.value,
|
||||
(val) => {
|
||||
if (!val) return
|
||||
initQrcode()
|
||||
},
|
||||
{
|
||||
deep: true,
|
||||
immediate: true
|
||||
}
|
||||
)
|
||||
|
||||
const createLogoCode = (canvasRef: HTMLCanvasElement) => {
|
||||
const canvasWidth = canvasRef.width
|
||||
const logoOptions: QrcodeLogo = Object.assign(
|
||||
{
|
||||
logoSize: 0.15,
|
||||
bgColor: '#ffffff',
|
||||
borderSize: 0.05,
|
||||
crossOrigin: 'anonymous',
|
||||
borderRadius: 8,
|
||||
logoRadius: 0
|
||||
},
|
||||
isString(props.logo) ? {} : props.logo
|
||||
)
|
||||
const {
|
||||
logoSize = 0.15,
|
||||
bgColor = '#ffffff',
|
||||
borderSize = 0.05,
|
||||
crossOrigin = 'anonymous',
|
||||
borderRadius = 8,
|
||||
logoRadius = 0
|
||||
} = logoOptions
|
||||
const logoSrc = isString(props.logo) ? props.logo : props.logo.src
|
||||
const logoWidth = canvasWidth * logoSize
|
||||
const logoXY = (canvasWidth * (1 - logoSize)) / 2
|
||||
const logoBgWidth = canvasWidth * (logoSize + borderSize)
|
||||
const logoBgXY = (canvasWidth * (1 - logoSize - borderSize)) / 2
|
||||
|
||||
const ctx = canvasRef.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
// logo 底色
|
||||
canvasRoundRect(ctx)(logoBgXY, logoBgXY, logoBgWidth, logoBgWidth, borderRadius)
|
||||
ctx.fillStyle = bgColor
|
||||
ctx.fill()
|
||||
|
||||
// logo
|
||||
const image = new Image()
|
||||
if (crossOrigin || logoRadius) {
|
||||
image.setAttribute('crossOrigin', crossOrigin)
|
||||
}
|
||||
;(image as any).src = logoSrc
|
||||
|
||||
// 使用image绘制可以避免某些跨域情况
|
||||
const drawLogoWithImage = (image: HTMLImageElement) => {
|
||||
ctx.drawImage(image, logoXY, logoXY, logoWidth, logoWidth)
|
||||
}
|
||||
|
||||
// 使用canvas绘制以获得更多的功能
|
||||
const drawLogoWithCanvas = (image: HTMLImageElement) => {
|
||||
const canvasImage = document.createElement('canvas')
|
||||
canvasImage.width = logoXY + logoWidth
|
||||
canvasImage.height = logoXY + logoWidth
|
||||
const imageCanvas = canvasImage.getContext('2d')
|
||||
if (!imageCanvas || !ctx) return
|
||||
imageCanvas.drawImage(image, logoXY, logoXY, logoWidth, logoWidth)
|
||||
|
||||
canvasRoundRect(ctx)(logoXY, logoXY, logoWidth, logoWidth, logoRadius)
|
||||
if (!ctx) return
|
||||
const fillStyle = ctx.createPattern(canvasImage, 'no-repeat')
|
||||
if (fillStyle) {
|
||||
ctx.fillStyle = fillStyle
|
||||
ctx.fill()
|
||||
}
|
||||
}
|
||||
|
||||
// 将 logo绘制到 canvas上
|
||||
return new Promise((resolve: any) => {
|
||||
image.onload = () => {
|
||||
logoRadius ? drawLogoWithCanvas(image) : drawLogoWithImage(image)
|
||||
resolve(canvasRef.toDataURL())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 得到原QrCode的大小,以便缩放得到正确的QrCode大小
|
||||
const getOriginWidth = async (content: string, options: QRCodeRenderersOptions) => {
|
||||
const _canvas = document.createElement('canvas')
|
||||
await toCanvas(_canvas, content, options)
|
||||
return _canvas.width
|
||||
}
|
||||
|
||||
// 对于内容少的QrCode,增大容错率
|
||||
const getErrorCorrectionLevel = (content: string) => {
|
||||
if (content.length > 36) {
|
||||
return 'M'
|
||||
} else if (content.length > 16) {
|
||||
return 'Q'
|
||||
} else {
|
||||
return 'H'
|
||||
}
|
||||
}
|
||||
|
||||
// copy来的方法,用于绘制圆角
|
||||
const canvasRoundRect = (ctx: CanvasRenderingContext2D) => {
|
||||
return (x: number, y: number, w: number, h: number, r: number) => {
|
||||
const minSize = Math.min(w, h)
|
||||
if (r > minSize / 2) {
|
||||
r = minSize / 2
|
||||
}
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x + r, y)
|
||||
ctx.arcTo(x + w, y, x + w, y + h, r)
|
||||
ctx.arcTo(x + w, y + h, x, y + h, r)
|
||||
ctx.arcTo(x, y + h, x, y, r)
|
||||
ctx.arcTo(x, y, x + w, y, r)
|
||||
ctx.closePath()
|
||||
return ctx
|
||||
}
|
||||
}
|
||||
|
||||
const clickCode = () => {
|
||||
emit('click')
|
||||
}
|
||||
|
||||
const disabledClick = () => {
|
||||
emit('disabled-click')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-loading="loading" :class="[prefixCls, 'relative inline-block']" :style="wrapStyle">
|
||||
<component :is="tag" ref="wrapRef" @click="clickCode" />
|
||||
<div
|
||||
v-if="disabled"
|
||||
:class="`${prefixCls}--disabled`"
|
||||
class="absolute top-0 left-0 flex w-full h-full items-center justify-center"
|
||||
@click="disabledClick"
|
||||
>
|
||||
<div class="absolute top-[50%] left-[50%] font-bold">
|
||||
<Icon :size="30" color="var(--el-color-primary)" icon="ep:refresh-right" />
|
||||
<div>{{ disabledText }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
$prefix-cls: #{$namespace}-qrcode;
|
||||
|
||||
.#{$prefix-cls} {
|
||||
&--disabled {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
|
||||
& > div {
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
76
src/components/RouterSearch/index.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<ElDialog v-model="showSearch" :show-close="false" title="菜单搜索">
|
||||
<el-select
|
||||
filterable
|
||||
:reserve-keyword="false"
|
||||
remote
|
||||
placeholder="请输入菜单内容"
|
||||
:remote-method="remoteMethod"
|
||||
style="width: 100%"
|
||||
@change="handleChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in options"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</ElDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const router = useRouter() // 路由对象
|
||||
const showSearch = ref(false) // 是否显示弹框
|
||||
const value: Ref = ref('') // 用户输入的值
|
||||
|
||||
const routers = router.getRoutes() // 路由对象
|
||||
const options = computed(() => {
|
||||
// 提示选项
|
||||
if (!value.value) {
|
||||
return []
|
||||
}
|
||||
const list = routers.filter((item: any) => {
|
||||
if (item.meta.title?.indexOf(value.value) > -1 || item.path.indexOf(value.value) > -1) {
|
||||
return true
|
||||
}
|
||||
})
|
||||
return list.map((item) => {
|
||||
return {
|
||||
label: `${item.meta.title}${item.path}`,
|
||||
value: item.path
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function remoteMethod(data) {
|
||||
// 这里可以执行相应的操作(例如打开搜索框等)
|
||||
value.value = data
|
||||
}
|
||||
|
||||
function handleChange(path) {
|
||||
router.push({ path })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', listenKey)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', listenKey)
|
||||
})
|
||||
|
||||
// 监听 ctrl + k
|
||||
function listenKey(event) {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === 'k') {
|
||||
showSearch.value = !showSearch.value
|
||||
// 这里可以执行相应的操作(例如打开搜索框等)
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
openSearch: () => {
|
||||
showSearch.value = true
|
||||
}
|
||||
})
|
||||
</script>
|
||||
135
src/components/SSTable/index.vue
Normal file
@@ -0,0 +1,135 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex">
|
||||
<el-table :data="tableObject.tableList" border style="flex: 1">
|
||||
<slot></slot>
|
||||
</el-table>
|
||||
<el-button
|
||||
ref="ColumnSetting"
|
||||
plain
|
||||
style="writing-mode: vertical-lr; height: 100px; letter-spacing: 2px"
|
||||
@click="clickSetting"
|
||||
>表格列控制</el-button
|
||||
>
|
||||
<el-popover
|
||||
ref="TableColumnPop"
|
||||
:virtual-ref="ColumnSetting"
|
||||
placement="left"
|
||||
width="120px"
|
||||
trigger="click"
|
||||
virtual-triggering
|
||||
>
|
||||
<el-checkbox-group v-model="checkedColumns" @change="confirm">
|
||||
<draggable
|
||||
v-model="allColumns"
|
||||
item-key="field"
|
||||
ghost-class="draggable-ghost"
|
||||
:animation="400"
|
||||
@end="onDragEnd"
|
||||
>
|
||||
<template #item="{ element: item }">
|
||||
<el-checkbox :key="item.id" :label="item.id">
|
||||
{{ item.label }}
|
||||
</el-checkbox>
|
||||
</template>
|
||||
</draggable>
|
||||
</el-checkbox-group>
|
||||
</el-popover>
|
||||
</div>
|
||||
<!-- 分页 -->
|
||||
<Pagination
|
||||
v-model:limit="pageSize"
|
||||
v-model:page="pageNo"
|
||||
:total="tableObject.total"
|
||||
@pagination="getList"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import draggable from 'vuedraggable'
|
||||
import * as ClueCacheApi from '@/api/clue/clueCache'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
const props = defineProps({
|
||||
tableObject: { type: Object, default: () => ({ tableList: [] }) },
|
||||
tableColumns: { type: Array, default: () => [] }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:tableObject', 'getList', 'getCheckedColumns'])
|
||||
const route = useRoute()
|
||||
|
||||
const pageNo = ref(props.tableObject?.pageNo || 1)
|
||||
|
||||
const pageSize = ref(props.tableObject?.pageSize || 20)
|
||||
|
||||
const ColumnSetting = ref()
|
||||
const TableColumnPop = ref()
|
||||
// 展示在设置里的所有表格列,由于会排序,所以使用新属性,不直接修改原值
|
||||
const allColumns = ref([...props.tableColumns])
|
||||
// 已勾选的选项
|
||||
const checkedColumns = ref([])
|
||||
|
||||
// 调用获取数据的接口,分页时需要使用
|
||||
function getList({ page, limit }) {
|
||||
emit('update:tableObject', { ...props.tableObject, pageNo: page, pageSize: limit })
|
||||
nextTick(() => {
|
||||
emit('getList')
|
||||
})
|
||||
}
|
||||
|
||||
// 点击"表格列控制"按钮,出现设置页面
|
||||
const clickSetting = () => {
|
||||
unref(TableColumnPop).TableColumnPop?.delayHide?.()
|
||||
}
|
||||
|
||||
const routeMap = {
|
||||
CluePool: 1,
|
||||
ClueOrder: 2
|
||||
}
|
||||
|
||||
// 获取用户已勾选的表头
|
||||
async function getUserCheckedColumns() {
|
||||
checkedColumns.value = await ClueCacheApi.getClueCache({
|
||||
settingType: 2,
|
||||
model: routeMap[route.name]
|
||||
})
|
||||
// 回显到表格中
|
||||
emitColumns()
|
||||
}
|
||||
|
||||
// 表格列排序
|
||||
function onDragEnd() {
|
||||
ClueCacheApi.setClueCache({
|
||||
settingType: 2,
|
||||
model: routeMap[route.name],
|
||||
clueParamId: checkedColumns.value
|
||||
})
|
||||
|
||||
// 表格回显
|
||||
emitColumns()
|
||||
}
|
||||
|
||||
// 勾选确认
|
||||
function confirm() {
|
||||
ClueCacheApi.setClueCache({
|
||||
settingType: 2,
|
||||
model: routeMap[route.name],
|
||||
clueParamId: checkedColumns.value
|
||||
})
|
||||
// usedSchema.value = allColumns.value.filter((it) => checkedColumns.value.includes(it.id))
|
||||
emitColumns()
|
||||
}
|
||||
|
||||
// 将表头数据返回至父组件
|
||||
function emitColumns() {
|
||||
const arr = allColumns.value.filter((item) => checkedColumns.value.includes(item.id))
|
||||
emit('getCheckedColumns', arr)
|
||||
}
|
||||
|
||||
getUserCheckedColumns()
|
||||
|
||||
defineExpose({ getUserCheckedColumns })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
3
src/components/Search/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import Search from './src/Search.vue'
|
||||
|
||||
export { Search }
|
||||
233
src/components/Search/src/Search.vue
Normal file
@@ -0,0 +1,233 @@
|
||||
<script lang="ts" name="Search" setup>
|
||||
import { PropType } from 'vue'
|
||||
import { propTypes } from '@/utils/propTypes'
|
||||
|
||||
import { useForm } from '@/hooks/web/useForm'
|
||||
import { findIndex } from '@/utils'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
import { FormSchema } from '@/types/form'
|
||||
|
||||
import { useRoute } from 'vue-router'
|
||||
import * as ClueCacheApi from '@/api/clue/clueCache'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps({
|
||||
// 生成Form的布局结构数组
|
||||
schema: {
|
||||
type: Array as PropType<FormSchema[]>,
|
||||
default: () => []
|
||||
},
|
||||
// 是否需要栅格布局
|
||||
isCol: propTypes.bool.def(false),
|
||||
// 表单label宽度
|
||||
labelWidth: propTypes.oneOfType([String, Number]).def('auto'),
|
||||
// 操作按钮风格位置
|
||||
layout: propTypes.string.validate((v: string) => ['inline', 'bottom'].includes(v)).def('inline'),
|
||||
// 底部按钮的对齐方式
|
||||
buttomPosition: propTypes.string
|
||||
.validate((v: string) => ['left', 'center', 'right'].includes(v))
|
||||
.def('center'),
|
||||
showLabel: propTypes.bool.def(false),
|
||||
showSearch: propTypes.bool.def(false),
|
||||
showReset: propTypes.bool.def(false),
|
||||
// 是否显示伸缩
|
||||
expand: propTypes.bool.def(false),
|
||||
// 伸缩的界限字段
|
||||
expandField: propTypes.string.def(''),
|
||||
inline: propTypes.bool.def(true),
|
||||
inlineBlock: propTypes.bool.def(false),
|
||||
model: {
|
||||
type: Object as PropType<Recordable>,
|
||||
default: () => ({})
|
||||
}
|
||||
})
|
||||
|
||||
// const emit = defineEmits(['search', 'reset'])
|
||||
|
||||
const visible = ref(true)
|
||||
|
||||
const SchemaSetting = ref()
|
||||
const SettingPop = ref()
|
||||
|
||||
const checkedSchema = ref([])
|
||||
|
||||
// 用户使用的查询条件
|
||||
const usedSchema = ref([])
|
||||
|
||||
const newSchema = computed(() => {
|
||||
let schema: FormSchema[] = cloneDeep(usedSchema.value)
|
||||
schema.forEach((item: FormSchema) => {
|
||||
if (item.component == 'TreeSelect') {
|
||||
item.componentProps['check-strictly'] = true
|
||||
}
|
||||
})
|
||||
if (props.expand && props.expandField && !unref(visible)) {
|
||||
const index = findIndex(schema, (v: FormSchema) => v.field === props.expandField)
|
||||
if (index > -1) {
|
||||
const length = schema.length
|
||||
schema.splice(index + 1, length)
|
||||
}
|
||||
}
|
||||
if (props.layout === 'inline') {
|
||||
schema = schema.concat([
|
||||
{
|
||||
field: 'action',
|
||||
formItemProps: {
|
||||
labelWidth: '0px'
|
||||
}
|
||||
}
|
||||
])
|
||||
}
|
||||
return schema
|
||||
})
|
||||
|
||||
const routeMap = {
|
||||
CluePool: 1,
|
||||
ClueOrder: 2
|
||||
}
|
||||
async function initSearch() {
|
||||
reset()
|
||||
checkedSchema.value = await ClueCacheApi.getClueCache({
|
||||
settingType: 1,
|
||||
model: routeMap[route.name]
|
||||
})
|
||||
usedSchema.value = props.schema.filter((it) => checkedSchema.value.includes(it.id))
|
||||
}
|
||||
|
||||
function changeSearch() {
|
||||
ClueCacheApi.setClueCache({
|
||||
settingType: 1,
|
||||
model: routeMap[route.name],
|
||||
clueParamId: checkedSchema.value
|
||||
})
|
||||
usedSchema.value = props.schema.filter((it) => checkedSchema.value.includes(it.id))
|
||||
}
|
||||
|
||||
function setSearch() {
|
||||
unref(SettingPop).SettingPop?.delayHide?.()
|
||||
}
|
||||
|
||||
const { register, elFormRef, methods } = useForm({
|
||||
model: props.model || {}
|
||||
})
|
||||
|
||||
// const search = async () => {
|
||||
// await unref(elFormRef)?.validate(async (isValid) => {
|
||||
// if (isValid) {
|
||||
// const { getFormData } = methods
|
||||
// const model = await getFormData()
|
||||
// emit('search', model)
|
||||
// }
|
||||
// })
|
||||
// }
|
||||
|
||||
const reset = async () => {
|
||||
unref(elFormRef)?.resetFields()
|
||||
// const { getFormData } = methods
|
||||
// const model = await getFormData()
|
||||
// emit('reset', model)
|
||||
}
|
||||
|
||||
async function getFormModel() {
|
||||
const { getFormData } = methods
|
||||
const model = await getFormData()
|
||||
return model
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
getFormModel,
|
||||
reset
|
||||
})
|
||||
|
||||
const bottonButtonStyle = computed(() => {
|
||||
return {
|
||||
textAlign: props.buttomPosition as unknown as 'left' | 'center' | 'right'
|
||||
}
|
||||
})
|
||||
|
||||
const setVisible = () => {
|
||||
unref(elFormRef)?.resetFields()
|
||||
visible.value = !unref(visible)
|
||||
}
|
||||
|
||||
initSearch()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- update by 芋艿:class="-mb-15px" 用于降低和 ContentWrap 组件的底部距离,避免空隙过大 -->
|
||||
<Form
|
||||
:inline="inline"
|
||||
:inlineBlock="inlineBlock"
|
||||
:is-col="isCol"
|
||||
:is-custom="false"
|
||||
isSearch
|
||||
:label-width="labelWidth"
|
||||
:showLabel="showLabel"
|
||||
:schema="newSchema"
|
||||
class="-mb-15px"
|
||||
hide-required-asterisk
|
||||
@register="register"
|
||||
>
|
||||
<template #action>
|
||||
<div v-if="layout === 'inline'">
|
||||
<ElButton ref="SchemaSetting" @click="setSearch"> 查询设置 </ElButton>
|
||||
<el-popover
|
||||
ref="SettingPop"
|
||||
:virtual-ref="SchemaSetting"
|
||||
placement="bottom"
|
||||
width="120px"
|
||||
trigger="click"
|
||||
virtual-triggering
|
||||
>
|
||||
<el-checkbox-group v-model="checkedSchema" @change="changeSearch">
|
||||
<el-checkbox v-for="item in schema" :key="item.id" :label="item.id">
|
||||
{{ item.label }}
|
||||
</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</el-popover>
|
||||
|
||||
<!-- update by 芋艿:去除搜索的 type="primary",颜色变淡一点 -->
|
||||
<ElButton v-if="showSearch" @click="search">
|
||||
<!-- <Icon class="mr-5px" icon="ep:search" /> -->
|
||||
{{ t('common.query') }}
|
||||
</ElButton>
|
||||
<!-- update by 芋艿:将 icon="ep:refresh-right" 修改成 icon="ep:refresh",和 ruoyi-vue 搜索保持一致 -->
|
||||
<ElButton v-if="showReset" @click="reset">
|
||||
<!-- <Icon class="mr-5px" icon="ep:refresh" /> -->
|
||||
{{ t('common.reset') }}
|
||||
</ElButton>
|
||||
<!-- add by 芋艿:补充在搜索后的按钮 -->
|
||||
<slot name="actionMore"></slot>
|
||||
<!-- <ElButton v-if="expand" text @click="setVisible">
|
||||
{{ t(visible ? 'common.shrink' : 'common.expand') }}
|
||||
<Icon :icon="visible ? 'ep:arrow-up' : 'ep:arrow-down'" />
|
||||
</ElButton> -->
|
||||
</div>
|
||||
</template>
|
||||
<template v-for="name in Object.keys($slots)" :key="name" #[name]>
|
||||
<slot :name="name"></slot>
|
||||
</template>
|
||||
</Form>
|
||||
|
||||
<template v-if="layout === 'bottom'">
|
||||
<div :style="bottonButtonStyle">
|
||||
<ElButton v-if="showSearch" type="primary" @click="search">
|
||||
<!-- <Icon class="mr-5px" icon="ep:search" /> -->
|
||||
{{ t('common.query') }}
|
||||
</ElButton>
|
||||
<ElButton v-if="showReset" @click="reset">
|
||||
<!-- <Icon class="mr-5px" icon="ep:refresh-right" /> -->
|
||||
{{ t('common.reset') }}
|
||||
</ElButton>
|
||||
<ElButton v-if="expand" text @click="setVisible">
|
||||
{{ t(visible ? 'common.shrink' : 'common.expand') }}
|
||||
<Icon :icon="visible ? 'ep:arrow-up' : 'ep:arrow-down'" />
|
||||
</ElButton>
|
||||
<!-- add by 芋艿:补充在搜索后的按钮 -->
|
||||
<slot name="actionMore"></slot>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
3
src/components/Sticky/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import Sticky from './src/Sticky.vue'
|
||||
|
||||
export { Sticky }
|
||||
141
src/components/Sticky/src/Sticky.vue
Normal file
@@ -0,0 +1,141 @@
|
||||
<script lang="ts" name="Sticky" setup>
|
||||
import { propTypes } from '@/utils/propTypes'
|
||||
import { isClient, useEventListener, useWindowSize } from '@vueuse/core'
|
||||
import type { CSSProperties } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
// 距离顶部或者底部的距离(单位px)
|
||||
offset: propTypes.number.def(0),
|
||||
// 设置元素的堆叠顺序
|
||||
zIndex: propTypes.number.def(999),
|
||||
// 设置指定的class
|
||||
className: propTypes.string.def(''),
|
||||
// 定位方式,默认为(top),表示距离顶部位置,可以设置为top或者bottom
|
||||
position: {
|
||||
type: String,
|
||||
validator: function (value: string) {
|
||||
return ['top', 'bottom'].indexOf(value) !== -1
|
||||
},
|
||||
default: 'top'
|
||||
}
|
||||
})
|
||||
const width = ref('auto' as string)
|
||||
const height = ref('auto' as string)
|
||||
const isSticky = ref(false)
|
||||
const refSticky = shallowRef<HTMLElement>()
|
||||
const scrollContainer = shallowRef<HTMLElement | Window>()
|
||||
const { height: windowHeight } = useWindowSize()
|
||||
onMounted(() => {
|
||||
height.value = refSticky.value?.getBoundingClientRect().height + 'px'
|
||||
|
||||
scrollContainer.value = getScrollContainer(refSticky.value!, true)
|
||||
useEventListener(scrollContainer, 'scroll', handleScroll)
|
||||
useEventListener('resize', handleReize)
|
||||
handleScroll()
|
||||
})
|
||||
onActivated(() => {
|
||||
handleScroll()
|
||||
})
|
||||
|
||||
const camelize = (str: string): string => {
|
||||
return str.replace(/-(\w)/g, (_, c) => (c ? c.toUpperCase() : ''))
|
||||
}
|
||||
|
||||
const getStyle = (element: HTMLElement, styleName: keyof CSSProperties): string => {
|
||||
if (!isClient || !element || !styleName) return ''
|
||||
|
||||
let key = camelize(styleName)
|
||||
if (key === 'float') key = 'cssFloat'
|
||||
try {
|
||||
const style = element.style[styleName]
|
||||
if (style) return style
|
||||
const computed = document.defaultView?.getComputedStyle(element, '')
|
||||
return computed ? computed[styleName] : ''
|
||||
} catch {
|
||||
return element.style[styleName]
|
||||
}
|
||||
}
|
||||
const isScroll = (el: HTMLElement, isVertical?: boolean): boolean => {
|
||||
if (!isClient) return false
|
||||
const key = (
|
||||
{
|
||||
undefined: 'overflow',
|
||||
true: 'overflow-y',
|
||||
false: 'overflow-x'
|
||||
} as const
|
||||
)[String(isVertical)]!
|
||||
const overflow = getStyle(el, key)
|
||||
return ['scroll', 'auto', 'overlay'].some((s) => overflow.includes(s))
|
||||
}
|
||||
|
||||
const getScrollContainer = (
|
||||
el: HTMLElement,
|
||||
isVertical: boolean
|
||||
): Window | HTMLElement | undefined => {
|
||||
if (!isClient) return
|
||||
let parent = el
|
||||
while (parent) {
|
||||
if ([window, document, document.documentElement].includes(parent)) return window
|
||||
if (isScroll(parent, isVertical)) return parent
|
||||
parent = parent.parentNode as HTMLElement
|
||||
}
|
||||
return parent
|
||||
}
|
||||
|
||||
const handleScroll = () => {
|
||||
width.value = refSticky.value!.getBoundingClientRect().width! + 'px'
|
||||
if (props.position === 'top') {
|
||||
const offsetTop = refSticky.value?.getBoundingClientRect().top
|
||||
if (offsetTop !== undefined && offsetTop < props.offset) {
|
||||
sticky()
|
||||
return
|
||||
}
|
||||
reset()
|
||||
} else {
|
||||
const offsetBottom = refSticky.value?.getBoundingClientRect().bottom
|
||||
|
||||
if (offsetBottom !== undefined && offsetBottom > windowHeight.value - props.offset) {
|
||||
sticky()
|
||||
return
|
||||
}
|
||||
reset()
|
||||
}
|
||||
}
|
||||
const handleReize = () => {
|
||||
if (isSticky.value && refSticky.value) {
|
||||
width.value = refSticky.value.getBoundingClientRect().width + 'px'
|
||||
}
|
||||
}
|
||||
const sticky = () => {
|
||||
if (isSticky.value) {
|
||||
return
|
||||
}
|
||||
isSticky.value = true
|
||||
}
|
||||
const reset = () => {
|
||||
if (!isSticky.value) {
|
||||
return
|
||||
}
|
||||
width.value = 'auto'
|
||||
isSticky.value = false
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div ref="refSticky" :style="{ height: height, zIndex: zIndex }">
|
||||
<div
|
||||
:class="className"
|
||||
:style="{
|
||||
top: position === 'top' ? offset + 'px' : '',
|
||||
bottom: position !== 'top' ? offset + 'px' : '',
|
||||
zIndex: zIndex,
|
||||
position: isSticky ? 'fixed' : 'static',
|
||||
width: width,
|
||||
height: height
|
||||
}"
|
||||
>
|
||||
<slot>
|
||||
<div>sticky</div>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||