Merge branch 'dev' of github.com:jetlinks/jetlinks-ui-vue into dev

This commit is contained in:
blp 2023-01-30 18:13:08 +08:00
commit 21efdef721
69 changed files with 9076 additions and 4662 deletions

View File

@ -2,6 +2,15 @@ export default {
theme: { theme: {
'primary-color': '#1d39c4', 'primary-color': '#1d39c4',
}, },
logo: '/favicon.ico', logo: '/favicon.ico', // 浏览器标签页logo
title: 'Jetlinks' title: 'Jetlinks', // 浏览器标签页title
layout: {
title: '物联网平台', // 平台title
logo: '/icons/icon-192x192.png', // 平台logo
siderWidth: 208, // 左侧菜单栏宽度
headerHeight: 48, // 头部高度
collapsedWidth: 48,
mode: 'inline',
theme: 'light', // 'dark' 'light'
}
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@ -1,9 +1,9 @@
<script setup lang="ts">
</script>
<template> <template>
<router-view /> <router-view />
</template> </template>
<script setup lang="ts">
</script>
<style scoped> <style scoped>
</style> </style>

View File

@ -23,3 +23,8 @@ export const getSearchHistory = (target:string) => server.get<SearchHistoryList[
* @param target * @param target
*/ */
export const deleteSearchHistory = (target:string, id:string) => server.remove<SearchHistoryList[]>(`/user/settings/${target}/${id}`) export const deleteSearchHistory = (target:string, id:string) => server.remove<SearchHistoryList[]>(`/user/settings/${target}/${id}`)
/**
*
*/
export const systemVersion = () => server.get<{edition?: string}>('/system/version')

View File

@ -16,7 +16,7 @@ export const queryTree = (params?: Record<string, any>) => server.post<CategoryI
/** /**
* Id修改 * Id修改
*/ */
export const updateTree = (data: any, id:string) => server.put(`/device/category/${id}`, data) export const updateTree = (id:string,data: any,) => server.put(`/device/category/${id}`, data)
/** /**
* Id删除数据 * Id删除数据

View File

@ -1,5 +1,5 @@
import server from '@/utils/request' import server from '@/utils/request'
import { DeviceMetadata, ProductItem } from '@/views/device/Product/typings' import { DeviceMetadata, ProductItem, DepartmentItem } from '@/views/device/Product/typings'
/** /**
* *
@ -40,13 +40,78 @@ export const detail = (id: string) => server.get<ProductItem>(`/device-product/$
/** /**
* *
* @param data * @param data
*/ */
export const category = (data: any) => server.post('/device/category/_tree', data) export const category = (data: any) => server.post('/device/category/_tree', data)
/**
*
*/
export const getProviders = () => server.get('/gateway/device/providers')
/**
*
* @param params
*/
export const queryOrgThree = (params?: Record<string, any>) => server.post<DepartmentItem>('/organization/_all/tree', params)
/**
*
* @param data
*/
export const queryGatewayList = (data: any) => server.post('/gateway/device/_query/no-paging', data)
/**
* ()
* @param data
*/
export const queryProductList = (data: any) => server.post('/device-product/_query', data)
/**
*
* @param productId ID
* @param data
* @returns
*/
export const _deploy = (productId: string) => server.post(`/device-product/${productId}/deploy`)
/**
*
* @param productId ID
* @param data
* @returns
*/
export const _undeploy = (productId: string) => server.post(`/device-product/${productId}/undeploy`)
/**
*
* @param data
* @returns
*/
export const addProduct = (data:any) => server.post('/device-product',data)
/**
*
* @param id ID
* @param data
* @returns
*/
export const editProduct = (data: any) => server.patch('/device-product', data)
/**
*
* @param id ID
*/
export const deleteProduct = (id: string) => server.patch(`/device-product/${id}`)
/**
* Id唯一性
* @param id ID
*/
export const queryProductId = (id: string) => server.post(`/device-product/${id}/exists`)
/** /**
* *
* @param data * @param data
* @returns * @returns
*/ */
export const saveProductMetadata = (data: Record<string, unknown>) => server.patch('/device-product', data) export const saveProductMetadata = (data: Record<string, unknown>) => server.patch('/device-product', data)

View File

@ -46,4 +46,9 @@ export const bindInfo = () => server.get(`/application/sso/_all`)
* *
* @returns * @returns
*/ */
export const settingDetail = (scopes: string) => server.get(`/system/config/${scopes}`) export const settingDetail = (scopes: string) => server.get(`/system/config/${scopes}`)
/**
*
*/
export const userDetail = () => server.get<any>('/user/detail')

View File

@ -16,9 +16,9 @@ export default {
debug: (data: any, configId: string, templateId: string) => post(`/notifier/${configId}/${templateId}/_send`, data), debug: (data: any, configId: string, templateId: string) => post(`/notifier/${configId}/${templateId}/_send`, data),
getHistory: (data: any, id: string) => post(`/notify/history/config/${id}/_query`, data), getHistory: (data: any, id: string) => post(`/notify/history/config/${id}/_query`, data),
// 获取所有平台用户 // 获取所有平台用户
getPlatformUsers: () => post(`/user/_query/no-paging`, { paging: false }), getPlatformUsers: () => post<any>(`/user/_query/no-paging`, { paging: false }),
// 钉钉部门 // 钉钉部门
dingTalkDept: (id: string) => get(`/notifier/dingtalk/corp/${id}/departments/tree`), dingTalkDept: (id: string) => get<any>(`/notifier/dingtalk/corp/${id}/departments/tree`),
// 钉钉部门人员 // 钉钉部门人员
getDingTalkUsers: (configId: string, deptId: string) => get(`/notifier/dingtalk/corp/${configId}/${deptId}/users`), getDingTalkUsers: (configId: string, deptId: string) => get(`/notifier/dingtalk/corp/${configId}/${deptId}/users`),
// 钉钉已经绑定的人员 // 钉钉已经绑定的人员
@ -26,7 +26,7 @@ export default {
// 钉钉绑定用户 // 钉钉绑定用户
dingTalkBindUser: (data: any, id: string) => patch(`/user/third-party/dingTalk_dingTalkMessage/${id}`, data), dingTalkBindUser: (data: any, id: string) => patch(`/user/third-party/dingTalk_dingTalkMessage/${id}`, data),
// 微信部门 // 微信部门
weChatDept: (id: string) => get(`/notifier/wechat/corp/${id}/departments`), weChatDept: (id: string) => get<any>(`/notifier/wechat/corp/${id}/departments`),
// 微信部门人员 // 微信部门人员
getWeChatUsers: (configId: string, deptId: string) => get(`/notifier/wechat/corp/${configId}/${deptId}/users`), getWeChatUsers: (configId: string, deptId: string) => get(`/notifier/wechat/corp/${configId}/${deptId}/users`),
// 微信已经绑定的人员 // 微信已经绑定的人员

13
src/api/system/menu.ts Normal file
View File

@ -0,0 +1,13 @@
import server from '@/utils/request';
// 获取当前用户可访问菜单
export const getMenuTree_api = (data: object) => server.post(`/menu/_all/tree`, data);
export const queryOwnThree = (data: any) => server.post<any>('/menu/user-own/tree', data)
// 获取资产类型
export const getAssetsType_api = () => server.get(`/asset/types`);
// 获取菜单详情
export const getMenuDetail_api = (id:string) => server.get(`/menu/${id}`);

View File

@ -39,6 +39,11 @@ const iconKeys = [
'ArrowDownOutlined', 'ArrowDownOutlined',
'SmallDashOutlined', 'SmallDashOutlined',
'TeamOutlined', 'TeamOutlined',
'MenuUnfoldOutlined',
'MenuFoldOutlined',
'QuestionCircleOutlined',
'InfoCircleOutlined',
'SearchOutlined',
] ]
const Icon = (props: {type: string}) => { const Icon = (props: {type: string}) => {

View File

@ -0,0 +1,221 @@
import {
computed,
reactive,
unref,
defineComponent,
toRefs,
provide
} from 'vue'
import type { ExtractPropTypes, PropType, CSSProperties} from 'vue'
import { Layout } from 'ant-design-vue'
import { defaultSettingProps } from './defaultSetting'
import type { BreadcrumbProps, RouteContextProps } from './RouteContext'
import type {
BreadcrumbRender,
CollapsedButtonRender, CustomRender,
HeaderRender,
MenuContentRender,
MenuExtraRender,
MenuHeaderRender,
MenuItemRender,
RightContentRender,
SubMenuItemRender
} from './typings'
import SiderMenuWrapper, { siderMenuProps } from 'components/Layout/components/SiderMenu/SiderMenu'
import { getSlot } from '@/utils/comm'
import { getMenuFirstChildren } from 'components/Layout/utils'
import { pick } from 'lodash-es'
import { routeContextInjectKey } from './RouteContext'
import { HeaderView, headerViewProps } from './components/Header'
export const basicLayoutProps = {
...defaultSettingProps,
...siderMenuProps,
...headerViewProps,
breadcrumb: {
type: [Object, Function] as PropType<BreadcrumbProps>,
default: () => null
},
breadcrumbRender: {
type: [Object, Function, Boolean] as PropType<BreadcrumbRender>,
default() {
return null
}
},
contentStyle: {
type: [String, Object] as PropType<CSSProperties>,
default: () => {
return null
}
},
pure: {
type: Boolean,
default: () => false
}
}
export type BasicLayoutProps = Partial<ExtractPropTypes<typeof basicLayoutProps>>;
export default defineComponent({
name: 'ProLayout',
inheritAttrs: false,
props: basicLayoutProps,
emits: [
'update:collapsed',
'update:open-keys',
'update:selected-keys',
'collapse',
'openKeys',
'select',
'menuHeaderClick',
'menuClick'
],
setup(props, { emit, attrs, slots }) {
const siderWidth = computed(() => (props.collapsed ? props.collapsedWidth : props.siderWidth))
const onCollapse = (collapsed: boolean) => {
emit('update:collapsed', collapsed)
emit('collapse', collapsed)
}
const onOpenKeys = (openKeys: string[] | false) => {
emit('update:open-keys', openKeys)
emit('openKeys', openKeys)
}
const onSelect = (selectedKeys: string[] | false) => {
emit('update:selected-keys', selectedKeys)
emit('select', selectedKeys)
}
const onMenuHeaderClick = (e: MouseEvent) => {
emit('menuHeaderClick', e)
}
const onMenuClick = (args: any) => {
emit('menuClick', args)
}
const headerRender = (
p: BasicLayoutProps & {
hasSiderMenu: boolean;
headerRender: HeaderRender;
rightContentRender: RightContentRender;
},
matchMenuKeys?: string[]
): CustomRender | null => {
if (p.headerRender === false) {
return null
}
return <HeaderView {...p} />
}
const breadcrumb = computed<BreadcrumbProps>(() => ({
...props.breadcrumb,
itemRender: getSlot<BreadcrumbRender>(slots, props, 'breadcrumbRender') as BreadcrumbRender
}))
const flatMenuData = computed(
() => (props.selectedKeys && getMenuFirstChildren(props.menuData, props.selectedKeys[0])) || [])
const routeContext = reactive<RouteContextProps>({
...(pick(toRefs(props), [
'menuData',
'openKeys',
'selectedKeys',
'contentWidth',
'headerHeight'
]) as any),
siderWidth,
breadcrumb,
flatMenuData
})
provide(routeContextInjectKey, routeContext)
return () => {
const {
pure,
onCollapse: propsOnCollapse,
onOpenKeys: propsOnOpenKeys,
onSelect: propsOnSelect,
onMenuClick: propsOnMenuClick,
...restProps
} = props
const collapsedButtonRender = getSlot<CollapsedButtonRender>(slots, props, 'collapsedButtonRender')
const rightContentRender = getSlot<RightContentRender>(slots, props, 'rightContentRender')
const customHeaderRender = getSlot<HeaderRender>(slots, props, 'headerRender')
// menu
const menuHeaderRender = getSlot<MenuHeaderRender>(slots, props, 'menuHeaderRender')
const menuExtraRender = getSlot<MenuExtraRender>(slots, props, 'menuExtraRender')
const menuContentRender = getSlot<MenuContentRender>(slots, props, 'menuContentRender')
const menuItemRender = getSlot<MenuItemRender>(slots, props, 'menuItemRender')
const subMenuItemRender = getSlot<SubMenuItemRender>(slots, props, 'subMenuItemRender')
const headerDom = computed(() =>
headerRender(
{
...props,
hasSiderMenu: true,
menuItemRender,
subMenuItemRender,
menuData: props.menuData,
onCollapse,
onOpenKeys,
onSelect,
onMenuHeaderClick,
rightContentRender,
collapsedButtonRender,
menuExtraRender,
menuContentRender,
headerRender: customHeaderRender,
theme: props.navTheme
},
[]
)
)
return (
<>
{
pure ? (
slots.default?.()
) : (
<Layout
class={'pro-layout'}
style={{
minHeight: '100vh'
}}
>
<SiderMenuWrapper
{...restProps}
theme={props.navTheme}
menuHeaderRender={menuHeaderRender}
menuExtraRender={menuExtraRender}
menuContentRender={menuContentRender}
menuItemRender={menuItemRender}
subMenuItemRender={subMenuItemRender}
collapsedButtonRender={collapsedButtonRender}
onCollapse={onCollapse}
onSelect={onSelect}
onOpenKeys={onOpenKeys}
onMenuClick={onMenuClick}
/>
<Layout>
{headerDom.value}
<Layout>
{slots.default?.()}
</Layout>
</Layout>
</Layout>
)
}
</>
)
}
}
})

View File

@ -0,0 +1,83 @@
<template>
<ProLayout
v-bind='layoutConf'
v-model:openKeys="state.openKeys"
v-model:collapsed="state.collapsed"
v-model:selectedKeys="state.selectedKeys"
:pure='state.pure'
:breadcrumb='{ routes: breadcrumb }'
>
<template #breadcrumbRender='slotProps'>
<a v-if='slotProps.route.index !== 0'>{{slotProps.route.breadcrumbName}}</a>
<span v-else>{{slotProps.route.breadcrumbName}}</span>
</template>
<router-view v-slot='{ Component}'>
<component :is='Component' />
</router-view>
</ProLayout>
</template>
<script setup lang="ts" name='BasicLayoutPage'>
import { ProLayout } from '@/components/Layout'
import DefaultSetting from '../../../config/config'
import { useMenuStore } from '@/store/menu'
type StateType = {
collapsed: boolean
openKeys: string[]
selectedKeys: string[]
pure: boolean
}
const router = useRouter()
const route = useRoute()
const menu = useMenuStore()
const layoutConf = reactive({
navTheme: DefaultSetting.layout.theme,
siderWidth: DefaultSetting.layout.siderWidth,
logo: DefaultSetting.layout.logo,
title: DefaultSetting.layout.title,
menuData: menu.menus,
});
const state = reactive<StateType>({
pure: false,
collapsed: false, // default value
openKeys: [],
selectedKeys: [],
});
const breadcrumb = computed(() =>
router.currentRoute.value.matched.concat().map((item, index) => {
return {
index,
path: item.path,
breadcrumbName: item.meta.title || ''
}
})
)
watchEffect(() => {
if (router.currentRoute) {
const matched = router.currentRoute.value.matched.concat()
state.selectedKeys = matched.map(r => r.path)
state.openKeys = matched.filter((r) => r.path !== router.currentRoute.value.path).map(r => r.path)
console.log(state.selectedKeys)
}
// TODO pure
})
watchEffect(() => {
if (route.query && 'layout' in route.query && route.query.layout === 'false') {
state.pure = true
} else {
state.pure = false
}
})
</script>
<style scoped>
</style>

View File

@ -0,0 +1,13 @@
<template>
<router-view />
</template>
<script>
export default {
name: 'BlankLayoutPage'
}
</script>
<style scoped>
</style>

View File

View File

@ -0,0 +1,45 @@
import type { ComputedRef, VNodeChild, InjectionKey } from 'vue'
import type { PureSettings } from 'components/Layout/defaultSetting'
import type { MenuDataItem } from 'components/Layout/typings'
import { useContext } from 'components/Layout/hooks/context'
export interface Route {
path: string;
breadcrumbName: string;
children?: Omit<Route, 'children'>[];
}
export interface BreadcrumbProps {
prefixCls?: string;
routes?: Route[];
params?: any;
separator?: VNodeChild;
itemRender?: (opts: { route: Route; params: any; routes: Array<Route>; paths: Array<string> }) => VNodeChild;
}
export type BreadcrumbListReturn = Pick<BreadcrumbProps, Extract<keyof BreadcrumbProps, 'routes' | 'itemRender'>>;
export interface MenuState {
selectedKeys: string[];
openKeys: string[];
}
export interface RouteContextProps extends Partial<PureSettings>, MenuState {
menuData: MenuDataItem[];
flatMenuData?: MenuDataItem[];
getPrefixCls?: (suffixCls?: string, customizePrefixCls?: string) => string;
breadcrumb?: BreadcrumbListReturn | ComputedRef<BreadcrumbListReturn>;
collapsed?: boolean;
hasSideMenu?: boolean;
siderWidth?: number;
headerHeight?: number;
/* 附加属性 */
[key: string]: any;
}
export const routeContextInjectKey: InjectionKey<RouteContextProps> = Symbol('route-context');
export const useRouteContext = () =>
useContext<Required<RouteContextProps>>(routeContextInjectKey, {});

View File

@ -0,0 +1,127 @@
import type { ExtractPropTypes, FunctionalComponent } from 'vue'
import { defineComponent, PropType } from 'vue'
import { defaultRenderLogo, siderMenuProps } from 'components/Layout/components/SiderMenu/SiderMenu'
import BaseMenu from '../SiderMenu/BaseMenu'
import { useRouteContext } from 'components/Layout/RouteContext'
import { default as ResizeObserver } from 'ant-design-vue/es/vc-resize-observer';
import { defaultSettingProps } from 'components/Layout/defaultSetting'
import PropTypes from 'ant-design-vue/es/_util/vue-types'
import { CustomRender, MenuDataItem, ProProps, RightContentRender, WithFalse } from 'components/Layout/typings'
import './index.less'
import { RouteRecordRaw } from 'vue-router'
import { clearMenuItem } from 'components/Layout/utils'
export const headerProps = {
...defaultSettingProps,
collapsed: PropTypes.looseBool,
menuData: {
type: Array as PropType<MenuDataItem[]>,
default: () => [],
},
logo: siderMenuProps.logo,
logoStyle: siderMenuProps.logoStyle,
menuRender: {
type: [Object, Function] as PropType<
WithFalse<(props: ProProps, defaultDom: CustomRender) => CustomRender>
>,
default: () => undefined,
},
menuItemRender: siderMenuProps.menuItemRender,
subMenuItemRender: siderMenuProps.subMenuItemRender,
rightContentRender: {
type: [Object, Function] as PropType<RightContentRender>,
default: () => undefined,
},
siderWidth: PropTypes.number.def(208),
// events
onMenuHeaderClick: PropTypes.func,
onCollapse: siderMenuProps.onCollapse,
onOpenKeys: siderMenuProps.onOpenKeys,
onSelect: siderMenuProps.onSelect,
}
export type HeaderProps = ExtractPropTypes<typeof headerProps>;
export default defineComponent({
name: 'Header',
emits: [
'menuHeaderClick', 'collapse', 'openKeys', 'select'
],
inheritAttrs: false,
props: headerProps,
setup(props, { slots, emit}) {
const {
onSelect,
onMenuHeaderClick,
onOpenKeys,
logo,
logoStyle,
menuData,
navTheme,
contentWidth,
title,
rightContentRender
} = props;
const rightSize = ref<number | string>('auto')
const context = useRouteContext();
const noChildrenMenuData = (menuData || []).map((item) => ({
...item,
children: undefined,
})) as RouteRecordRaw[];
const clearMenuData = clearMenuItem(noChildrenMenuData);
return () => (
<>
<div
class={`header-content ${navTheme}`}
>
<div class={`header-main ${contentWidth === 'Fixed' ? 'wide' : ''}`}>
<div class={'header-main-left'} onClick={onMenuHeaderClick}>
<div class={'header-logo'}>
<a>
{defaultRenderLogo(logo, logoStyle)}
<h1 title={title}>{ title }</h1>
</a>
</div>
</div>
<div style={{ flex: 1 }} class={'header-menu'}>
<BaseMenu
theme={props.navTheme === 'realDark' ? 'dark' : props.navTheme}
menuData={clearMenuData}
mode={'horizontal'}
menuItemRender={props.menuItemRender}
subMenuItemRender={props.subMenuItemRender}
openKeys={context.openKeys}
selectedKeys={context.selectedKeys}
{...{
'onUpdate:openKeys': ($event: string[]) => onOpenKeys && onOpenKeys($event),
'onUpdate:selectedKeys': ($event: string[]) => onSelect && onSelect($event),
}}
/>
</div>
<div class={'header-right'} style={{ minWidth: rightSize.value }}>
<div>
<ResizeObserver
onResize={({ width }: { width: number}) => {
rightSize.value = width
}}
>
{
rightContentRender && typeof rightContentRender === 'function' ? (
<div>{rightContentRender({...props})}</div>
) : rightContentRender
}
</ResizeObserver>
</div>
</div>
</div>
</div>
</>
)
}
})

View File

@ -0,0 +1,64 @@
.header-content {
position: relative;
width: 100%;
height: 100%;
box-shadow: 0 1px 4px 0 rgb(0 21 41 / 12%);
transition: background .3s,width .2s;
&.light {
background-color: #fff;
}
&.dark {
.header-logo {
>a {
> h1 {
color: #fff !important;
}
}
}
}
.header-main {
padding-left: 16px;
display: flex;
height: 100%;
.header-main-left {
display: flex;
min-width: 192px;
.header-logo {
height: 100%;
position: relative;
display: flex;
justify-content: center;
min-width: 165px;
overflow: hidden;
> a {
color: @primary-color;
text-decoration: none;
background-color: transparent;
outline: none;
cursor: pointer;
transition: color .3s;
> h1 {
vertical-align: top;
display: inline-block;
margin: 0 0 0 12px;
font-size: 16px;
color: rgba(0,0,0,.85);
}
}
img {
display: inline-block;
height: 32px;
vertical-align: middle;
}
}
}
}
}

View File

@ -0,0 +1,59 @@
import type { ExtractPropTypes } from 'vue'
import Header, { headerProps } from './Header'
import { useRouteContext } from 'components/Layout/RouteContext'
import type { RouteRecordRaw } from 'vue-router'
import { clearMenuItem } from 'components/Layout/utils'
import { Layout } from 'ant-design-vue'
export const headerViewProps = {
...headerProps
}
export type HeaderViewProps = Partial<ExtractPropTypes<typeof headerViewProps>>;
export const HeaderView = defineComponent({
name: 'HeaderView',
inheritAttrs: false,
props: headerViewProps,
setup(props) {
const { headerHeight, onCollapse } = toRefs(props);
const context = useRouteContext();
const clearMenuData = computed(
() => (context.menuData && clearMenuItem(context.menuData as RouteRecordRaw[])) || []
);
return () => (
<>
<Layout.Header
style={{
padding: 0,
height: `${headerHeight.value}px`,
lineHeight: `${headerHeight.value}px`,
width: `100%`,
}}
/>
<Layout.Header
style={{
padding: 0,
height: `${headerHeight.value}px`,
lineHeight: `${headerHeight.value}px`,
width: `100%`,
zIndex: 19,
position: 'fixed',
top: 0,
right: 0
}}
>
<Header
{...props}
onCollapse={onCollapse.value}
menuData={clearMenuData.value}
/>
</Layout.Header>
</>
)
}
})

View File

@ -0,0 +1,23 @@
.page-container {
min-height: 100%;
display: flex;
flex-direction: column;
.page-container-grid-content {
padding: 24px;
flex: 1 1 auto;
display: flex;
flex-direction: column;
> div, .page-container-children-content, .page-container-full-height {
min-height: 100%;
display: flex;
flex-direction: column;
}
.children-full-height {
> :nth-child(1) {
min-height: 100%;
}
}
}
}

View File

@ -0,0 +1,250 @@
import { TabPaneProps } from 'ant-design-vue'
import type { ExtractPropTypes, FunctionalComponent, PropType, VNodeChild } from 'vue'
import { pageHeaderProps } from 'ant-design-vue/es/page-header';
import type { DefaultPropRender, PageHeaderRender } from 'components/Layout/typings'
import type { AffixProps, TabBarExtraContent } from 'components/Layout/components/PageContainer/types'
import { useRouteContext } from 'components/Layout/RouteContext'
import { getSlotVNode } from '@/utils/comm'
import { Affix, Spin, PageHeader, Tabs } from 'ant-design-vue';
import './index.less'
export const pageHeaderTabConfig = {
/**
* @name tabs
*/
tabList: {
type: [Object, Function, Array] as PropType<(Omit<TabPaneProps, 'id'> & { key?: string })[]>,
default: () => undefined,
},
/**
* @name tab key
*/
tabActiveKey: String, //PropTypes.string,
/**
* @name tab
*/
tabBarExtraContent: {
type: [Object, Function] as PropType<TabBarExtraContent>,
default: () => undefined,
},
/**
* @name tabs
*/
tabProps: {
type: Object, //as PropType<TabsProps>,
default: () => undefined,
},
/**
* @name PageHeader
*/
fixedHeader: Boolean, //PropTypes.looseBool,
// events
onTabChange: Function, //PropTypes.func,
};
export type PageHeaderTabConfig = Partial<ExtractPropTypes<typeof pageHeaderTabConfig>>;
export const pageContainerProps = {
...pageHeaderTabConfig,
...pageHeaderProps,
prefixCls: {
type: String,
default: 'ant-pro',
}, //PropTypes.string.def('ant-pro'),
title: {
type: [Object, String, Boolean, Function] as PropType<DefaultPropRender>,
default: () => null,
},
subTitle: {
type: [Object, String, Boolean, Function] as PropType<DefaultPropRender>,
default: () => null,
},
content: {
type: [Object, String, Boolean, Function] as PropType<DefaultPropRender>,
default: () => null,
},
extra: {
type: [Object, String, Boolean, Function] as PropType<DefaultPropRender>,
default: () => null,
},
extraContent: {
type: [Object, String, Boolean, Function] as PropType<DefaultPropRender>,
default: () => null,
},
header: {
type: [Object, String, Boolean, Function] as PropType<DefaultPropRender>,
default: () => null,
},
pageHeaderRender: {
type: [Object, Function, Boolean] as PropType<PageHeaderRender>,
default: () => undefined,
},
affixProps: {
type: [Object, Function] as PropType<AffixProps>,
},
ghost: {
type: Boolean,
default: () => false,
}, //PropTypes.looseBool,
loading: {
type: Boolean,
default: () => undefined,
}, //PropTypes.looseBool,
childrenFullHeight: {
type: Boolean,
default: () => true,
}
};
export type PageContainerProps = Partial<ExtractPropTypes<typeof pageContainerProps>>;
const renderFooter = (
props: Omit< PageContainerProps, 'title' >
): VNodeChild | JSX.Element => {
const { tabList, tabActiveKey, onTabChange, tabBarExtraContent, tabProps } = props;
if (tabList && tabList.length) {
return (
<Tabs
class={`page-container-tabs`}
activeKey={tabActiveKey}
onChange={(key: string | number) => {
if (onTabChange) {
onTabChange(key);
}
}}
tabBarExtraContent={tabBarExtraContent}
{...tabProps}
>
{tabList.map((item) => (
<Tabs.TabPane {...item} tab={item.tab} key={item.key} />
))}
</Tabs>
);
}
return null;
}
const ProPageHeader: FunctionalComponent<PageContainerProps> = (props) => {
const {
title,
tabList,
tabActiveKey,
content,
pageHeaderRender,
header,
extraContent,
prefixCls,
fixedHeader: _,
...restProps
} = props;
const value = useRouteContext()
if (pageHeaderRender === false) {
return null;
}
if (pageHeaderRender) {
return pageHeaderRender({ ...props });
}
let pageHeaderTitle = title;
if (!title && title !== false) {
pageHeaderTitle = value.title;
}
const unrefBreadcrumb = unref(value.breadcrumb || {});
const breadcrumb = (props as any).breadcrumb || {
...unrefBreadcrumb,
routes: unrefBreadcrumb.routes,
itemRender: unrefBreadcrumb.itemRender,
};
return (
<div class={`page-container-wrap`}>
<PageHeader
{...restProps}
// {...value}
title={pageHeaderTitle}
breadcrumb={breadcrumb}
footer={renderFooter({
...restProps,
tabList,
tabActiveKey
})}
prefixCls={prefixCls}
>
{/*{header || renderPageHeader(content, extraContent)}*/}
{ header }
</PageHeader>
</div>
);
}
const PageContainer = defineComponent({
name: 'PageContainer',
inheritAttrs: false,
props: pageContainerProps,
setup(props, { slots }) {
const { loading, affixProps, ghost, childrenFullHeight } = toRefs(props);
const value = useRouteContext();
const headerDom = computed(() => {
// const tags = getSlotVNode<DefaultPropRender>(slots, props, 'tags');
const headerContent = getSlotVNode<DefaultPropRender>(slots, props, 'content');
const extra = getSlotVNode<DefaultPropRender>(slots, props, 'extra');
const extraContent = getSlotVNode<DefaultPropRender>(slots, props, 'extraContent');
const subTitle = getSlotVNode<DefaultPropRender>(slots, props, 'subTitle');
const title = getSlotVNode<DefaultPropRender>(slots, props, 'title');
// @ts-ignore
return (
<ProPageHeader
{...props}
prefixCls={undefined}
ghost={ghost.value}
title={title}
subTitle={subTitle}
content={headerContent}
// tags={tags}
extra={extra}
extraContent={extraContent}
/>
);
})
return () => {
const { fixedHeader } = props;
return (
<div class={'page-container'}>
{fixedHeader && headerDom.value ? (
<Affix {...affixProps.value} offsetTop={value.hasHeader && value.fixedHeader ? value.headerHeight : 0}>
{headerDom.value}
</Affix>
) : (
headerDom.value
)}
<div class={'page-container-grid-content'}>
{loading.value ? (
<Spin />
) : slots.default ? (
<div>
<div class={`page-container-children-content ${childrenFullHeight.value ? 'children-full-height' : ''}`}>{slots.default()}</div>
{value.hasFooterToolbar && (
<div
style={{
height: 48,
marginTop: 24,
}}
/>
)}
</div>
) : null}
</div>
</div>
);
};
}
})
export default PageContainer

View File

@ -0,0 +1,56 @@
import type { VNodeChild, CSSProperties, VNode } from 'vue';
export interface Tab {
key: string;
tab: string | VNode | JSX.Element;
}
export type TabBarType = 'line' | 'card' | 'editable-card';
export type TabSize = 'default' | 'large' | 'small';
export type TabPosition = 'left' | 'right';
export type TabBarExtraPosition = TabPosition;
export type TabBarExtraMap = Partial<Record<TabBarExtraPosition, VNodeChild>>;
export type TabBarExtraContent = VNodeChild | TabBarExtraMap;
export interface TabsProps {
prefixCls?: string;
class?: string | string[];
style?: CSSProperties;
id?: string;
activeKey?: string;
hideAdd?: boolean;
// Unchangeable
// size?: TabSize;
tabBarStyle?: CSSProperties;
tabPosition?: TabPosition;
type?: TabBarType;
tabBarGutter?: number;
}
export interface AffixProps {
offsetBottom: number;
offsetTop: number;
target?: () => HTMLElement;
onChange?: (affixed: boolean) => void;
}
export interface TabPaneProps {
tab?: string | VNodeChild | JSX.Element;
class?: string | string[];
style?: CSSProperties;
disabled?: boolean;
forceRender?: boolean;
closable?: boolean;
closeIcon?: VNodeChild | JSX.Element;
prefixCls?: string;
tabKey?: string;
id: string;
animated?: boolean;
active?: boolean;
destroyInactiveTabPane?: boolean;
}

View File

@ -0,0 +1,217 @@
import { isVNode, defineComponent, getCurrentInstance, withCtx } from 'vue'
import type { FunctionalComponent, PropType, VNodeChild, ComponentInternalInstance, ConcreteComponent, ExtractPropTypes, VNode } from 'vue'
import type {
MenuDataItem,
WithFalse,
MenuItemRender,
LayoutType,
MenuTheme,
SubMenuItemRender,
MenuMode
} from '../../typings'
import type {
SelectEventHandler,
MenuClickEventHandler,
SelectInfo,
MenuInfo,
} from 'ant-design-vue/es/menu/src/interface';
import type { Key } from 'ant-design-vue/es/_util/type';
import IconFont from '@/components/AIcon'
import { Menu } from 'ant-design-vue';
import { isUrl } from '@/utils/regular'
export const baseMenuProps = {
mode: {
type: String as PropType<MenuMode>,
default: 'inline',
},
menuData: {
type: Array as PropType<MenuDataItem[]>,
default: () => [],
},
layout: {
type: String as PropType<LayoutType>,
default: 'side',
},
theme: {
type: String as PropType<MenuTheme | 'realDark'>,
default: 'dark',
},
collapsed: {
type: Boolean as PropType<boolean | undefined>,
default: () => false,
},
openKeys: {
type: Array as PropType<WithFalse<string[]>>,
default: () => undefined,
},
selectedKeys: {
type: Array as PropType<WithFalse<string[]>>,
default: () => undefined,
},
menuProps: {
type: Object as PropType<Record<string, any>>,
default: () => null,
},
menuItemRender: {
type: [Object, Function, Boolean] as PropType<MenuItemRender>,
default: () => undefined,
},
subMenuItemRender: {
type: [Object, Function, Boolean] as PropType<SubMenuItemRender>,
default: () => undefined,
},
onClick: [Function, Object] as PropType<(...args: any) => void>,
}
export type BaseMenuProps = ExtractPropTypes<typeof baseMenuProps>;
const LazyIcon: FunctionalComponent<{ icon: VNodeChild | string;}> = (props) => {
const {icon} = props
if (!icon) return null
if (typeof icon === 'string' && icon !== '') {
return <IconFont type={icon} />;
}
if (isVNode(icon)) {
return icon;
}
}
class MenuUtil {
props: BaseMenuProps;
ctx: ComponentInternalInstance | null;
RouterLink: ConcreteComponent;
constructor(props: BaseMenuProps, ctx: ComponentInternalInstance | null) {
this.props = props
this.ctx = ctx
this.RouterLink = resolveComponent('router-link') as ConcreteComponent
}
getNavMenuItems = (menusData: MenuDataItem[] = []) => {
return menusData.map((item) => this.getSubMenuOrItem(item)).filter((item) => item);
};
getSubMenuOrItem = (item: MenuDataItem): VNode => {
if (
Array.isArray(item.children) &&
item.children.length > 0 &&
!item?.meta?.hideInMenu &&
!item?.meta?.hideChildrenInMenu
) {
if (this.props.subMenuItemRender) {
const subMenuItemRender = withCtx(this.props.subMenuItemRender, this.ctx);
return subMenuItemRender({
item,
children: this.getNavMenuItems(item.children),
}) as VNode;
}
const menuTitle = item.meta?.title
const defaultTitle = item.meta?.icon ? (
<span class={`header-menu-item`}>
<span class={`header-menu-item-title`}>{menuTitle}</span>
</span>
) : (
<span class={`header-menu-item`}>{menuTitle}</span>
);
return (
<Menu.SubMenu
title={defaultTitle}
key={item.path}
icon={<LazyIcon icon={item.meta?.icon} />}
>
{this.getNavMenuItems(item.children)}
</Menu.SubMenu>
)
}
const menuItemRender = this.props.menuItemRender && withCtx(this.props.menuItemRender, this.ctx);
const [title, icon] = this.getMenuItem(item);
return (
(menuItemRender && (menuItemRender({ item, title, icon }) as VNode)) || (
<Menu.Item disabled={item.meta?.disabled} danger={item.meta?.danger} key={item.path}>
{title}
</Menu.Item>
)
)
}
getMenuItem = (item: MenuDataItem) => {
const meta = { ...item.meta };
const target = (meta.target || null) as string | null;
const hasUrl = isUrl(item.path);
const CustomTag: any = (target && 'a') || this.RouterLink;
const props = { to: { path: item.path, ...item.meta } };
const attrs = hasUrl || target ? { ...item.meta, href: item.path, target } : {};
const icon = (item.meta?.icon && <LazyIcon icon={item.meta.icon} />) || undefined;
const menuTitle = item.meta?.title;
const defaultTitle = item.meta?.icon ? (
<CustomTag {...attrs} {...props} class={`header-menu-item`}>
{icon}
<span class={`header-menu-item-title`}>{menuTitle}</span>
</CustomTag>
) : (
<CustomTag {...attrs} {...props} class={`header-menu-item`}>
<span>{menuTitle}</span>
</CustomTag>
);
return [defaultTitle, icon];
}
conversionPath = (path: string) => {
if (path && path.indexOf('http') === 0) {
return path;
}
return `/${path || ''}`.replace(/\/+/g, '/');
}
}
export default defineComponent({
name: 'BaseMenu',
props: baseMenuProps,
emits: ['update:openKeys', 'update:selectedKeys', 'click'],
setup(props, { emit }) {
const ctx = getCurrentInstance()
const menuUtil = new MenuUtil(props, ctx);
const handleOpenChange = (openKeys: Key[]): void => {
emit('update:openKeys', openKeys);
};
const handleSelect: SelectEventHandler = (args: SelectInfo): void => {
// ignore https? link handle selectkeys
if (isUrl(args.key as string)) {
return;
}
emit('update:selectedKeys', args.selectedKeys);
};
const handleClick: MenuClickEventHandler = (args: MenuInfo) => {
emit('click', args);
};
return () => (
<Menu
{...props}
key='Menu'
inlineIndent={16}
theme={props.theme as 'dark' | 'light'}
openKeys={props.openKeys === false ? [] : props.openKeys}
selectedKeys={props.selectedKeys || []}
onOpenChange={handleOpenChange}
onSelect={handleSelect}
onClick={handleClick}
>
{menuUtil.getNavMenuItems(props.menuData)}
</Menu>
)
}
})

View File

@ -0,0 +1,16 @@
.pro-layout-sider {
position: fixed;
top: 0;
left: 0;
z-index: 100;
height: 100%;
overflow: auto;
overflow-x: hidden;
box-shadow: 2px 0 8px 0 rgb(29 35 41 / 5%);
.ant-layout-sider-children {
display: flex;
flex-direction: column;
height: 100%;
}
}

View File

@ -0,0 +1,186 @@
import { Layout, Menu } from 'ant-design-vue';
import type { CSSProperties, ExtractPropTypes, FunctionalComponent, PropType } from 'vue'
import type {
LogoRender,
MenuHeaderRender,
MenuExtraRender,
MenuContentRender,
CollapsedButtonRender,
WithFalse,
CustomRender
} from '../../typings'
import PropTypes from 'ant-design-vue/es/_util/vue-types';
import { baseMenuProps } from 'components/Layout/components/SiderMenu/BaseMenu'
import AIcon from '@/components/AIcon'
import { useRouteContext } from 'components/Layout/RouteContext'
import BaseMenu from './BaseMenu'
import './SiderMenu.less'
import { computed } from 'vue'
import { omit } from 'lodash-es'
const { Sider } = Layout
export const defaultRenderLogo = (logo?: CustomRender, logoStyle?: CSSProperties): CustomRender => {
if (!logo) {
return null;
}
if (typeof logo === 'string') {
return <img src={logo} alt="logo" style={logoStyle} />;
}
if (typeof logo === 'function') {
// @ts-ignore
return logo();
}
return logo;
};
export const siderMenuProps = {
...baseMenuProps,
logo: {
type: [Object, String, Function] as PropType<LogoRender>,
default: () => '',
},
logoStyle: {
type: Object as PropType<CSSProperties>,
default: () => undefined,
},
siderWidth: PropTypes.number.def(208),
headerHeight: PropTypes.number.def(48),
collapsedWidth: PropTypes.number.def(48),
menuHeaderRender: {
type: [Function, Object, Boolean] as PropType<MenuHeaderRender>,
default: () => undefined,
},
menuContentRender: {
type: [Function, Object, Boolean] as PropType<MenuContentRender>,
default: () => undefined,
},
menuExtraRender: {
type: [Function, Object, Boolean] as PropType<MenuExtraRender>,
default: () => undefined,
},
collapsedButtonRender: {
type: [Function, Object, Boolean] as PropType<CollapsedButtonRender>,
default: () => undefined,
},
onMenuHeaderClick: PropTypes.func,
onMenuClick: PropTypes.func,
onCollapse: {
type: Function as PropType<(collapsed: boolean) => void>,
},
onOpenKeys: {
type: Function as PropType<(openKeys: WithFalse<string[]>) => void>,
},
onSelect: {
type: Function as PropType<(selectedKeys: WithFalse<string[]>) => void>,
},
}
export type SiderMenuProps = Partial<ExtractPropTypes<typeof siderMenuProps>>;
export const defaultRenderCollapsedButton = (collapsed?: boolean): CustomRender =>
collapsed ? <AIcon type={'MenuUnfoldOutlined'} /> : <AIcon type={'MenuFoldOutlined'} />;
const SiderMenu: FunctionalComponent<SiderMenuProps> = (props, { slots, emit}) => {
const {
collapsed,
collapsedWidth = 48,
menuContentRender = false,
collapsedButtonRender = defaultRenderCollapsedButton,
} = props;
const context = useRouteContext();
const sSideWidth = computed(() => (props.collapsed ? props.collapsedWidth : props.siderWidth));
const handleSelect = ($event: string[]) => {
if (props.onSelect) {
props.onSelect([context.selectedKeys[0], ...$event]);
}
}
const defaultMenuDom = (
<BaseMenu
theme={props.theme as 'dark' | 'light'}
mode="inline"
menuData={context.flatMenuData}
collapsed={props.collapsed}
openKeys={context.openKeys}
selectedKeys={context.selectedKeys}
menuItemRender={props.menuItemRender}
subMenuItemRender={props.subMenuItemRender}
onClick={props.onMenuClick}
style={{
width: '100%',
}}
{...{
'onUpdate:openKeys': ($event: string[]) => props.onOpenKeys && props.onOpenKeys($event),
'onUpdate:selectedKeys': handleSelect,
}}
/>
)
const Style = computed(() => {
return {
overflow: 'hidden',
height: '100vh',
zIndex: 18,
paddingTop: `${props.headerHeight}px`,
flex: `0 0 ${sSideWidth.value}px`,
minWidth: `${sSideWidth.value}px`,
maxWidth: `${sSideWidth.value}px`,
width: `${sSideWidth.value}px`,
transition: 'background-color 0.3s ease 0s, min-width 0.3s ease 0s, max-width 0.3s cubic-bezier(0.645, 0.045, 0.355, 1) 0s'
}
})
return (
<>
<div
style={Style.value}
></div>
<Sider
collapsible
trigger={null}
collapsed={collapsed}
onCollapse={(collapse: boolean) => {
props.onCollapse?.(collapse);
}}
collapsedWidth={collapsedWidth}
style={omit(Style.value, ['transition'])}
width={sSideWidth.value}
theme={props.theme as 'dark' | 'light'}
class={'pro-layout-sider'}
>
<div style="flex: 1; overflow: hidden auto;">
{(menuContentRender && menuContentRender(props, defaultMenuDom)) || defaultMenuDom}
</div>
<div class={`header-links`}>
{collapsedButtonRender !== false ? (
<Menu
class={`header-link-menu`}
inlineIndent={16}
theme={props.theme as 'light' | 'dark'}
selectedKeys={[]}
openKeys={[]}
mode="inline"
onClick={() => {
if (props.onCollapse) {
props.onCollapse(!props.collapsed);
}
}}
>
<Menu.Item key={'collapsed-button'} class={`header-collapsed-button`} title={false}>
{collapsedButtonRender && typeof collapsedButtonRender === 'function'
? collapsedButtonRender(collapsed)
: collapsedButtonRender}
</Menu.Item>
</Menu>
) : null}
</div>
</Sider>
</>
)
}
export default SiderMenu

View File

@ -0,0 +1,68 @@
import type { PropType } from 'vue'
import config from '../../../config/config'
export interface PureSettings {
title: string
/**
* theme for nav menu
*/
navTheme: 'dark' | 'light' | 'realDark' | undefined;
/**
* nav menu position: `side` or `top`
*/
headerHeight?: number;
/**
* customize header height
*/
layout: 'side' | 'top' | 'mix';
/**
* layout of content: `Fluid` or `Fixed`, only works when layout is top
*/
contentWidth: 'Fluid' | 'Fixed';
menu: { locale?: boolean; defaultOpenAll?: boolean };
splitMenus?: boolean;
}
export const defaultSettings = {
navTheme: 'dark',
layout: 'side',
contentWidth: 'Fluid',
fixedHeader: false,
fixSiderbar: false,
menu: {},
headerHeight: 48,
iconfontUrl: '',
title: config.title
};
export const defaultSettingProps = {
navTheme: {
type: String as PropType<PureSettings['navTheme']>,
default: defaultSettings.navTheme,
},
title: {
type: String as PropType<PureSettings['title']>,
default: () => defaultSettings.title,
},
layout: {
type: String as PropType<PureSettings['layout']>,
default: defaultSettings.layout,
},
contentWidth: {
type: String as PropType<PureSettings['contentWidth']>,
default: defaultSettings.contentWidth,
},
menu: {
type: Object as PropType<PureSettings['menu']>,
default: () => {
return {
locale: true,
};
},
},
headerHeight: {
type: Number as PropType<PureSettings['headerHeight']>,
default: defaultSettings.headerHeight,
},
}

View File

@ -0,0 +1,10 @@
import { InjectionKey } from 'vue'
export type ContextType<T> = any;
export const useContext = <T>(
contextInjectKey: string | InjectionKey<ContextType<T>> = Symbol(),
defaultValue?: ContextType<T>
): T => {
return inject(contextInjectKey, defaultValue || ({} as T));
};

View File

@ -0,0 +1,4 @@
export { default as ProLayout } from './BasicLayout';
export { default as BasicLayoutPage } from './BasicLayoutPage.vue'
export { default as BlankLayoutPage } from './BlankLayoutPage.vue'
export { default as PageContainer } from './components/PageContainer'

View File

@ -0,0 +1,68 @@
import type { VNode, Slots } from 'vue';
import { BreadcrumbProps } from 'components/Layout/RouteContext'
import { VueNode } from 'ant-design-vue/es/_util/type'
export interface MetaRecord {
/**
* @name icon
*/
icon?: string | VNode;
/**
* @name key
*/
title?: string;
/**
* @name
*/
hideChildInMenu?: boolean;
/**
* @name
*/
hideInMenu?: boolean;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[key: string]: any;
}
export interface MenuDataItem {
/**
* @name path
*/
path: string;
name?: string | symbol;
meta?: MetaRecord;
/**
* @name
*/
children?: MenuDataItem[];
}
export type Theme = 'dark' | 'light';
export type MenuTheme = Theme;
export type MenuMode = 'horizontal' | 'vertical' | 'inline';
export type LayoutType = 'side' | 'top' | 'mix';
export type ProProps = Record<never, never>;
export type TargetType = '_blank' | '_self' | unknown;
export type BreadcrumbRender = BreadcrumbProps['itemRender'];
export type CustomRender = VueNode;
export type WithFalse<T> = T | false;
export type LogoRender = WithFalse<CustomRender>;
export type DefaultPropRender = WithFalse<CustomRender>;
export type HeaderContentRender = WithFalse<() => CustomRender>;
export type HeaderRender = WithFalse<(props: ProProps) => CustomRender>;
export type FooterRender = WithFalse<(props: ProProps) => CustomRender>;
export type RightContentRender = WithFalse<(props: ProProps) => CustomRender>;
export type MenuItemRender = WithFalse<
(args: { item: MenuDataItem; title?: JSX.Element; icon?: JSX.Element }) => CustomRender
>;
export type SubMenuItemRender = WithFalse<(args: { item: MenuDataItem; children?: CustomRender[] }) => CustomRender>;
export type MenuHeaderRender = WithFalse<(logo: CustomRender, title: CustomRender, props?: ProProps) => CustomRender>;
export type MenuContentRender = WithFalse<(props: ProProps, defaultDom: CustomRender) => CustomRender>;
export type MenuFooterRender = WithFalse<(props?: ProProps) => CustomRender>;
export type MenuExtraRender = WithFalse<(props?: ProProps) => CustomRender>;
export type CollapsedButtonRender = WithFalse<(collapsed?: boolean) => CustomRender>;
export type PageHeaderRender = WithFalse<(props?: ProProps) => CustomRender>;

View File

@ -0,0 +1,48 @@
import type { MenuDataItem } from 'components/Layout/typings'
import type { RouteRecord, RouteRecordRaw } from 'vue-router'
export function getMenuFirstChildren(menus: MenuDataItem[], key?: string) {
return key === undefined ? [] : (menus[menus.findIndex((menu) => menu.path === key)] || {}).children || [];
}
export function clearMenuItem(menusData: RouteRecord[] | RouteRecordRaw[]): RouteRecordRaw[] {
return menusData
.map((item: RouteRecord | RouteRecordRaw) => {
const finalItem = { ...item };
if (finalItem.meta?.hideInMenu) {
return null;
}
if (finalItem && finalItem?.children) {
if (
!finalItem.meta?.hideChildInMenu &&
finalItem.children.some(
(child: RouteRecord | RouteRecordRaw) => child && !child.meta?.hideInMenu
)
) {
return {
...item,
children: clearMenuItem(finalItem.children),
};
}
delete finalItem.children;
}
return finalItem;
})
.filter((item) => item) as RouteRecordRaw[];
}
export function flatMap(menusData: RouteRecord[]): MenuDataItem[] {
return menusData
.map((item) => {
const finalItem = { ...item } as MenuDataItem;
if (!finalItem.name || finalItem.meta?.hideInMenu) {
return null;
}
if (finalItem.children) {
delete finalItem.children;
}
return finalItem;
})
.filter((item) => item) as MenuDataItem[];
}

View File

@ -4,31 +4,62 @@
<a-popconfirm v-bind="popConfirm" :disabled="!isPermission || props.disabled"> <a-popconfirm v-bind="popConfirm" :disabled="!isPermission || props.disabled">
<a-tooltip v-if="tooltip" v-bind="tooltip"> <a-tooltip v-if="tooltip" v-bind="tooltip">
<slot v-if="noButton"></slot> <slot v-if="noButton"></slot>
<a-button v-else v-bind="buttonProps" :disabled="_isPermission"></a-button> <a-button v-else v-bind="buttonProps" :disabled="_isPermission" @click="handleClick">
<slot></slot>
<template #icon>
<slot name="icon"></slot>
</template>
</a-button>
</a-tooltip> </a-tooltip>
<a-button v-else v-bind="buttonProps" :disabled="_isPermission"></a-button> <a-button v-else v-bind="buttonProps" :disabled="_isPermission" @click="handleClick">
<slot></slot>
<template #icon>
<slot name="icon"></slot>
</template>
</a-button>
</a-popconfirm> </a-popconfirm>
</template> </template>
<template v-else-if="tooltip"> <template v-else-if="tooltip">
<a-tooltip v-bind="tooltip"> <a-tooltip v-bind="tooltip">
<slot v-if="noButton"></slot> <slot v-if="noButton"></slot>
<a-button v-else v-bind="buttonProps" :disabled="_isPermission"></a-button> <a-button v-else v-bind="buttonProps" :disabled="_isPermission" @click="handleClick">
<slot></slot>
<template #icon>
<slot name="icon"></slot>
</template>
</a-button>
</a-tooltip> </a-tooltip>
</template> </template>
<template v-else> <template v-else>
<slot v-if="noButton"></slot> <slot v-if="noButton"></slot>
<a-button v-else v-bind="buttonProps" :disabled="_isPermission"></a-button> <a-button v-else v-bind="buttonProps" :disabled="_isPermission" @click="handleClick">
<slot></slot>
<template #icon>
<slot name="icon"></slot>
</template>
</a-button>
</template> </template>
</template> </template>
<a-tooltip v-else title="没有权限"> <a-tooltip v-else title="没有权限">
<slot v-if="noButton"></slot> <slot v-if="noButton"></slot>
<a-button v-else v-bind="buttonProps" :disabled="_isPermission"></a-button> <a-button v-else v-bind="buttonProps" :disabled="_isPermission" @click="handleClick">
<slot></slot>
<template #icon>
<slot name="icon"></slot>
</template>
</a-button>
</a-tooltip> </a-tooltip>
</template> </template>
<script setup lang="ts" name="PermissionButton"> <script setup lang="ts" name="PermissionButton">
import type { ButtonProps, TooltipProps, PopconfirmProps } from 'ant-design-vue' import type { ButtonProps, TooltipProps, PopconfirmProps } from 'ant-design-vue'
import { usePermissionStore } from '@/store/permission'; import { usePermissionStore } from '@/store/permission';
interface PermissionButtonEmits {
(e: 'click', data: MouseEvent): void;
}
const emits = defineEmits<PermissionButtonEmits>()
interface PermissionButtonProps extends ButtonProps { interface PermissionButtonProps extends ButtonProps {
tooltip?: TooltipProps; tooltip?: TooltipProps;
popConfirm?: PopconfirmProps; popConfirm?: PopconfirmProps;
@ -49,13 +80,16 @@ const isPermission = computed(() => {
return permissionStore.hasPermission(props.hasPermission) return permissionStore.hasPermission(props.hasPermission)
}) })
const _isPermission = computed(() => const _isPermission = computed(() =>
'hasPermission' in props && isPermission 'hasPermission' in props && isPermission.value
? 'disabled' in buttonProps ? 'disabled' in buttonProps
? buttonProps.disabled ? buttonProps.disabled
: false : false
: true : true
) )
const handleClick = (e: MouseEvent) => {
emits('click', e)
}
</script> </script>
<style scoped lang="scss"> <style scoped lang="less">
</style> </style>

View File

@ -9,6 +9,7 @@ import Search from './Search'
import NormalUpload from './NormalUpload/index.vue' import NormalUpload from './NormalUpload/index.vue'
import FileFormat from './FileFormat/index.vue' import FileFormat from './FileFormat/index.vue'
import JUpload from './JUpload/index.vue' import JUpload from './JUpload/index.vue'
import { BasicLayoutPage, BlankLayoutPage, PageContainer } from './Layout'
export default { export default {
install(app: App) { install(app: App) {
@ -22,5 +23,8 @@ export default {
.component('NormalUpload', NormalUpload) .component('NormalUpload', NormalUpload)
.component('FileFormat', FileFormat) .component('FileFormat', FileFormat)
.component('JUpload', JUpload) .component('JUpload', JUpload)
.component('BasicLayoutPage', BasicLayoutPage)
.component('BlankLayoutPage', BlankLayoutPage)
.component('PageContainer', PageContainer)
} }
} }

View File

@ -1,11 +1,12 @@
import { createRouter, createWebHashHistory } from 'vue-router'; import { createRouter, createWebHashHistory } from 'vue-router'
import menus, { LoginPath } from './menu' import menus, { LoginPath } from './menu'
import { LocalStore } from "@/utils/comm"; import { cleanToken, getToken } from '@/utils/comm'
import { TOKEN_KEY } from "@/utils/variable"; import { useUserInfo } from '@/store/userInfo'
import { useSystem } from '@/store/system'
const router = createRouter({ const router = createRouter({
history: createWebHashHistory(), history: createWebHashHistory(),
routes: menus routes: menus
}) })
const filterPath = [ const filterPath = [
@ -14,17 +15,50 @@ const filterPath = [
] ]
router.beforeEach((to, from, next) => { router.beforeEach((to, from, next) => {
const token = LocalStore.get(TOKEN_KEY) // TODO 切换路由取消请求
// TODO 切换路由取消请求 const isFilterPath = filterPath.includes(to.path)
if (token || filterPath.includes(to.path)) { if (isFilterPath) {
next() next()
} else { } else {
if (to.path === LoginPath) { const token = getToken()
next() if (token) {
} else { if (to.path === LoginPath) {
next({ path: '/' })
} else {
const userInfo = useUserInfo()
const system = useSystem()
if (!userInfo.userInfos.username) {
userInfo.getUserInfo().then(() => {
system.getSystemVersion().then((menuData: any[]) => {
menuData.forEach(r => {
router.addRoute('main', r)
})
const redirect = decodeURIComponent((from.query.redirect as string) || to.path)
if(to.path === redirect) {
next({ ...to, replace: true })
} else {
next({ path: redirect })
}
})
}).catch(() => {
console.log('userInfo', userInfo)
cleanToken()
next({ path: LoginPath }) next({ path: LoginPath })
})
} else {
next()
} }
}
} else {
if (to.path === LoginPath) {
next()
} else {
next({ path: LoginPath })
}
} }
}
}) })
export default router export default router

View File

@ -113,6 +113,14 @@ export default [
path:'/system/Permission', path:'/system/Permission',
component: ()=>import('@/views/system/Permission/index.vue') component: ()=>import('@/views/system/Permission/index.vue')
}, },
{
path:'/system/Menu',
component: ()=>import('@/views/system/Menu/index.vue')
},
{
path:'/system/Menu/detail/:id',
component: ()=>import('@/views/system/Menu/Detail/index.vue')
},
// 初始化 // 初始化
{ {
path: '/init-home', path: '/init-home',
@ -145,5 +153,10 @@ export default [
{ {
path: '/iot/device/Category', path: '/iot/device/Category',
component: () => import('@/views/device/Category/index.vue') component: () => import('@/views/device/Category/index.vue')
} } ,
// 产品
{
path: '/iot/device/Product',
component: () => import('@/views/device/Product/index.vue')
}
] ]

View File

@ -1,9 +1,12 @@
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import { queryOwnThree } from '@/api/system/menu'
import { filterAsnycRouter } from '@/utils/menu'
export const useMenuStore = defineStore({ export const useMenuStore = defineStore({
id: 'menu', id: 'menu',
state: () => ({ state: () => ({
menus: {} as {[key: string]: string}, menus: {},
menusKey: []
}), }),
getters: { getters: {
hasPermission(state) { hasPermission(state) {
@ -19,6 +22,47 @@ export const useMenuStore = defineStore({
} }
return false return false
} }
},
},
actions: {
queryMenuTree(isCommunity = false): Promise<any[]> {
return new Promise(async (res) => {
//过滤非集成的菜单
const params = [
{
terms: [
{
terms: [
{
column: 'owner',
termType: 'eq',
value: 'iot',
},
{
column: 'owner',
termType: 'isnull',
value: '1',
type: 'or',
},
],
},
],
},
];
const resp = await queryOwnThree({ paging: false, terms: params })
if (resp.success) {
const menus = filterAsnycRouter(resp.result)
menus.push({
path: '/',
redirect: menus[0]?.path,
meta: {
hideInMenu: true
}
})
this.menus = menus
res(menus)
}
})
} }
} }
}) })

24
src/store/system.ts Normal file
View File

@ -0,0 +1,24 @@
import { defineStore } from 'pinia';
import { systemVersion } from '@/api/comm'
import { useMenuStore } from './menu'
export const useSystem = defineStore('system', {
state: () => ({
isCommunity: false
}),
actions: {
getSystemVersion(): Promise<any[]> {
return new Promise(async(res, rej) => {
const resp = await systemVersion()
if (resp.success && resp.result) {
const isCommunity = resp.result.edition === 'community'
this.isCommunity = isCommunity
// 获取菜单
const menu = useMenuStore()
const menuData: any[] = await menu.queryMenuTree(isCommunity)
res(menuData)
}
})
}
}
})

View File

@ -1,5 +1,5 @@
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { authLogin } from '@/api/login'; import { authLogin, userDetail } from '@/api/login';
import { LocalStore } from '@/utils/comm'; import { LocalStore } from '@/utils/comm';
import { TOKEN_KEY } from '@/utils/variable'; import { TOKEN_KEY } from '@/utils/variable';
@ -38,5 +38,17 @@ export const useUserInfo = defineStore('userInfo', {
}); });
}); });
}, },
getUserInfo() {
return new Promise((res, rej) => {
userDetail().then(resp => {
if (resp.success) {
res(true)
this.userInfos = resp.result
} else {
rej()
}
}).catch(() => rej())
})
}
}, },
}); });

View File

@ -1,5 +1,5 @@
import type { Slots } from 'vue'
import { TOKEN_KEY } from '@/utils/variable' import { TOKEN_KEY } from '@/utils/variable'
import { Terms } from 'components/Search/types'
/** /**
* *
@ -10,32 +10,36 @@ export const getImage = (path: string) => {
} }
export const LocalStore = { export const LocalStore = {
set(key: string, data: any) { set(key: string, data: any) {
localStorage.setItem(key, typeof data === 'string' ? data : JSON.stringify(data)) localStorage.setItem(key, typeof data === 'string' ? data : JSON.stringify(data))
}, },
get(key: string) { get(key: string) {
const dataStr = localStorage.getItem(key) const dataStr = localStorage.getItem(key)
try { try {
if (dataStr) { if (dataStr) {
const data = JSON.parse(dataStr) const data = JSON.parse(dataStr)
return data && typeof data === 'object' ? data : dataStr return data && typeof data === 'object' ? data : dataStr
} else { } else {
return dataStr return dataStr
} }
} catch (e) { } catch (e) {
return dataStr return dataStr
}
},
remove(key: string) {
localStorage.removeItem(key)
},
removeAll() {
localStorage.clear()
} }
},
remove(key: string) {
localStorage.removeItem(key)
},
removeAll() {
localStorage.clear()
}
} }
export const getToken = () => { export const getToken = () => {
return LocalStore.get(TOKEN_KEY) return LocalStore.get(TOKEN_KEY)
}
export const cleanToken = () => {
LocalStore.remove(TOKEN_KEY)
} }
/** /**
@ -45,7 +49,7 @@ export const getToken = () => {
* @param key * @param key
*/ */
export const filterTreeSelectNode = (value: string, treeNode: any, key: string = 'name'): boolean => { export const filterTreeSelectNode = (value: string, treeNode: any, key: string = 'name'): boolean => {
return treeNode[key]?.includes(value) return treeNode[key]?.includes(value)
} }
/** /**
@ -55,32 +59,20 @@ export const filterTreeSelectNode = (value: string, treeNode: any, key: string =
* @param key * @param key
*/ */
export const filterSelectNode = (value: string, option: any, key: string = 'label'): boolean => { export const filterSelectNode = (value: string, option: any, key: string = 'label'): boolean => {
return option[key]?.includes(value) return option[key]?.includes(value)
} }
/** export function getSlot<T>(slots: Slots, props: Record<string, unknown>, prop = 'default'): T | false {
* '2022-01-02 14:03:05' if (props[prop] === false) {
* @param date // force not render
* @returns return false
*/ }
export const dateFormat = (dateSouce:any):string|Error => { return (props[prop] || slots[prop]) as T
let date = null }
try {
date = new Date(dateSouce) export function getSlotVNode<T>(slots: Slots, props: Record<string, unknown>, prop = 'default'): T | false {
} catch (error) { if (props[prop] === false) {
return new Error('请传入日期格式数据') return false;
} }
let year = date.getFullYear(); return (props[prop] || slots[prop]?.()) as T;
let month: number | string = date.getMonth() + 1;
let day: number | string = date.getDate();
let hour: number | string = date.getHours();
let minutes: number | string = date.getMinutes();
let seconds: number | string = date.getSeconds();
month = (month < 10) ? '0' + month : month;
day = (day < 10) ? '0' + day : day;
hour = (hour < 10) ? '0' + hour : hour;
minutes = (minutes < 10) ? '0' + minutes : minutes;
seconds = (seconds < 10) ? '0' + seconds : seconds;
return year + "-" + month + "-" + day
+ " " + hour + ":" + minutes + ":" + seconds;
} }

View File

@ -1,3 +1,5 @@
import { isObject } from 'lodash-es'
export default function encodeQuery(params: any) { export default function encodeQuery(params: any) {
if (!params) return {}; if (!params) return {};
const queryParam = { const queryParam = {
@ -15,7 +17,7 @@ export default function encodeQuery(params: any) {
terms[k] === '' || terms[k] === '' ||
terms[k] === undefined || terms[k] === undefined ||
terms[k].length === 0 || terms[k].length === 0 ||
terms[k] === {} || (isObject(terms[k]) && Object.keys(terms[k]).length === 0) ||
terms[k] === null terms[k] === null
) )
) { ) {

111
src/utils/menu.ts Normal file
View File

@ -0,0 +1,111 @@
const pagesComponent = import.meta.glob('../views/system/**/*.vue', { eager: true });
import { BlankLayoutPage, BasicLayoutPage } from 'components/Layout'
type ExtraRouteItem = {
code: string
name: string
url?: string
}
// 额外子级路由
const extraRouteObj = {
'media/Cascade': {
children: [
{ code: 'Save', name: '新增' },
{ code: 'Channel', name: '选择通道' },
],
},
'media/Device': {
children: [
{ code: 'Save', name: '详情' },
{ code: 'Channel', name: '通道列表' },
{ code: 'Playback', name: '回放' },
],
},
'rule-engine/Scene': {
children: [
{ code: 'Save', name: '详情' },
{ code: 'Save2', name: '测试详情' },
],
},
'rule-engine/Alarm/Configuration': {
children: [{ code: 'Save', name: '详情' }],
},
'device/Firmware': {
children: [{ code: 'Task', name: '升级任务' }],
},
demo: {
children: [{ code: 'AMap', name: '地图' }],
},
'system/Platforms': {
children: [
{ code: 'Api', name: '赋权' },
{ code: 'View', name: 'Api详情' },
],
},
'system/DataSource': {
children: [{ code: 'Management', name: '管理' }],
},
'system/Menu': {
children: [{ code: 'Setting', name: '菜单配置' }],
},
'system/Apply': {
children: [
{ code: 'Api', name: '赋权' },
{ code: 'View', name: 'Api详情' },
{ code: 'Save', name: '详情' },
],
},
};
const resolveComponent = (name: any) => {
// TODO 暂时用system进行测试
const importPage = pagesComponent[`../views/${name}/index.vue`];
// if (!importPage) {
// throw new Error(`Unknown page ${name}. Is it located under Pages with a .vue extension?`);
// }
//@ts-ignore
return !importPage ? BlankLayoutPage : importPage.default
// return importPage.default
}
const findChildrenRoute = (code: string, url: string): ExtraRouteItem[] => {
if (extraRouteObj[code]) {
return extraRouteObj[code].children.map((route: ExtraRouteItem) => {
return {
url: `${url}/${route.code}`,
code: route.code,
name: route.name
}
})
}
return []
}
export function filterAsnycRouter(asyncRouterMap: any, parentCode = '', level = 1) {
return asyncRouterMap.map((route: any) => {
route.path = `${route.url}`
route.meta = {
icon: route.icon,
title: route.name
}
// 查看是否有隐藏子路由
route.children = route.children && route.children.length ? [...route.children, ...findChildrenRoute(route.code, route.url)] : findChildrenRoute(route.code, route.url)
// TODO 查看是否具有详情页
// route.children = [...route.children, ]
if (route.children && route.children.length) {
route.component = () => level === 1 ? BasicLayoutPage : BlankLayoutPage
route.children = filterAsnycRouter(route.children, `${parentCode}/${route.code}`, level + 1)
route.redirect = route.children[0].url
} else {
route.component = resolveComponent(route.code);
}
console.log(route.code, route)
return route
})
}

4
src/utils/regular.ts Normal file
View File

@ -0,0 +1,4 @@
// 用于校验 url
export const urlReg = /(((^https?:(?:\/\/)?)(?:[-;:&=\+\$,\w]+@)?[A-Za-z0-9.-]+(?::\d+)?|(?:www.|[-;:&=\+\$,\w]+@)[A-Za-z0-9.-]+)((?:\/[\+~%\/.\w-_]*)?\??(?:[-\+=&;%@.\w_]*)#?(?:[\w]*))?)$/;
export const isUrl = (path: string): boolean => urlReg.test(path)

View File

@ -118,6 +118,7 @@ const errorHandler = (error: any) => {
const status = error.response.status const status = error.response.status
if (status === 403) { if (status === 403) {
Notification.error({ Notification.error({
key: '403',
message: 'Forbidden', message: 'Forbidden',
description: (data.message + '').substr(0, 90) description: (data.message + '').substr(0, 90)
}) })
@ -129,22 +130,25 @@ const errorHandler = (error: any) => {
}, 0) }, 0)
} else if (status === 500) { } else if (status === 500) {
Notification.error({ Notification.error({
key: '500',
message: 'Server Side Error', message: 'Server Side Error',
description: (data.message + '').substr(0, 90) description: (data.message + '').substr(0, 90)
}) })
} else if (status === 400) { } else if (status === 400) {
Notification.error({ Notification.error({
key: '400',
message: 'Request Error', message: 'Request Error',
description: (data.message + '').substr(0, 90) description: (data.message + '').substr(0, 90)
}) })
} else if (status === 401) { } else if (status === 401) {
Notification.error({ Notification.error({
key: '401',
message: 'Unauthorized', message: 'Unauthorized',
description: 'Authorization verification failed' description: 'Authorization verification failed'
}) })
setTimeout(() => { setTimeout(() => {
router.replace({ router.replace({
name: 'login' path: LoginPath
}) })
}, 0) }, 0)
} }

View File

@ -13,23 +13,24 @@
> >
<a-form <a-form
layout="vertical" layout="vertical"
v-model="formModel"
:rules="rules"
ref="formRef" ref="formRef"
:rules="rules"
:model="formModel"
> >
<a-form-item label="名称" name="name" v-bind="validateInfos.name"> <a-form-item label="名称" name="name">
<a-input v-model:value="formModel.name" :maxlength="64" /> <a-input
v-model:value="formModel.name"
:maxlength="64"
placeholder="请输入名称"
/>
</a-form-item> </a-form-item>
<a-form-item <a-form-item label="排序" name="sortIndex">
label="排序"
name="sortIndex"
v-bind="validateInfos.sortIndex"
>
<a-input-number <a-input-number
style="width: 100%" style="width: 100%"
id="inputNumber" id="inputNumber"
v-model:value="formModel.sortIndex" v-model:value="formModel.sortIndex"
:min="1" :min="1"
placeholder="请输入排序"
/> />
</a-form-item> </a-form-item>
<a-form-item label="说明"> <a-form-item label="说明">
@ -37,6 +38,7 @@
v-model:value="formModel.description" v-model:value="formModel.description"
show-count show-count
:maxlength="200" :maxlength="200"
placeholder="请输入说明"
/> />
</a-form-item> </a-form-item>
</a-form> </a-form>
@ -44,10 +46,12 @@
</template> </template>
<script setup lang="ts" name="modifyModal"> <script setup lang="ts" name="modifyModal">
import { PropType } from 'vue'; import { PropType } from 'vue';
import { Form } from 'ant-design-vue'; import { Form, message } from 'ant-design-vue';
import { queryTree } from '@/api/device/category'; import { queryTree, saveTree, updateTree } from '@/api/device/category';
import { ValidateErrorEntity } from 'ant-design-vue/es/form/interface'; import { ValidateErrorEntity } from 'ant-design-vue/es/form/interface';
import { list } from '@/api/iot-card/home'; import { list } from '@/api/iot-card/home';
import { number } from 'echarts';
const emits = defineEmits(['refresh']); const emits = defineEmits(['refresh']);
const formRef = ref(); const formRef = ref();
const useForm = Form.useForm; const useForm = Form.useForm;
@ -65,8 +69,8 @@ const props = defineProps({
default: 0, default: 0,
}, },
isChild: { isChild: {
type: Boolean, type: Number,
default: false, default: 0,
}, },
}); });
interface formState { interface formState {
@ -75,6 +79,11 @@ interface formState {
description: string; description: string;
} }
const listData = ref([]); const listData = ref([]);
const childArr = ref([]);
const arr = ref([]);
const updateObj = ref({});
const addObj = ref({});
const addParams = ref({});
/** /**
* 表单数据 * 表单数据
*/ */
@ -84,8 +93,8 @@ const formModel = ref<formState>({
description: '', description: '',
}); });
const rules = ref({ const rules = ref({
name: [{ required: true, message: '请输入名称' }], name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
sortIndex: [{ required: true, message: '请输入排序' }], sortIndex: [{ required: true, message: '请输入排序', trigger: 'blur' }],
}); });
const visible = ref(false); const visible = ref(false);
const { resetFields, validate, validateInfos } = useForm( const { resetFields, validate, validateInfos } = useForm(
@ -96,53 +105,99 @@ const { resetFields, validate, validateInfos } = useForm(
* 提交数据 * 提交数据
*/ */
const submitData = async () => { const submitData = async () => {
validate() formRef.value.validate().then(async () => {
.then(async () => {}) addParams.value = {};
.catch((error: ValidateErrorEntity<formState>) => {}); if (props.isAdd === 0) {
if (props.isChild === 1) {
addParams.value = {
...formModel.value,
sortIndex:
childArr.value[childArr.value.length - 1].sortIndex + 1,
parentId: addObj.value.id,
};
} else if (props.isChild === 2) {
addParams.value = {
parentId: addObj.value.id,
...formModel.value,
sortIndex: 1,
};
} else if (props.isChild === 3) {
addParams.value = {
...formModel.value,
sortIndex: arr.value[arr.value.length - 1].sortIndex + 1,
};
}
const res = await saveTree(addParams.value);
if (res.status === 200) {
message.success('操作成功!');
visible.value = false;
emits('refresh');
} else {
message.error('操作失败!');
}
} else if (props.isAdd === 2) {
const id = updateObj.value.id;
const updateParams = {
...formModel.value,
id: updateObj.value.id,
key: updateObj.value.key,
parentId: updateObj.value.parentId,
};
const res = await updateTree(id, updateParams);
if (res.status === 200) {
message.success('操作成功!');
visible.value = false;
emits('refresh');
} else {
message.error('操作失败!');
}
}
});
}; };
/** /**
* 显示弹窗 * 显示弹窗
*/ */
const show = (row: any) => { const show = (row: any) => {
//
if (props.isAdd === 0) { if (props.isAdd === 0) {
// if (props.isChild === 1) {
if (props.isChild) { addObj.value = row;
//
if (row.children && row.children.length > 0) { if (row.children && row.children.length > 0) {
let childArr = []; childArr.value = row.children.sort(compare('sortIndex'));
childArr = row.children.sort(compare('sortIndex'));
formModel.value = { formModel.value = {
name: '', name: '',
sortIndex: childArr[childArr.length - 1].sortIndex + 1, sortIndex:
childArr.value[childArr.value.length - 1].sortIndex + 1,
description: '', description: '',
}; };
} else { visible.value = true;
}
} else if (props.isChild === 3) {
arr.value = listData.value.sort(compare('sortIndex'));
if (arr.value.length > 0) {
formModel.value = { formModel.value = {
name: '', name: '',
sortIndex: 1, sortIndex: arr.value[arr.value.length - 1].sortIndex + 1,
description: '', description: '',
}; };
} }
} else { visible.value = true;
let arr = []; } else if (props.isChild === 2) {
arr = listData.value.sort(compare('sortIndex')); if (row.level === 5) {
if (arr.length > 0) { message.warning('树形结构最多添加5层');
formModel.value = { visible.value = false;
name: '',
sortIndex: arr[arr.length - 1].sortIndex + 1,
description: '',
};
} else { } else {
addObj.value = row;
formModel.value = { formModel.value = {
name: '', name: '',
sortIndex: 1, sortIndex: 1,
description: '', description: '',
}; };
visible.value = true;
} }
} }
visible.value = true;
} else if (props.isAdd === 2) { } else if (props.isAdd === 2) {
updateObj.value = row;
// //
formModel.value = { formModel.value = {
name: row.name, name: row.name,
@ -152,10 +207,7 @@ const show = (row: any) => {
visible.value = true; visible.value = true;
} }
}; };
/**
* 判断是新增还是编辑
*/
const judgeIsAdd = () => {};
/** /**
* 排序 * 排序
*/ */
@ -183,7 +235,6 @@ const compare = (property: any) => {
const res = await queryTree(params); const res = await queryTree(params);
if (res.status === 200) { if (res.status === 200) {
listData.value = res.result; listData.value = res.result;
console.log(listData.value, 'listData.value');
} }
}; };
/** /**

View File

@ -1,7 +1,7 @@
<!--产品分类 --> <!--产品分类 -->
<template> <template>
<a-card class="product-category"> <a-card class="product-category">
<Search :columns="query.columns" target="category" /> <Search :columns="query.columns" target="category" @search="search" />
<JTable <JTable
ref="tableRef" ref="tableRef"
:columns="table.columns" :columns="table.columns"
@ -57,7 +57,7 @@
:title="title" :title="title"
:isAdd="isAdd" :isAdd="isAdd"
:isChild="isChild" :isChild="isChild"
@refresh="() => modifyRef.value?.reload()" @refresh="refresh"
/> />
</a-card> </a-card>
</template> </template>
@ -73,34 +73,41 @@ const dataSource = ref([]);
const currentForm = ref({}); const currentForm = ref({});
const title = ref(''); const title = ref('');
const isAdd = ref(0); const isAdd = ref(0);
const isChild = ref(false); const isChild = ref(0);
// //
const query = reactive({ const query = reactive({
columns: [ columns: [
{ {
title: '名称', title: '名称',
dataIndex: 'name', dataIndex: 'name',
ellipsis: true, key: 'name',
search: {
type: 'string',
},
}, },
{ {
title: '排序', title: '排序',
dataIndex: 'sortIndex', dataIndex: 'sortIndex',
valueType: 'digit', key: 'sortIndex',
sorter: true, search: {
type: 'number',
},
scopedSlots: true,
}, },
{ {
title: '描述', title: '描述',
key: 'description', key: 'description',
ellipsis: true,
dataIndex: 'description', dataIndex: 'description',
filters: true, search: {
onFilter: true, type: 'string',
},
}, },
{ {
title: '操作', title: '操作',
valueType: 'option', key: 'action',
width: 200,
fixed: 'right', fixed: 'right',
width: 250,
scopedSlots: true,
}, },
], ],
params: { params: {
@ -150,8 +157,12 @@ const getActions = (
onClick: () => { onClick: () => {
title.value = '新增子分类'; title.value = '新增子分类';
isAdd.value = 0; isAdd.value = 0;
isChild.value = true;
currentForm.value = {}; currentForm.value = {};
if (data.children && data.children.length > 0) {
isChild.value = 1;
} else {
isChild.value = 2;
}
nextTick(() => { nextTick(() => {
modifyRef.value.show(data); modifyRef.value.show(data);
}); });
@ -187,6 +198,7 @@ const table = reactive({
title: '排序', title: '排序',
dataIndex: 'sortIndex', dataIndex: 'sortIndex',
key: 'sortIndex', key: 'sortIndex',
scopedSlots: true,
}, },
{ {
title: '说明', title: '说明',
@ -207,13 +219,19 @@ const table = reactive({
add: async () => { add: async () => {
title.value = '新增分类'; title.value = '新增分类';
isAdd.value = 0; isAdd.value = 0;
isChild.value = false; isChild.value = 3;
nextTick(() => { nextTick(() => {
modifyRef.value.show(currentForm.value); modifyRef.value.show(currentForm.value);
}); });
}, },
/**
* 刷新表格数据
*/
refresh: () => {
tableRef.value?.reload();
},
}); });
const { add, columns } = toRefs(table); const { add, columns, refresh } = toRefs(table);
/** /**
* 初始化 * 初始化
*/ */

View File

@ -1,12 +1,12 @@
<template> <template>
<JTable <JTable
ref="instanceRef" ref="instanceRef"
:columns="columns" :columns="columns"
:request="query" :request="query"
:defaultParams="{sorts: [{name: 'createTime', order: 'desc'}]}" :defaultParams="{ sorts: [{ name: 'createTime', order: 'desc' }] }"
:rowSelection="{ :rowSelection="{
selectedRowKeys: _selectedRowKeys, selectedRowKeys: _selectedRowKeys,
onChange: onSelectChange onChange: onSelectChange,
}" }"
@cancelSelect="cancelSelect" @cancelSelect="cancelSelect"
:params="params" :params="params"
@ -17,37 +17,79 @@
<a-dropdown> <a-dropdown>
<a-button>批量操作 <AIcon type="DownOutlined" /></a-button> <a-button>批量操作 <AIcon type="DownOutlined" /></a-button>
<template #overlay> <template #overlay>
<a-menu> <a-menu>
<a-menu-item> <a-menu-item>
<a-button @click="exportVisible = true"><AIcon type="ExportOutlined" />批量导出设备</a-button> <a-button @click="exportVisible = true"
</a-menu-item> ><AIcon
<a-menu-item> type="ExportOutlined"
<a-button @click="importVisible = true"><AIcon type="ImportOutlined" />批量导入设备</a-button> /></a-button
</a-menu-item> >
<a-menu-item> </a-menu-item>
<a-popconfirm @confirm="activeAllDevice" title="确认激活全部设备?"> <a-menu-item>
<a-button type="primary" ghost><AIcon type="CheckCircleOutlined" />激活全部设备</a-button> <a-button @click="importVisible = true"
</a-popconfirm> ><AIcon
</a-menu-item> type="ImportOutlined"
<a-menu-item> /></a-button
<a-button @click="syncDeviceStatus" type="primary"><AIcon type="SyncOutlined" />同步设备状态</a-button> >
</a-menu-item> </a-menu-item>
<a-menu-item v-if="_selectedRowKeys.length"> <a-menu-item>
<a-popconfirm @confirm="delSelectedDevice" title="已启用的设备无法删除,确认删除选中的禁用状态设备?"> <a-popconfirm
<a-button type="primary" danger><AIcon type="DeleteOutlined" />删除选中设备</a-button> @confirm="activeAllDevice"
</a-popconfirm> title="确认激活全部设备?"
</a-menu-item> >
<a-menu-item v-if="_selectedRowKeys.length" title="确认激活选中设备?"> <a-button type="primary" ghost
<a-popconfirm @confirm="activeSelectedDevice" > ><AIcon
<a-button type="primary"><AIcon type="CheckOutlined" />激活选中设备</a-button> type="CheckCircleOutlined"
</a-popconfirm> /></a-button
</a-menu-item> >
<a-menu-item v-if="_selectedRowKeys.length"> </a-popconfirm>
<a-popconfirm @confirm="disabledSelectedDevice" title="确认禁用选中设备?"> </a-menu-item>
<a-button type="primary" danger><AIcon type="StopOutlined" />禁用选中设备</a-button> <a-menu-item>
</a-popconfirm> <a-button
</a-menu-item> @click="syncDeviceStatus"
</a-menu> type="primary"
><AIcon
type="SyncOutlined"
/></a-button
>
</a-menu-item>
<a-menu-item v-if="_selectedRowKeys.length">
<a-popconfirm
@confirm="delSelectedDevice"
title="已启用的设备无法删除,确认删除选中的禁用状态设备?"
>
<a-button type="primary" danger
><AIcon
type="DeleteOutlined"
/></a-button
>
</a-popconfirm>
</a-menu-item>
<a-menu-item
v-if="_selectedRowKeys.length"
title="确认激活选中设备?"
>
<a-popconfirm @confirm="activeSelectedDevice">
<a-button type="primary"
><AIcon
type="CheckOutlined"
/></a-button
>
</a-popconfirm>
</a-menu-item>
<a-menu-item v-if="_selectedRowKeys.length">
<a-popconfirm
@confirm="disabledSelectedDevice"
title="确认禁用选中设备?"
>
<a-button type="primary" danger
><AIcon
type="StopOutlined"
/></a-button
>
</a-popconfirm>
</a-menu-item>
</a-menu>
</template> </template>
</a-dropdown> </a-dropdown>
</a-space> </a-space>
@ -69,31 +111,44 @@
> >
<template #img> <template #img>
<slot name="img"> <slot name="img">
<img :src="getImage('/device/instance/device-card.png')" /> <img
:src="getImage('/device/instance/device-card.png')"
/>
</slot> </slot>
</template> </template>
<template #content> <template #content>
<h3 class="card-item-content-title" @click.stop="handleView(slotProps.id)">{{ slotProps.name }}</h3> <h3
class="card-item-content-title"
@click.stop="handleView(slotProps.id)"
>
{{ slotProps.name }}
</h3>
<a-row> <a-row>
<a-col :span="12"> <a-col :span="12">
<div class="card-item-content-text">设备类型</div> <div class="card-item-content-text">设备类型</div>
<div>{{slotProps.deviceType.text}}</div> <div>{{ slotProps.deviceType.text }}</div>
</a-col> </a-col>
<a-col :span="12"> <a-col :span="12">
<div class="card-item-content-text">产品名称</div> <div class="card-item-content-text">产品名称</div>
<div>{{slotProps.productName}}</div> <div>{{ slotProps.productName }}</div>
</a-col> </a-col>
</a-row> </a-row>
</template> </template>
<template #actions="item"> <template #actions="item">
<a-tooltip v-bind="item.tooltip" :title="item.disabled && item.tooltip.title"> <a-tooltip
v-bind="item.tooltip"
:title="item.disabled && item.tooltip.title"
>
<a-popconfirm <a-popconfirm
v-if="item.popConfirm" v-if="item.popConfirm"
v-bind="item.popConfirm" v-bind="item.popConfirm"
:disabled="item.disabled" :disabled="item.disabled"
> >
<a-button :disabled="item.disabled"> <a-button :disabled="item.disabled">
<AIcon type="DeleteOutlined" v-if="item.key === 'delete'" /> <AIcon
type="DeleteOutlined"
v-if="item.key === 'delete'"
/>
<template v-else> <template v-else>
<AIcon :type="item.icon" /> <AIcon :type="item.icon" />
<span>{{ item.text }}</span> <span>{{ item.text }}</span>
@ -101,8 +156,14 @@
</a-button> </a-button>
</a-popconfirm> </a-popconfirm>
<template v-else> <template v-else>
<a-button :disabled="item.disabled" @click="item.onClick"> <a-button
<AIcon type="DeleteOutlined" v-if="item.key === 'delete'" /> :disabled="item.disabled"
@click="item.onClick"
>
<AIcon
type="DeleteOutlined"
v-if="item.key === 'delete'"
/>
<template v-else> <template v-else>
<AIcon :type="item.icon" /> <AIcon :type="item.icon" />
<span>{{ item.text }}</span> <span>{{ item.text }}</span>
@ -114,7 +175,10 @@
</CardBox> </CardBox>
</template> </template>
<template #state="slotProps"> <template #state="slotProps">
<a-badge :text="slotProps.state.text" :status="statusMap.get(slotProps.state.value)" /> <a-badge
:text="slotProps.state === 1 ? ' 正常' : '禁用'"
:status="statusMap.get(slotProps.state)"
/>
</template> </template>
<template #action="slotProps"> <template #action="slotProps">
<a-space :size="16"> <a-space :size="16">
@ -123,7 +187,11 @@
:key="i.key" :key="i.key"
v-bind="i.tooltip" v-bind="i.tooltip"
> >
<a-popconfirm v-if="i.popConfirm" v-bind="i.popConfirm" :disabled="i.disabled"> <a-popconfirm
v-if="i.popConfirm"
v-bind="i.popConfirm"
:disabled="i.disabled"
>
<a-button <a-button
:disabled="i.disabled" :disabled="i.disabled"
style="padding: 0" style="padding: 0"
@ -149,43 +217,58 @@
</template> </template>
</JTable> </JTable>
<Import v-if="importVisible" @close="importVisible = false" /> <Import v-if="importVisible" @close="importVisible = false" />
<Export v-if="exportVisible" @close="exportVisible = false" :data="params" /> <Export
<Process v-if="operationVisible" @close="operationVisible = false" :api="api" :type="type" /> v-if="exportVisible"
@close="exportVisible = false"
:data="params"
/>
<Process
v-if="operationVisible"
@close="operationVisible = false"
:api="api"
:type="type"
/>
<Save v-if="visible" :data="current" /> <Save v-if="visible" :data="current" />
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { query, _delete, _deploy, _undeploy, batchUndeployDevice, batchDeployDevice, batchDeleteDevice } from '@/api/device/instance' import {
import type { ActionsType } from '@/components/Table/index.vue' query,
_delete,
_deploy,
_undeploy,
batchUndeployDevice,
batchDeployDevice,
batchDeleteDevice,
} from '@/api/device/instance';
import type { ActionsType } from '@/components/Table/index.vue';
import { getImage, LocalStore } from '@/utils/comm'; import { getImage, LocalStore } from '@/utils/comm';
import { message } from "ant-design-vue"; import { message } from 'ant-design-vue';
import Import from './Import/index.vue' import Import from './Import/index.vue';
import Export from './Export/index.vue' import Export from './Export/index.vue';
import Process from './Process/index.vue' import Process from './Process/index.vue';
import Save from './Save/index.vue' import Save from './Save/index.vue';
import { BASE_API_PATH, TOKEN_KEY } from '@/utils/variable'; import { BASE_API_PATH, TOKEN_KEY } from '@/utils/variable';
const instanceRef = ref<Record<string, any>>({}); const instanceRef = ref<Record<string, any>>({});
const params = ref<Record<string, any>>({}) const params = ref<Record<string, any>>({});
const _selectedRowKeys = ref<string[]>([]) const _selectedRowKeys = ref<string[]>([]);
const importVisible = ref<boolean>(false) const importVisible = ref<boolean>(false);
const exportVisible = ref<boolean>(false) const exportVisible = ref<boolean>(false);
const visible = ref<boolean>(false) const visible = ref<boolean>(false);
const current = ref<Record<string, any>>({}) const current = ref<Record<string, any>>({});
const operationVisible = ref<boolean>(false) const operationVisible = ref<boolean>(false);
const api = ref<string>('') const api = ref<string>('');
const type = ref<string>('') const type = ref<string>('');
const statusMap = new Map(); const statusMap = new Map();
statusMap.set('online', 'processing'); statusMap.set(1, 'processing');
statusMap.set('offline', 'error'); statusMap.set(0, 'error');
statusMap.set('notActive', 'warning');
const columns = [ const columns = [
{ {
title: 'ID', title: 'ID',
dataIndex: 'id', dataIndex: 'id',
key: 'id' key: 'id',
}, },
{ {
title: '设备名称', title: '设备名称',
@ -201,206 +284,237 @@ const columns = [
title: '创建时间', title: '创建时间',
dataIndex: 'createTime', dataIndex: 'createTime',
key: 'createTime', key: 'createTime',
scopedSlots: true scopedSlots: true,
}, },
{ {
title: '状态', title: '状态',
dataIndex: 'state', dataIndex: 'state',
key: 'state', key: 'state',
scopedSlots: true scopedSlots: true,
}, },
{ {
title: '说明', title: '说明',
dataIndex: 'describe', dataIndex: 'describe',
key: 'describe' key: 'describe',
}, },
{ {
title: '操作', title: '操作',
key: 'action', key: 'action',
fixed: 'right', fixed: 'right',
width: 250, width: 250,
scopedSlots: true scopedSlots: true,
} },
] ];
const paramsFormat = (config: Record<string, any>, _terms: Record<string, any>, name?: string) => { const paramsFormat = (
if (config?.terms && Array.isArray(config.terms) && config?.terms.length > 0) { config: Record<string, any>,
(config?.terms || []).map((item: Record<string, any>, index: number) => { _terms: Record<string, any>,
if (item?.type) { name?: string,
_terms[`${name ? `${name}.` : ''}terms[${index}].type`] = item.type; ) => {
} if (
paramsFormat(item, _terms, `${name ? `${name}.` : ''}terms[${index}]`); config?.terms &&
}); Array.isArray(config.terms) &&
config?.terms.length > 0
) {
(config?.terms || []).map(
(item: Record<string, any>, index: number) => {
if (item?.type) {
_terms[`${name ? `${name}.` : ''}terms[${index}].type`] =
item.type;
}
paramsFormat(
item,
_terms,
`${name ? `${name}.` : ''}terms[${index}]`,
);
},
);
} else if (!config?.terms && Object.keys(config).length > 0) { } else if (!config?.terms && Object.keys(config).length > 0) {
Object.keys(config).forEach((key) => { Object.keys(config).forEach((key) => {
if (config[key]) { if (config[key]) {
_terms[`${name ? `${name}.` : ''}${key}`] = config[key]; _terms[`${name ? `${name}.` : ''}${key}`] = config[key];
} }
}); });
} }
} };
const handleParams = (config: Record<string, any>) => { const handleParams = (config: Record<string, any>) => {
const _terms: Record<string, any> = {}; const _terms: Record<string, any> = {};
paramsFormat(config, _terms); paramsFormat(config, _terms);
if(Object.keys(_terms._value).length && Object.keys(_terms).length) { if (Object.keys(_terms._value).length && Object.keys(_terms).length) {
const url = new URLSearchParams(); const url = new URLSearchParams();
Object.keys(_terms).forEach((key) => { Object.keys(_terms).forEach((key) => {
url.append(key, _terms[key]); url.append(key, _terms[key]);
}); });
return url.toString(); return url.toString();
} else { } else {
return '' return '';
} }
} };
/** /**
* 新增 * 新增
*/ */
const handleAdd = () => { const handleAdd = () => {
visible.value = true visible.value = true;
current.value = {} current.value = {};
} };
/** /**
* 查看 * 查看
*/ */
const handleView = (id: string) => { const handleView = (id: string) => {
message.warn(id + '暂未开发') message.warn(id + '暂未开发');
} };
const getActions = (data: Partial<Record<string, any>>, type: 'card' | 'table'): ActionsType[] => { const getActions = (
if(!data) return [] data: Partial<Record<string, any>>,
type: 'card' | 'table',
): ActionsType[] => {
if (!data) return [];
const actions = [ const actions = [
{ {
key: 'view', key: 'view',
text: "查看", text: '查看',
tooltip: { tooltip: {
title: '查看' title: '查看',
}, },
icon: 'EyeOutlined', icon: 'EyeOutlined',
onClick: () => { onClick: () => {
handleView(data.id) handleView(data.id);
} },
}, },
{ {
key: 'edit', key: 'edit',
text: "编辑", text: '编辑',
tooltip: { tooltip: {
title: '编辑' title: '编辑',
}, },
icon: 'EditOutlined', icon: 'EditOutlined',
onClick: () => { onClick: () => {
visible.value = true visible.value = true;
current.value = data current.value = data;
} },
}, },
{ {
key: 'action', key: 'action',
text: data.state.value !== 'notActive' ? "禁用" : "启用", text: data.state.value !== 'notActive' ? '禁用' : '启用',
tooltip: { tooltip: {
title: data.state.value !== 'notActive' ? "禁用" : "启用", title: data.state.value !== 'notActive' ? '禁用' : '启用',
}, },
icon: data.state.value !== 'notActive' ? 'StopOutlined' : 'CheckCircleOutlined', icon:
data.state.value !== 'notActive'
? 'StopOutlined'
: 'CheckCircleOutlined',
popConfirm: { popConfirm: {
title: `确认${data.state.value !== 'notActive' ? "禁用" : "启用"}?`, title: `确认${
data.state.value !== 'notActive' ? '禁用' : '启用'
}?`,
onConfirm: async () => { onConfirm: async () => {
let response = undefined let response = undefined;
if(data.state.value !== 'notActive') { if (data.state.value !== 'notActive') {
response = await _undeploy(data.id) response = await _undeploy(data.id);
} else { } else {
response = await _deploy(data.id) response = await _deploy(data.id);
} }
if(response && response.status === 200) { if (response && response.status === 200) {
message.success('操作成功!') message.success('操作成功!');
instanceRef.value?.reload() instanceRef.value?.reload();
} else { } else {
message.error('操作失败!') message.error('操作失败!');
} }
} },
} },
}, },
{ {
key: 'delete', key: 'delete',
text: "删除", text: '删除',
disabled: data.state.value !== 'notActive', disabled: data.state.value !== 'notActive',
tooltip: { tooltip: {
title: data.state.value !== 'notActive' ? '已启用的设备不能删除' : '删除' title:
data.state.value !== 'notActive'
? '已启用的设备不能删除'
: '删除',
}, },
popConfirm: { popConfirm: {
title: '确认删除?', title: '确认删除?',
onConfirm: async () => { onConfirm: async () => {
const resp = await _delete(data.id) const resp = await _delete(data.id);
if(resp.status === 200) { if (resp.status === 200) {
message.success('操作成功!') message.success('操作成功!');
instanceRef.value?.reload() instanceRef.value?.reload();
} else { } else {
message.error('操作失败!') message.error('操作失败!');
} }
} },
}, },
icon: 'DeleteOutlined' icon: 'DeleteOutlined',
} },
] ];
if(type === 'card') return actions.filter((i: ActionsType) => i.key !== 'view') if (type === 'card')
return actions return actions.filter((i: ActionsType) => i.key !== 'view');
} return actions;
};
const onSelectChange = (keys: string[]) => { const onSelectChange = (keys: string[]) => {
_selectedRowKeys.value = [...keys] _selectedRowKeys.value = [...keys];
} };
const cancelSelect = () => { const cancelSelect = () => {
_selectedRowKeys.value = [] _selectedRowKeys.value = [];
} };
const handleClick = (dt: any) => { const handleClick = (dt: any) => {
if(_selectedRowKeys.value.includes(dt.id)) { if (_selectedRowKeys.value.includes(dt.id)) {
const _index = _selectedRowKeys.value.findIndex(i => i === dt.id) const _index = _selectedRowKeys.value.findIndex((i) => i === dt.id);
_selectedRowKeys.value.splice(_index, 1) _selectedRowKeys.value.splice(_index, 1);
} else { } else {
_selectedRowKeys.value = [..._selectedRowKeys.value, dt.id] _selectedRowKeys.value = [..._selectedRowKeys.value, dt.id];
} }
} };
const activeAllDevice = () => { const activeAllDevice = () => {
type.value = 'active' type.value = 'active';
const activeAPI = `${BASE_API_PATH}/device-instance/deploy?:X_Access_Token=${LocalStore.get(TOKEN_KEY)}&${handleParams(params)}`; const activeAPI = `${BASE_API_PATH}/device-instance/deploy?:X_Access_Token=${LocalStore.get(
api.value = activeAPI TOKEN_KEY,
operationVisible.value = true )}&${handleParams(params)}`;
} api.value = activeAPI;
operationVisible.value = true;
};
const syncDeviceStatus = () => { const syncDeviceStatus = () => {
type.value = 'sync' type.value = 'sync';
const syncAPI = `${BASE_API_PATH}/device-instance/state/_sync?:X_Access_Token=${LocalStore.get(TOKEN_KEY)}&${handleParams(params)}`; const syncAPI = `${BASE_API_PATH}/device-instance/state/_sync?:X_Access_Token=${LocalStore.get(
api.value = syncAPI TOKEN_KEY,
operationVisible.value = true )}&${handleParams(params)}`;
} api.value = syncAPI;
operationVisible.value = true;
};
const delSelectedDevice = async () => { const delSelectedDevice = async () => {
const resp = await batchDeleteDevice(_selectedRowKeys.value) const resp = await batchDeleteDevice(_selectedRowKeys.value);
if(resp.status === 200){ if (resp.status === 200) {
message.success('操作成功!') message.success('操作成功!');
_selectedRowKeys.value = [] _selectedRowKeys.value = [];
instanceRef.value?.reload() instanceRef.value?.reload();
} }
} };
const activeSelectedDevice = async () => { const activeSelectedDevice = async () => {
const resp = await batchDeployDevice(_selectedRowKeys.value) const resp = await batchDeployDevice(_selectedRowKeys.value);
if(resp.status === 200){ if (resp.status === 200) {
message.success('操作成功!') message.success('操作成功!');
_selectedRowKeys.value = [] _selectedRowKeys.value = [];
instanceRef.value?.reload() instanceRef.value?.reload();
} }
} };
const disabledSelectedDevice = async () => { const disabledSelectedDevice = async () => {
const resp = await batchUndeployDevice(_selectedRowKeys.value) const resp = await batchUndeployDevice(_selectedRowKeys.value);
if(resp.status === 200){ if (resp.status === 200) {
message.success('操作成功!') message.success('操作成功!');
_selectedRowKeys.value = [] _selectedRowKeys.value = [];
instanceRef.value?.reload() instanceRef.value?.reload();
} }
} };
</script> </script>

View File

@ -0,0 +1,313 @@
<template>
<div class="card">
<div
class="card-warp"
:class="{ active: active ? 'active' : '' }"
@click="handleClick"
>
<div class="card-content">
<a-row :gutter="20">
<a-col :span="10">
<!-- 图片 -->
<div class="card-item-avatar">
<slot name="img"> </slot>
</div>
</a-col>
<a-col :span="14">
<!-- 内容 -->
<slot name="content"></slot>
</a-col>
</a-row>
<!-- 勾选 -->
<div v-if="active" class="checked-icon">
<div>
<CheckOutlined />
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {
SearchOutlined,
CheckOutlined,
DeleteOutlined,
} from '@ant-design/icons-vue';
import { StatusColorEnum } from '@/utils/consts.ts';
import type { ActionsType } from '@/components/Table/index.vue';
import { PropType } from 'vue';
type EmitProps = {
// (e: 'update:modelValue', data: Record<string, any>): void;
(e: 'click', data: Record<string, any>): void;
};
type TableActionsType = Partial<ActionsType>;
const emit = defineEmits<EmitProps>();
const props = defineProps({
value: {
type: Object as PropType<Record<string, any>>,
default: () => {},
},
active: {
type: Boolean,
default: false,
},
});
const handleClick = () => {
emit('click', props.value);
};
</script>
<style lang="less" scoped>
.card {
width: 100%;
background-color: #fff;
.checked-icon {
position: absolute;
right: -22px;
bottom: -22px;
z-index: 2;
width: 44px;
height: 44px;
color: #fff;
background-color: red;
background-color: #2f54eb;
transform: rotate(-45deg);
> div {
position: relative;
height: 100%;
transform: rotate(45deg);
> span {
position: absolute;
top: 6px;
left: 6px;
font-size: 12px;
}
}
}
.card-warp {
position: relative;
border: 1px solid #e6e6e6;
height: 66px;
&.hover {
cursor: pointer;
box-shadow: 0 0 24px rgba(#000, 0.1);
}
&.active {
position: relative;
border: 1px solid #2f54eb;
}
.card-content {
position: relative;
padding: 30px 12px 16px 30px;
overflow: hidden;
position: relative;
top: -16px;
&::before {
position: absolute;
top: 0;
left: 30px + 10px;
display: block;
width: 15%;
min-width: 64px;
height: 2px;
// background-image: url('/images/rectangle.png');
background-repeat: no-repeat;
background-size: 100% 100%;
content: ' ';
}
.card-item-avatar {
// position: relative;
// top: -16px;
}
.card-state {
position: absolute;
top: 30px;
right: -12px;
display: flex;
justify-content: center;
width: 100px;
padding: 2px 0;
background-color: rgba(#5995f5, 0.15);
transform: skewX(45deg);
&.success {
background-color: @success-color-deprecated-bg;
}
&.warning {
background-color: rgba(#ff9000, 0.1);
}
&.error {
background-color: rgba(#e50012, 0.1);
}
.card-state-content {
transform: skewX(-45deg);
}
}
:deep(.card-item-content-title) {
cursor: pointer;
}
}
.card-mask {
position: absolute;
top: 0;
left: 0;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
color: #fff;
background-color: rgba(#000, 0);
visibility: hidden;
cursor: pointer;
transition: all 0.3s;
> div {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
padding: 0 !important;
}
&.show {
background-color: rgba(#000, 0.5);
visibility: visible;
}
}
}
&.item-active {
position: relative;
color: #2f54eb;
.checked-icon {
display: block;
}
.card-warp {
border: 1px solid #2f54eb;
}
}
.card-tools {
display: flex;
margin-top: 8px;
.card-button {
display: flex;
flex-grow: 1;
& > :deep(span, button) {
width: 100%;
border-radius: 0;
}
:deep(button) {
width: 100%;
border-radius: 0;
background: #f6f6f6;
border: 1px solid #e6e6e6;
color: #2f54eb;
&:hover {
background-color: @primary-color-hover;
border-color: @primary-color-hover;
span {
color: #fff !important;
}
}
&:active {
background-color: @primary-color-active;
border-color: @primary-color-active;
span {
color: #fff !important;
}
}
}
&:not(:last-child) {
margin-right: 8px;
}
&.delete {
flex-basis: 60px;
flex-grow: 0;
:deep(button) {
background: @error-color-deprecated-bg;
border: 1px solid @error-color-outline;
span {
color: @error-color !important;
}
&:hover {
background-color: @error-color-hover;
span {
color: #fff !important;
}
}
&:active {
background-color: @error-color-active;
span {
color: #fff !important;
}
}
}
}
:deep(button[disabled]) {
background: @disabled-bg;
border-color: @disabled-color;
span {
color: @disabled-color !important;
}
&:hover {
background-color: @disabled-active-bg;
}
&:active {
background-color: @disabled-active-bg;
}
}
// :deep(.ant-tooltip-disabled-compatible-wrapper) {
// width: 100%;
// }
}
}
}
</style>

View File

@ -0,0 +1,386 @@
<!-- 新增编辑产品 -->
<template>
<a-modal
:title="props.title"
:maskClosable="false"
destroy-on-close
v-model:visible="visible"
@ok="submitData"
@cancel="close"
okText="确定"
cancelText="取消"
v-bind="layout"
width="650px"
>
<div style="margin-top: 10px">
<a-form :layout="'vertical'">
<a-row type="flex">
<a-col flex="180px">
<a-form-item>
<div class="upload-image-warp-logo">
<div class="upload-image-border-logo">
<a-upload
name="file"
:action="FILE_UPLOAD"
:headers="headers"
:showUploadList="false"
:beforeUpload="beforeUpload"
@change="handleChange"
:accept="
imageTypes && imageTypes.length
? imageTypes.toString()
: ''
"
>
<div class="upload-image-content-logo">
<div
class="loading-logo"
v-if="logoLoading"
>
<LoadingOutlined
style="font-size: 28px"
/>
</div>
<div
class="upload-image"
v-if="photoValue"
:style="
photoValue
? `background-image: url(${photoValue});`
: ''
"
></div>
<div
v-if="photoValue"
class="upload-image-mask"
>
点击修改
</div>
<div v-else>
<div v-if="logoLoading">
<LoadingOutlined
style="font-size: 28px"
/>
</div>
<div v-else>
<PlusOutlined
style="font-size: 28px"
/>
</div>
</div>
</div>
</a-upload>
<div v-if="logoLoading">
<div class="upload-loading-mask">
<LoadingOutlined
v-if="logoLoading"
style="font-size: 28px"
/>
</div>
</div>
</div>
</div>
</a-form-item>
</a-col>
<a-col flex="auto">
<a-form-item>
<template #label>
<span>ID</span>
<a-tooltip
title="若不填写系统将自动生成唯一ID"
>
<img
class="img-style"
:src="getImage('/init-home/mark.png')"
/>
</a-tooltip>
</template>
<a-input
v-model:value="modelRef.id"
placeholder="请输入ID"
/>
</a-form-item>
<a-form-item label="名称">
<a-input
v-model:value="modelRef.name"
placeholder="请输入名称"
/>
</a-form-item>
</a-col>
</a-row>
<a-form-item label="产品分类">
<a-tree-select
showSearch
v-model:value="modelRef.productId"
placeholder="请选择产品分类"
>
</a-tree-select>
</a-form-item>
<a-form-item label="设备类型">
<a-row :span="24" :gutter="20">
<a-col
:span="8"
v-for="item in deviceList"
:key="item.value"
>
<ChooseCard
:value="item"
v-bind="item"
@click="handleClick"
:active="_selectedRowKeys.includes(item.value)"
>
<template #img>
<slot name="img">
<img
v-if="item.value === 'device'"
:src="
getImage('/device-type-1.png')
"
/>
<img
v-if="
item.value === 'childrenDevice'
"
:src="
getImage('/device-type-2.png')
"
/>
<img
v-if="item.value === 'gateway'"
:src="
getImage(
'/device/device-type-3.png',
)
"
/>
</slot>
</template>
<template #content>
<span
class="card-style"
:style="
_selectedRowKeys.includes(
item.value,
)
? 'color: #10239e'
: ''
"
>{{
item.value === 'device'
? '直连设备'
: item.value ===
'childrenDevice'
? '网关子设备'
: item.value === 'gateway'
? '网关设备'
: ''
}}</span
>
</template>
</ChooseCard>
</a-col>
</a-row>
</a-form-item>
<a-form-item label="说明">
<a-textarea
v-model:value="modelRef.describe"
placeholder="请输入说明"
/>
</a-form-item>
</a-form>
</div>
</a-modal>
</template>
<script lang="ts" setup>
import { queryTree } from '@/api/device/category';
import { Form } from 'ant-design-vue';
import { getImage } from '@/utils/comm.ts';
import { message } from 'ant-design-vue';
import ChooseCard from '../ChooseCard/index.vue';
import { FILE_UPLOAD } from '@/api/comm';
const emit = defineEmits(['close', 'save']);
const props = defineProps({
title: {
type: String,
defult: '',
},
isAdd: {
type: Number,
default: 0,
},
});
const treeList = ref<Record<string, any>[]>([]);
const visible = ref(false);
const logoLoading = ref(false);
const useForm = Form.useForm;
const _selectedRowKeys = ref([]);
const photoValue = ref('/images/device-product.png');
const imageTypes = reactive([
'image/jpeg',
'image/png',
'image/jpg',
'image/jfif',
'image/pjp',
'image/pjpeg',
]);
const deviceList = ref([
{
label: '直连设备',
value: 'device',
},
{
label: '网关子设备',
value: 'childrenDevice',
},
{
label: '网关设备',
value: 'gateway',
},
]);
const modelRef = reactive({
id: '',
name: '',
classifiedId: '',
classifiedName: '',
deviceType: '',
describe: '',
photoUrl: '',
});
watch(
() => props.isAdd,
() => {
queryTree({ paging: false }).then((resp) => {
if (resp.status === 200) {
treeList.value = resp.result;
}
});
},
{ immediate: true, deep: true },
);
/**
* 显示弹窗
*/
const show = () => {
visible.value = true;
};
/**
* 关闭弹窗
*/
const close = () => {
visible.value = false;
};
/**
* 卡片点击事件
*/
const handleClick = (dt: any) => {
_selectedRowKeys.value = dt;
};
/**
* 文件上传之前
*/
const beforeUpload = (file: any) => {
const isType: any = imageTypes.includes(file.type);
if (!isType) {
message.error(`请上传.jpg.png.jfif.pjp.pjpeg.jpeg格式的图片`);
return false;
}
const isSize = file.size / 1024 / 1024 < 4;
if (!isSize) {
message.error(`图片大小必须小于${4}M`);
}
return isType && isSize;
};
/**
* 文件改变事件
*/
const handleChange = (info: any) => {
if (info.file.status === 'uploading') {
logoLoading.value = true;
}
if (info.file.status === 'done') {
info.file.url = info.file.response?.result;
logoLoading.value = false;
logoLoading.value = info.file.response?.result;
}
};
defineExpose({
show: show,
});
</script>
<style scoped lang="less">
.card-style {
position: relative;
top: 8px;
}
.upload-image-warp-logo {
display: flex;
justify-content: flex-start;
.upload-image-border-logo {
position: relative;
overflow: hidden;
border: 1px dashed #d9d9d9;
transition: all 0.3s;
width: 160px;
height: 150px;
&:hover {
border: 1px dashed #1890ff;
display: flex;
}
.upload-image-content-logo {
align-items: center;
justify-content: center;
position: relative;
display: flex;
flex-direction: column;
width: 160px;
height: 150px;
padding: 8px;
background-color: rgba(0, 0, 0, 0.06);
cursor: pointer;
.loading-logo {
position: absolute;
top: 50%;
}
.loading-icon {
position: absolute;
}
.upload-image {
width: 100%;
height: 100%;
background-repeat: no-repeat;
background-position: 50%;
background-size: cover;
}
.upload-image-icon {
width: 100%;
height: 100%;
background-repeat: no-repeat;
background-position: 50%;
background-size: inherit;
}
.upload-image-mask {
align-items: center;
justify-content: center;
position: absolute;
top: 0;
left: 0;
display: none;
width: 100%;
height: 100%;
color: #fff;
font-size: 16px;
background-color: rgba(0, 0, 0, 0.35);
}
&:hover .upload-image-mask {
display: flex;
}
}
}
}
</style>

View File

@ -0,0 +1,498 @@
<template>
<a-card class="device-product">
<Search :columns="query.columns" target="category" />
<JTable :columns="columns" :request="queryProductList" ref="tableRef">
<template #headerTitle>
<a-button type="primary" @click="add"
><plus-outlined />新增</a-button
>
</template>
<template #deviceType="slotProps">
<div>{{ slotProps.deviceType.text }}</div>
</template>
<template #card="slotProps">
<CardBox
:value="slotProps"
@click="handleClick"
:actions="getActions(slotProps, 'card')"
v-bind="slotProps"
:active="_selectedRowKeys.includes(slotProps.id)"
:status="slotProps.state"
:statusText="slotProps.state === 1 ? '正常' : '禁用'"
:statusNames="{
1: 'success',
0: 'error',
}"
>
<template #img>
<slot name="img">
<img :src="getImage('/device-product.png')" />
</slot>
</template>
<template #content>
<h3>{{ slotProps.name }}</h3>
<a-row>
<a-col :span="12">
<div class="card-item-content-text">
设备类型
</div>
<div>直连设备</div>
</a-col>
</a-row>
</template>
<template #actions="item">
<a-popconfirm
v-if="item.popConfirm"
v-bind="item.popConfirm"
>
<a-button :disabled="item.disabled">
<DeleteOutlined v-if="item.key === 'delete'" />
<template v-else>
<AIcon :type="item.icon" />
<span>{{ item.text }}</span>
</template>
</a-button>
</a-popconfirm>
<template v-else>
<a-button :disabled="item.disabled">
<DeleteOutlined v-if="item.key === 'delete'" />
<template v-else>
<AIcon :type="item.icon" />
<span>{{ item.text }}</span>
</template>
</a-button>
</template>
</template>
</CardBox>
</template>
<template #state="slotProps">
<a-badge
:text="slotProps.state === 1 ? '正常' : '禁用'"
:status="statusMap.get(slotProps.state)"
/>
</template>
<template #id="slotProps">
<a>{{ slotProps.id }}</a>
</template>
<template #action="slotProps">
<a-space :size="16">
<a-tooltip
v-for="i in getActions(slotProps)"
:key="i.key"
v-bind="i.tooltip"
>
<a-popconfirm v-if="i.popConfirm" v-bind="i.popConfirm">
<a-button
:disabled="i.disabled"
style="padding: 0"
type="link"
><AIcon :type="i.icon"
/></a-button>
</a-popconfirm>
<a-button
style="padding: 0"
type="link"
v-else
@click="i.onClick && i.onClick(slotProps)"
>
<a-button
:disabled="i.disabled"
style="padding: 0"
type="link"
><AIcon :type="i.icon"
/></a-button>
</a-button>
</a-tooltip>
</a-space>
</template>
</JTable>
<!-- 新增编辑 -->
<Save ref="saveRef" />
</a-card>
</template>
<script setup lang="ts">
import server from '@/utils/request';
import type { ActionsType } from '@/components/Table/index.vue';
import { getImage } from '@/utils/comm';
import {
EditOutlined,
DeleteOutlined,
PlusOutlined,
} from '@ant-design/icons-vue';
import { message } from 'ant-design-vue';
import {
getProviders,
category,
queryOrgThree,
queryGatewayList,
queryProductList,
_deploy,
_undeploy,
deleteProduct,
addProduct,
editProduct,
queryProductId,
} from '@/api/device/product';
import { isNoCommunity } from '@/utils/utils';
import { typeOptions } from '@/components/Search/util';
import Save from './Save/index.vue';
/**
* 表格数据
*/
const statusMap = new Map();
statusMap.set(1, 'success');
statusMap.set(0, 'error');
const columns = [
{
title: 'ID',
dataIndex: 'id',
key: 'id',
scopedSlots: true,
},
{
title: '名称',
dataIndex: 'name',
key: 'name',
},
{
title: '接入方式',
dataIndex: 'accessName',
key: 'accessName',
},
{
title: '设备类型',
dataIndex: 'deviceType',
key: 'deviceType',
scopedSlots: true,
},
{
title: '状态',
dataIndex: 'state',
key: 'state',
scopedSlots: true,
},
{
title: '说明',
dataIndex: 'describe',
key: 'describe',
},
{
title: '操作',
key: 'action',
fixed: 'right',
width: 250,
scopedSlots: true,
},
];
const _selectedRowKeys = ref<string[]>([]);
const onSelectChange = (keys: string[]) => {
_selectedRowKeys.value = [...keys];
};
const cancelSelect = () => {
_selectedRowKeys.value = [];
};
const handleClick = (dt: any) => {
if (_selectedRowKeys.value.includes(dt.id)) {
const _index = _selectedRowKeys.value.findIndex((i) => i === dt.id);
_selectedRowKeys.value.splice(_index, 1);
} else {
_selectedRowKeys.value = [..._selectedRowKeys.value, dt.id];
}
};
const getActions = (data: Partial<Record<string, any>>): ActionsType[] => {
if (!data) {
return [];
}
return [
{
key: 'view',
text: '查看',
tooltip: {
title: '查看',
},
icon: 'EyeOutlined',
},
{
key: 'edit',
text: '编辑',
tooltip: {
title: '编辑',
},
icon: 'EditOutlined',
},
{
key: 'download',
text: '导出',
tooltip: {
title: '导出',
},
icon: 'icon-xiazai',
},
{
key: 'action',
text: data.state !== 0 ? '禁用' : '启用',
tooltip: {
title: data.state !== 0 ? '禁用' : '启用',
},
icon: data.state !== 0 ? 'StopOutlined' : 'CheckCircleOutlined',
popConfirm: {
title: `确认${data.state !== 0 ? '禁用' : '启用'}?`,
onConfirm: async () => {
let response = undefined;
if (data.state !== 0) {
response = await _undeploy(data.id);
} else {
response = await _deploy(data.id);
}
if (response && response.status === 200) {
message.success('操作成功!');
tableRef.value?.reload();
} else {
message.error('操作失败!');
}
},
},
},
{
key: 'delete',
text: '删除',
disabled: data.state !== 0,
tooltip: {
title: data.state !== 0 ? '已启用的设备不能删除' : '删除',
},
popConfirm: {
title: '确认删除?',
onConfirm: async () => {
const resp = await deleteProduct(data.id);
if (resp.status === 200) {
message.success('操作成功!');
tableRef.value?.reload();
} else {
message.error('操作失败!');
}
},
},
icon: 'DeleteOutlined',
},
];
};
const add = () => {
saveRef.value.show();
};
//
const listData = ref([]);
const typeList = ref([]);
const tableRef = ref<Record<string, any>>({});
const query = reactive({
columns: [
{
title: '名称',
dataIndex: 'name',
key: 'name',
search: {
first: true,
type: 'string',
},
},
{
title: 'ID',
dataIndex: 'id',
key: 'id',
search: {
type: 'string',
},
},
{
title: '网关类型',
key: 'accessProvider',
dataIndex: 'accessProvider',
search: {
type: 'select',
options: async () => {
return new Promise((res) => {
getProviders().then((resp: any) => {
listData.value = [];
// const list = () => {
if (isNoCommunity) {
listData.value = (resp?.result || []).map(
(item: any) => ({
label: item.name,
value: item.id,
}),
);
} else {
listData.value = (resp?.result || [])
.filter((i: any) =>
[
'mqtt-server-gateway',
'http-server-gateway',
'mqtt-client-gateway',
'tcp-server-gateway',
].includes(i.id),
)
.map((item: any) => ({
label: item.name,
value: item.id,
}));
// }
}
res(listData.value);
});
});
},
},
},
{
title: '接入方式',
key: 'accessName',
dataIndex: 'accessName',
search: {
type: 'select',
options: async () => {
return new Promise((res) => {
queryGatewayList({
paging: false,
}).then((resp: any) => {
typeList.value = [];
typeList.value = resp.result.map((item: any) => ({
label: item.name,
value: item.name,
}));
res(typeList.value);
});
});
},
},
},
{
title: '设备类型',
key: 'deviceType',
dataIndex: 'deviceType',
search: {
type: 'select',
options: [
{
label: '直连设备',
value: 'device',
},
{
label: '网关子设备',
value: 'childrenDevice',
},
{
label: '网关设备',
value: 'gateway',
},
],
},
},
{
title: '状态',
key: 'state',
dataIndex: 'state',
search: {
type: 'select',
options: [
{
label: '正常',
value: 1,
},
{
label: '禁用',
value: 0,
},
],
},
},
{
title: '说明',
key: 'describe',
dataIndex: 'describe',
search: {
type: 'string',
},
},
{
title: '分类',
key: 'classified',
dataIndex: 'classifiedId',
search: {
type: 'treeSelect',
options: async () => {
return new Promise((res) => {
category({
paging: false,
}).then((resp) => {
res(resp.result);
});
});
},
},
},
{
title: '所属部门',
key: 'id$dim-assets',
dataIndex: 'id$dim-assets',
search: {
first: true,
type: 'treeSelect',
options: async () => {
return new Promise((res) => {
queryOrgThree({ paging: false }).then((resp: any) => {
const formatValue = (list: any[]) => {
const _list: any[] = [];
list.forEach((item) => {
if (item.children) {
item.children = formatValue(
item.children,
);
}
_list.push({
...item,
value: JSON.stringify({
assetType: 'product',
targets: [
{
type: 'org',
id: item.id,
},
],
}),
});
});
return _list;
};
res(formatValue(resp.result));
});
});
},
},
},
{
title: '操作',
key: 'action',
fixed: 'right',
width: 250,
scopedSlots: true,
},
],
});
const saveRef = ref();
</script>
<style lang="less" scoped>
.box {
padding: 20px;
background: #f0f2f5;
}
</style>

View File

@ -177,3 +177,15 @@ type ObserverMetadata = {
subscribe: (data: any) => void; subscribe: (data: any) => void;
next: (data: any) => void; next: (data: any) => void;
}; };
// 部门
export type DepartmentItem = {
id: string;
name: string;
path: string;
sortIndex: number;
level: number;
code: string;
parentId: string;
children: DepartmentItem[];
};

View File

@ -114,6 +114,6 @@ const form = reactive({
model: {} model: {}
}) })
</script> </script>
<style lang="scss" scoped> <style lang="less" scoped>
</style> </style>

View File

@ -96,5 +96,5 @@ const operateLimits = (action: 'add' | 'updata', types: MetadataType) => {
); );
}; };
</script> </script>
<style scoped lang="scss"> <style scoped lang="less">
</style> </style>

View File

@ -140,7 +140,7 @@ watchEffect(() => {
} }
}) })
</script> </script>
<style scoped lang="scss"> <style scoped lang="less">
.cat-content { .cat-content {
background: #F6F6F6; background: #F6F6F6;

View File

@ -8,8 +8,8 @@
</p> </p>
</div> </div>
<a-form layout="vertical" v-model="formModel"> <a-form layout="vertical" v-model="formModel">
<a-form-item label="导入方式" v-bind="validateInfos.type"> <a-form-item v-if="type === 'product'" label="导入方式" v-bind="validateInfos.type">
<a-select v-if="type === 'product'" v-model:value="formModel.type"> <a-select v-model:value="formModel.type">
<a-select-option value="copy">拷贝产品</a-select-option> <a-select-option value="copy">拷贝产品</a-select-option>
<a-select-option value="import">导入物模型</a-select-option> <a-select-option value="import">导入物模型</a-select-option>
</a-select> </a-select>
@ -32,11 +32,19 @@
</a-select> </a-select>
</a-form-item> </a-form-item>
<a-form-item label="文件上传" v-bind="validateInfos.upload" v-if="formModel.metadataType === 'file'"> <a-form-item label="文件上传" v-bind="validateInfos.upload" v-if="formModel.metadataType === 'file'">
<a-upload v-model:file-list="formModel.upload" name="files" :before-upload="beforeUpload" accept=".json" <a-input v-model:value="formModel.upload">
:show-upload-list="false"></a-upload> <template #addonAfter>
<label for="uploadFile"><upload-outlined/></label>
</template>
</a-input>
<a-upload v-model:file-list="fileList" name="files" :before-upload="beforeUpload" accept=".json"
:show-upload-list="false" :action="FILE_UPLOAD" @change="fileChange" :headers="{ 'X-Access-Token': token }">
<button id="uploadFile" style="display: none;"></button>
</a-upload>
</a-form-item> </a-form-item>
<a-form-item label="物模型" v-bind="validateInfos.import" v-if="formModel.metadataType === 'script'"> <a-form-item label="物模型" v-bind="validateInfos.import" v-if="formModel.metadataType === 'script'">
<!-- TODO代码编辑器 --> <!-- TODO代码编辑器 -->
<a-textarea v-model:value="formModel.import"></a-textarea>
</a-form-item> </a-form-item>
</a-form> </a-form>
</a-modal> </a-modal>
@ -46,13 +54,17 @@ import { useForm } from 'ant-design-vue/es/form';
import { saveMetadata } from '@/api/device/instance' import { saveMetadata } from '@/api/device/instance'
import { queryNoPagingPost, convertMetadata, modify } from '@/api/device/product' import { queryNoPagingPost, convertMetadata, modify } from '@/api/device/product'
import type { DefaultOptionType } from 'ant-design-vue/es/select'; import type { DefaultOptionType } from 'ant-design-vue/es/select';
import { UploadProps } from 'ant-design-vue/es'; import type { UploadProps, UploadFile, UploadChangeParam } from 'ant-design-vue/es';
import type { DeviceMetadata, ProductItem } from '@/views/device/Product/typings' import type { DeviceMetadata, ProductItem } from '@/views/device/Product/typings'
import { message } from 'ant-design-vue/es'; import { message } from 'ant-design-vue/es';
import { Store } from 'jetlinks-store'; import { Store } from 'jetlinks-store';
import { SystemConst } from '@/utils/consts'; import { SystemConst } from '@/utils/consts';
import { useInstanceStore } from '@/store/instance' import { useInstanceStore } from '@/store/instance'
import { useProductStore } from '@/store/product'; import { useProductStore } from '@/store/product';
import { UploadOutlined } from '@ant-design/icons-vue';
import { FILE_UPLOAD } from '@/api/comm';
import { LocalStore } from '@/utils/comm';
import { TOKEN_KEY } from '@/utils/variable';
const route = useRoute() const route = useRoute()
const instanceStore = useInstanceStore() const instanceStore = useInstanceStore()
@ -79,6 +91,7 @@ const _visible = computed({
}) })
const close = () => { const close = () => {
console.log(1)
emits('update:visible', false); emits('update:visible', false);
} }
@ -132,6 +145,8 @@ const onSubmit = () => {
}) })
} }
const fileList = ref<UploadFile[]>([])
const token = ref(LocalStore.get(TOKEN_KEY));
const productList = ref<DefaultOptionType[]>([]) const productList = ref<DefaultOptionType[]>([])
@ -157,6 +172,11 @@ const beforeUpload: UploadProps['beforeUpload'] = file => {
formModel.import = json.target?.result; formModel.import = json.target?.result;
}; };
} }
const fileChange = (info: UploadChangeParam) => {
if (info.file.status === 'done') {
console.log(info)
}
}
const operateLimits = (mdata: DeviceMetadata) => { const operateLimits = (mdata: DeviceMetadata) => {
const obj: DeviceMetadata = { ...mdata }; const obj: DeviceMetadata = { ...mdata };
@ -257,7 +277,7 @@ const handleImport = async () => {
// const showProduct = computed(() => formModel.type === 'copy') // const showProduct = computed(() => formModel.type === 'copy')
</script> </script>
<style scoped lang="scss"> <style scoped lang="less">
.import-content { .import-content {
background: rgb(236, 237, 238); background: rgb(236, 237, 238);

View File

@ -1,6 +1,6 @@
<template> <template>
<div class='device-detail-metadata' style="position: relative;"> <div class='device-detail-metadata' style="position: relative;">
<div class="tips" style="width: 40%"> <div class="tips">
<a-tooltip :title="instanceStore.detail?.independentMetadata && type === 'device' <a-tooltip :title="instanceStore.detail?.independentMetadata && type === 'device'
? '该设备已脱离产品物模型,修改产品物模型对该设备无影响' ? '该设备已脱离产品物模型,修改产品物模型对该设备无影响'
: '设备会默认继承产品的物模型,修改设备物模型后将脱离产品物模型'"> : '设备会默认继承产品的物模型,修改设备物模型后将脱离产品物模型'">
@ -14,16 +14,20 @@
</div> </div>
</a-tooltip> </a-tooltip>
</div> </div>
<a-tabs class="metadataNav" destroyInactiveTabPane> <a-tabs class="metadataNav" destroyInactiveTabPane type="card">
<template #rightExtra> <template #rightExtra>
<a-space> <a-space>
<PermissionButton v-if="type === 'device'" :hasPermission="`${permission}:update`" <PermissionButton v-if="type === 'device' && instanceStore.detail?.independentMetadata"
:popConfirm="{ title: '确认重置?', onConfirm: resetMetadata, }" :tooltip="{ title: '重置后将使用产品的物模型配置' }" :hasPermission="`${permission}:update`" :popConfirm="{ title: '确认重置?', onConfirm: resetMetadata, }"
key="reload"> :tooltip="{ title: '重置后将使用产品的物模型配置' }" key="reload">
重置操作 重置操作
</PermissionButton> </PermissionButton>
<PermissionButton :isPermission="`${permission}:update`" @click="visible = true">快速导入</PermissionButton> <PermissionButton
<PermissionButton :isPermission="`${permission}:update`" @click="cat = true">物模型TSL</PermissionButton> :uhasPermission="`${permission}:update`"
@click="visible = true">快速导入</PermissionButton>
<PermissionButton
:uhasPermission="`${permission}:update`"
@click="cat = true">物模型TSL</PermissionButton>
</a-space> </a-space>
</template> </template>
@ -40,11 +44,13 @@
<BaseMetadata target={props.type} type="tags" :permission="permission" /> <BaseMetadata target={props.type} type="tags" :permission="permission" />
</a-tab-pane> </a-tab-pane>
</a-tabs> </a-tabs>
<Import :visible="visible" :type="type" @close="visible = false" /> {{ visible }}
<Cat :visible="cat" @close="cat = false" :type="type" /> <Import v-model:visible="visible" :type="type" @close="visible = false" />
<Cat v-model:visible="cat" @close="cat = false" :type="type" />
</div> </div>
</template> </template>
<script setup lang="ts" name="Metadata"> <script setup lang="ts" name="Metadata">
import { InfoCircleOutlined } from '@ant-design/icons-vue';
import PermissionButton from '@/components/PermissionButton/index.vue' import PermissionButton from '@/components/PermissionButton/index.vue'
import { deleteMetadata } from '@/api/device/instance.js' import { deleteMetadata } from '@/api/device/instance.js'
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue'
@ -80,21 +86,20 @@ const resetMetadata = async () => {
} }
} }
</script> </script>
<style scoped lang="scss"> <style scoped lang="less">
.device-detail-metadata { .device-detail-metadata {
.tips { .tips {
width: calc(100% - 670px);
position: absolute; position: absolute;
top: 12px; top: 12px;
z-index: 1; z-index: 1;
margin-left: 330px; margin-left: 380px;
font-weight: 100; font-weight: 100;
} }
.metadataNav { .metadataNav {
:global { :deep(.ant-card-body) {
.ant-card-body { padding: 0;
padding: 0;
}
} }
} }
} }

View File

@ -22,7 +22,9 @@
}" }"
> >
<template #modifyTime="slotProps"> <template #modifyTime="slotProps">
<span>{{ dateFormat(slotProps.modifyTime) }}</span> <span>{{
moment(slotProps.modifyTime).format('HHHH-MM-DD HH:mm:ss')
}}</span>
</template> </template>
<template #state="slotProps"> <template #state="slotProps">
<StatusLabel <StatusLabel
@ -46,8 +48,8 @@ import StatusLabel from '../StatusLabel.vue';
import { ComponentInternalInstance } from 'vue'; import { ComponentInternalInstance } from 'vue';
import { getDeviceList_api } from '@/api/home'; import { getDeviceList_api } from '@/api/home';
import { dateFormat } from '@/utils/comm';
import { message } from 'ant-design-vue'; import { message } from 'ant-design-vue';
import moment from 'moment';
const { proxy } = getCurrentInstance() as ComponentInternalInstance; const { proxy } = getCurrentInstance() as ComponentInternalInstance;
const emits = defineEmits(['confirm']); const emits = defineEmits(['confirm']);

View File

@ -801,7 +801,7 @@ const rulesFrom = ref({
{ {
required: true, required: true,
message: '请输入系统名称', message: '请输入系统名称',
trigger: 'blur', trigger: 'change',
}, },
], ],
headerTheme: [ headerTheme: [

View File

@ -1,13 +1,441 @@
<template> <template>
<div class="page-container"> <div>
<a-modal
v-model:visible="_vis"
title="同步用户"
:footer="null"
@cancel="_vis = false"
width="80%"
>
<a-row :gutter="10">
<a-col :span="4">
<a-input
v-model:value="deptName"
@keyup.enter="getDepartment"
allowClear
placeholder="请输入部门名称"
style="margin-bottom: 8px"
>
<template #addonAfter>
<AIcon
type="SearchOutlined"
style="cursor: pointer"
@click="getDepartment"
/>
</template>
</a-input>
<a-tree
:tree-data="deptTreeData"
:fieldNames="{ title: 'name', key: 'id' }"
:selectedKeys="[deptId]"
@select="onTreeSelect"
>
</a-tree>
<a-empty v-if="!deptTreeData.length" />
</a-col>
<a-col :span="20">
<JTable
ref="tableRef"
:columns="columns"
:dataSource="dataSource"
:loading="tableLoading"
model="table"
>
<template #headerTitle>
<a-button type="primary" @click="handleAutoBind">
自动绑定
</a-button>
</template>
<template #status="slotProps">
<a-space>
<a-badge
:status="slotProps.status.value"
:text="slotProps.status.text"
></a-badge>
</a-space>
</template>
<template #action="slotProps">
<a-space :size="16">
<a-tooltip
v-for="i in getActions(slotProps, 'table')"
:key="i.key"
v-bind="i.tooltip"
>
<a-popconfirm
v-if="i.popConfirm"
v-bind="i.popConfirm"
:disabled="i.disabled"
>
<a-button
:disabled="i.disabled"
style="padding: 0"
type="link"
><AIcon :type="i.icon"
/></a-button>
</a-popconfirm>
<a-button
style="padding: 0"
type="link"
v-else
@click="
i.onClick && i.onClick(slotProps)
"
>
<a-button
:disabled="i.disabled"
style="padding: 0"
type="link"
><AIcon :type="i.icon"
/></a-button>
</a-button>
</a-tooltip>
</a-space>
</template>
</JTable>
</a-col>
</a-row>
</a-modal>
<!-- 绑定用户 -->
<a-modal
v-model:visible="bindVis"
title="绑定用户"
:maskClosable="false"
:confirm-loading="confirmLoading"
@cancel="handleCancel"
@ok="handleBindSubmit"
>
<a-form layout="vertical">
<a-form-item label="用户" v-bind="validateInfos.userId">
<a-select
v-model:value="formData.userId"
:options="allUserList"
allowClear
show-search
option-filter-prop="children"
:filter-option="filterOption"
placeholder="请选择用户"
/>
</a-form-item>
</a-form>
</a-modal>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts" name="SyncUser">
import configApi from '@/api/notice/config';
import { PropType } from 'vue';
import moment from 'moment';
import { Modal, message } from 'ant-design-vue';
import type { ActionsType } from '@/components/Table/index.vue';
import { Form } from 'ant-design-vue';
const useForm = Form.useForm;
type Emits = {
(e: 'update:visible', data: boolean): void;
};
const emit = defineEmits<Emits>();
const props = defineProps({
visible: { type: Boolean, default: false },
data: {
type: Object as PropType<Partial<Record<string, any>>>,
default: () => ({}),
},
});
const _vis = computed({
get: () => props.visible,
set: (val) => emit('update:visible', val),
});
watch(
() => _vis.value,
(val) => {
if (val) {
getDepartment();
}
},
);
//
const deptTreeData = ref([]);
const deptName = ref('');
const deptId = ref('');
/**
* 获取部门
*/
const getDepartment = async () => {
let res = null;
if (props.data.type === 'dingTalk') {
res = await configApi.dingTalkDept(props.data.id);
} else if (props.data.type === 'weixin') {
res = await configApi.weChatDept(props.data.id);
}
let _result = res?.result;
if (deptName.value) {
_result = res?.result?.filter(
(f: any) => f.name.indexOf(deptName.value) > -1,
);
}
// deptTreeData.value = arrayToTree(_result, _result[0]?.parentId);
deptTreeData.value = _result;
deptId.value = _result[0]?.id;
};
/**
* 扁平数据转树形结构
*/
// const arrayToTree = (arr: any, pid: string | number) => {
// return arr
// .filter((item: any) => item.parentId === pid)
// .map((item: any) => ({
// ...item,
// children: arrayToTree(arr, item.id),
// }));
// };
/**
* 部门点击
*/
const onTreeSelect = (keys: any) => {
deptId.value = keys[0];
};
//
const columns = [
{
title: '钉钉用户名',
dataIndex: 'thirdPartyUserName',
key: 'thirdPartyUserName',
},
{
title: '用户',
dataIndex: 'userName',
key: 'userName',
scopedSlots: true,
},
{
title: '绑定状态',
dataIndex: 'status',
key: 'status',
scopedSlots: true,
},
{
title: '操作',
key: 'action',
scopedSlots: true,
},
];
const getActions = (
data: Partial<Record<string, any>>,
type: 'card' | 'table',
): ActionsType[] => {
if (!data) return [];
const actions = [
{
key: 'bind',
text: '绑定',
tooltip: {
title: '绑定',
},
icon: 'EditOutlined',
onClick: () => {
handleBind(data);
},
},
{
key: 'unbind',
text: '解绑',
icon: 'DisconnectOutlined',
popConfirm: {
title: '确认解绑?',
onConfirm: async () => {
configApi
.unBindUser({ bindingId: data.bindId }, data.bindId)
.then(() => {
message.success('操作成功');
getTableData();
});
},
},
},
];
if (data.status.value === 'success') return actions;
return actions.filter((i: ActionsType) => i.key !== 'unbind');
};
/**
* 自动绑定
*/
const handleAutoBind = () => {
configApi.dingTalkBindUser([], props.data.id).then(() => {
message.success('操作成功');
getTableData();
});
};
/**
* 获取钉钉部门用户
*/
const getDeptUsers = async () => {
let res = null;
if (props.data.type === 'dingTalk') {
res = await configApi.getDingTalkUsers(props.data.id, deptId.value);
} else if (props.data.type === 'weixin') {
res = await configApi.getWeChatUsers(props.data.id, deptId.value);
}
return res?.result;
};
/**
* 获取已经绑定的用户
*/
const getBindUsers = async () => {
let res = null;
if (props.data.type === 'dingTalk') {
res = await configApi.getDingTalkBindUsers(props.data.id);
} else if (props.data.type === 'weixin') {
res = await configApi.getWeChatBindUsers(props.data.id);
}
return res?.result;
};
/**
* 获取所有用户
*/
const allUserList = ref([]);
const getAllUsers = async () => {
const { result } = await configApi.getPlatformUsers();
allUserList.value = result.map((m: any) => ({
label: m.name,
value: m.id,
}));
return result;
};
/**
* 处理列表数据
*/
const dataSource = ref<any>([]);
const tableLoading = ref(false);
const getTableData = () => {
tableLoading.value = true;
Promise.all<any>([getDeptUsers(), getBindUsers(), getAllUsers()]).then(
(res) => {
dataSource.value = [];
const [deptUsers, bindUsers, allUsers] = res;
(deptUsers || []).forEach((item: any) => {
//
const bindUser = bindUsers.find(
(f: any) => f.thirdPartyUserId === item.id,
);
//
const allUser = allUsers.find(
(f: any) => f.id === bindUser?.userId,
);
dataSource.value.push({
thirdPartyUserId: item.id,
thirdPartyUserName: item.name,
userId: bindUser?.userId,
userName: allUser
? `${allUser.name}(${allUser.username})`
: '',
status: {
text: bindUser?.providerName ? '已绑定' : '未绑定',
value: bindUser?.providerName ? 'success' : 'error',
},
bindId: bindUser?.id,
});
});
console.log('dataSource.value: ', dataSource.value);
},
);
tableLoading.value = false;
};
watch(
() => deptId.value,
() => {
getTableData();
},
{ immediate: true },
);
/**
* 绑定用户
*/
const bindVis = ref(false);
const confirmLoading = ref(false);
const formData = ref({ userId: '' });
const formRules = ref({
userId: [{ required: true, message: '请选择用户', trigger: 'change' }],
});
const { resetFields, validate, validateInfos, clearValidate } = useForm(
formData.value,
formRules.value,
);
const handleBind = (row: any) => {
bindVis.value = true;
formData.value = row;
getAllUsers();
};
/**
* 绑定用户, 用户下拉筛选
*/
const filterOption = (input: string, option: any) => {
return (
option.componentOptions.children[0].text
.toLowerCase()
.indexOf(input.toLowerCase()) >= 0
);
};
/**
* 绑定提交
*/
const handleBindSubmit = () => {
validate().then(async () => {
const params = {
// providerName: formData.value.thirdPartyUserName,
// thirdPartyUserId: formData.value.thirdPartyUserId,
userId: formData.value.userId,
};
confirmLoading.value = true;
if (props.data.type === 'dingTalk') {
configApi
.dingTalkBindUser([params], props.data.id)
.then(() => {
message.success('操作成功');
bindVis.value = false;
getTableData();
})
.finally(() => {
confirmLoading.value = false;
});
} else if (props.data.type === 'weixin') {
configApi
.weChatBindUser([params], props.data.id)
.then(() => {
message.success('操作成功');
bindVis.value = false;
getTableData();
})
.finally(() => {
confirmLoading.value = false;
});
}
});
};
const handleCancel = () => {
bindVis.value = false;
resetFields()
};
</script> </script>
<style lang="less" scoped> <style lang="less" scoped></style>
</style>

View File

@ -1,162 +1,153 @@
<template> <template>
<div class="page-container"> <div class="page-container">
<a-card style="margin-bottom: 20px"> <Search
<Search :columns="columns"
:columns="columns" target="notice-config"
target="notice-config" @search="handleSearch"
@search="handleSearch" />
/> <JTable
</a-card> ref="configRef"
<a-card> :columns="columns"
<JTable :request="ConfigApi.list"
ref="configRef" :defaultParams="{
:columns="columns" sorts: [{ name: 'createTime', order: 'desc' }],
:request="ConfigApi.list" }"
:defaultParams="{ :params="params"
sorts: [{ name: 'createTime', order: 'desc' }], >
}" <template #headerTitle>
:params="params" <a-space>
> <a-button type="primary" @click="handleAdd">
<template #headerTitle> 新增
<a-space> </a-button>
<a-button type="primary" @click="handleAdd"> <a-upload
新增 name="file"
</a-button> accept="json"
<a-upload :showUploadList="false"
name="file" :before-upload="beforeUpload"
accept="json"
:showUploadList="false"
:before-upload="beforeUpload"
>
<a-button>导入</a-button>
</a-upload>
<a-popconfirm
title="确认导出当前页数据?"
ok-text="确定"
cancel-text="取消"
@confirm="handleExport"
>
<a-button>导出</a-button>
</a-popconfirm>
</a-space>
</template>
<template #card="slotProps">
<CardBox
:showStatus="false"
:value="slotProps"
:actions="getActions(slotProps, 'card')"
v-bind="slotProps"
> >
<template #img> <a-button>导入</a-button>
<slot name="img"> </a-upload>
<img <a-popconfirm
:src=" title="确认导出当前页数据?"
getLogo( ok-text="确定"
slotProps.type, cancel-text="取消"
slotProps.provider, @confirm="handleExport"
) >
" <a-button>导出</a-button>
/> </a-popconfirm>
</slot> </a-space>
</template> </template>
<template #content> <template #card="slotProps">
<h3 class="card-item-content-title"> <CardBox
{{ slotProps.name }} :showStatus="false"
</h3> :value="slotProps"
<a-row> :actions="getActions(slotProps, 'card')"
<a-col :span="12"> v-bind="slotProps"
<div class="card-item-content-text"> >
通知方式 <template #img>
</div> <slot name="img">
<div> <img
{{ getMethodTxt(slotProps.type) }} :src="
</div> getLogo(slotProps.type, slotProps.provider)
</a-col> "
<a-col :span="12"> />
<div class="card-item-content-text"> </slot>
说明 </template>
</div> <template #content>
<div>{{ slotProps.description }}</div> <h3 class="card-item-content-title">
</a-col> {{ slotProps.name }}
</a-row> </h3>
</template> <a-row>
<template #actions="item"> <a-col :span="12">
<a-tooltip <div class="card-item-content-text">
v-bind="item.tooltip" 通知方式
:title="item.disabled && item.tooltip.title" </div>
> <div>
<a-popconfirm {{ getMethodTxt(slotProps.type) }}
v-if="item.popConfirm" </div>
v-bind="item.popConfirm" </a-col>
:disabled="item.disabled" <a-col :span="12">
> <div class="card-item-content-text">说明</div>
<a-button :disabled="item.disabled"> <div>{{ slotProps.description }}</div>
<AIcon </a-col>
type="DeleteOutlined" </a-row>
v-if="item.key === 'delete'" </template>
/> <template #actions="item">
<template v-else>
<AIcon :type="item.icon" />
<span>{{ item.text }}</span>
</template>
</a-button>
</a-popconfirm>
<template v-else>
<a-button
:disabled="item.disabled"
@click="item.onClick"
>
<AIcon
type="DeleteOutlined"
v-if="item.key === 'delete'"
/>
<template v-else>
<AIcon :type="item.icon" />
<span>{{ item.text }}</span>
</template>
</a-button>
</template>
</a-tooltip>
</template>
</CardBox>
</template>
<template #action="slotProps">
<a-space :size="16">
<a-tooltip <a-tooltip
v-for="i in getActions(slotProps, 'table')" v-bind="item.tooltip"
:key="i.key" :title="item.disabled && item.tooltip.title"
v-bind="i.tooltip"
> >
<a-popconfirm <a-popconfirm
v-if="i.popConfirm" v-if="item.popConfirm"
v-bind="i.popConfirm" v-bind="item.popConfirm"
:disabled="i.disabled" :disabled="item.disabled"
> >
<a-button <a-button :disabled="item.disabled">
:disabled="i.disabled" <AIcon
style="padding: 0" type="DeleteOutlined"
type="link" v-if="item.key === 'delete'"
><AIcon :type="i.icon" />
/></a-button> <template v-else>
<AIcon :type="item.icon" />
<span>{{ item.text }}</span>
</template>
</a-button>
</a-popconfirm> </a-popconfirm>
<template v-else>
<a-button
:disabled="item.disabled"
@click="item.onClick"
>
<AIcon
type="DeleteOutlined"
v-if="item.key === 'delete'"
/>
<template v-else>
<AIcon :type="item.icon" />
<span>{{ item.text }}</span>
</template>
</a-button>
</template>
</a-tooltip>
</template>
</CardBox>
</template>
<template #action="slotProps">
<a-space :size="16">
<a-tooltip
v-for="i in getActions(slotProps, 'table')"
:key="i.key"
v-bind="i.tooltip"
>
<a-popconfirm
v-if="i.popConfirm"
v-bind="i.popConfirm"
:disabled="i.disabled"
>
<a-button <a-button
:disabled="i.disabled"
style="padding: 0" style="padding: 0"
type="link" type="link"
v-else ><AIcon :type="i.icon"
@click="i.onClick && i.onClick(slotProps)" /></a-button>
> </a-popconfirm>
<a-button <a-button
:disabled="i.disabled" style="padding: 0"
style="padding: 0" type="link"
type="link" v-else
><AIcon :type="i.icon" @click="i.onClick && i.onClick(slotProps)"
/></a-button> >
</a-button> <a-button
</a-tooltip> :disabled="i.disabled"
</a-space> style="padding: 0"
</template> type="link"
</JTable> ><AIcon :type="i.icon"
</a-card> /></a-button>
</a-button>
</a-tooltip>
</a-space>
</template>
</JTable>
<Debug v-model:visible="debugVis" :data="currentConfig" /> <Debug v-model:visible="debugVis" :data="currentConfig" />
<Log v-model:visible="logVis" :data="currentConfig" /> <Log v-model:visible="logVis" :data="currentConfig" />
@ -371,6 +362,18 @@ const getActions = (
downloadObject(data, `通知配置`); downloadObject(data, `通知配置`);
}, },
}, },
{
key: 'sync',
text: '同步用户',
tooltip: {
title: '同步用户',
},
icon: 'TeamOutlined',
onClick: () => {
syncVis.value = true;
currentConfig.value = data;
},
},
{ {
key: 'delete', key: 'delete',
text: '删除', text: '删除',
@ -389,7 +392,9 @@ const getActions = (
icon: 'DeleteOutlined', icon: 'DeleteOutlined',
}, },
]; ];
return actions; if (data.provider === 'dingTalkMessage' || data.provider === 'corpMessage')
return actions;
return actions.filter((i: ActionsType) => i.key !== 'sync');
}; };
</script> </script>
<style lang="less" scoped> <style lang="less" scoped>

View File

@ -9,14 +9,13 @@
:confirmLoading="btnLoading" :confirmLoading="btnLoading"
> >
<a-form layout="vertical"> <a-form layout="vertical">
<a-form-item label="通知模版" v-bind="validateInfos.templateId"> <a-form-item label="通知配置" v-bind="validateInfos.configId">
<a-select <a-select
v-model:value="formData.templateId" v-model:value="formData.configId"
placeholder="请选择通知模版" placeholder="请选择通知配置"
@change="getTemplateDetail"
> >
<a-select-option <a-select-option
v-for="(item, index) in templateList" v-for="(item, index) in configList"
:key="index" :key="index"
:value="item.id" :value="item.id"
> >
@ -63,10 +62,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { Form } from 'ant-design-vue'; import { Form } from 'ant-design-vue';
import { PropType } from 'vue'; import { PropType } from 'vue';
import ConfigApi from '@/api/notice/config'; import TemplateApi from '@/api/notice/template';
import { import {
TemplateFormData, TemplateFormData,
IVariableDefinitions, IVariableDefinitions,
BindConfig,
} from '@/views/notice/Template/types'; } from '@/views/notice/Template/types';
import { message } from 'ant-design-vue'; import { message } from 'ant-design-vue';
@ -93,22 +93,25 @@ const _vis = computed({
/** /**
* 获取通知模板 * 获取通知模板
*/ */
const templateList = ref<TemplateFormData[]>([]); const configList = ref<BindConfig[]>([]);
const getTemplateList = async () => { const getConfigList = async () => {
const params = { const params = {
terms: [ terms: [
{ column: 'type', value: props.data.type }, { column: 'type', value: props.data.type },
{ column: 'provider', value: props.data.provider }, { column: 'provider', value: props.data.provider },
], ],
}; };
const { result } = await ConfigApi.getTemplate(params, props.data.id); const { result } = await TemplateApi.getConfig(params);
templateList.value = result; configList.value = result;
}; };
watch( watch(
() => _vis.value, () => _vis.value,
(val) => { (val) => {
if (val) getTemplateList(); if (val) {
getConfigList();
getTemplateDetail();
}
}, },
); );
@ -117,9 +120,7 @@ watch(
*/ */
const templateDetailTable = ref<IVariableDefinitions[]>(); const templateDetailTable = ref<IVariableDefinitions[]>();
const getTemplateDetail = async () => { const getTemplateDetail = async () => {
const { result } = await ConfigApi.getTemplateDetail( const { result } = await TemplateApi.getTemplateDetail(props.data.id);
formData.value.templateId,
);
templateDetailTable.value = result.variableDefinitions.map((m: any) => ({ templateDetailTable.value = result.variableDefinitions.map((m: any) => ({
...m, ...m,
value: undefined, value: undefined,
@ -147,13 +148,13 @@ const columns = [
// //
const formData = ref({ const formData = ref({
templateId: '', configId: '',
variableDefinitions: '', variableDefinitions: '',
}); });
// //
const formRules = ref({ const formRules = ref({
templateId: [{ required: true, message: '请选择通知模板' }], configId: [{ required: true, message: '请选择通知模板' }],
variableDefinitions: [{ required: false, message: '该字段是必填字段' }], variableDefinitions: [{ required: false, message: '该字段是必填字段' }],
}); });
@ -175,7 +176,7 @@ const handleOk = () => {
}); });
// console.log('params: ', params); // console.log('params: ', params);
btnLoading.value = true; btnLoading.value = true;
ConfigApi.debug(params, props.data.id, formData.value.templateId) TemplateApi.debug(params, formData.value.configId, props.data.id)
.then((res) => { .then((res) => {
if (res.success) { if (res.success) {
message.success('操作成功'); message.success('操作成功');

View File

@ -10,7 +10,7 @@
<JTable <JTable
ref="instanceRef" ref="instanceRef"
:columns="columns" :columns="columns"
:request="(e:any) => configApi.getHistory(e, data.id)" :request="(e:any) => templateApi.getHistory(e, data.id)"
:defaultParams="{ :defaultParams="{
sorts: [{ name: 'notifyTime', order: 'desc' }], sorts: [{ name: 'notifyTime', order: 'desc' }],
terms: [{ column: 'notifyType$IN', value: data.type }], terms: [{ column: 'notifyType$IN', value: data.type }],
@ -47,7 +47,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import configApi from '@/api/notice/config'; import templateApi from '@/api/notice/template';
import { PropType } from 'vue'; import { PropType } from 'vue';
import moment from 'moment'; import moment from 'moment';
import { Modal } from 'ant-design-vue'; import { Modal } from 'ant-design-vue';

View File

@ -1,162 +1,153 @@
<template> <template>
<div class="page-container"> <div class="page-container">
<a-card style="margin-bottom: 20px"> <Search
<Search :columns="columns"
:columns="columns" target="notice-config"
target="notice-config" @search="handleSearch"
@search="handleSearch" />
/> <JTable
</a-card> ref="configRef"
<a-card> :columns="columns"
<JTable :request="TemplateApi.list"
ref="configRef" :defaultParams="{
:columns="columns" sorts: [{ name: 'createTime', order: 'desc' }],
:request="ConfigApi.list" }"
:defaultParams="{ :params="params"
sorts: [{ name: 'createTime', order: 'desc' }], >
}" <template #headerTitle>
:params="params" <a-space>
> <a-button type="primary" @click="handleAdd">
<template #headerTitle> 新增
<a-space> </a-button>
<a-button type="primary" @click="handleAdd"> <a-upload
新增 name="file"
</a-button> accept="json"
<a-upload :showUploadList="false"
name="file" :before-upload="beforeUpload"
accept="json"
:showUploadList="false"
:before-upload="beforeUpload"
>
<a-button>导入</a-button>
</a-upload>
<a-popconfirm
title="确认导出当前页数据?"
ok-text="确定"
cancel-text="取消"
@confirm="handleExport"
>
<a-button>导出</a-button>
</a-popconfirm>
</a-space>
</template>
<template #card="slotProps">
<CardBox
:showStatus="false"
:value="slotProps"
:actions="getActions(slotProps, 'card')"
v-bind="slotProps"
> >
<template #img> <a-button>导入</a-button>
<slot name="img"> </a-upload>
<img <a-popconfirm
:src=" title="确认导出当前页数据?"
getLogo( ok-text="确定"
slotProps.type, cancel-text="取消"
slotProps.provider, @confirm="handleExport"
) >
" <a-button>导出</a-button>
/> </a-popconfirm>
</slot> </a-space>
</template> </template>
<template #content> <template #card="slotProps">
<h3 class="card-item-content-title"> <CardBox
{{ slotProps.name }} :showStatus="false"
</h3> :value="slotProps"
<a-row> :actions="getActions(slotProps, 'card')"
<a-col :span="12"> v-bind="slotProps"
<div class="card-item-content-text"> >
通知方式 <template #img>
</div> <slot name="img">
<div> <img
{{ getMethodTxt(slotProps.type) }} :src="
</div> getLogo(slotProps.type, slotProps.provider)
</a-col> "
<a-col :span="12"> />
<div class="card-item-content-text"> </slot>
说明 </template>
</div> <template #content>
<div>{{ slotProps.description }}</div> <h3 class="card-item-content-title">
</a-col> {{ slotProps.name }}
</a-row> </h3>
</template> <a-row>
<template #actions="item"> <a-col :span="12">
<a-tooltip <div class="card-item-content-text">
v-bind="item.tooltip" 通知方式
:title="item.disabled && item.tooltip.title" </div>
> <div>
<a-popconfirm {{ getMethodTxt(slotProps.type) }}
v-if="item.popConfirm" </div>
v-bind="item.popConfirm" </a-col>
:disabled="item.disabled" <a-col :span="12">
> <div class="card-item-content-text">说明</div>
<a-button :disabled="item.disabled"> <div>{{ slotProps.description }}</div>
<AIcon </a-col>
type="DeleteOutlined" </a-row>
v-if="item.key === 'delete'" </template>
/> <template #actions="item">
<template v-else>
<AIcon :type="item.icon" />
<span>{{ item.text }}</span>
</template>
</a-button>
</a-popconfirm>
<template v-else>
<a-button
:disabled="item.disabled"
@click="item.onClick"
>
<AIcon
type="DeleteOutlined"
v-if="item.key === 'delete'"
/>
<template v-else>
<AIcon :type="item.icon" />
<span>{{ item.text }}</span>
</template>
</a-button>
</template>
</a-tooltip>
</template>
</CardBox>
</template>
<template #action="slotProps">
<a-space :size="16">
<a-tooltip <a-tooltip
v-for="i in getActions(slotProps, 'table')" v-bind="item.tooltip"
:key="i.key" :title="item.disabled && item.tooltip.title"
v-bind="i.tooltip"
> >
<a-popconfirm <a-popconfirm
v-if="i.popConfirm" v-if="item.popConfirm"
v-bind="i.popConfirm" v-bind="item.popConfirm"
:disabled="i.disabled" :disabled="item.disabled"
> >
<a-button <a-button :disabled="item.disabled">
:disabled="i.disabled" <AIcon
style="padding: 0" type="DeleteOutlined"
type="link" v-if="item.key === 'delete'"
><AIcon :type="i.icon" />
/></a-button> <template v-else>
<AIcon :type="item.icon" />
<span>{{ item.text }}</span>
</template>
</a-button>
</a-popconfirm> </a-popconfirm>
<template v-else>
<a-button
:disabled="item.disabled"
@click="item.onClick"
>
<AIcon
type="DeleteOutlined"
v-if="item.key === 'delete'"
/>
<template v-else>
<AIcon :type="item.icon" />
<span>{{ item.text }}</span>
</template>
</a-button>
</template>
</a-tooltip>
</template>
</CardBox>
</template>
<template #action="slotProps">
<a-space :size="16">
<a-tooltip
v-for="i in getActions(slotProps, 'table')"
:key="i.key"
v-bind="i.tooltip"
>
<a-popconfirm
v-if="i.popConfirm"
v-bind="i.popConfirm"
:disabled="i.disabled"
>
<a-button <a-button
:disabled="i.disabled"
style="padding: 0" style="padding: 0"
type="link" type="link"
v-else ><AIcon :type="i.icon"
@click="i.onClick && i.onClick(slotProps)" /></a-button>
> </a-popconfirm>
<a-button <a-button
:disabled="i.disabled" style="padding: 0"
style="padding: 0" type="link"
type="link" v-else
><AIcon :type="i.icon" @click="i.onClick && i.onClick(slotProps)"
/></a-button> >
</a-button> <a-button
</a-tooltip> :disabled="i.disabled"
</a-space> style="padding: 0"
</template> type="link"
</JTable> ><AIcon :type="i.icon"
</a-card> /></a-button>
</a-button>
</a-tooltip>
</a-space>
</template>
</JTable>
<Debug v-model:visible="debugVis" :data="currentConfig" /> <Debug v-model:visible="debugVis" :data="currentConfig" />
<Log v-model:visible="logVis" :data="currentConfig" /> <Log v-model:visible="logVis" :data="currentConfig" />
@ -164,7 +155,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import ConfigApi from '@/api/notice/config'; import TemplateApi from '@/api/notice/template';
import type { ActionsType } from '@/components/Table/index.vue'; import type { ActionsType } from '@/components/Table/index.vue';
import { getImage, LocalStore } from '@/utils/comm'; import { getImage, LocalStore } from '@/utils/comm';
import { message } from 'ant-design-vue'; import { message } from 'ant-design-vue';
@ -188,7 +179,7 @@ const params = ref<Record<string, any>>({});
const columns = [ const columns = [
{ {
title: '配置名称', title: '模板名称',
dataIndex: 'name', dataIndex: 'name',
key: 'name', key: 'name',
search: { search: {
@ -243,9 +234,9 @@ const columns = [
* @param params * @param params
*/ */
const handleSearch = (e: any) => { const handleSearch = (e: any) => {
console.log('handleSearch:', e); // console.log('handleSearch:', e);
params.value = e; params.value = e;
console.log('params.value: ', params.value); // console.log('params.value: ', params.value);
}; };
/** /**
@ -284,7 +275,7 @@ const beforeUpload = (file: any) => {
} }
try { try {
const data = JSON.parse(text || '{}'); const data = JSON.parse(text || '{}');
const { success } = await ConfigApi.update(data); const { success } = await TemplateApi.update(data);
if (success) { if (success) {
message.success('操作成功'); message.success('操作成功');
configRef.value.reload(); configRef.value.reload();
@ -347,6 +338,17 @@ const getActions = (
currentConfig.value = data; currentConfig.value = data;
}, },
}, },
{
key: 'debug',
text: '导出',
tooltip: {
title: '导出',
},
icon: 'ArrowDownOutlined',
onClick: () => {
downloadObject(data, `通知配置`);
},
},
{ {
key: 'debug', key: 'debug',
text: '通知记录', text: '通知记录',
@ -359,24 +361,13 @@ const getActions = (
currentConfig.value = data; currentConfig.value = data;
}, },
}, },
{
key: 'debug',
text: '导出',
tooltip: {
title: '导出',
},
icon: 'ArrowDownOutlined',
onClick: () => {
downloadObject(data, `通知配置`);
},
},
{ {
key: 'delete', key: 'delete',
text: '删除', text: '删除',
popConfirm: { popConfirm: {
title: '确认删除?', title: '确认删除?',
onConfirm: async () => { onConfirm: async () => {
const resp = await ConfigApi.del(data.id); const resp = await TemplateApi.del(data.id);
if (resp.status === 200) { if (resp.status === 200) {
message.success('操作成功!'); message.success('操作成功!');
configRef.value?.reload(); configRef.value?.reload();

View File

@ -1,4 +1,5 @@
<template> <template>
<page-container>
<a-card class="basis-container"> <a-card class="basis-container">
<a-form <a-form
layout="vertical" layout="vertical"
@ -277,6 +278,7 @@
>保存</a-button >保存</a-button
> >
</a-card> </a-card>
</page-container>
</template> </template>
<script setup lang="ts" name="Basis"> <script setup lang="ts" name="Basis">
@ -289,6 +291,7 @@ import { LocalStore } from '@/utils/comm';
import { save_api, getDetails_api } from '@/api/system/basis'; import { save_api, getDetails_api } from '@/api/system/basis';
import { usePermissionStore } from '@/store/permission'; import { usePermissionStore } from '@/store/permission';
import PageContainer from 'components/Layout/components/PageContainer'
const action = ref<string>(`${BASE_API_PATH}/file/static`); const action = ref<string>(`${BASE_API_PATH}/file/static`);
const headers = ref({ [TOKEN_KEY]: LocalStore.get(TOKEN_KEY) }); const headers = ref({ [TOKEN_KEY]: LocalStore.get(TOKEN_KEY) });

View File

@ -0,0 +1,333 @@
<template>
<div class="basic-info-container">
<a-card>
<h3>基本信息</h3>
<a-form :model="form.data" class="basic-form">
<div class="row" style="display: flex">
<a-form-item
label="菜单图标"
name="icon"
:rules="[
{
required: true,
message: '请上传图标',
},
]"
style="flex: 0 0 186px"
>
<div class="icon-upload has-icon" v-if="form.data.icon">
<svg aria-hidden="true">
<use :xlinkHref="`#${form.data.icon}`" />
</svg>
<span class="mark">点击修改</span>
</div>
<div v-else class="icon-upload no-icon">
<span>
<plus-outlined style="font-size: 30px" />
<p>点击选择图标</p>
</span>
</div>
</a-form-item>
<a-row :gutter="24" style="flex: 1 1 auto">
<a-col :span="12">
<a-form-item
label="名称"
name="name"
:rules="[
{ required: true, message: '请输入名称' },
{ max: 64, message: '最多可输入64个字符' },
]"
>
<a-input v-model:value="form.data.name" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item
label="编码"
name="code"
:rules="[
{ required: true, message: '请输入编码' },
{ max: 64, message: '最多可输入64个字符' },
]"
>
<a-input v-model:value="form.data.code" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item
label="页面地址"
name="url"
:rules="[
{
required: true,
message: '请输入页面地址',
},
{ max: 128, message: '最多可输入128字符' },
]"
>
<a-input v-model:value="form.data.url" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item
label="排序"
name="sortIndex"
:rules="[
{
pattern: /^[0-9]*[1-9][0-9]*$/,
message: '请输入大于0的整数',
},
]"
>
<a-input v-model:value="form.data.sortIndex" />
</a-form-item>
</a-col>
</a-row>
</div>
<a-form-item label="说明" name="describe">
<a-textarea
v-model:value="form.data.describe"
:rows="4"
placeholder="请输入说明"
/>
</a-form-item>
</a-form>
</a-card>
<a-card>
<h3>权限配置</h3>
<a-form :model="form.data" class="basic-form permiss-form">
<a-form-item name="accessSupport" required>
<template #label>
<span style="margin-right: 3px">数据权限控制</span>
<a-tooltip title="此菜单页面数据所对应的资产类型">
<question-circle-outlined
class="img-style"
style="color: #a6a6a6"
/>
</a-tooltip>
</template>
<a-radio-group
v-model:value="form.data.accessSupport"
name="radioGroup"
>
<a-radio value="unsupported">不支持</a-radio>
<a-radio value="support">支持</a-radio>
<a-radio value="indirect">
<span style="margin-right: 3px">间接控制</span>
<a-tooltip
title="此菜单内的数据基于其他菜单的数据权限控制"
>
<question-circle-filled class="img-style" />
</a-tooltip>
</a-radio>
</a-radio-group>
<a-form-item
name="assetType"
v-if="form.data.accessSupport === 'support'"
:rules="[{ required: true, message: '请选择资产类型' }]"
style="margin-top: 24px; margin-bottom: 0"
>
<a-select
v-model:value="form.data.assetType"
style="width: 500px"
placeholder="请选择资产类型"
>
<a-select-option
v-for="item in form.assetsType"
:value="item.value"
>{{ item.label }}</a-select-option
>
</a-select>
</a-form-item>
<a-form-item
name="indirectMenus"
v-if="form.data.accessSupport === 'indirect'"
:rules="[{ required: true, message: '请选择关联菜单' }]"
style="margin-top: 24px; margin-bottom: 0"
>
<a-tree-select
v-model:value="form.data.indirectMenus"
style="width: 400px"
:dropdown-style="{
maxHeight: '400px',
overflow: 'auto',
}"
placeholder="请选择关联菜单"
multiple
show-search
tree-default-expand-all
:tree-data="form.treeData"
>
<template #title="{ value: val, title }">
<b
v-if="val === 'parent 1-1'"
style="color: #08c"
>{{ val }}</b
>
<template v-else>{{ title }}</template>
</template>
</a-tree-select>
</a-form-item>
</a-form-item>
<a-form-item label="权限">
<a-input
v-model:value="form.data.permissions"
style="width: 300px"
allowClear
placeholder="请输入权限名称"
/>
</a-form-item>
</a-form>
<a-button type="primary" @click="clickSave">保存</a-button>
</a-card>
</div>
</template>
<script setup lang="ts">
import {
PlusOutlined,
QuestionCircleFilled,
QuestionCircleOutlined,
} from '@ant-design/icons-vue';
import {
getMenuTree_api,
getAssetsType_api,
getMenuDetail_api,
} from '@/api/system/menu';
import { exportPermission_api } from '@/api/system/permission';
const route = useRoute();
const routeParams = {
id: route.params.id === ':id' ? '' : (route.params.id as string),
...route.query,
url: route.query.basePath,
};
const form = reactive({
data: {
name: '',
code: '',
sortIndex: '',
icon: '',
describe: '',
permissions: '',
accessSupport: '',
assetType: undefined,
indirectMenus: [],
...routeParams,
} as formType,
treeData: [], //
assetsType: [] as assetType[], //
premissonList: [], //
init: () => {
//
routeParams.id &&
getMenuDetail_api(routeParams.id).then((resp) => {
console.log('菜单详情', resp);
});
//
// exportPermission_api()
//
getMenuTree_api({ paging: false }).then((resp) => {
console.log('关联菜单', resp);
});
//
getAssetsType_api().then((resp:any) => {
form.assetsType = resp.result.map((item:any)=>({label:item.name,value:item.id}))
});
},
});
form.init();
const clickSave = () => {};
type formType = {
name: string;
code: string;
url: string;
sortIndex: string;
icon: string;
permissions: string;
describe: string;
accessSupport: string;
assetType: string | undefined;
indirectMenus: any[];
};
type assetType = {
label: string;
value: string;
};
</script>
<style lang="less" scoped>
.basic-info-container {
.ant-card {
margin-bottom: 24px;
h3 {
position: relative;
display: flex;
align-items: center;
margin-bottom: 20px;
padding: 4px 0 4px 12px;
font-weight: bold;
font-size: 16px;
&::before {
position: absolute;
top: 5px px;
left: 0;
width: 4px;
height: calc(100% - 10px);
background-color: #1d39c4;
border-radius: 2px;
content: ' ';
}
}
.basic-form {
.ant-form-item {
display: block;
:deep(.ant-form-item-label) {
overflow: inherit;
.img-style {
cursor: help;
}
label::after {
display: none;
}
}
:deep(.ant-form-item-control-input-content) {
.icon-upload {
width: 160px;
height: 150px;
border: 1px dashed #d9d9d9;
font-size: 14px;
display: flex;
justify-content: center;
align-items: center;
text-align: center;
cursor: pointer;
transition: 0.5s;
&:hover {
border-color: #415ed1;
}
}
.has-icon {
}
.no-icon {
background-color: rgba(0, 0, 0, 0.06);
}
}
}
}
}
}
</style>

View File

@ -0,0 +1,18 @@
<template>
<div class="button-mange-container">
</div>
</template>
<script setup lang="ts">
const route = useRoute();
const routeParams = {
id: route.params.id === ':id' ? '' : route.params.id,
...route.query,
};
</script>
<style scoped>
</style>

View File

@ -0,0 +1,25 @@
<template>
<div class="menu-detail-container">
<a-tabs v-model:activeKey="activeKey">
<a-tab-pane key="basic" tab="基本信息"> <BasicInfo /> </a-tab-pane>
<a-tab-pane key="button" tab="按钮管理">
<ButtonMange />
</a-tab-pane>
</a-tabs>
</div>
</template>
<script setup lang="ts">
import BasicInfo from './BasicInfo.vue';
import ButtonMange from './ButtonMange.vue';
const activeKey = ref('basic');
</script>
<style lang="less" scoped>
.menu-detail-container {
.ant-tabs-tabpane {
background-color: #f0f2f5;
padding: 24px;
}
}
</style>

0
src/views/system/Menu/index.d.ts vendored Normal file
View File

View File

@ -0,0 +1,247 @@
<template>
<div class="menu-container">
<Search :columns="query.columns" @search="query.search" />
<JTable
ref="tableRef"
:columns="table.columns"
:request="table.getList"
model="TABLE"
:params="query.params"
>
<template #headerTitle>
<a-button
type="primary"
@click="table.toDetails({})"
style="margin-right: 10px"
><plus-outlined />新增</a-button
>
<a-button>菜单实例</a-button>
</template>
<template #createTime="slotProps">
{{ slotProps.createTime }}
</template>
<template #action="slotProps">
<a-space :size="16">
<a-tooltip>
<template #title>查看</template>
<a-button
style="padding: 0"
type="link"
@click="table.toDetails(slotProps)"
>
<edit-outlined />
</a-button>
</a-tooltip>
<a-tooltip>
<template #title>新增子菜单</template>
<a-button
style="padding: 0"
type="link"
@click="table.toDetails(slotProps)"
>
<edit-outlined />
</a-button>
</a-tooltip>
<a-popconfirm
title="是否删除该菜单"
ok-text="确定"
cancel-text="取消"
@confirm="table.clickDel(slotProps)"
:disabled="slotProps.status"
>
<a-tooltip>
<template #title>删除</template>
<a-button style="padding: 0" type="link">
<delete-outlined />
</a-button>
</a-tooltip>
</a-popconfirm>
</a-space>
</template>
</JTable>
</div>
</template>
<script setup lang="ts">
import { getMenuTree_api } from '@/api/system/menu';
const router = useRouter();
//
const query = reactive({
columns: [
{
title: '编码',
dataIndex: 'code',
key: 'code',
ellipsis: true,
fixed: 'left',
search: {
type: 'string',
},
},
{
title: '名称',
dataIndex: 'name',
key: 'name',
ellipsis: true,
search: {
type: 'string',
},
},
{
title: '页面地址',
dataIndex: 'url',
key: 'url',
ellipsis: true,
search: {
type: 'string',
},
},
{
title: '排序',
dataIndex: 'sortIndex',
key: 'sortIndex',
ellipsis: true,
search: {
type: 'number',
},
},
{
title: '创建时间',
dataIndex: 'createTime',
key: 'createTime',
ellipsis: true,
search: {
type: 'date',
},
},
],
params: {
terms: [],
},
search: (params: any) => {
query.params = params;
},
});
const tableRef = ref<Record<string, any>>({}); //
const table = reactive({
columns: [
{
title: '编码',
dataIndex: 'code',
key: 'code',
width: 300,
},
{
title: '名称',
dataIndex: 'name',
key: 'name',
width: 220,
},
{
title: '页面地址',
dataIndex: 'url',
key: 'url',
},
{
title: '排序',
dataIndex: 'sortIndex',
key: 'sortIndex',
width: 80,
},
{
title: '说明',
dataIndex: 'describe',
key: 'describe',
width: 200,
},
{
title: '创建时间',
dataIndex: 'createTime',
key: 'createTime',
scopedSlots: true,
width: 180,
},
{
title: '操作',
dataIndex: 'action',
key: 'action',
scopedSlots: true,
width: 140,
},
],
tableData: [],
total: 0,
getList: async (_params: any) => {
//
const item = {
terms: [
{
terms: [
{
column: 'owner',
termType: 'eq',
value: 'iot',
},
{
column: 'owner',
termType: 'isnull',
value: '1',
type: 'or',
},
],
},
],
};
const params = {
..._params,
terms:
_params.terms && _params.length !== 0
? [...query.params.terms, item]
: [item],
sorts: [{ name: 'sortIndex', order: 'asc' }],
paging: false,
};
const resp: any = await getMenuTree_api(params);
const lastItem = resp.result[resp.result.length - 1];
table.total == lastItem ? lastItem.sortIndex + 1 : 1;
return {
code: resp.message,
result: {
data: resp.result,
pageIndex: 0,
pageSize: 0,
total: 0,
},
status: resp.status,
};
},
//
toDetails: (row: any) => {
router.push(
`/system/Menu/detail/${row.id || ':id'}?pid=${
row.pid || ''
}&basePath=${row.basePath || ''}&sortIndex=${table.total + 1}`,
);
},
//
clickDel: (row: any) => {
// delPermission_api(row.id).then((resp: any) => {
// if (resp.status === 200) {
// tableRef.value?.reload();
// message.success('!');
// }
// });
},
//
refresh: () => {
tableRef.value.reload();
},
});
</script>
<style lang="less" scoped></style>

View File

@ -22,7 +22,7 @@
"store/*": ["./src/store/*"], "store/*": ["./src/store/*"],
"style/*": ["./src/style/*"], "style/*": ["./src/style/*"],
}, },
"types": ["ant-design-vue/typings/global"], "types": ["ant-design-vue/typings/global", "vite/client"],
"suppressImplicitAnyIndexErrors": true "suppressImplicitAnyIndexErrors": true
}, },
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"], "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],

View File

@ -82,6 +82,7 @@ export default defineConfig(({ mode}) => {
// target: 'http://192.168.33.22:8800', // target: 'http://192.168.33.22:8800',
// target: 'http://192.168.32.244:8881', // target: 'http://192.168.32.244:8881',
// target: 'http://47.112.135.104:5096', // opcua // target: 'http://47.112.135.104:5096', // opcua
// target: 'http://120.77.179.54:8844', // 120测试
target: 'http://47.108.63.174:8845', // 测试 target: 'http://47.108.63.174:8845', // 测试
changeOrigin: true, changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '') rewrite: (path) => path.replace(/^\/api/, '')

7959
yarn.lock

File diff suppressed because it is too large Load Diff