chore: initial commit
This commit is contained in:
35
src/App.spec.ts
Normal file
35
src/App.spec.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
|
||||
/*
|
||||
* https://pinia.vuejs.org/zh/cookbook/testing.html#unit-testing-components
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
component: {
|
||||
template: 'Welcome to the blogging app',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createMemoryHistory, createRouter } from 'vue-router';
|
||||
import App from './App.vue';
|
||||
|
||||
describe('App', () => {
|
||||
it('renders RouterView', async () => {
|
||||
router.push('/');
|
||||
await router.isReady();
|
||||
|
||||
const wrapper = mount(App, { global: { plugins: [router, createPinia()] } });
|
||||
expect(wrapper.text()).toContain('Welcome to the blogging app');
|
||||
});
|
||||
});
|
||||
14
src/App.vue
Normal file
14
src/App.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router';
|
||||
import { darkTheme } from 'naive-ui';
|
||||
const appStore = useAppStore();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DynamicDialog />
|
||||
<ConfirmDialog />
|
||||
<Toast />
|
||||
<n-config-provider preflight-style-disabled :theme="appStore.isDark ? darkTheme : null" abstract>
|
||||
<RouterView />
|
||||
</n-config-provider>
|
||||
</template>
|
||||
16
src/assets/icons/svgs/demo.svg
Normal file
16
src/assets/icons/svgs/demo.svg
Normal file
@@ -0,0 +1,16 @@
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- 中心圆形 -->
|
||||
<circle cx="100" cy="100" r="35" fill="#FDB813"/>
|
||||
|
||||
<!-- 光芒 -->
|
||||
<g stroke="#FDB813" stroke-width="8" stroke-linecap="round">
|
||||
<line x1="100" y1="30" x2="100" y2="50"/>
|
||||
<line x1="141" y1="41" x2="129" y2="59"/>
|
||||
<line x1="170" y1="100" x2="150" y2="100"/>
|
||||
<line x1="141" y1="159" x2="129" y2="141"/>
|
||||
<line x1="100" y1="170" x2="100" y2="150"/>
|
||||
<line x1="59" y1="159" x2="71" y2="141"/>
|
||||
<line x1="30" y1="100" x2="50" y2="100"/>
|
||||
<line x1="59" y1="41" x2="71" y2="59"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 608 B |
50
src/layouts/base-layout/base-layout-header.vue
Normal file
50
src/layouts/base-layout/base-layout-header.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
const collapsed = defineModel<boolean>('collapsed');
|
||||
const buttonRef = useTemplateRef('buttonRef');
|
||||
const appStore = useAppStore();
|
||||
function toggleCollapsed() {
|
||||
// https://github.com/tusen-ai/naive-ui/issues/3688
|
||||
// hover style 鼠标移出就会消失 如果是点击 button 会聚焦需要失去焦点才会恢复
|
||||
buttonRef.value?.$el.blur();
|
||||
collapsed.value = !collapsed.value;
|
||||
}
|
||||
|
||||
const themeLabels: Record<AppThemeMode, string> = {
|
||||
light: '浅色',
|
||||
dark: '深色',
|
||||
system: '跟随系统',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full flex items-center justify-between px-12px shadow-header dark:shadow-gray-700">
|
||||
<NTooltip :disabled="appStore.isMobile" placement="bottom-start">
|
||||
{{ collapsed ? '展开菜单' : '收起菜单' }}
|
||||
<template #trigger>
|
||||
<NButton ref="buttonRef" quaternary @click="toggleCollapsed">
|
||||
<icon-line-md:menu-fold-right v-if="collapsed" w-4.5 h-4.5 />
|
||||
<icon-line-md:menu-fold-left v-else w-4.5 h-4.5 />
|
||||
</NButton>
|
||||
</template>
|
||||
</NTooltip>
|
||||
|
||||
<NTooltip :disabled="appStore.isMobile" placement="bottom-end">
|
||||
{{ themeLabels[appStore.themeMode] }}
|
||||
<template #trigger>
|
||||
<NButton quaternary @click="appStore.cycleTheme">
|
||||
<icon-line-md:sunny-filled-loop-to-moon-filled-loop-transition
|
||||
v-if="appStore.themeMode === 'light'"
|
||||
w-4.5
|
||||
h-4.5
|
||||
/>
|
||||
<icon-line-md:moon-filled-to-sunny-filled-loop-transition
|
||||
v-else-if="appStore.themeMode === 'dark'"
|
||||
w-4.5
|
||||
h-4.5
|
||||
/>
|
||||
<icon-line-md:computer v-else w-4.5 h-4.5 />
|
||||
</NButton>
|
||||
</template>
|
||||
</NTooltip>
|
||||
</div>
|
||||
</template>
|
||||
44
src/layouts/base-layout/base-layout.vue
Normal file
44
src/layouts/base-layout/base-layout.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
import { AdminLayout } from '@sa/materials';
|
||||
import BaseLayoutHeader from './base-layout-header.vue';
|
||||
|
||||
const siderCollapse = ref(false);
|
||||
const appStore = useAppStore();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminLayout :is-mobile="appStore.isMobile" v-model:sider-collapse="siderCollapse">
|
||||
<template #header>
|
||||
<BaseLayoutHeader v-model:collapsed="siderCollapse" />
|
||||
</template>
|
||||
<template #tab>
|
||||
<div class="bg-green-100 dark:bg-green-900 text-green-900 dark:text-green-100 p-4">
|
||||
2#GlobalTab
|
||||
</div>
|
||||
</template>
|
||||
<template #sider>
|
||||
<div
|
||||
class="bg-purple-100 dark:bg-purple-900 text-purple-900 dark:text-purple-100 p-4 h-full overflow-hidden"
|
||||
>
|
||||
3#GlobalSider
|
||||
</div>
|
||||
</template>
|
||||
<div class="bg-yellow-100 dark:bg-yellow-900 text-yellow-900 dark:text-yellow-100 p-4">
|
||||
4#GlobalMenu
|
||||
</div>
|
||||
<!-- <div>GlobalContent</div> -->
|
||||
<RouterView />
|
||||
<!-- <div>ThemeDrawer</div> -->
|
||||
<template #footer>
|
||||
<div class="bg-red-100 dark:bg-red-900 text-red-900 dark:text-red-100 h-full">
|
||||
5#GlobalFooter
|
||||
</div>
|
||||
</template>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
#__SCROLL_EL_ID__ {
|
||||
@include scrollbar;
|
||||
}
|
||||
</style>
|
||||
9
src/layouts/naive-ui/AppLayout.vue
Normal file
9
src/layouts/naive-ui/AppLayout.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<script setup lang="ts"></script>
|
||||
|
||||
<template>
|
||||
<div class="app-layout min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100">
|
||||
<main class="max-w-7xl mx-auto px-4 py-8">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
12
src/main.ts
Normal file
12
src/main.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import './styles/index.ts';
|
||||
|
||||
// import { LogLevels } from 'consola';
|
||||
// consola.level = LogLevels.verbose;
|
||||
|
||||
import App from './App.vue';
|
||||
|
||||
const autoInstallModules = import.meta.glob('./plugins/!(index).ts', { eager: true });
|
||||
|
||||
import { setupPlugins } from './plugins';
|
||||
|
||||
setupPlugins(createApp(App), autoInstallModules).mount('#app');
|
||||
16
src/pages/[...path].page.vue
Normal file
16
src/pages/[...path].page.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<script lang="ts" setup>
|
||||
defineProps<{ path: string }>();
|
||||
</script>
|
||||
<template>
|
||||
<main flex-1 class="flex flex-col items-center justify-center h-full space-y-4">
|
||||
<h1>Not Found</h1>
|
||||
<p>{{ path }} does not exist.</p>
|
||||
<Button @click="$router.back()">Back</Button>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<route lang="yaml">
|
||||
props: true
|
||||
meta:
|
||||
layout: false
|
||||
</route>
|
||||
40
src/pages/index.page.vue
Normal file
40
src/pages/index.page.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
|
||||
const apiResult = ref<string>('');
|
||||
const loading = ref(false);
|
||||
|
||||
const callApi = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await fetch('/api/');
|
||||
const data = await response.json();
|
||||
apiResult.value = JSON.stringify(data, null, 2);
|
||||
} catch (error) {
|
||||
apiResult.value = `Error: ${error}`;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center p-3">
|
||||
<div class="bg-white rounded-lg shadow-md p-4 max-w-xs w-full">
|
||||
<h1 class="text-xl font-bold text-gray-800 mb-3 text-center">API 示例</h1>
|
||||
|
||||
<button
|
||||
@click="callApi"
|
||||
:disabled="loading"
|
||||
class="w-full bg-gradient-to-r from-blue-500 to-purple-600 text-white font-semibold py-1.5 px-3 rounded-md hover:from-blue-600 hover:to-purple-700 transition-all duration-200 disabled:opacity-50 shadow-sm text-sm"
|
||||
>
|
||||
{{ loading ? '调用中...' : '调用 API' }}
|
||||
</button>
|
||||
|
||||
<div v-if="apiResult" class="mt-3 bg-gray-50 rounded-md p-2">
|
||||
<h3 class="text-gray-700 font-semibold mb-1.5 text-xs">响应结果:</h3>
|
||||
<pre class="text-gray-600 text-xs overflow-x-auto">{{ apiResult }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
32
src/plugins/_.ts
Normal file
32
src/plugins/_.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { autoAnimatePlugin } from '@formkit/auto-animate/vue';
|
||||
import { createHead } from '@unhead/vue/client';
|
||||
|
||||
export function install({ app }: { app: import('vue').App<Element> }) {
|
||||
app.config.globalProperties.__DEV__ = __DEV__;
|
||||
|
||||
app.use(autoAnimatePlugin); // v-auto-animate="{ duration: 100 }"
|
||||
|
||||
app.use(createHead());
|
||||
app.config.errorHandler = (error, instance, info) => {
|
||||
console.error('Global error:', error);
|
||||
console.error('Component:', instance);
|
||||
console.error('Error Info:', info);
|
||||
// 这里你可以:
|
||||
// 1. 发送错误到日志服务
|
||||
// 2. 显示全局错误提示
|
||||
// 3. 进行错误分析和处理
|
||||
};
|
||||
|
||||
// if (import.meta.env.MODE === 'development' && '1' === ('2' as never)) {
|
||||
// // TODO: https://github.com/hu3dao/vite-plugin-debug/
|
||||
// // https://eruda.liriliri.io/zh/docs/#快速上手
|
||||
// import('eruda').then(({ default: eruda }) => {
|
||||
// eruda.init({
|
||||
// defaults: {
|
||||
// transparency: 0.9,
|
||||
// },
|
||||
// })
|
||||
// /* eruda.show(); */
|
||||
// })
|
||||
// }
|
||||
}
|
||||
24
src/plugins/index.ts
Normal file
24
src/plugins/index.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* https://github.com/antfu-collective/vitesse/blob/47618e72dfba76c77b9b85b94784d739e35c492b/src/modules/README.md
|
||||
*/
|
||||
type UserPlugin = (ctx: UserPluginContext) => void;
|
||||
type AutoInstallModule = { [K: string]: unknown; install?: UserPlugin };
|
||||
type UserPluginContext = { app: import('vue').App<Element> };
|
||||
export function setupPlugins(
|
||||
app: import('vue').App,
|
||||
modules: AutoInstallModule | Record<string, unknown>,
|
||||
) {
|
||||
console.group('🔌 Plugins');
|
||||
for (const path in modules) {
|
||||
const module = modules[path] as AutoInstallModule;
|
||||
if (module.install) {
|
||||
module.install({ app });
|
||||
console.debug(`%c✔ ${path}`, 'color: #07a');
|
||||
} else {
|
||||
if (typeof module.setupPlugins === 'function') continue;
|
||||
console.warn(`%c✘ ${path} has no install function`, 'color: #f50');
|
||||
}
|
||||
}
|
||||
console.groupEnd();
|
||||
return app;
|
||||
}
|
||||
6
src/plugins/pinia-plugin.ts
Normal file
6
src/plugins/pinia-plugin.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { PiniaColada } from '@pinia/colada';
|
||||
// import piniaPluginPersistedstate from 'pinia-plugin-persistedstate';
|
||||
export function install({ app }: { app: import('vue').App<Element> }) {
|
||||
app.use(createPinia() /* .use(piniaPluginPersistedstate) */);
|
||||
app.use(PiniaColada, {});
|
||||
}
|
||||
28
src/plugins/primevue-plugin.ts
Normal file
28
src/plugins/primevue-plugin.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* 需要把 <DynamicDialog /> <ConfirmDialog /> <Toast /> 放在 App.vue 的 template 中
|
||||
*/
|
||||
|
||||
import Aura from '@primeuix/themes/aura';
|
||||
import zhCN from 'primelocale/zh-CN.json';
|
||||
import PrimeVue from 'primevue/config';
|
||||
import StyleClass from 'primevue/styleclass';
|
||||
|
||||
export function install({ app }: { app: import('vue').App<Element> }) {
|
||||
app.directive('styleclass', StyleClass);
|
||||
app.use(PrimeVue, {
|
||||
locale: {
|
||||
...zhCN['zh-CN'],
|
||||
completed: '已上传',
|
||||
noFileChosenMessage: '未选择文件',
|
||||
pending: '待上传',
|
||||
}, // usePrimeVue().config.locale
|
||||
theme: {
|
||||
options: {
|
||||
cssLayer: false,
|
||||
darkModeSelector: '.app-dark' /* 'system' */,
|
||||
prefix: 'p',
|
||||
},
|
||||
preset: Aura,
|
||||
},
|
||||
});
|
||||
}
|
||||
58
src/plugins/router-plugin.ts
Normal file
58
src/plugins/router-plugin.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { DataLoaderPlugin } from 'unplugin-vue-router/data-loaders';
|
||||
import { setupLayouts } from 'virtual:meta-layouts';
|
||||
// import { createGetRoutes, setupLayouts } from 'virtual:generated-layouts';
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
import { handleHotUpdate, routes } from 'vue-router/auto-routes';
|
||||
|
||||
const setupLayoutsResult = setupLayouts(routes);
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: /* routes ?? */ setupLayoutsResult,
|
||||
scrollBehavior: (_to, _from, savedPosition) => {
|
||||
return savedPosition ?? { left: 0, top: 0 };
|
||||
},
|
||||
strict: true,
|
||||
});
|
||||
if (import.meta.hot) handleHotUpdate(router);
|
||||
if (__DEV__) Object.assign(globalThis, { router });
|
||||
router.onError((error) => {
|
||||
console.debug('🚨 [router error]:', error);
|
||||
});
|
||||
|
||||
export { router, setupLayoutsResult };
|
||||
export function install({ app }: { app: import('vue').App<Element> }) {
|
||||
app
|
||||
// 在路由之前注册插件
|
||||
.use(DataLoaderPlugin, { router })
|
||||
// 添加路由会触发初始导航
|
||||
.use(router);
|
||||
}
|
||||
// ========================================================================
|
||||
// =========================== Router Guards ==============================
|
||||
// ========================================================================
|
||||
{
|
||||
// 警告:路由守卫的创建顺序会影响执行流程,请勿调整
|
||||
createNProgressGuard(router);
|
||||
createLogGuard(router);
|
||||
Object.assign(globalThis, { stack: createStackGuard(router) });
|
||||
}
|
||||
|
||||
/*
|
||||
definePage({
|
||||
meta: { },
|
||||
});
|
||||
*/
|
||||
declare module 'vue-router' {
|
||||
interface RouteMeta {
|
||||
/**
|
||||
* @description 是否在菜单中隐藏
|
||||
*/
|
||||
hidden?: boolean;
|
||||
/**
|
||||
* @description 菜单标题
|
||||
*/
|
||||
title?: string;
|
||||
}
|
||||
}
|
||||
|
||||
export { createGetRoutes } from 'virtual:meta-layouts';
|
||||
17
src/plugins/vueI18n-plugin.ts
Normal file
17
src/plugins/vueI18n-plugin.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/* https://github.com/intlify/bundle-tools/tree/main/packages/unplugin-vue-i18n#static-bundle-importing
|
||||
* All i18n resources specified in the plugin `include` option can be loaded
|
||||
* at once using the import syntax
|
||||
*/
|
||||
import messages from '@intlify/unplugin-vue-i18n/messages';
|
||||
import { createI18n } from 'vue-i18n';
|
||||
|
||||
export function install({ app }: { app: import('vue').App<Element> }) {
|
||||
app.use(
|
||||
// https://vue-i18n.intlify.dev/guide/essentials/started.html#registering-the-i18n-plugin
|
||||
createI18n({
|
||||
legacy: false, // you must set `false`, to use Composition API
|
||||
locale: navigator.language,
|
||||
messages,
|
||||
}),
|
||||
);
|
||||
}
|
||||
61
src/stores/app-store.ts
Normal file
61
src/stores/app-store.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useMediaQuery, usePreferredColorScheme } from '@vueuse/core';
|
||||
import { defineStore } from 'pinia';
|
||||
import { computed, watch } from 'vue';
|
||||
|
||||
export const APP_THEME_MODES = ['light', 'dark', 'system'] as const;
|
||||
export type AppThemeMode = (typeof APP_THEME_MODES)[number];
|
||||
|
||||
const DARK_CLASS = 'app-dark';
|
||||
|
||||
export const useAppStore = defineStore('app', () => {
|
||||
const themeMode = useLocalStorage<AppThemeMode>('app-theme-mode', 'system');
|
||||
const preferredColor = usePreferredColorScheme();
|
||||
|
||||
// 计算实际使用的主题
|
||||
const actualTheme = computed(() =>
|
||||
themeMode.value === 'system'
|
||||
? preferredColor.value === 'dark'
|
||||
? 'dark'
|
||||
: 'light'
|
||||
: themeMode.value,
|
||||
);
|
||||
|
||||
// 是否是暗色主题
|
||||
const isDark = computed(() => actualTheme.value === 'dark');
|
||||
|
||||
// 是否是移动端
|
||||
const isMobile = useMediaQuery('(max-width: 768px)');
|
||||
|
||||
// 更新 DOM 类名
|
||||
function updateDomClass() {
|
||||
document.documentElement.classList.toggle(DARK_CLASS, isDark.value);
|
||||
}
|
||||
|
||||
// 设置主题
|
||||
function setTheme(mode: AppThemeMode) {
|
||||
themeMode.value = mode;
|
||||
}
|
||||
|
||||
// 循环切换主题
|
||||
function cycleTheme() {
|
||||
const currentIndex = APP_THEME_MODES.indexOf(themeMode.value);
|
||||
const nextIndex = (currentIndex + 1) % APP_THEME_MODES.length;
|
||||
setTheme(APP_THEME_MODES[nextIndex]!);
|
||||
}
|
||||
|
||||
// 监听主题变化,更新 DOM
|
||||
watch(isDark, updateDomClass, { immediate: true });
|
||||
|
||||
return {
|
||||
themeMode,
|
||||
actualTheme,
|
||||
isDark,
|
||||
isMobile,
|
||||
setTheme,
|
||||
cycleTheme,
|
||||
};
|
||||
});
|
||||
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.accept(acceptHMRUpdate(useAppStore, import.meta.hot));
|
||||
}
|
||||
4
src/styles/index.ts
Normal file
4
src/styles/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import 'nprogress/nprogress.css'; // <link rel="stylesheet" href="https://testingcf.jsdelivr.net/npm/nprogress/nprogress.css" />
|
||||
|
||||
//
|
||||
import 'virtual:uno.css';
|
||||
1
src/styles/scss/global.scss
Normal file
1
src/styles/scss/global.scss
Normal file
@@ -0,0 +1 @@
|
||||
@forward 'scrollbar';
|
||||
24
src/styles/scss/scrollbar.scss
Normal file
24
src/styles/scss/scrollbar.scss
Normal file
@@ -0,0 +1,24 @@
|
||||
@mixin scrollbar($size: 7px, $color: rgba(0, 0, 0, 0.5)) {
|
||||
scrollbar-color: $color transparent;
|
||||
scrollbar-width: thin;
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: $color;
|
||||
border-radius: $size;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background-color: $color;
|
||||
border-radius: $size;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: $size;
|
||||
height: $size;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track-piece {
|
||||
background-color: rgb(0 0 0 / 0%);
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
9
src/types/global.ts
Normal file
9
src/types/global.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
declare global {
|
||||
const __DEV__: boolean;
|
||||
}
|
||||
|
||||
declare module 'vue' {
|
||||
export interface ComponentCustomProperties {
|
||||
__DEV__: boolean;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user