diff --git a/config/config.ts b/config/config.ts index 108726d5..9a3c3b8a 100644 --- a/config/config.ts +++ b/config/config.ts @@ -2,6 +2,15 @@ export default { theme: { 'primary-color': '#1d39c4', }, - logo: '/favicon.ico', - title: 'Jetlinks' + logo: '/favicon.ico', // 浏览器标签页logo + title: 'Jetlinks', // 浏览器标签页title + layout: { + title: '物联网平台', // 平台title + logo: '/icons/icon-192x192.png', // 平台logo + siderWidth: 208, // 左侧菜单栏宽度 + headerHeight: 48, // 头部高度 + collapsedWidth: 48, + mode: 'inline', + theme: 'light', // 'dark' 'light' + } } \ No newline at end of file diff --git a/public/images/device/device-type-3.png b/public/images/device/device-type-3.png new file mode 100644 index 00000000..3561237e Binary files /dev/null and b/public/images/device/device-type-3.png differ diff --git a/src/App.vue b/src/App.vue index 1299fb94..02bc5677 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,9 +1,9 @@ - - + + diff --git a/src/api/comm.ts b/src/api/comm.ts index 71d6aa82..29cf4f11 100644 --- a/src/api/comm.ts +++ b/src/api/comm.ts @@ -23,3 +23,8 @@ export const getSearchHistory = (target:string) => server.get server.remove(`/user/settings/${target}/${id}`) + +/** + * 获取当前系统版本 + */ +export const systemVersion = () => server.get<{edition?: string}>('/system/version') \ No newline at end of file diff --git a/src/api/device/category.ts b/src/api/device/category.ts index d18e3587..53c93ec4 100644 --- a/src/api/device/category.ts +++ b/src/api/device/category.ts @@ -16,7 +16,7 @@ export const queryTree = (params?: Record) => server.post server.put(`/device/category/${id}`, data) + export const updateTree = (id:string,data: any,) => server.put(`/device/category/${id}`, data) /** * 根据Id删除数据 diff --git a/src/api/device/product.ts b/src/api/device/product.ts index 11c70020..6ae11030 100644 --- a/src/api/device/product.ts +++ b/src/api/device/product.ts @@ -1,5 +1,5 @@ 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(`/device-product/$ /** * 产品分类 - * @param data + * @param 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) => server.post('/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 产品信息 * @returns */ -export const saveProductMetadata = (data: Record) => server.patch('/device-product', data) \ No newline at end of file +export const saveProductMetadata = (data: Record) => server.patch('/device-product', data) diff --git a/src/api/login.ts b/src/api/login.ts index 33684901..6391c6b8 100644 --- a/src/api/login.ts +++ b/src/api/login.ts @@ -46,4 +46,9 @@ export const bindInfo = () => server.get(`/application/sso/_all`) * 查询配置信息 * @returns */ -export const settingDetail = (scopes: string) => server.get(`/system/config/${scopes}`) \ No newline at end of file +export const settingDetail = (scopes: string) => server.get(`/system/config/${scopes}`) + +/** + * 获取当前登录用户信息 + */ +export const userDetail = () => server.get('/user/detail') \ No newline at end of file diff --git a/src/api/notice/config.ts b/src/api/notice/config.ts index ccdebc7c..c75aa1f5 100644 --- a/src/api/notice/config.ts +++ b/src/api/notice/config.ts @@ -16,9 +16,9 @@ export default { 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), // 获取所有平台用户 - getPlatformUsers: () => post(`/user/_query/no-paging`, { paging: false }), + getPlatformUsers: () => post(`/user/_query/no-paging`, { paging: false }), // 钉钉部门 - dingTalkDept: (id: string) => get(`/notifier/dingtalk/corp/${id}/departments/tree`), + dingTalkDept: (id: string) => get(`/notifier/dingtalk/corp/${id}/departments/tree`), // 钉钉部门人员 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), // 微信部门 - weChatDept: (id: string) => get(`/notifier/wechat/corp/${id}/departments`), + weChatDept: (id: string) => get(`/notifier/wechat/corp/${id}/departments`), // 微信部门人员 getWeChatUsers: (configId: string, deptId: string) => get(`/notifier/wechat/corp/${configId}/${deptId}/users`), // 微信已经绑定的人员 diff --git a/src/api/system/menu.ts b/src/api/system/menu.ts new file mode 100644 index 00000000..554b2502 --- /dev/null +++ b/src/api/system/menu.ts @@ -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('/menu/user-own/tree', data) + + + +// 获取资产类型 +export const getAssetsType_api = () => server.get(`/asset/types`); +// 获取菜单详情 +export const getMenuDetail_api = (id:string) => server.get(`/menu/${id}`); diff --git a/src/components/AIcon/index.tsx b/src/components/AIcon/index.tsx index 58efea27..c580070d 100644 --- a/src/components/AIcon/index.tsx +++ b/src/components/AIcon/index.tsx @@ -39,6 +39,11 @@ const iconKeys = [ 'ArrowDownOutlined', 'SmallDashOutlined', 'TeamOutlined', + 'MenuUnfoldOutlined', + 'MenuFoldOutlined', + 'QuestionCircleOutlined', + 'InfoCircleOutlined', + 'SearchOutlined', ] const Icon = (props: {type: string}) => { diff --git a/src/components/Layout/BasicLayout.tsx b/src/components/Layout/BasicLayout.tsx new file mode 100644 index 00000000..18cfcdf2 --- /dev/null +++ b/src/components/Layout/BasicLayout.tsx @@ -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, + default: () => null + }, + breadcrumbRender: { + type: [Object, Function, Boolean] as PropType, + default() { + return null + } + }, + contentStyle: { + type: [String, Object] as PropType, + default: () => { + return null + } + }, + pure: { + type: Boolean, + default: () => false + } +} + +export type BasicLayoutProps = Partial>; + +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 + } + + const breadcrumb = computed(() => ({ + ...props.breadcrumb, + itemRender: getSlot(slots, props, 'breadcrumbRender') as BreadcrumbRender + })) + + const flatMenuData = computed( + () => (props.selectedKeys && getMenuFirstChildren(props.menuData, props.selectedKeys[0])) || []) + + const routeContext = reactive({ + ...(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(slots, props, 'collapsedButtonRender') + const rightContentRender = getSlot(slots, props, 'rightContentRender') + const customHeaderRender = getSlot(slots, props, 'headerRender') + + // menu + const menuHeaderRender = getSlot(slots, props, 'menuHeaderRender') + const menuExtraRender = getSlot(slots, props, 'menuExtraRender') + const menuContentRender = getSlot(slots, props, 'menuContentRender') + const menuItemRender = getSlot(slots, props, 'menuItemRender') + const subMenuItemRender = getSlot(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?.() + ) : ( + + + + + {headerDom.value} + + {slots.default?.()} + + + + ) + } + + ) + } + } +}) + + + + diff --git a/src/components/Layout/BasicLayoutPage.vue b/src/components/Layout/BasicLayoutPage.vue new file mode 100644 index 00000000..054f54ad --- /dev/null +++ b/src/components/Layout/BasicLayoutPage.vue @@ -0,0 +1,83 @@ + + + + + diff --git a/src/components/Layout/BlankLayoutPage.vue b/src/components/Layout/BlankLayoutPage.vue new file mode 100644 index 00000000..69d6fa16 --- /dev/null +++ b/src/components/Layout/BlankLayoutPage.vue @@ -0,0 +1,13 @@ + + + + + \ No newline at end of file diff --git a/src/components/Layout/PageContainer.tsx b/src/components/Layout/PageContainer.tsx new file mode 100644 index 00000000..e69de29b diff --git a/src/components/Layout/RouteContext.tsx b/src/components/Layout/RouteContext.tsx new file mode 100644 index 00000000..78f995b4 --- /dev/null +++ b/src/components/Layout/RouteContext.tsx @@ -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[]; +} + +export interface BreadcrumbProps { + prefixCls?: string; + routes?: Route[]; + params?: any; + separator?: VNodeChild; + itemRender?: (opts: { route: Route; params: any; routes: Array; paths: Array }) => VNodeChild; +} + +export type BreadcrumbListReturn = Pick>; + +export interface MenuState { + selectedKeys: string[]; + openKeys: string[]; +} + +export interface RouteContextProps extends Partial, MenuState { + menuData: MenuDataItem[]; + flatMenuData?: MenuDataItem[]; + + getPrefixCls?: (suffixCls?: string, customizePrefixCls?: string) => string; + breadcrumb?: BreadcrumbListReturn | ComputedRef; + collapsed?: boolean; + hasSideMenu?: boolean; + siderWidth?: number; + headerHeight?: number; + /* 附加属性 */ + [key: string]: any; +} + +export const routeContextInjectKey: InjectionKey = Symbol('route-context'); + +export const useRouteContext = () => + useContext>(routeContextInjectKey, {}); + diff --git a/src/components/Layout/components/Header/Header.tsx b/src/components/Layout/components/Header/Header.tsx new file mode 100644 index 00000000..8d7de6d7 --- /dev/null +++ b/src/components/Layout/components/Header/Header.tsx @@ -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, + 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, + default: () => undefined, + }, + siderWidth: PropTypes.number.def(208), + // events + onMenuHeaderClick: PropTypes.func, + onCollapse: siderMenuProps.onCollapse, + onOpenKeys: siderMenuProps.onOpenKeys, + onSelect: siderMenuProps.onSelect, +} + +export type HeaderProps = ExtractPropTypes; + +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('auto') + + const context = useRouteContext(); + + const noChildrenMenuData = (menuData || []).map((item) => ({ + ...item, + children: undefined, + })) as RouteRecordRaw[]; + + const clearMenuData = clearMenuItem(noChildrenMenuData); + + return () => ( + <> +
+
+ +
+ onOpenKeys && onOpenKeys($event), + 'onUpdate:selectedKeys': ($event: string[]) => onSelect && onSelect($event), + }} + /> +
+
+
+ { + rightSize.value = width + }} + > + { + rightContentRender && typeof rightContentRender === 'function' ? ( +
{rightContentRender({...props})}
+ ) : rightContentRender + } +
+
+
+
+
+ + ) + } +}) \ No newline at end of file diff --git a/src/components/Layout/components/Header/index.less b/src/components/Layout/components/Header/index.less new file mode 100644 index 00000000..d9734f2f --- /dev/null +++ b/src/components/Layout/components/Header/index.less @@ -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; + } + } + } + } +} \ No newline at end of file diff --git a/src/components/Layout/components/Header/index.tsx b/src/components/Layout/components/Header/index.tsx new file mode 100644 index 00000000..c4bfc014 --- /dev/null +++ b/src/components/Layout/components/Header/index.tsx @@ -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>; + +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 () => ( + <> + + +
+ + + ) +} +}) \ No newline at end of file diff --git a/src/components/Layout/components/PageContainer/index.less b/src/components/Layout/components/PageContainer/index.less new file mode 100644 index 00000000..fd4e709a --- /dev/null +++ b/src/components/Layout/components/PageContainer/index.less @@ -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%; + } + } + } +} \ No newline at end of file diff --git a/src/components/Layout/components/PageContainer/index.tsx b/src/components/Layout/components/PageContainer/index.tsx new file mode 100644 index 00000000..6ae909b1 --- /dev/null +++ b/src/components/Layout/components/PageContainer/index.tsx @@ -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 & { key?: string })[]>, + default: () => undefined, + }, + /** + * @name 当前选中 tab 的 key + */ + tabActiveKey: String, //PropTypes.string, + /** + * @name tab 上多余的区域 + */ + tabBarExtraContent: { + type: [Object, Function] as PropType, + default: () => undefined, + }, + /** + * @name tabs 的其他配置 + */ + tabProps: { + type: Object, //as PropType, + default: () => undefined, + }, + /** + * @name 固定 PageHeader 到页面顶部 + */ + fixedHeader: Boolean, //PropTypes.looseBool, + // events + onTabChange: Function, //PropTypes.func, +}; +export type PageHeaderTabConfig = Partial>; + + +export const pageContainerProps = { + ...pageHeaderTabConfig, + ...pageHeaderProps, + prefixCls: { + type: String, + default: 'ant-pro', + }, //PropTypes.string.def('ant-pro'), + title: { + type: [Object, String, Boolean, Function] as PropType, + default: () => null, + }, + subTitle: { + type: [Object, String, Boolean, Function] as PropType, + default: () => null, + }, + content: { + type: [Object, String, Boolean, Function] as PropType, + default: () => null, + }, + extra: { + type: [Object, String, Boolean, Function] as PropType, + default: () => null, + }, + extraContent: { + type: [Object, String, Boolean, Function] as PropType, + default: () => null, + }, + header: { + type: [Object, String, Boolean, Function] as PropType, + default: () => null, + }, + pageHeaderRender: { + type: [Object, Function, Boolean] as PropType, + default: () => undefined, + }, + affixProps: { + type: [Object, Function] as PropType, + }, + ghost: { + type: Boolean, + default: () => false, + }, //PropTypes.looseBool, + loading: { + type: Boolean, + default: () => undefined, + }, //PropTypes.looseBool, + childrenFullHeight: { + type: Boolean, + default: () => true, + } +}; + +export type PageContainerProps = Partial>; + +const renderFooter = ( + props: Omit< PageContainerProps, 'title' > +): VNodeChild | JSX.Element => { + const { tabList, tabActiveKey, onTabChange, tabBarExtraContent, tabProps } = props; + if (tabList && tabList.length) { + return ( + { + if (onTabChange) { + onTabChange(key); + } + }} + tabBarExtraContent={tabBarExtraContent} + {...tabProps} + > + {tabList.map((item) => ( + + ))} + + ); + } + return null; +} + +const ProPageHeader: FunctionalComponent = (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 ( +
+ + {/*{header || renderPageHeader(content, extraContent)}*/} + { header } + +
+ ); +} + +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(slots, props, 'tags'); + const headerContent = getSlotVNode(slots, props, 'content'); + const extra = getSlotVNode(slots, props, 'extra'); + const extraContent = getSlotVNode(slots, props, 'extraContent'); + const subTitle = getSlotVNode(slots, props, 'subTitle'); + const title = getSlotVNode(slots, props, 'title'); + + // @ts-ignore + return ( + + ); + }) + + return () => { + const { fixedHeader } = props; + return ( +
+ {fixedHeader && headerDom.value ? ( + + {headerDom.value} + + ) : ( + headerDom.value + )} +
+ {loading.value ? ( + + ) : slots.default ? ( +
+
{slots.default()}
+ {value.hasFooterToolbar && ( +
+ )} +
+ ) : null} +
+
+ ); + }; + } +}) + +export default PageContainer \ No newline at end of file diff --git a/src/components/Layout/components/PageContainer/types.d.ts b/src/components/Layout/components/PageContainer/types.d.ts new file mode 100644 index 00000000..f189788a --- /dev/null +++ b/src/components/Layout/components/PageContainer/types.d.ts @@ -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>; + +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; +} \ No newline at end of file diff --git a/src/components/Layout/components/SiderMenu/BaseMenu.tsx b/src/components/Layout/components/SiderMenu/BaseMenu.tsx new file mode 100644 index 00000000..c73f5002 --- /dev/null +++ b/src/components/Layout/components/SiderMenu/BaseMenu.tsx @@ -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, + default: 'inline', + }, + menuData: { + type: Array as PropType, + default: () => [], + }, + layout: { + type: String as PropType, + default: 'side', + }, + theme: { + type: String as PropType, + default: 'dark', + }, + collapsed: { + type: Boolean as PropType, + default: () => false, + }, + openKeys: { + type: Array as PropType>, + default: () => undefined, + }, + selectedKeys: { + type: Array as PropType>, + default: () => undefined, + }, + menuProps: { + type: Object as PropType>, + default: () => null, + }, + menuItemRender: { + type: [Object, Function, Boolean] as PropType, + default: () => undefined, + }, + subMenuItemRender: { + type: [Object, Function, Boolean] as PropType, + default: () => undefined, + }, + onClick: [Function, Object] as PropType<(...args: any) => void>, +} + +export type BaseMenuProps = ExtractPropTypes; + +const LazyIcon: FunctionalComponent<{ icon: VNodeChild | string;}> = (props) => { + const {icon} = props + if (!icon) return null + if (typeof icon === 'string' && icon !== '') { + return ; + } + 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 ? ( + + {menuTitle} + + ) : ( + {menuTitle} + ); + + return ( + } + > + {this.getNavMenuItems(item.children)} + + ) + } + + 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)) || ( + + {title} + + ) + ) + } + + 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 && ) || undefined; + const menuTitle = item.meta?.title; + const defaultTitle = item.meta?.icon ? ( + + {icon} + {menuTitle} + + ) : ( + + {menuTitle} + + ); + + 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 () => ( + + {menuUtil.getNavMenuItems(props.menuData)} + + ) + } +}) \ No newline at end of file diff --git a/src/components/Layout/components/SiderMenu/SiderMenu.less b/src/components/Layout/components/SiderMenu/SiderMenu.less new file mode 100644 index 00000000..6b230073 --- /dev/null +++ b/src/components/Layout/components/SiderMenu/SiderMenu.less @@ -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%; + } +} diff --git a/src/components/Layout/components/SiderMenu/SiderMenu.tsx b/src/components/Layout/components/SiderMenu/SiderMenu.tsx new file mode 100644 index 00000000..89496a93 --- /dev/null +++ b/src/components/Layout/components/SiderMenu/SiderMenu.tsx @@ -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 logo; + } + if (typeof logo === 'function') { + // @ts-ignore + return logo(); + } + return logo; +}; + +export const siderMenuProps = { + ...baseMenuProps, + logo: { + type: [Object, String, Function] as PropType, + default: () => '', + }, + logoStyle: { + type: Object as PropType, + default: () => undefined, + }, + siderWidth: PropTypes.number.def(208), + headerHeight: PropTypes.number.def(48), + collapsedWidth: PropTypes.number.def(48), + menuHeaderRender: { + type: [Function, Object, Boolean] as PropType, + default: () => undefined, + }, + menuContentRender: { + type: [Function, Object, Boolean] as PropType, + default: () => undefined, + }, + menuExtraRender: { + type: [Function, Object, Boolean] as PropType, + default: () => undefined, + }, + collapsedButtonRender: { + type: [Function, Object, Boolean] as PropType, + default: () => undefined, + }, + onMenuHeaderClick: PropTypes.func, + onMenuClick: PropTypes.func, + onCollapse: { + type: Function as PropType<(collapsed: boolean) => void>, + }, + onOpenKeys: { + type: Function as PropType<(openKeys: WithFalse) => void>, + }, + onSelect: { + type: Function as PropType<(selectedKeys: WithFalse) => void>, + }, +} + +export type SiderMenuProps = Partial>; + +export const defaultRenderCollapsedButton = (collapsed?: boolean): CustomRender => + collapsed ? : ; + +const SiderMenu: FunctionalComponent = (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 = ( + 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 ( + <> +
+ { + props.onCollapse?.(collapse); + }} + collapsedWidth={collapsedWidth} + style={omit(Style.value, ['transition'])} + width={sSideWidth.value} + theme={props.theme as 'dark' | 'light'} + class={'pro-layout-sider'} + > +
+ {(menuContentRender && menuContentRender(props, defaultMenuDom)) || defaultMenuDom} +
+
+ {collapsedButtonRender !== false ? ( + { + if (props.onCollapse) { + props.onCollapse(!props.collapsed); + } + }} + > + + {collapsedButtonRender && typeof collapsedButtonRender === 'function' + ? collapsedButtonRender(collapsed) + : collapsedButtonRender} + + + ) : null} +
+
+ + ) +} + +export default SiderMenu \ No newline at end of file diff --git a/src/components/Layout/defaultSetting.ts b/src/components/Layout/defaultSetting.ts new file mode 100644 index 00000000..741ceda0 --- /dev/null +++ b/src/components/Layout/defaultSetting.ts @@ -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, + default: defaultSettings.navTheme, + }, + title: { + type: String as PropType, + default: () => defaultSettings.title, + }, + layout: { + type: String as PropType, + default: defaultSettings.layout, + }, + contentWidth: { + type: String as PropType, + default: defaultSettings.contentWidth, + }, + menu: { + type: Object as PropType, + default: () => { + return { + locale: true, + }; + }, + }, + headerHeight: { + type: Number as PropType, + default: defaultSettings.headerHeight, + }, +} + diff --git a/src/components/Layout/hooks/context.ts b/src/components/Layout/hooks/context.ts new file mode 100644 index 00000000..c02aba9e --- /dev/null +++ b/src/components/Layout/hooks/context.ts @@ -0,0 +1,10 @@ +import { InjectionKey } from 'vue' + +export type ContextType = any; + +export const useContext = ( + contextInjectKey: string | InjectionKey> = Symbol(), + defaultValue?: ContextType +): T => { + return inject(contextInjectKey, defaultValue || ({} as T)); +}; \ No newline at end of file diff --git a/src/components/Layout/index.ts b/src/components/Layout/index.ts new file mode 100644 index 00000000..66fb03d7 --- /dev/null +++ b/src/components/Layout/index.ts @@ -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' \ No newline at end of file diff --git a/src/components/Layout/typings.ts b/src/components/Layout/typings.ts new file mode 100644 index 00000000..b4e90976 --- /dev/null +++ b/src/components/Layout/typings.ts @@ -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; +export type TargetType = '_blank' | '_self' | unknown; +export type BreadcrumbRender = BreadcrumbProps['itemRender']; + +export type CustomRender = VueNode; +export type WithFalse = T | false; +export type LogoRender = WithFalse; + +export type DefaultPropRender = WithFalse; +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>; + diff --git a/src/components/Layout/utils.ts b/src/components/Layout/utils.ts new file mode 100644 index 00000000..2422b857 --- /dev/null +++ b/src/components/Layout/utils.ts @@ -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[]; +} diff --git a/src/components/PermissionButton/index.vue b/src/components/PermissionButton/index.vue index d211e2ee..9ec5b437 100644 --- a/src/components/PermissionButton/index.vue +++ b/src/components/PermissionButton/index.vue @@ -4,31 +4,62 @@ - + + + + - + + + + - + + + + - \ No newline at end of file diff --git a/src/components/index.ts b/src/components/index.ts index ed95c26d..49f9d91d 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -9,6 +9,7 @@ import Search from './Search' import NormalUpload from './NormalUpload/index.vue' import FileFormat from './FileFormat/index.vue' import JUpload from './JUpload/index.vue' +import { BasicLayoutPage, BlankLayoutPage, PageContainer } from './Layout' export default { install(app: App) { @@ -22,5 +23,8 @@ export default { .component('NormalUpload', NormalUpload) .component('FileFormat', FileFormat) .component('JUpload', JUpload) + .component('BasicLayoutPage', BasicLayoutPage) + .component('BlankLayoutPage', BlankLayoutPage) + .component('PageContainer', PageContainer) } } diff --git a/src/router/index.ts b/src/router/index.ts index 11ffce4c..91ae60a0 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -1,11 +1,12 @@ -import { createRouter, createWebHashHistory } from 'vue-router'; +import { createRouter, createWebHashHistory } from 'vue-router' import menus, { LoginPath } from './menu' -import { LocalStore } from "@/utils/comm"; -import { TOKEN_KEY } from "@/utils/variable"; +import { cleanToken, getToken } from '@/utils/comm' +import { useUserInfo } from '@/store/userInfo' +import { useSystem } from '@/store/system' const router = createRouter({ - history: createWebHashHistory(), - routes: menus + history: createWebHashHistory(), + routes: menus }) const filterPath = [ @@ -14,17 +15,50 @@ const filterPath = [ ] router.beforeEach((to, from, next) => { - const token = LocalStore.get(TOKEN_KEY) - // TODO 切换路由取消请求 - if (token || filterPath.includes(to.path)) { - next() - } else { - if (to.path === LoginPath) { - next() - } else { + // TODO 切换路由取消请求 + const isFilterPath = filterPath.includes(to.path) + if (isFilterPath) { + next() + } else { + const token = getToken() + if (token) { + 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 }) + }) + } else { + next() } + } + + } else { + if (to.path === LoginPath) { + next() + } else { + next({ path: LoginPath }) + } } + } }) export default router \ No newline at end of file diff --git a/src/router/menu.ts b/src/router/menu.ts index 2e92daff..e9288584 100644 --- a/src/router/menu.ts +++ b/src/router/menu.ts @@ -113,6 +113,14 @@ export default [ path:'/system/Permission', 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', @@ -145,5 +153,10 @@ export default [ { path: '/iot/device/Category', component: () => import('@/views/device/Category/index.vue') - } + } , + // 产品 + { + path: '/iot/device/Product', + component: () => import('@/views/device/Product/index.vue') + } ] \ No newline at end of file diff --git a/src/store/menu.ts b/src/store/menu.ts index b03e4202..42f1946d 100644 --- a/src/store/menu.ts +++ b/src/store/menu.ts @@ -1,9 +1,12 @@ import { defineStore } from "pinia"; +import { queryOwnThree } from '@/api/system/menu' +import { filterAsnycRouter } from '@/utils/menu' export const useMenuStore = defineStore({ id: 'menu', state: () => ({ - menus: {} as {[key: string]: string}, + menus: {}, + menusKey: [] }), getters: { hasPermission(state) { @@ -19,6 +22,47 @@ export const useMenuStore = defineStore({ } return false } + }, + }, + actions: { + queryMenuTree(isCommunity = false): Promise { + 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) + } + }) } } }) \ No newline at end of file diff --git a/src/store/system.ts b/src/store/system.ts new file mode 100644 index 00000000..e4d37681 --- /dev/null +++ b/src/store/system.ts @@ -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 { + 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) + } + }) + } + } +}) \ No newline at end of file diff --git a/src/store/userInfo.ts b/src/store/userInfo.ts index 4cf56cc6..3a6de12e 100644 --- a/src/store/userInfo.ts +++ b/src/store/userInfo.ts @@ -1,5 +1,5 @@ import { defineStore } from 'pinia'; -import { authLogin } from '@/api/login'; +import { authLogin, userDetail } from '@/api/login'; import { LocalStore } from '@/utils/comm'; 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()) + }) + } }, }); diff --git a/src/utils/comm.ts b/src/utils/comm.ts index 3c032a79..e7b4363f 100644 --- a/src/utils/comm.ts +++ b/src/utils/comm.ts @@ -1,5 +1,5 @@ +import type { Slots } from 'vue' import { TOKEN_KEY } from '@/utils/variable' -import { Terms } from 'components/Search/types' /** * 静态图片资源处理 @@ -10,32 +10,36 @@ export const getImage = (path: string) => { } export const LocalStore = { - set(key: string, data: any) { - localStorage.setItem(key, typeof data === 'string' ? data : JSON.stringify(data)) - }, - get(key: string) { - const dataStr = localStorage.getItem(key) - try { - if (dataStr) { - const data = JSON.parse(dataStr) - return data && typeof data === 'object' ? data : dataStr - } else { - return dataStr - } - } catch (e) { - return dataStr - } - }, - remove(key: string) { - localStorage.removeItem(key) - }, - removeAll() { - localStorage.clear() + set(key: string, data: any) { + localStorage.setItem(key, typeof data === 'string' ? data : JSON.stringify(data)) + }, + get(key: string) { + const dataStr = localStorage.getItem(key) + try { + if (dataStr) { + const data = JSON.parse(dataStr) + return data && typeof data === 'object' ? data : dataStr + } else { + return dataStr + } + } catch (e) { + return dataStr } + }, + remove(key: string) { + localStorage.removeItem(key) + }, + removeAll() { + localStorage.clear() + } } 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 */ 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 */ export const filterSelectNode = (value: string, option: any, key: string = 'label'): boolean => { - return option[key]?.includes(value) + return option[key]?.includes(value) } -/** - * 时间转换为'2022-01-02 14:03:05' - * @param date 时间对象 - * @returns - */ -export const dateFormat = (dateSouce:any):string|Error => { - let date = null - try { - date = new Date(dateSouce) - } catch (error) { - return new Error('请传入日期格式数据') - } - let year = date.getFullYear(); - 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; +export function getSlot(slots: Slots, props: Record, prop = 'default'): T | false { + if (props[prop] === false) { + // force not render + return false + } + return (props[prop] || slots[prop]) as T +} + +export function getSlotVNode(slots: Slots, props: Record, prop = 'default'): T | false { + if (props[prop] === false) { + return false; + } + return (props[prop] || slots[prop]?.()) as T; } diff --git a/src/utils/encodeQuery.ts b/src/utils/encodeQuery.ts index 19d11640..748bba26 100644 --- a/src/utils/encodeQuery.ts +++ b/src/utils/encodeQuery.ts @@ -1,3 +1,5 @@ +import { isObject } from 'lodash-es' + export default function encodeQuery(params: any) { if (!params) return {}; const queryParam = { @@ -15,7 +17,7 @@ export default function encodeQuery(params: any) { terms[k] === '' || terms[k] === undefined || terms[k].length === 0 || - terms[k] === {} || + (isObject(terms[k]) && Object.keys(terms[k]).length === 0) || terms[k] === null ) ) { diff --git a/src/utils/menu.ts b/src/utils/menu.ts new file mode 100644 index 00000000..22230e84 --- /dev/null +++ b/src/utils/menu.ts @@ -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 + }) +} \ No newline at end of file diff --git a/src/utils/regular.ts b/src/utils/regular.ts new file mode 100644 index 00000000..9d88336f --- /dev/null +++ b/src/utils/regular.ts @@ -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) \ No newline at end of file diff --git a/src/utils/request.ts b/src/utils/request.ts index cc606fea..9e43cb5b 100644 --- a/src/utils/request.ts +++ b/src/utils/request.ts @@ -118,6 +118,7 @@ const errorHandler = (error: any) => { const status = error.response.status if (status === 403) { Notification.error({ + key: '403', message: 'Forbidden', description: (data.message + '').substr(0, 90) }) @@ -129,22 +130,25 @@ const errorHandler = (error: any) => { }, 0) } else if (status === 500) { Notification.error({ + key: '500', message: 'Server Side Error', description: (data.message + '').substr(0, 90) }) } else if (status === 400) { Notification.error({ + key: '400', message: 'Request Error', description: (data.message + '').substr(0, 90) }) } else if (status === 401) { Notification.error({ + key: '401', message: 'Unauthorized', description: 'Authorization verification failed' }) setTimeout(() => { router.replace({ - name: 'login' + path: LoginPath }) }, 0) } diff --git a/src/views/device/Category/components/modifyModal/index.vue b/src/views/device/Category/components/modifyModal/index.vue index ea420fa9..371b7900 100644 --- a/src/views/device/Category/components/modifyModal/index.vue +++ b/src/views/device/Category/components/modifyModal/index.vue @@ -13,23 +13,24 @@ > - - + + - + @@ -37,6 +38,7 @@ v-model:value="formModel.description" show-count :maxlength="200" + placeholder="请输入说明" /> @@ -44,10 +46,12 @@ \ No newline at end of file +}; + diff --git a/src/views/device/Product/ChooseCard/index.vue b/src/views/device/Product/ChooseCard/index.vue new file mode 100644 index 00000000..a60c1753 --- /dev/null +++ b/src/views/device/Product/ChooseCard/index.vue @@ -0,0 +1,313 @@ + + + + + diff --git a/src/views/device/Product/Save/index.vue b/src/views/device/Product/Save/index.vue new file mode 100644 index 00000000..1c928ba6 --- /dev/null +++ b/src/views/device/Product/Save/index.vue @@ -0,0 +1,386 @@ + + + + + diff --git a/src/views/device/Product/index.vue b/src/views/device/Product/index.vue new file mode 100644 index 00000000..c5d6d505 --- /dev/null +++ b/src/views/device/Product/index.vue @@ -0,0 +1,498 @@ + + + + + diff --git a/src/views/device/Product/typings.d.ts b/src/views/device/Product/typings.d.ts index d7b9c831..3cdffb00 100644 --- a/src/views/device/Product/typings.d.ts +++ b/src/views/device/Product/typings.d.ts @@ -177,3 +177,15 @@ type ObserverMetadata = { subscribe: (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[]; +}; \ No newline at end of file diff --git a/src/views/device/components/Metadata/Base/Edit/index.vue b/src/views/device/components/Metadata/Base/Edit/index.vue index 1f0f0de7..0df22ae5 100644 --- a/src/views/device/components/Metadata/Base/Edit/index.vue +++ b/src/views/device/components/Metadata/Base/Edit/index.vue @@ -114,6 +114,6 @@ const form = reactive({ model: {} }) - \ No newline at end of file diff --git a/src/views/device/components/Metadata/Base/index.vue b/src/views/device/components/Metadata/Base/index.vue index 35e9dfed..dd03ca7e 100644 --- a/src/views/device/components/Metadata/Base/index.vue +++ b/src/views/device/components/Metadata/Base/index.vue @@ -96,5 +96,5 @@ const operateLimits = (action: 'add' | 'updata', types: MetadataType) => { ); }; - \ No newline at end of file diff --git a/src/views/device/components/Metadata/Cat/index.vue b/src/views/device/components/Metadata/Cat/index.vue index c9fc1952..5936eb0d 100644 --- a/src/views/device/components/Metadata/Cat/index.vue +++ b/src/views/device/components/Metadata/Cat/index.vue @@ -140,7 +140,7 @@ watchEffect(() => { } }) - + diff --git a/src/views/notice/Config/index.vue b/src/views/notice/Config/index.vue index 598235a9..801f8b39 100644 --- a/src/views/notice/Config/index.vue +++ b/src/views/notice/Config/index.vue @@ -1,162 +1,153 @@