Commit 57c7dfd7 authored by nick zheng's avatar nick zheng

build: 项目初始化

parents
root = true
[*]
indent_style = space
charset = utf-8
indent_size = 2
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false
insert_final_newline = false
VITE_APP_ENV = 'DEV'
VITE_APP_THEME_COLOR = '#2468f2'
VITE_PORT = 8848
VITE_PUBLIC_PATH = /
VITE_ROUTER_MODE = 'hash'
VITE_VITEST = true
VITE_HIDE_HOME = false
VITE_APP_ENV = 'PROD'
VITE_APP_THEME_COLOR = '#2468f2'
VITE_PUBLIC_PATH = /
VITE_ROUTER_MODE = 'hash'
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
auto-imports.d.ts
components.d.ts
package-lock.json
pnpm dlx commitlint --edit $1
pnpm run lint:lint-staged
node_modules
build
dist
{
"recommendations": [
"Vue.volar",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"EditorConfig.EditorConfig",
"bradlc.vscode-tailwindcss",
"stylelint.vscode-stylelint",
"redhat.vscode-yaml",
"antfu.unocss"
],
"unwantedRecommendations": ["bradlc.vscode-tailwindcss"]
}
{
"editor.formatOnSave": true,
"editor.formatOnPaste": true,
"editor.tabSize": 2,
"[vue]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[css]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
},
"i18n-ally.localesPaths": [
"src/locales",
"src/locales/langs"
],
}
/** 处理环境变量 */
export const wrapperEnv = (envConf: Recordable): ViteEnv => {
const ret: ViteEnv = {
VITE_APP_ENV: 'DEV',
VITE_PORT: 8848,
VITE_PUBLIC_PATH: '/',
VITE_ROUTER_MODE: 'hash',
VITE_VITEST: true,
VITE_HIDE_HOME: false,
}
for (const envName of Object.keys(envConf)) {
let realName = envConf[envName].replace(/\\n/g, '\n')
realName = realName === 'true' ? true : realName === 'false' ? false : realName
if (envName === 'VITE_PORT') {
realName = Number(realName)
}
;(ret as any)[envName] = realName
if (typeof realName === 'string') {
process.env[envName] = realName
} else if (typeof realName === 'object') {
process.env[envName] = JSON.stringify(realName)
}
}
return ret
}
import { type PluginOption } from 'vite'
import vue from '@vitejs/plugin-vue'
import checker from 'vite-plugin-checker'
import { visualizer } from 'rollup-plugin-visualizer'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { NaiveUiResolver } from 'unplugin-vue-components/resolvers'
import VueI18nPlugin from '@intlify/unplugin-vue-i18n/vite'
import UnoCSS from 'unocss/vite'
export function setupPlugins(isBuild: boolean, envConf: ViteEnv, pathResolve: (dir: string) => string): PluginOption[] {
const lifecycle = process.env.npm_lifecycle_event
const plugins: PluginOption = [
vue(),
AutoImport({
imports: [
{
'naive-ui': ['useDialog', 'useMessage', 'useNotification', 'useLoadingBar'],
},
],
}),
Components({
resolvers: [NaiveUiResolver()],
}),
VueI18nPlugin({
include: [pathResolve('./src/locales/langs/**')],
}),
UnoCSS(),
]
if (envConf.VITE_VITEST && !isBuild) {
plugins.push(checker({ vueTsc: true }))
}
if (lifecycle === 'report') {
plugins.push(visualizer())
}
return plugins
}
import type { UserConfig } from '@commitlint/types'
const configuration: UserConfig = {
extends: ['@commitlint/config-conventional'],
rules: {
'body-leading-blank': [2, 'always'],
'footer-leading-blank': [1, 'always'],
'subject-empty': [2, 'never'],
'type-empty': [2, 'never'],
'type-enum': [
2,
'always',
[
'feat',
'fix',
'perf',
'style',
'docs',
'test',
'refactor',
'build',
'ci',
'chore',
'revert',
'types',
'release',
],
],
},
}
export default configuration
import globals from 'globals'
import pluginJs from '@eslint/js'
import tseslint from 'typescript-eslint'
import pluginVue from 'eslint-plugin-vue'
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'
import vueParser from 'vue-eslint-parser'
import tsParser from '@typescript-eslint/parser'
import unocss from '@unocss/eslint-config/flat'
export default [
unocss,
pluginJs.configs.recommended,
...tseslint.configs.recommended,
...pluginVue.configs['flat/recommended'],
{
languageOptions: {
globals: {
...globals.browser,
NodeJS: 'readonly',
Recordable: 'readonly',
ViteEnv: 'readonly',
AnyObject: 'readonly',
ConversationMessageItem: 'readonly',
ConversationMessageItemInfo: 'readonly',
},
parser: vueParser,
parserOptions: {
parser: tsParser,
sourceType: 'module',
ecmaVersion: 'latest',
ecmaFeatures: {
jsx: true,
},
},
},
rules: {
'no-console': 'warn',
'vue/attribute-hyphenation': 'error',
'vue/multi-word-component-names': 'off',
'vue/component-name-in-template-casing': [
'error',
'PascalCase',
{
registeredComponentsOnly: true,
ignores: [],
},
],
'vue/v-on-event-hyphenation': [
'error',
'always',
{
autofix: true,
ignore: [],
},
],
'@typescript-eslint/no-unused-vars': [
'error',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
},
],
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/array-type': 'error',
'unocss/order': 'off',
},
},
eslintPluginPrettierRecommended,
{
ignores: ['dist/', 'public/'],
},
]
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>IBT_CANTONESE_ADMIN</title>
</head>
<body>
<div id="app">
<style>
.blob-wrapper {
width: 100%;
padding-top: 30vh;
display: flex;
justify-content: center;
}
.blob {
width: 176px;
height: 176px;
display: grid;
background: #fff;
filter: blur(7px) contrast(10);
padding: 17.6px;
mix-blend-mode: darken;
}
.blob:before {
content: '';
margin: auto;
width: 52.8px;
height: 52.8px;
border-radius: 50%;
color: #ccc;
background: currentColor;
box-shadow:
-52.8px 0,
52.8px 0,
0 52.8px,
0 -52.8px;
animation: blob-031hhghg 1s infinite alternate;
}
@keyframes blob-031hhghg {
90%,
100% {
box-shadow:
-17.6px 0,
17.6px 0,
0 17.6px,
0 -17.6px;
transform: rotate(180deg);
}
}
</style>
<div class="blob-wrapper">
<div class="blob"></div>
</div>
</div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
export default {
'*.md': ['prettier --write'],
'package.json': ['prettier --write'],
'!(package).json': ['prettier --write--parser json'],
'*.{css,scss,postcss,less}': ['stylelint --fix', 'prettier --write'],
'*.{js,jsx,ts,tsx}': ['eslint --fix', 'prettier --write'],
'*.vue': ['eslint --fix', 'stylelint --fix', 'prettier --write', 'echo "统一格式化完成🌸"'],
}
{
"name": "poc-fe",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite",
"build": "npm run build:uat",
"build:sit": "vue-tsc --noEmit && vite build --mode development",
"build:uat": "vue-tsc --noEmit && vite build",
"preview": "vite preview",
"lint:lint-staged": "lint-staged -c ./lint-staged.config.js",
"lint:prettier": "prettier --write \"src/**/*.{js,ts,json,tsx,css,scss,vue,html,md}\"",
"lint:stylelint": "stylelint \"./src/**/*.{html,vue,css,scss}\" --fix",
"prepare": "husky",
"preinstall": "npx only-allow pnpm"
},
"dependencies": {
"@iconify/vue": "^4.1.2",
"@unocss/reset": "^0.61.3",
"@vueuse/core": "^10.11.0",
"axios": "^1.7.2",
"nanoid": "^5.0.7",
"pinia": "^2.1.7",
"vue": "^3.4.31",
"vue-i18n": "9",
"vue-router": "^4.4.0"
},
"devDependencies": {
"@commitlint/cli": "^19.3.0",
"@commitlint/config-conventional": "^19.2.2",
"@commitlint/types": "^19.0.3",
"@intlify/unplugin-vue-i18n": "^4.0.0",
"@types/node": "^20.14.10",
"@typescript-eslint/parser": "^7.15.0",
"@unocss/eslint-config": "^0.61.3",
"@vitejs/plugin-vue": "^4.6.2",
"autoprefixer": "^10.4.19",
"eslint": "^9.6.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-vue": "^9.27.0",
"globals": "^15.8.0",
"husky": "^9.0.11",
"lint-staged": "^15.2.7",
"naive-ui": "^2.38.2",
"postcss": "^8.4.39",
"postcss-html": "^1.7.0",
"prettier": "^3.3.2",
"prettier-plugin-tailwindcss": "^0.6.5",
"rollup-plugin-visualizer": "^5.12.0",
"sass": "^1.77.6",
"stylelint": "^16.6.1",
"stylelint-config-recess-order": "^4.6.0",
"stylelint-config-recommended-scss": "^14.0.0",
"stylelint-config-recommended-vue": "^1.5.0",
"stylelint-config-standard": "^36.0.1",
"stylelint-config-standard-scss": "^13.1.0",
"stylelint-order": "^6.0.4",
"typescript": "^5.5.3",
"typescript-eslint": "^7.15.0",
"unocss": "^0.61.3",
"unplugin-auto-import": "^0.17.6",
"unplugin-vue-components": "^0.26.0",
"vite": "^5.3.3",
"vite-plugin-checker": "^0.7.1",
"vue-eslint-parser": "^9.4.3",
"vue-tsc": "^2.0.26"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0",
"pnpm": ">=9"
}
}
This diff is collapsed.
export default {
plugins: {
autoprefixer: {},
},
}
export default {
plugins: ['prettier-plugin-tailwindcss'],
experimentalTernaries: false,
printWidth: 120,
tabWidth: 2,
useTabs: false,
semi: false,
singleQuote: true,
quoteProps: 'as-needed', // 对象属性引号问题
jsxSingleQuote: true,
trailingComma: 'all', // 尾随逗号
bracketSpacing: true,
bracketSameLine: false,
arrowParens: 'always', // 箭头函数单个参数 提供括号支持
requirePragma: false,
insertPragma: false,
proseWrap: 'preserve',
htmlWhitespaceSensitivity: 'css',
vueIndentScriptAndStyle: false,
endOfLine: 'auto',
singleAttributePerLine: false, // 在 HTML、Vue和JSX中强制每行使用单一属性
}
<script setup lang="ts">
import { zhCN, dateZhCN } from 'naive-ui'
import { ref } from 'vue'
import { themeOverrides } from '@/config/theme-config'
import { useResizeObserver } from '@vueuse/core'
import { useDesignSettingStore } from '@/store/modules/design-setting'
// import { NThemeEditor } from 'naive-ui'
const designSettingStore = useDesignSettingStore()
const currentLocale = ref(zhCN)
const currentDateLocale = ref(dateZhCN)
const rootContainer = ref<HTMLDivElement | null>(null)
useResizeObserver(rootContainer, (entries) => {
const entry = entries[0]
const { width } = entry.contentRect
/**
* 0 < width <= 760 隐藏侧边栏
* 760 < width <= 990 折叠侧边栏
* width > 990 展开侧边栏
*/
if (width <= 760) {
designSettingStore.toggleIsMobileLayout(true)
designSettingStore.toggleSidebarDisplayStatus('hidden')
} else if (760 < width && width <= 990) {
designSettingStore.toggleIsMobileLayout(false)
designSettingStore.toggleSidebarDisplayStatus('collapse')
} else {
designSettingStore.toggleIsMobileLayout(false)
designSettingStore.toggleSidebarDisplayStatus('expand')
}
})
</script>
<template>
<div ref="rootContainer" class="h-full w-full">
<NConfigProvider
class="h-full w-full"
:locale="currentLocale"
:date-locale="currentDateLocale"
:theme-overrides="themeOverrides"
>
<RouterView v-slot="{ Component }">
<Component :is="Component" />
</RouterView>
</NConfigProvider>
</div>
<!-- <NThemeEditor /> -->
</template>
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
<script setup lang="ts">
import { Icon } from '@iconify/vue'
defineProps<{
icon: string
}>()
</script>
<template>
<Icon :icon="icon" />
</template>
export const appConfig = {
title: 'poc-fe',
}
export const BASE_URLS: Record<'DEV' | 'PROD', string> = {
DEV: '',
PROD: '',
}
import { GlobalThemeOverrides } from 'naive-ui'
export const themeOverrides: GlobalThemeOverrides = {
common: {
primaryColor: '#2468f2',
primaryColorHover: '#528eff',
primaryColorPressed: '#2468f2',
primaryColorSuppl: '#528eff',
},
LoadingBar: {
colorLoading: '#2468f2',
},
}
<script setup lang="ts">
import { useUserStore } from '@/store/modules/user'
import { readonly } from 'vue'
import { useRouter } from 'vue-router'
const defaultAvatar = 'https://mkp-dev.oss-cn-shenzhen.aliyuncs.com/game-template/20221018/1666079174947.png'
const userStore = useUserStore()
const router = useRouter()
const avatarOptions = readonly([
{
label: '退出登录',
key: 1,
},
])
function handleDropdownSelect(key: number) {
if (key === 1) {
userStore.logout().then(() => {
router.push({ name: 'Login' })
})
}
}
</script>
<template>
<div class="flex select-none items-center justify-between bg-white px-4 shadow-[inset_0_-1px_#e8e9eb]">
<div class="flex">APPBuilder</div>
<div class="flex cursor-pointer items-center px-2">
<NDropdown trigger="click" :options="avatarOptions" @select="handleDropdownSelect">
<div class="flex h-full items-center">
<NAvatar round :size="30" object-fit="cover" :src="userStore.userInfo.avatar || defaultAvatar" />
</div>
</NDropdown>
</div>
</div>
</template>
<script setup lang="ts">
import { sidebarMenus } from '@/router/index'
import { type MenuOption } from '@/router/utils'
import { useRoute, useRouter } from 'vue-router'
import { ref, watch } from 'vue'
import CustomIcon from '@/components/custom-icon/custom-icon.vue'
const currentRoute = useRoute()
const router = useRouter()
const menuValue = ref(currentRoute.meta.belong)
watch(
() => currentRoute.fullPath,
() => {
menuValue.value = currentRoute.meta.belong
},
)
function handleUpdateValue(_key: string, menuItemOption: MenuOption) {
router.push({ name: menuItemOption.routeName })
}
// function handleToPersonAppSettingPage() {
// router.push({ name: 'PersonalAppSetting' })
// }
</script>
<template>
<!-- <button
class="mx-2 mb-[14px] flex h-[32px] w-[188px] items-center justify-center rounded-md bg-gradient-to-r from-[#005aff] to-[#60e0fd] text-white outline-none hover:opacity-80"
@click="handleToPersonAppSettingPage"
>
<CustomIcon icon="zondicons:add-solid" class="mr-1.5" />
<span>创建应用</span>
</button> -->
<ul>
<li
v-for="sidebarMenuItem in sidebarMenus"
:key="sidebarMenuItem.key"
class="my-1 flex h-10 cursor-pointer items-center rounded-md pl-3 hover:bg-[#f2f5f9]"
:class="[menuValue === sidebarMenuItem.routeName ? 'bg-[#f2f5f9] text-[#151b26]' : 'text-[#5c5f66]']"
@click="handleUpdateValue(sidebarMenuItem.routeName, sidebarMenuItem)"
>
<CustomIcon :icon="sidebarMenuItem.icon" class="mr-2 text-base" />
<span class="line-clamp-1 max-w-[150px]">{{ sidebarMenuItem.label }}</span>
</li>
</ul>
</template>
<script setup lang="ts">
import Sidebar from './components/sidebar/sidebar.vue'
import NavBar from './components/navbar/navbar.vue'
const layoutSideWidth = 221
</script>
<template>
<NLayout content-class="layout-content" position="absolute">
<NLayoutHeader class="box-border h-[56px] flex-none">
<NavBar class="h-full" />
</NLayoutHeader>
<NLayout has-sider class="h-[calc(100%-56px)]">
<NLayoutSider
class="border-r border-[#e8e9eb] px-2 pt-4"
collapse-mode="width"
:collapsed-width="64"
:width="layoutSideWidth"
>
<Sidebar />
</NLayoutSider>
<NLayoutContent class="bg-[#f3f6f9]! flex-1">
<main class="box-border h-full px-6 py-4">
<RouterView v-slot="{ Component }">
<Transition appear name="fade-slide" mode="out-in">
<Component :is="Component" />
</Transition>
</RouterView>
</main>
</NLayoutContent>
</NLayout>
</NLayout>
</template>
<style lang="scss" scoped>
:deep(.layout-content) {
@apply flex flex-col bg-[#f5f7f9];
}
.fade-slide-leave-active,
.fade-slide-enter-active {
transition: all 0.3s;
}
.fade-slide-enter-from {
opacity: 0;
transform: translateX(-30px);
}
.fade-slide-leave-to {
opacity: 0;
transform: translateX(30px);
}
</style>
import { type App } from 'vue'
import { createI18n } from 'vue-i18n'
import messages from './messages'
const i18n = createI18n({
legacy: false,
locale: 'zh-HK',
fallbackLocale: 'zh-CN',
messages,
})
export function setupI18n(app: App) {
app.use(i18n)
}
buttons:
btnLogin: '登录'
buttons:
btnLogin: '登錄'
import zhHK from './langs/zh-hk.yaml'
import zhCN from './langs/zh-cn.yaml'
const messages: Record<I18n.LangType, I18n.Schema> = {
'zh-HK': zhHK,
'zh-CN': zhCN,
}
export default messages
import { createApp } from 'vue'
import { setupStore } from './store/'
import { setupRouter } from './router'
import { setupI18n } from './locales'
import { setupDiscrete } from '@/utils/create-discrete'
import App from './app.vue'
import '@/styles/reset.scss'
import 'virtual:uno.css'
import '@unocss/reset/normalize.css'
import '@unocss/reset/tailwind.css'
async function bootstrap() {
const app = createApp(App)
setupDiscrete()
setupStore(app)
setupI18n(app)
await setupRouter(app)
// 添加meta标签,用于处理使用 Naive UI 和 Tailwind CSS 时的样式覆盖问题
const meta = document.createElement('meta')
meta.name = 'naive-ui-style'
document.head.appendChild(meta)
app.mount('#app')
}
bootstrap()
import type { Router } from 'vue-router'
import { useUserStore } from '@/store/modules/user'
/** 路由白名单 */
const whitePathList = ['/login']
export function createRouterGuards(router: Router) {
router.beforeEach((to) => {
window.$loadingBar.start()
const userStore = useUserStore()
if (userStore.isLogin && to.fullPath === '/login') {
return false
}
// 白名单直接跳过
if (whitePathList.includes(to.path)) {
return true
}
if (!userStore.isLogin && !whitePathList.includes(to.fullPath)) {
return { path: '/login', query: { redirect: encodeURIComponent(to.fullPath) } }
}
return true
})
router.afterEach((to) => {
document.title = to.meta.hiddenTitle ? '' : (to.meta.title as string) || document.title
window.$loadingBar.finish()
})
router.onError((error) => {
console.log(error, '--路由错误--')
})
}
import { type App } from 'vue'
import { createRouter, type RouteRecordRaw } from 'vue-router'
import { createRouterGuards } from './guards'
import baseRoutes from './modules/base'
import { getHistoryMode, menuFilterSort } from './utils'
/** 原始静态路由(未做任何处理) */
const routes: RouteRecordRaw[] = []
/* 自动导入全部静态路由,无需再手动引入! */
const modules: Record<string, any> = import.meta.glob(['./modules/**/*.ts', '!./modules/**/base.ts'], {
eager: true,
})
;(function () {
const routesDraft: RouteRecordRaw[] = []
Object.keys(modules).forEach((key) => {
routesDraft.push(modules[key].default)
})
routesDraft.forEach((routesItem) => {
Array.isArray(routesItem) ? routes.push(...routesItem) : routes.push(routesItem)
})
})()
/** 导出处理后的静态路由(三级及以上的路由全部拍成二级) */
// export const constantRoutes: RouteRecordRaw[] = formatTwoStageRoutes(
// formatFlatteningRoutes(ascending(routes.flat(Infinity))),
// )
/** 用于渲染菜单,保持原始层级 */
// export const constantMenus: RouteRecordRaw[] = ascending(routes.flat(Infinity)).concat(...baseRoutes)
export const sidebarMenus: any[] = menuFilterSort([...routes])
const router = createRouter({
history: getHistoryMode(import.meta.env.VITE_ROUTER_MODE),
routes: [...routes, ...baseRoutes],
strict: true,
scrollBehavior(_to, from, savedPosition) {
return new Promise((resolve) => {
if (savedPosition) {
return savedPosition
} else {
if (from.meta.saveScrollTop) {
const top: number = document.documentElement.scrollTop || document.body.scrollTop
resolve({ left: 0, top })
}
}
})
},
})
export async function setupRouter(app: App) {
app.use(router)
// 创建路由守卫
createRouterGuards(router)
await router.isReady()
}
import { type RouteRecordRaw } from 'vue-router'
export default [
{
path: '/login',
name: 'Login',
meta: {
rank: 1001,
title: '登录',
},
component: () => import('@/views/login/login.vue'),
},
// {
// path: '/404',
// name: 'NotFound',
// meta: {
// title: '未找到该页面',
// },
// component: () => import('@/views/exception/404.vue'),
// },
{
path: '/500',
name: 'ServerError',
meta: {
rank: 1001,
title: '服务器错误',
},
component: () => import('@/views/exception/500.vue'),
},
{
path: '/:pathMatch(.*)*',
name: 'Universal',
meta: {
rank: 1001,
title: '未找到该页面',
},
component: () => import('@/views/exception/404.vue'),
},
] as RouteRecordRaw[]
import { type RouteRecordRaw } from 'vue-router'
import Layout from '@/layout/index.vue'
import Home from '@/views/home/home.vue'
export default [
{
path: '/',
name: 'Root',
meta: {
rank: 1001,
title: '',
},
component: Layout,
redirect: '/home',
children: [
{
path: '/home',
name: 'Home',
meta: {
rank: 1001,
title: '首页',
icon: 'material-symbols:home-outline',
belong: 'Home',
},
component: Home,
},
],
},
] as RouteRecordRaw[]
import { nanoid } from 'nanoid/non-secure'
import { createWebHashHistory, createWebHistory, type RouteRecordRaw, type RouterHistory } from 'vue-router'
export interface MenuOption {
// label: (() => VNode) | string
label: string
key: string
routeName: string
icon: string
children?: MenuOption[]
}
export function getHistoryMode(modeString: ViteEnv['VITE_ROUTER_MODE']): RouterHistory {
if (modeString === 'h5') {
return createWebHistory()
}
return createWebHashHistory()
}
function menuSort(routes: RouteRecordRaw[]) {
routes.sort((a, b) => {
if (!a.meta || !a.meta.rank) {
return 1
}
if (!b.meta || !b.meta.rank) {
return -1
}
return (a.meta.rank as number) - (b.meta.rank as number)
})
return routes
}
export function menuFilterSort(routes: RouteRecordRaw[]) {
function createRouteKey(routes: RouteRecordRaw[]) {
routes.forEach((route, index) => {
route.meta ? (route.meta.key = nanoid()) : (route.meta = { key: nanoid(), title: '', rank: 1001 + index })
if (route.children) {
createRouteKey(route.children)
}
})
}
createRouteKey(routes)
function menuChildrenSort(routes: RouteRecordRaw[]) {
routes.forEach((routeItem) => {
if (routeItem.children) {
const newRouteChildren = menuSort(routeItem.children)
routeItem.children = newRouteChildren
menuChildrenSort(routeItem.children)
}
})
}
menuChildrenSort(routes)
menuSort(routes)
const rootRouteIndex = routes.findIndex((route) => route.name === 'Root')
if (rootRouteIndex >= 0) {
const rootRouteChildren = routes[rootRouteIndex].children || []
routes.splice(rootRouteIndex, 1)
routes.unshift(...rootRouteChildren)
}
function createMenuOptions(routes: RouteRecordRaw[]) {
const menuOptions: MenuOption[] = []
routes.forEach((route) => {
// 菜单Item隐藏判断
if (route.meta?.hideSideMenItem) {
return
}
const menuOption: MenuOption = {
// label: route.meta?.title
// ? () => h(RouterLink, { to: { name: route.name || 'Root' } }, { default: () => route.meta?.title })
// : '-',
label: (route.meta?.title as string) || '-',
key: route.meta?.key as string,
routeName: route.name as string,
icon: route.meta?.icon as string,
}
if (route.children) {
menuOption.children = createMenuOptions(route.children)
}
menuOptions.push(menuOption)
})
return menuOptions
}
return createMenuOptions(routes)
}
import type { App } from 'vue'
import { createPinia } from 'pinia'
const pinia = createPinia()
export function setupStore(app: App) {
app.use(pinia)
}
import { defineStore } from 'pinia'
export const useAppStore = defineStore('app-store', {
state: () => ({
layoutDevice: 'desktop',
isMenuCollapse: false,
}),
actions: {
toggleLayoutDevice(layoutDevice: 'desktop' | 'mobile') {
if (this.layoutDevice !== layoutDevice) {
this.layoutDevice = layoutDevice
if (layoutDevice === 'mobile') {
this.isMenuCollapse = true
}
}
},
toggleMenuCollapse(isCollapse: boolean) {
if (this.isMenuCollapse !== isCollapse) {
this.isMenuCollapse = isCollapse
}
},
},
})
import { defineStore } from 'pinia'
export const useDesignSettingStore = defineStore('design-setting-store', {
state: () => ({
sidebarDisplayStatus: 'expand', // status: 'expand' | 'collapse' | 'hidden'
isMobile: /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent),
isMobileLayout: false,
showSidebarDrawer: false,
}),
actions: {
toggleSidebarDisplayStatus(status: 'expand' | 'collapse' | 'hidden') {
this.sidebarDisplayStatus = status
},
toggleIsMobileLayout(result: boolean) {
this.isMobileLayout = result
},
changeShowSidebarDrawer(result: boolean) {
this.showSidebarDrawer = result
},
},
})
import { defineStore } from 'pinia'
import { ss } from '@/utils/storage'
import { TOKEN, IS_LOGIN, USER_INFO } from '@/utils/storage-key'
import type { UserState, UserInfo } from '../types/user'
function getDefaultUserInfo(): UserInfo {
return {
account: '',
userAccount: '',
userId: null,
userName: '用户',
createdTime: '',
avatar: '',
}
}
export const useUserStore = defineStore('user-store', {
state: (): UserState => ({
isLogin: ss.get(IS_LOGIN),
token: ss.get(TOKEN) || '',
userInfo: ss.get(USER_INFO) || getDefaultUserInfo(),
}),
actions: {
async logout() {
this.isLogin = false
this.token = ''
this.userInfo = getDefaultUserInfo()
ss.remove(IS_LOGIN)
ss.remove(TOKEN)
ss.remove(USER_INFO)
},
updateIsLogin(status: boolean) {
this.isLogin = status
ss.set(IS_LOGIN, status)
},
updateToken(token: string) {
this.token = token
ss.set(TOKEN, token)
},
updateUserInfo(userInfo: UserInfo) {
this.userInfo = userInfo
ss.set(USER_INFO, userInfo)
},
},
})
export interface UserInfo {
account: string
userAccount: string
userId: null | number
userName: string
createdTime: string
avatar: string
}
export interface UserState {
isLogin: boolean
token: string
userInfo: UserInfo
}
@import './variable.scss';
@import './mixin.scss';
// mixin.scss
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif,
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
}
html {
box-sizing: border-box;
width: 100%;
height: 100%;
}
body {
width: 100%;
height: 100%;
margin: 0;
}
#app {
width: 100%;
height: 100%;
}
import { createDiscreteApi } from 'naive-ui'
import type { DialogApiInjection, DialogOptions, DialogReactive } from 'naive-ui/es/dialog/src/DialogProvider'
/* 拦截一层添加默认配置 */
function createDialogProvider(dialog: DialogApiInjection): DialogApiInjection {
/* 默认配置 */
const defaultConfig: DialogOptions = {
style: 'margin-top: 180px',
titleStyle: 'font-size: 16px',
contentStyle: 'margin-left: 32px',
}
return {
destroyAll() {
dialog.destroyAll()
},
create(options: DialogOptions): DialogReactive {
return dialog.create(Object.assign(defaultConfig, options))
},
success(options: DialogOptions): DialogReactive {
return dialog.success(Object.assign(defaultConfig, options))
},
warning(options: DialogOptions): DialogReactive {
return dialog.warning(Object.assign(defaultConfig, options))
},
error(options: DialogOptions): DialogReactive {
return dialog.error(Object.assign(defaultConfig, options))
},
info(options: DialogOptions): DialogReactive {
return dialog.info(Object.assign(defaultConfig, options))
},
}
}
export function setupDiscrete() {
const { message, notification, loadingBar, dialog } = createDiscreteApi(
['message', 'dialog', 'notification', 'loadingBar'],
{
configProviderProps: {
themeOverrides: {
LoadingBar: {
colorLoading: import.meta.env.VITE_APP_THEME_COLOR,
},
},
},
},
)
window.$message = message
window.$notification = notification
window.$loadingBar = loadingBar
window.$dialog = createDialogProvider(dialog)
}
export function isAllEmpty(value: string | number | any[] | AnyObject) {
if (!value && typeof value !== 'number') {
return false
}
switch (typeof value) {
case 'object': {
if (Array.isArray(value)) {
return value.length === 0
} else {
return Object.keys(value).length === 0
}
}
default:
return true
}
}
import axios, { type AxiosRequestConfig, type AxiosResponse } from 'axios'
import { BASE_URLS } from '@/config/base-url'
import { useUserStore } from '@/store/modules/user'
import { useRouter } from 'vue-router'
interface PagingInfoParams {
pageNo: number
pageSize: number
}
export interface Response<T> {
data: T
message: string | null
code: number
pagingInfo?: PagingInfoParams & { totalPages: number; totalRows: number }
}
const ENV = import.meta.env.VITE_APP_ENV
function handleLogout() {
const router = useRouter()
const currentRoute = router.currentRoute.value
router.replace({ name: 'Login', query: { redirect: encodeURIComponent(currentRoute.fullPath) } })
window.$message.warning('身份已过期,请重新登录')
}
const service = axios.create({
baseURL: `${BASE_URLS[ENV]}/api`,
timeout: 7000,
headers: {
'Content-Type': 'application/json',
},
})
service.interceptors.request.use(
(config) => {
const token = useUserStore().token
if (token) config.headers['X-Request-Token'] = token
return config
},
(error) => {
return Promise.reject(error.response)
},
)
service.interceptors.response.use(
(response: AxiosResponse) => {
if (response.status === 200) {
return response.data
}
throw new Error(response.status.toString())
},
(error) => {
const response = error.response
// 处理响应错误
if (response && [500, 502].includes(response.status) && window.location.hash === '#/500')
useRouter().push({ name: 'ServerError' })
return Promise.reject(error)
},
)
type RequestOptions = AxiosRequestConfig & { ignoreErrorCheck?: boolean }
function http<T>(
method: 'GET' | 'POST' = 'POST',
url: string,
payload?: { pagingInfo?: PagingInfoParams; [key: string]: any } | null,
options?: RequestOptions,
) {
/* 请求成功处理函数 */
const successHandler = (res: Response<T>): Promise<Response<T>> | Response<T> => {
if (res.code === 0 || options?.ignoreErrorCheck) return res
if (res.code === -10) {
handleLogout()
}
if (res.message) {
const msg = res.message.match(/\[(?<msg>[\s\S]+)\]/)?.groups?.msg
window.$message.error(msg || res.message)
}
return Promise.reject(res)
}
/* 请求失败处理函数 */
const failHandler = (error: Error) => {
throw new Error(error?.message || 'Error')
}
// 分页参数携带
if (payload?.pagingInfo) {
const { pageNo, pageSize } = payload.pagingInfo
delete payload.pagingInfo
if (pageNo && pageSize)
options
? options.params
? (options.params = { ...options.params, ...{ pageNo, pageSize } })
: (options.params = { pageNo, pageSize })
: (options = { params: { pageNo, pageSize } })
}
return method === 'GET'
? service.get<any, Response<T>>(url, Object.assign(options || {}, payload || {})).then(successHandler, failHandler)
: service.post<any, Response<T>>(url, payload || null, options || {}).then(successHandler, failHandler)
}
export const request = {
get<T>(url: string, params?: { pagingInfo?: PagingInfoParams; [key: string]: any } | null, options?: RequestOptions) {
return http<T>('GET', url, params, options)
},
post<T>(
url: string,
payload?: { pagingInfo?: PagingInfoParams; [key: string]: any } | null,
options?: RequestOptions,
) {
return http<T>('POST', url, payload, options)
},
}
export const TOKEN = 'TOKEN'
export const IS_LOGIN = 'IS_LOGIN'
export const USER_INFO = 'USER_INFO'
interface StorageData<T = any> {
data: T
expire: number | null
}
export function createLocalStorage(options?: { expire?: number | null }) {
const DEFAULT_CACHE_TIME = 60 * 60 * 24 * 7
const { expire } = Object.assign({ expire: DEFAULT_CACHE_TIME }, options)
function set<T = any>(key: string, data: T) {
const storageData: StorageData<T> = {
data,
expire: expire !== null ? new Date().getTime() + expire * 1000 : null,
}
const json = JSON.stringify(storageData)
window.localStorage.setItem(key, json)
}
function get(key: string) {
const json = window.localStorage.getItem(key)
if (json) {
let storageData: StorageData | null = null
try {
storageData = JSON.parse(json)
} catch {
// Prevent failure
}
if (storageData) {
const { data, expire } = storageData
if (expire === null || expire >= Date.now()) return data
}
remove(key)
return null
}
}
function remove(key: string) {
window.localStorage.removeItem(key)
}
function clear() {
window.localStorage.clear()
}
return { set, get, remove, clear }
}
export const ls = createLocalStorage()
export const ss = createLocalStorage({ expire: null })
/**
* @description 创建层级关系
* @param tree 树
* @param pathList 每一项的id组成的数组
* @returns 创建层级关系后的树
*/
export const buildHierarchyTree = (tree: any[], pathList = []): any => {
if (!Array.isArray(tree)) {
console.warn('tree must be an array')
return []
}
if (!tree || tree.length === 0) return []
for (const [key, node] of tree.entries()) {
node.id = key
node.parentId = pathList.length ? pathList[pathList.length - 1] : null
node.pathList = [...pathList, node.id]
const hasChildren = node.children && node.children.length > 0
if (hasChildren) {
buildHierarchyTree(node.children, node.pathList)
}
}
return tree
}
<script setup lang="ts">
function toHomePage() {}
</script>
<template>
<div class="h-screen overflow-hidden">
<div class="absolute left-10 top-10">
<n-button type="primary" @click="toHomePage">返回首页</n-button>
</div>
<div class="flex h-full items-center justify-center">
<img
class="w-10/12 transition-[width] duration-200 ease-linear sm:w-5/12"
src="@/assets/svgs/404.svg"
alt="404"
/>
</div>
</div>
</template>
<script setup lang="ts">
function toHomePage() {}
</script>
<template>
<div class="h-screen overflow-hidden">
<div class="absolute left-10 top-10">
<n-button type="primary" @click="toHomePage">返回首页</n-button>
</div>
<div class="flex h-full items-center justify-center">
<img
class="w-10/12 transition-[width] duration-200 ease-linear sm:w-5/12"
src="@/assets/svgs/500.svg"
alt="500"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
onMounted(() => {
console.log('----加载----')
console.log()
})
</script>
<template>
<h1 class="text-2xl">Home Page</h1>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { appConfig } from '@/config/app-config'
import CustomIcon from '@/components/custom-icon/custom-icon.vue'
import { FormInst } from 'naive-ui'
// import { fetchLogin } from '@/apis/user'
import { useUserStore } from '@/store/modules/user'
// import type { UserInfo } from '@/store/types/user'
import { useRouter } from 'vue-router'
const userStore = useUserStore()
const router = useRouter()
const loginForm = ref({
username: '',
password: '',
})
const loginBtnLoading = ref(false)
const loginFormRef = ref<FormInst | null>(null)
const loginFormRules = {
username: { required: true, message: '请输入用户名', trigger: 'blur' },
password: { required: true, message: '请输入密码', trigger: 'blur' },
}
function handleLogin(e: MouseEvent) {
e.preventDefault()
loginFormRef.value?.validate((errors) => {
if (!errors) {
// loginBtnLoading.value = true
userStore.updateIsLogin(true)
userStore.updateToken('res.data.token')
const userInfoRes = {
account: 'account',
userAccount: 'userAccount',
userId: 134,
userName: 'userName',
createdTime: 'createdTime',
}
userStore.updateUserInfo({
account: userInfoRes.account,
userAccount: userInfoRes.userAccount,
userId: userInfoRes.userId,
userName: userInfoRes.userName,
createdTime: userInfoRes.createdTime,
avatar: '',
})
const currentRoute = router.currentRoute.value
const redirectUrl = decodeURIComponent((currentRoute.query.redirect as string) || '')
if (redirectUrl) {
router.replace({ path: redirectUrl })
return
}
router.replace({ name: 'Root' })
// fetchLogin<{ token: string; user: UserInfo }>(loginForm.value)
// .then((res) => {
// if (res.code !== null && res.code !== 0) {
// window.$message.error(res.message || '登录失败请重试')
// return
// }
// userStore.updateIsLogin(true)
// userStore.updateToken(res.data.token)
// const userInfoRes = res.data.user || {}
// userStore.updateUserInfo({
// account: userInfoRes.account,
// userAccount: userInfoRes.userAccount,
// userId: userInfoRes.userId,
// userName: userInfoRes.userName,
// createdTime: userInfoRes.createdTime,
// avatar: '',
// })
// const currentRoute = router.currentRoute.value
// const redirectUrl = decodeURIComponent((currentRoute.query.redirect as string) || '')
// if (redirectUrl) {
// router.replace({ path: redirectUrl })
// return
// }
// router.replace({ name: 'Root' })
// })
// .catch((err) => {
// console.log(err)
// })
// .finally(() => {
// loginBtnLoading.value = false
// })
}
})
}
</script>
<template>
<div class="bg-svg-login-bg h-full w-full">
<div class="fixed left-1/2 top-1/3 w-[90%] -translate-x-1/2 -translate-y-1/3 rounded-lg bg-white p-6 sm:w-[410px]">
<div class="mb-6 flex justify-center sm:mb-7">
<div class="bg-px-logo-png h-24 w-36"></div>
</div>
<div class="mb-8 text-center text-2xl font-bold text-[#999] outline-none sm:mb-10 sm:text-2xl">
{{ appConfig.title }}
</div>
<div>
<NForm ref="loginFormRef" label-placement="left" size="large" :model="loginForm" :rules="loginFormRules">
<NFormItem path="username">
<NInput v-model:value="loginForm.username" placeholder="请输入用户名">
<template #prefix>
<CustomIcon class="h-5 w-5 text-[#868686]" icon="material-symbols:person-outline" />
</template>
</NInput>
</NFormItem>
<NFormItem path="password">
<NInput
v-model:value="loginForm.password"
type="password"
show-password-on="click"
placeholder="请输入密码"
>
<template #prefix>
<CustomIcon class="h-5 w-5 text-[#868686]" icon="mdi:lock-outline" />
</template>
</NInput>
</NFormItem>
<NFormItem class="mt-4">
<NButton
type="primary"
size="large"
block
:loading="loginBtnLoading"
:disabled="!loginForm.username || !loginForm.password"
@click="handleLogin"
>
登录
</NButton>
</NFormItem>
</NForm>
</div>
</div>
</div>
</template>
export default {
extends: [
'stylelint-config-standard',
'stylelint-config-recess-order',
'stylelint-config-standard-scss',
'stylelint-config-recommended-vue/scss',
],
plugins: ['stylelint-order'],
rules: {
'color-function-notation': 'legacy',
'scss/at-rule-no-unknown': [
true,
{
ignoreAtRules: [
'tailwind',
'apply',
'variants',
'responsive',
'screen',
'function',
'if',
'each',
'include',
'mixin',
'use',
],
},
],
'font-family-name-quotes': null,
'font-family-no-missing-generic-family-keyword': null,
'scss/at-import-partial-extension': 'always',
'alpha-value-notation': 'number',
},
}
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@build/*": ["build/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "types/**/*.d.ts"]
}
{
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.node.json"
}
]
}
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true,
"noEmit": true
},
"include": ["types/**/*.d.ts", "build/**/*.ts", "vite.config.ts"]
}
declare interface Window {
$loadingBar: import('naive-ui').LoadingBarProviderInst
$dialog: import('naive-ui').DialogProviderInst
$message: import('naive-ui').MessageProviderInst
$notification: import('naive-ui').NotificationProviderInst
}
type Recordable<T = any> = Record<string, T>
declare type AnyObject = { [k: string]: any }
declare namespace I18n {
type LangType = 'zh-HK' | 'zh-CN'
type Schema = {
buttons: {
btnLogin: string
}
}
}
import 'vue-router'
export {}
declare module 'vue-router' {
interface RouteMeta {
rank: number
title: string
}
}
/// <reference types="vite/client" />
declare interface ViteEnv {
readonly VITE_APP_ENV: 'DEV' | 'PROD'
readonly VITE_PORT: number
readonly VITE_PUBLIC_PATH: string
readonly VITE_ROUTER_MODE: 'hash' | 'h5'
readonly VITE_VITEST: boolean
readonly VITE_HIDE_HOME: boolean
}
interface ImportMetaEnv extends ViteEnv {}
interface ImportMeta {
readonly env: ImportMetaEnv
}
import { defineConfig } from 'unocss'
export default defineConfig({
rules: [
[
/^bg-svg-([\w-]+)$/,
([, fname]) => ({ 'background-image': `url(@/assets/svgs/${fname}.svg)`, 'background-size': 'cover' }),
],
[
/^bg-px-([\w-]+)-(png|jpg|gif)$/,
([, fname, suffix]) => ({
'background-image': `url(@/assets/images/${fname}.${suffix})`,
'background-size': 'cover',
}),
],
],
theme: {
colors: {
'theme-color': '#2468f2',
},
},
})
import { defineConfig, loadEnv } from 'vite'
import { resolve } from 'node:path'
import { wrapperEnv } from './build'
import { setupPlugins } from './build/plugins'
/** 当前执行node命令时文件夹的地址(工作目录) */
const root: string = process.cwd()
/** 路径查找 */
const pathResolve = (dir: string): string => {
return resolve(__dirname, '.', dir)
}
export default defineConfig(({ command, mode }) => {
const isBuild = command === 'build'
const envConf = wrapperEnv(loadEnv(mode, root))
return {
base: envConf.VITE_PUBLIC_PATH,
resolve: {
alias: {
'@': pathResolve('src'),
'@build': pathResolve('build'),
},
},
plugins: setupPlugins(isBuild, envConf, pathResolve),
server: {
host: true,
port: envConf.VITE_PORT,
},
css: {
preprocessorOptions: {
scss: {
additionalData: '@use "@/styles/index.scss" as *;',
},
},
},
}
})
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment