chore: initial commit
Some checks failed
/ playwright (push) Successful in 1m33s
/ build-and-test (push) Failing after 2m7s
CI/CD Pipeline / build-and-deploy (push) Successful in 2m16s
CI/CD Pipeline / playwright (push) Successful in 3m26s

This commit is contained in:
严浩
2025-10-15 16:24:49 +08:00
commit e50d699a2a
81 changed files with 22534 additions and 0 deletions

35
src/App.spec.ts Normal file
View 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
View 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>

View 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

View 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>

View 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>

View 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
View 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');

View 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
View 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
View 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
View 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;
}

View 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, {});
}

View 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,
},
});
}

View 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';

View 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
View 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
View 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';

View File

@@ -0,0 +1 @@
@forward 'scrollbar';

View 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
View File

@@ -0,0 +1,9 @@
declare global {
const __DEV__: boolean;
}
declare module 'vue' {
export interface ComponentCustomProperties {
__DEV__: boolean;
}
}