From 9c8ab50a8a748fc1c43aa331b9e80b73667927a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=83=A1=E9=95=87?= Date: Thu, 17 Oct 2024 17:07:19 +0800 Subject: [PATCH] feat: add alova examples --- build/plugins/router.ts | 3 +- src/assets/svg-icon/alova.svg | 1 + src/locales/langs/en-us.ts | 21 +- src/locales/langs/zh-cn.ts | 21 +- src/router/elegant/imports.ts | 3 + src/router/elegant/routes.ts | 45 ++++ src/router/elegant/transform.ts | 5 + src/router/routes/index.ts | 14 ++ src/serviceAlova/api/index.ts | 1 + src/serviceAlova/api/system-manage.ts | 59 +++++ src/typings/app.d.ts | 14 ++ src/typings/components.d.ts | 4 + src/typings/elegant-router.d.ts | 11 + src/utils/agent.ts | 4 +- src/views/alova/request/index.vue | 63 +++++ src/views/alova/scenes/index.vue | 34 +++ .../modules/BrowserVisivilityRequest.vue | 43 ++++ src/views/alova/scenes/modules/Captcha.vue | 71 ++++++ .../scenes/modules/CrossComponentRequest.vue | 13 + .../scenes/modules/NetworkToggleRequest.vue | 43 ++++ .../alova/scenes/modules/PollingRequest.vue | 41 ++++ .../alova/user/hooks/useCheckedColumns.ts | 78 ++++++ src/views/alova/user/hooks/useTableOperate.ts | 83 +++++++ src/views/alova/user/index.vue | 226 ++++++++++++++++++ .../user/modules/user-operate-drawer.vue | 169 +++++++++++++ src/views/alova/user/modules/user-search.vue | 113 +++++++++ 26 files changed, 1179 insertions(+), 4 deletions(-) create mode 100644 src/assets/svg-icon/alova.svg create mode 100644 src/serviceAlova/api/system-manage.ts create mode 100644 src/views/alova/request/index.vue create mode 100644 src/views/alova/scenes/index.vue create mode 100644 src/views/alova/scenes/modules/BrowserVisivilityRequest.vue create mode 100644 src/views/alova/scenes/modules/Captcha.vue create mode 100644 src/views/alova/scenes/modules/CrossComponentRequest.vue create mode 100644 src/views/alova/scenes/modules/NetworkToggleRequest.vue create mode 100644 src/views/alova/scenes/modules/PollingRequest.vue create mode 100644 src/views/alova/user/hooks/useCheckedColumns.ts create mode 100644 src/views/alova/user/hooks/useTableOperate.ts create mode 100644 src/views/alova/user/index.vue create mode 100644 src/views/alova/user/modules/user-operate-drawer.vue create mode 100644 src/views/alova/user/modules/user-search.vue diff --git a/build/plugins/router.ts b/build/plugins/router.ts index 40a5ae51..65460f92 100644 --- a/build/plugins/router.ts +++ b/build/plugins/router.ts @@ -19,7 +19,8 @@ export function setupElegantRouter() { 'document_vite', 'document_unocss', 'document_naive', - 'document_antd' + 'document_antd', + 'document_alova' ] }, routePathTransformer(routeName, routePath) { diff --git a/src/assets/svg-icon/alova.svg b/src/assets/svg-icon/alova.svg new file mode 100644 index 00000000..a21d0e27 --- /dev/null +++ b/src/assets/svg-icon/alova.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/locales/langs/en-us.ts b/src/locales/langs/en-us.ts index 9ba2d5a3..4cf367bc 100644 --- a/src/locales/langs/en-us.ts +++ b/src/locales/langs/en-us.ts @@ -113,7 +113,7 @@ const local: App.I18n.Schema = { }, tab: { visible: 'Tab Visible', - cache: 'Tab Cache', + cache: 'Tag Bar Info Cache', height: 'Tab Height', mode: { title: 'Tab Mode', @@ -163,9 +163,14 @@ const local: App.I18n.Schema = { document_unocss: 'UnoCSS Document', document_naive: 'Naive UI Document', document_antd: 'Ant Design Vue Document', + document_alova: 'Alova Document', 'user-center': 'User Center', about: 'About', function: 'System Function', + alova: 'Alova Example', + alova_request: 'Alova Request', + alova_user: 'User List', + alova_scenes: 'Scenario Request', function_tab: 'Tab', 'function_multi-tab': 'Multi Tab', 'function_hide-child': 'Hide Child', @@ -337,6 +342,20 @@ const local: App.I18n.Schema = { repeatedErrorMsg2: 'Custom Request Error 2' } }, + alova: { + scenes: { + captchaSend: 'Captcha Send', + autoRequest: 'Auto Request', + visibilityRequestTips: 'Automatically request when switching browser window', + pollingRequestTips: 'It will request every 3 seconds', + networkRequestTips: 'Automatically request after network reconnecting', + refreshTime: 'Refresh Time', + startRequest: 'Start Request', + stopRequest: 'Stop Request', + requestCrossComponent: 'Request Cross Component', + triggerAllRequest: 'Manually Trigger All Automated Requests' + } + }, manage: { common: { status: { diff --git a/src/locales/langs/zh-cn.ts b/src/locales/langs/zh-cn.ts index 8c2cdffd..5bdbda26 100644 --- a/src/locales/langs/zh-cn.ts +++ b/src/locales/langs/zh-cn.ts @@ -113,7 +113,7 @@ const local: App.I18n.Schema = { }, tab: { visible: '显示标签栏', - cache: '缓存标签页', + cache: '标签栏信息缓存', height: '标签栏高度', mode: { title: '标签栏风格', @@ -163,9 +163,14 @@ const local: App.I18n.Schema = { document_unocss: 'UnoCSS文档', document_naive: 'Naive UI文档', document_antd: 'Ant Design Vue文档', + document_alova: 'Alova文档', 'user-center': '个人中心', about: '关于', function: '系统功能', + alova: 'alova示例', + alova_request: 'alova请求', + alova_user: '用户列表', + alova_scenes: '场景化请求', function_tab: '标签页', 'function_multi-tab': '多标签页', 'function_hide-child': '隐藏子菜单', @@ -337,6 +342,20 @@ const local: App.I18n.Schema = { repeatedErrorMsg2: '自定义请求错误 2' } }, + alova: { + scenes: { + captchaSend: '发送验证码', + autoRequest: '自动请求', + visibilityRequestTips: '浏览器窗口切换自动请求数据', + pollingRequestTips: '每3秒自动请求一次', + networkRequestTips: '网络重连后自动请求', + refreshTime: '更新时间', + startRequest: '开始请求', + stopRequest: '停止请求', + requestCrossComponent: '跨组件触发请求', + triggerAllRequest: '手动触发所有自动请求' + } + }, manage: { common: { status: { diff --git a/src/router/elegant/imports.ts b/src/router/elegant/imports.ts index c992e847..b06a9180 100644 --- a/src/router/elegant/imports.ts +++ b/src/router/elegant/imports.ts @@ -21,6 +21,9 @@ export const views: Record Promise import("@/views/_builtin/iframe-page/[url].vue"), login: () => import("@/views/_builtin/login/index.vue"), about: () => import("@/views/about/index.vue"), + alova_request: () => import("@/views/alova/request/index.vue"), + alova_scenes: () => import("@/views/alova/scenes/index.vue"), + alova_user: () => import("@/views/alova/user/index.vue"), "function_hide-child_one": () => import("@/views/function/hide-child/one/index.vue"), "function_hide-child_three": () => import("@/views/function/hide-child/three/index.vue"), "function_hide-child_two": () => import("@/views/function/hide-child/two/index.vue"), diff --git a/src/router/elegant/routes.ts b/src/router/elegant/routes.ts index 30a21da5..ed5a3dff 100644 --- a/src/router/elegant/routes.ts +++ b/src/router/elegant/routes.ts @@ -50,6 +50,51 @@ export const generatedRoutes: GeneratedRoute[] = [ order: 10 } }, + { + name: 'alova', + path: '/alova', + component: 'layout.base', + meta: { + title: 'alova', + i18nKey: 'route.alova', + icon: 'carbon:http', + order: 7 + }, + children: [ + { + name: 'alova_request', + path: '/alova/request', + component: 'view.alova_request', + meta: { + title: 'alova_request', + i18nKey: 'route.alova_request', + order: 1 + } + }, + { + name: 'alova_scenes', + path: '/alova/scenes', + component: 'view.alova_scenes', + meta: { + title: 'alova_scenes', + i18nKey: 'route.alova_scenes', + icon: 'cbi:scene-dynamic', + order: 3 + } + }, + { + name: 'alova_user', + path: '/alova/user', + component: 'view.alova_user', + meta: { + title: 'alova_user', + i18nKey: 'route.alova_user', + icon: 'carbon:user-multiple', + order: 2 + } + } + ] + }, { name: 'function', path: '/function', diff --git a/src/router/elegant/transform.ts b/src/router/elegant/transform.ts index 05d86a8a..734197e6 100644 --- a/src/router/elegant/transform.ts +++ b/src/router/elegant/transform.ts @@ -175,10 +175,15 @@ const routeMap: RouteMap = { "document_unocss": "/document/unocss", "document_naive": "/document/naive", "document_antd": "/document/antd", + "document_alova": "/document/alova", "403": "/403", "404": "/404", "500": "/500", "about": "/about", + "alova": "/alova", + "alova_request": "/alova/request", + "alova_scenes": "/alova/scenes", + "alova_user": "/alova/user", "function": "/function", "function_hide-child": "/function/hide-child", "function_hide-child_one": "/function/hide-child/one", diff --git a/src/router/routes/index.ts b/src/router/routes/index.ts index 96322a9e..5be1e3b7 100644 --- a/src/router/routes/index.ts +++ b/src/router/routes/index.ts @@ -91,6 +91,20 @@ const customRoutes: CustomRoute[] = [ icon: 'logos:naiveui' } }, + { + name: 'document_alova', + path: '/document/alova', + component: 'view.iframe-page', + props: { + url: 'https://alova.js.org' + }, + meta: { + title: 'document_alova', + i18nKey: 'route.document_alova', + order: 7, + localIcon: 'alova' + } + }, { name: 'document_project', path: '/document/project', diff --git a/src/serviceAlova/api/index.ts b/src/serviceAlova/api/index.ts index 89f4e581..c9d31d11 100644 --- a/src/serviceAlova/api/index.ts +++ b/src/serviceAlova/api/index.ts @@ -1,2 +1,3 @@ export * from './auth'; export * from './route'; +export * from './system-manage'; diff --git a/src/serviceAlova/api/system-manage.ts b/src/serviceAlova/api/system-manage.ts new file mode 100644 index 00000000..986b5daf --- /dev/null +++ b/src/serviceAlova/api/system-manage.ts @@ -0,0 +1,59 @@ +import { alova } from '../request'; + +/** get role list */ +export function fetchGetRoleList(params?: Api.SystemManage.RoleSearchParams) { + return alova.Get('/systemManage/getRoleList', { params }); +} + +/** + * get all roles + * + * these roles are all enabled + */ +export function fetchGetAllRoles() { + return alova.Get('/systemManage/getAllRoles'); +} + +/** get user list */ +export function fetchGetUserList(params?: Api.SystemManage.UserSearchParams) { + return alova.Get('/systemManage/getUserList', { params }); +} + +export type UserModel = Pick< + Api.SystemManage.User, + 'userName' | 'userGender' | 'nickName' | 'userPhone' | 'userEmail' | 'userRoles' | 'status' +>; +/** add user */ +export function addUser(data: UserModel) { + return alova.Post('/systemManage/addUser', data); +} + +/** update user */ +export function updateUser(data: UserModel) { + return alova.Post('/systemManage/updateUser', data); +} + +/** delete user */ +export function deleteUser(id: number) { + return alova.Delete('/systemManage/deleteUser', { id }); +} + +/** batch delete user */ +export function batchDeleteUser(ids: number[]) { + return alova.Delete('/systemManage/batchDeleteUser', { ids }); +} + +/** get menu list */ +export function fetchGetMenuList() { + return alova.Get('/systemManage/getMenuList/v2'); +} + +/** get all pages */ +export function fetchGetAllPages() { + return alova.Get('/systemManage/getAllPages'); +} + +/** get menu tree */ +export function fetchGetMenuTree() { + return alova.Get('/systemManage/getMenuTree'); +} diff --git a/src/typings/app.d.ts b/src/typings/app.d.ts index f3a807f6..ae67f22e 100644 --- a/src/typings/app.d.ts +++ b/src/typings/app.d.ts @@ -523,6 +523,20 @@ declare namespace App { repeatedErrorMsg2: string; }; }; + alova: { + scenes: { + captchaSend: string; + autoRequest: string; + visibilityRequestTips: string; + pollingRequestTips: string; + networkRequestTips: string; + refreshTime: string; + startRequest: string; + stopRequest: string; + requestCrossComponent: string; + triggerAllRequest: string; + }; + }; manage: { common: { status: { diff --git a/src/typings/components.d.ts b/src/typings/components.d.ts index 09f7cdd1..4a1ab6af 100644 --- a/src/typings/components.d.ts +++ b/src/typings/components.d.ts @@ -19,6 +19,8 @@ declare module 'vue' { IconAntDesignEnterOutlined: typeof import('~icons/ant-design/enter-outlined')['default'] IconAntDesignReloadOutlined: typeof import('~icons/ant-design/reload-outlined')['default'] IconAntDesignSettingOutlined: typeof import('~icons/ant-design/setting-outlined')['default'] + IconCarbonPlay: typeof import('~icons/carbon/play')['default'] + IconCarbonStop: typeof import('~icons/carbon/stop')['default'] 'IconCharm:download': typeof import('~icons/charm/download')['default'] 'IconFileIcons:microsoftExcel': typeof import('~icons/file-icons/microsoft-excel')['default'] IconGridiconsFullscreen: typeof import('~icons/gridicons/fullscreen')['default'] @@ -45,6 +47,7 @@ declare module 'vue' { LangSwitch: typeof import('./../components/common/lang-switch.vue')['default'] LookForward: typeof import('./../components/custom/look-forward.vue')['default'] MenuToggler: typeof import('./../components/common/menu-toggler.vue')['default'] + NAlert: typeof import('naive-ui')['NAlert'] NBreadcrumb: typeof import('naive-ui')['NBreadcrumb'] NBreadcrumbItem: typeof import('naive-ui')['NBreadcrumbItem'] NButton: typeof import('naive-ui')['NButton'] @@ -87,6 +90,7 @@ declare module 'vue' { NSelect: typeof import('naive-ui')['NSelect'] NSkeleton: typeof import('naive-ui')['NSkeleton'] NSpace: typeof import('naive-ui')['NSpace'] + NSpin: typeof import('naive-ui')['NSpin'] NStatistic: typeof import('naive-ui')['NStatistic'] NSwitch: typeof import('naive-ui')['NSwitch'] NTab: typeof import('naive-ui')['NTab'] diff --git a/src/typings/elegant-router.d.ts b/src/typings/elegant-router.d.ts index 7c905d73..3dbe3e82 100644 --- a/src/typings/elegant-router.d.ts +++ b/src/typings/elegant-router.d.ts @@ -29,10 +29,15 @@ declare module "@elegant-router/types" { "document_unocss": "/document/unocss"; "document_naive": "/document/naive"; "document_antd": "/document/antd"; + "document_alova": "/document/alova"; "403": "/403"; "404": "/404"; "500": "/500"; "about": "/about"; + "alova": "/alova"; + "alova_request": "/alova/request"; + "alova_scenes": "/alova/scenes"; + "alova_user": "/alova/user"; "function": "/function"; "function_hide-child": "/function/hide-child"; "function_hide-child_one": "/function/hide-child/one"; @@ -107,6 +112,7 @@ declare module "@elegant-router/types" { | "document_unocss" | "document_naive" | "document_antd" + | "document_alova" >; /** @@ -123,6 +129,7 @@ declare module "@elegant-router/types" { | "404" | "500" | "about" + | "alova" | "function" | "home" | "iframe-page" @@ -155,6 +162,9 @@ declare module "@elegant-router/types" { | "iframe-page" | "login" | "about" + | "alova_request" + | "alova_scenes" + | "alova_user" | "function_hide-child_one" | "function_hide-child_three" | "function_hide-child_two" @@ -205,6 +215,7 @@ declare module "@elegant-router/types" { | "document_unocss" | "document_naive" | "document_antd" + | "document_alova" >; /** diff --git a/src/utils/agent.ts b/src/utils/agent.ts index 736cbd8f..a8416b2a 100644 --- a/src/utils/agent.ts +++ b/src/utils/agent.ts @@ -1,5 +1,7 @@ export function isPC() { const agents = ['Android', 'iPhone', 'webOS', 'BlackBerry', 'SymbianOS', 'Windows Phone', 'iPad', 'iPod']; - return !agents.includes(window.navigator.userAgent); + const isMobile = agents.some(agent => window.navigator.userAgent.includes(agent)); + + return !isMobile; } diff --git a/src/views/alova/request/index.vue b/src/views/alova/request/index.vue new file mode 100644 index 00000000..e5dee1f1 --- /dev/null +++ b/src/views/alova/request/index.vue @@ -0,0 +1,63 @@ + + + + + diff --git a/src/views/alova/scenes/index.vue b/src/views/alova/scenes/index.vue new file mode 100644 index 00000000..173f504a --- /dev/null +++ b/src/views/alova/scenes/index.vue @@ -0,0 +1,34 @@ + + + + + diff --git a/src/views/alova/scenes/modules/BrowserVisivilityRequest.vue b/src/views/alova/scenes/modules/BrowserVisivilityRequest.vue new file mode 100644 index 00000000..0a770450 --- /dev/null +++ b/src/views/alova/scenes/modules/BrowserVisivilityRequest.vue @@ -0,0 +1,43 @@ + + + diff --git a/src/views/alova/scenes/modules/Captcha.vue b/src/views/alova/scenes/modules/Captcha.vue new file mode 100644 index 00000000..c5f3ab6f --- /dev/null +++ b/src/views/alova/scenes/modules/Captcha.vue @@ -0,0 +1,71 @@ + + + + + diff --git a/src/views/alova/scenes/modules/CrossComponentRequest.vue b/src/views/alova/scenes/modules/CrossComponentRequest.vue new file mode 100644 index 00000000..404c3544 --- /dev/null +++ b/src/views/alova/scenes/modules/CrossComponentRequest.vue @@ -0,0 +1,13 @@ + + + diff --git a/src/views/alova/scenes/modules/NetworkToggleRequest.vue b/src/views/alova/scenes/modules/NetworkToggleRequest.vue new file mode 100644 index 00000000..f11bd5a0 --- /dev/null +++ b/src/views/alova/scenes/modules/NetworkToggleRequest.vue @@ -0,0 +1,43 @@ + + + diff --git a/src/views/alova/scenes/modules/PollingRequest.vue b/src/views/alova/scenes/modules/PollingRequest.vue new file mode 100644 index 00000000..0efbf5c6 --- /dev/null +++ b/src/views/alova/scenes/modules/PollingRequest.vue @@ -0,0 +1,41 @@ + + + diff --git a/src/views/alova/user/hooks/useCheckedColumns.ts b/src/views/alova/user/hooks/useCheckedColumns.ts new file mode 100644 index 00000000..2d4edfa4 --- /dev/null +++ b/src/views/alova/user/hooks/useCheckedColumns.ts @@ -0,0 +1,78 @@ +import type { TableColumnCheck } from '@sa/hooks'; +import { computed, ref } from 'vue'; +import type { DataTableBaseColumn, DataTableColumn } from 'naive-ui'; +import { $t } from '@/locales'; +import type { AlovaGenerics, Method } from '~/packages/alova/src'; + +function isTableColumnHasKey(column: DataTableColumn): column is DataTableBaseColumn { + return Boolean((column as NaiveUI.TableColumnWithKey).key); +} + +type TableAlovaApiFn = ( + params: R +) => Method>>; + +// this hook is used to manage table columns +// if you choose alova, you can move this hook to the `src/hooks` to handle all list page in your project +export default function useCheckedColumns>['records'][number]>( + getColumns: () => DataTableColumn[] +) { + const SELECTION_KEY = '__selection__'; + + const EXPAND_KEY = '__expand__'; + + const getColumnChecks = (cols: DataTableColumn[]) => { + const checks: NaiveUI.TableColumnCheck[] = []; + cols.forEach(column => { + if (isTableColumnHasKey(column)) { + checks.push({ + key: column.key as string, + title: column.title as string, + checked: true + }); + } else if (column.type === 'selection') { + checks.push({ + key: SELECTION_KEY, + title: $t('common.check'), + checked: true + }); + } else if (column.type === 'expand') { + checks.push({ + key: EXPAND_KEY, + title: $t('common.expandColumn'), + checked: true + }); + } + }); + + return checks; + }; + + const columnChecks = ref(getColumnChecks(getColumns())); + + const columns = computed(() => { + const cols = getColumns(); + const columnMap = new Map>(); + + cols.forEach(column => { + if (isTableColumnHasKey(column)) { + columnMap.set(column.key as string, column); + } else if (column.type === 'selection') { + columnMap.set(SELECTION_KEY, column); + } else if (column.type === 'expand') { + columnMap.set(EXPAND_KEY, column); + } + }); + + const filteredColumns = columnChecks.value + .filter(item => item.checked) + .map(check => columnMap.get(check.key) as NaiveUI.TableColumn); + + return filteredColumns; + }); + + return { + columnChecks, + columns + }; +} diff --git a/src/views/alova/user/hooks/useTableOperate.ts b/src/views/alova/user/hooks/useTableOperate.ts new file mode 100644 index 00000000..a8ed5921 --- /dev/null +++ b/src/views/alova/user/hooks/useTableOperate.ts @@ -0,0 +1,83 @@ +import { useBoolean } from '@sa/hooks'; +import type { Ref } from 'vue'; +import { ref } from 'vue'; +import { jsonClone } from '@sa/utils'; +import { $t } from '@/locales'; + +type TableData = NaiveUI.TableData; +interface Operations { + delete?: (row: T) => Promise; + batchDelete?: (rows: T[]) => Promise; +} + +// this hook is used to handle the table operations +// if you choose alova, you can move this hook to the `src/hooks` to handle all list page in your project +export default function useTableOperate(data: Ref, operations: Operations) { + const { bool: drawerVisible, setTrue: openDrawer, setFalse: closeDrawer } = useBoolean(); + const { bool: deleting, setTrue: deletify, setFalse: antiDelete } = useBoolean(); + const { bool: batchDeleting, setTrue: batchDeletify, setFalse: antiBatchDelete } = useBoolean(); + + const operateType = ref('add'); + + const getRowByDataId = (id: T['id']) => data.value.find(item => item.id === id) || null; + + function handleAdd() { + operateType.value = 'add'; + openDrawer(); + } + + /** the editing row data */ + const editingData: Ref = ref(null); + + function handleEdit(id: T['id']) { + operateType.value = 'edit'; + editingData.value = jsonClone(getRowByDataId(id)); + + openDrawer(); + } + + /** the checked row keys of table */ + const checkedRowKeys = ref([]); + + /** handler to batch delete rows */ + async function handleBatchDelete() { + batchDeletify(); + try { + const rows = checkedRowKeys.value.map(id => getRowByDataId(id)).filter(Boolean); + await operations.batchDelete?.(rows as T[]); + window.$message?.success($t('common.deleteSuccess')); + checkedRowKeys.value = []; + } finally { + antiBatchDelete(); + } + } + + /** handler to delete row */ + async function handleDelete(id: T['id']) { + deletify(); + const row = getRowByDataId(id); + if (!row) return; + try { + await operations.delete?.(row); + window.$message?.success($t('common.deleteSuccess')); + checkedRowKeys.value = []; + } finally { + antiDelete(); + } + } + + return { + drawerVisible, + openDrawer, + closeDrawer, + operateType, + handleAdd, + editingData, + handleEdit, + checkedRowKeys, + deleting, + handleDelete, + batchDeleting, + handleBatchDelete + }; +} diff --git a/src/views/alova/user/index.vue b/src/views/alova/user/index.vue new file mode 100644 index 00000000..d99d8535 --- /dev/null +++ b/src/views/alova/user/index.vue @@ -0,0 +1,226 @@ + + + + + diff --git a/src/views/alova/user/modules/user-operate-drawer.vue b/src/views/alova/user/modules/user-operate-drawer.vue new file mode 100644 index 00000000..9c07130c --- /dev/null +++ b/src/views/alova/user/modules/user-operate-drawer.vue @@ -0,0 +1,169 @@ + + + + + diff --git a/src/views/alova/user/modules/user-search.vue b/src/views/alova/user/modules/user-search.vue new file mode 100644 index 00000000..9b458d79 --- /dev/null +++ b/src/views/alova/user/modules/user-search.vue @@ -0,0 +1,113 @@ + + + + +