first init

This commit is contained in:
2025-11-09 20:01:55 +08:00
commit 4c131cbc38
35 changed files with 2298 additions and 0 deletions

1
.env.development Normal file
View File

@@ -0,0 +1 @@
VITE_SERVER=http://localhost:8080

1
.env.production Normal file
View File

@@ -0,0 +1 @@
VITE_SERVER=http://localhost:8080

24
.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# 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/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

11
.prettierrc Normal file
View File

@@ -0,0 +1,11 @@
{
"tabWidth": 2,
"useTabs": false,
"semi": true,
"singleQuote": false,
"trailingComma": "all",
"bracketSpacing": true,
"objectWrap": "preserve",
"bracketSameLine": false,
"arrowParens": "always"
}

78
auto-imports.d.ts vendored Normal file
View File

@@ -0,0 +1,78 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
// biome-ignore lint: disable
export {}
declare global {
const EffectScope: typeof import('vue')['EffectScope']
const computed: typeof import('vue')['computed']
const createApp: typeof import('vue')['createApp']
const customRef: typeof import('vue')['customRef']
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
const defineComponent: typeof import('vue')['defineComponent']
const effectScope: typeof import('vue')['effectScope']
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
const getCurrentScope: typeof import('vue')['getCurrentScope']
const getCurrentWatcher: typeof import('vue')['getCurrentWatcher']
const h: typeof import('vue')['h']
const inject: typeof import('vue')['inject']
const isProxy: typeof import('vue')['isProxy']
const isReactive: typeof import('vue')['isReactive']
const isReadonly: typeof import('vue')['isReadonly']
const isRef: typeof import('vue')['isRef']
const isShallow: typeof import('vue')['isShallow']
const markRaw: typeof import('vue')['markRaw']
const nextTick: typeof import('vue')['nextTick']
const onActivated: typeof import('vue')['onActivated']
const onBeforeMount: typeof import('vue')['onBeforeMount']
const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave']
const onBeforeRouteUpdate: typeof import('vue-router')['onBeforeRouteUpdate']
const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
const onDeactivated: typeof import('vue')['onDeactivated']
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
const onMounted: typeof import('vue')['onMounted']
const onRenderTracked: typeof import('vue')['onRenderTracked']
const onRenderTriggered: typeof import('vue')['onRenderTriggered']
const onScopeDispose: typeof import('vue')['onScopeDispose']
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
const onUnmounted: typeof import('vue')['onUnmounted']
const onUpdated: typeof import('vue')['onUpdated']
const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
const provide: typeof import('vue')['provide']
const reactive: typeof import('vue')['reactive']
const readonly: typeof import('vue')['readonly']
const ref: typeof import('vue')['ref']
const resolveComponent: typeof import('vue')['resolveComponent']
const shallowReactive: typeof import('vue')['shallowReactive']
const shallowReadonly: typeof import('vue')['shallowReadonly']
const shallowRef: typeof import('vue')['shallowRef']
const toRaw: typeof import('vue')['toRaw']
const toRef: typeof import('vue')['toRef']
const toRefs: typeof import('vue')['toRefs']
const toValue: typeof import('vue')['toValue']
const triggerRef: typeof import('vue')['triggerRef']
const unref: typeof import('vue')['unref']
const useAttrs: typeof import('vue')['useAttrs']
const useCssModule: typeof import('vue')['useCssModule']
const useCssVars: typeof import('vue')['useCssVars']
const useId: typeof import('vue')['useId']
const useLink: typeof import('vue-router')['useLink']
const useModel: typeof import('vue')['useModel']
const useRoute: typeof import('vue-router')['useRoute']
const useRouter: typeof import('vue-router')['useRouter']
const useSlots: typeof import('vue')['useSlots']
const useTemplateRef: typeof import('vue')['useTemplateRef']
const watch: typeof import('vue')['watch']
const watchEffect: typeof import('vue')['watchEffect']
const watchPostEffect: typeof import('vue')['watchPostEffect']
const watchSyncEffect: typeof import('vue')['watchSyncEffect']
}
// for type re-export
declare global {
// @ts-ignore
export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, ShallowRef, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
}

1050
bun.lock Normal file

File diff suppressed because it is too large Load Diff

28
eslint.config.js Normal file
View File

@@ -0,0 +1,28 @@
import eslint from "@eslint/js";
import eslintPluginVue from "eslint-plugin-vue";
import globals from "globals";
import typescriptEslint from "typescript-eslint";
export default typescriptEslint.config(
{ ignores: ["*.d.ts", "**/coverage", "**/dist"] },
{
extends: [
eslint.configs.recommended,
...typescriptEslint.configs.recommended,
...eslintPluginVue.configs["flat/recommended"],
],
files: ["**/*.{ts,vue}"],
languageOptions: {
ecmaVersion: "latest",
sourceType: "module",
globals: globals.browser,
parserOptions: {
parser: typescriptEslint.parser,
},
},
rules: {
"vue/singleline-html-element-content-newline": "off",
"vue/max-attributes-per-line": "off",
},
},
);

View File

@@ -0,0 +1,35 @@
name: 构建并部署前端项目
on:
push:
branches: ["master"]
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: 拉取代码
uses: actions/checkout@v4
- name: 安装Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: latest
- name: 安装依赖
run: bun install
- name: 构建项目
run: bun run build
- name: 部署到服务器
uses: appleboy/scp-action@master
with:
host: ${{ secrets.SERVER_HOST }}
port: 22
username: ${{ secrets.SERVER_USER }}
password: ${{ secrets.SERVER_PASSWORD }}
source: "dist/*"
target: ${{ secrets.SERVER_TARGET_DIR }}
rm: true

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Hucky</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

45
package.json Normal file
View File

@@ -0,0 +1,45 @@
{
"name": "template-vue-hucky",
"version": "0.0.0",
"type": "module",
"private": true,
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview",
"generate:page": "node --no-warnings=ExperimentalWarning --experimental-strip-types scripts/generatePage.ts",
"gp": "node --no-warnings=ExperimentalWarning --experimental-strip-types scripts/generatePage.ts",
"generate:component": "node --no-warnings=ExperimentalWarning --experimental-strip-types scripts/generateComponent.ts",
"gc": "node --no-warnings=ExperimentalWarning --experimental-strip-types scripts/generateComponent.ts"
},
"dependencies": {
"@vueuse/core": "^13.9.0",
"axios": "^1.11.0",
"daisyui": "^5.0.50",
"motion-v": "^1.6.1",
"pinia": "^3.0.3",
"pinia-plugin-persistedstate": "^4.4.1",
"radash": "^12.1.1",
"vue": "^3.5.17",
"vue-router": "^4.5.1"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.11",
"@types/node": "^24.1.0",
"@vitejs/plugin-vue": "^6.0.0",
"@vitejs/plugin-vue-jsx": "^5.0.1",
"@vue/babel-plugin-jsx": "^1.4.0",
"@vue/tsconfig": "^0.7.0",
"eslint": "^9.35.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-vue": "^10.4.0",
"prettier": "^3.6.2",
"tailwindcss": "^4.1.11",
"typescript": "~5.8.3",
"typescript-eslint": "^8.43.0",
"unplugin-auto-import": "^20.1.0",
"vite": "^7.0.0",
"vue-tsc": "^2.2.10"
}
}

BIN
public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 888 KiB

View File

@@ -0,0 +1,132 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as readline from "node:readline";
// Constants
const PAGES_DIR = path.resolve(process.cwd(), "src/components");
const TEMPLATE = `<script lang="ts" setup>
</script>
<template>
<div>
</div>
</template>
<style scoped>
</style>
`;
/**
* Ensures the pages directory exists
*/
function ensurePagesDirectory() {
if (!fs.existsSync(PAGES_DIR)) {
console.log(`检测到 ${PAGES_DIR} 目录不存在, 正在为您创建该目录`);
fs.mkdirSync(PAGES_DIR, { recursive: true });
}
}
/**
* Formats the component name to PascalCase and ensures it ends with .vue
* @param name The input name to format
* @returns Properly formatted component name
*/
function formatComponentName(name: string): string {
// .vue extension if present
if (name.endsWith(".vue")) {
return name;
}
// Page suffix if present
if (name.endsWith("Component")) {
return `${name}.vue`;
}
// Convert to PascalCase
let baseName = name
.split(/[-_\s.]+/)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
.join("");
// Ensure name is not empty
if (!baseName) {
throw new Error("组件名称不能为空");
}
// Add Page suffix if not already present
if (!baseName.endsWith("Component")) {
baseName += "Component";
}
// Add .vue extension
return `${baseName}.vue`;
}
/**
* Creates a new Vue component file
* @param componentName The name of the component to create
*/
async function createComponent(componentName: string) {
const formattedName = formatComponentName(componentName);
const filePath = path.join(PAGES_DIR, formattedName);
// Check if file already exists
if (fs.existsSync(filePath)) {
console.error(`❌ 失败: 组件 ${formattedName} 已存在于 ${filePath}`);
return false;
}
try {
// Write the file
fs.writeFileSync(filePath, TEMPLATE);
console.log(`✅ 成功: 创建组件 ${formattedName} at ${filePath}`);
return true;
} catch (error) {
console.error(
`❌ 失败: ${error instanceof Error ? error.message : String(error)}`,
);
return false;
}
}
/**
* Main function to run the script
*/
async function main() {
ensurePagesDirectory();
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
console.log("\n🚀 组件生成器");
console.log("==========================");
try {
const componentName = await new Promise<string>((resolve) => {
rl.question(
"✨ 请输入组件名称, 程序将自动转换为 PascalCase: (例如: about-me => AboutMeComponent)\n",
(answer: string) => {
resolve(answer.trim());
},
);
});
if (!componentName) {
console.error("❌ 失败: 组件名称不能为空");
return;
}
await createComponent(componentName);
} catch (error) {
console.error(
`❌ 失败: ${error instanceof Error ? error.message : String(error)}`,
);
} finally {
rl.close();
}
}
// Run the script
main().catch(console.error);

132
scripts/generatePage.ts Normal file
View File

@@ -0,0 +1,132 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as readline from "node:readline";
// Constants
const PAGES_DIR = path.resolve(process.cwd(), "src/pages");
const TEMPLATE = `<script lang="ts" setup>
</script>
<template>
<div>
</div>
</template>
<style scoped>
</style>
`;
/**
* Ensures the pages directory exists
*/
function ensurePagesDirectory() {
if (!fs.existsSync(PAGES_DIR)) {
console.log(`检测到 ${PAGES_DIR} 目录不存在, 正在为您创建该目录`);
fs.mkdirSync(PAGES_DIR, { recursive: true });
}
}
/**
* Formats the component name to PascalCase and ensures it ends with .vue
* @param name The input name to format
* @returns Properly formatted component name
*/
function formatComponentName(name: string): string {
// .vue extension if present
if (name.endsWith(".vue")) {
return name;
}
// Page suffix if present
if (name.endsWith("Page")) {
return `${name}.vue`;
}
// Convert to PascalCase
let baseName = name
.split(/[-_\s.]+/)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
.join("");
// Ensure name is not empty
if (!baseName) {
throw new Error("页面名称不能为空");
}
// Add Page suffix if not already present
if (!baseName.endsWith("Page")) {
baseName += "Page";
}
// Add .vue extension
return `${baseName}.vue`;
}
/**
* Creates a new Vue component file
* @param componentName The name of the component to create
*/
async function createComponent(componentName: string) {
const formattedName = formatComponentName(componentName);
const filePath = path.join(PAGES_DIR, formattedName);
// Check if file already exists
if (fs.existsSync(filePath)) {
console.error(`❌ 失败: 组件 ${formattedName} 已存在于 ${filePath}`);
return false;
}
try {
// Write the file
fs.writeFileSync(filePath, TEMPLATE);
console.log(`✅ 成功: 创建组件 ${formattedName} at ${filePath}`);
return true;
} catch (error) {
console.error(
`❌ 失败: ${error instanceof Error ? error.message : String(error)}`,
);
return false;
}
}
/**
* Main function to run the script
*/
async function main() {
ensurePagesDirectory();
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
console.log("\n🚀 页面生成器");
console.log("==========================");
try {
const componentName = await new Promise<string>((resolve) => {
rl.question(
"✨ 请输入页面名称, 程序将自动转换为 PascalCase: (例如: about-me => AboutMePage)\n",
(answer: string) => {
resolve(answer.trim());
},
);
});
if (!componentName) {
console.error("❌ 失败: 页面名称不能为空");
return;
}
await createComponent(componentName);
} catch (error) {
console.error(
`❌ 失败: ${error instanceof Error ? error.message : String(error)}`,
);
} finally {
rl.close();
}
}
// Run the script
main().catch(console.error);

9
src/App.vue Normal file
View File

@@ -0,0 +1,9 @@
<script lang="ts" setup></script>
<template>
<div>
<router-view />
</div>
</template>
<style scoped></style>

19
src/apis/user.ts Normal file
View File

@@ -0,0 +1,19 @@
import http from "../utils/http";
/**
* This is an example, please remove if not needed
*/
export const getUserList = () => {
return http({
url: "/user/list",
method: "get",
});
};
export const insertUser = (data: { account: string; passowrd: string }) => {
return http({
url: "/user/insert",
method: "post",
data,
});
};

BIN
src/assets/hucky.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 888 KiB

View File

@@ -0,0 +1,16 @@
import { createEventHook } from "@vueuse/core";
export interface ChangeUserBus {
token: string;
}
export const changeUserEvent = createEventHook<ChangeUserBus>();
/**
* This is an example, please remove if not needed
*/
export const changeUserBus = {
emit: changeUserEvent.emit,
on: changeUserEvent.on,
off: changeUserEvent.off,
};

11
src/main.ts Normal file
View File

@@ -0,0 +1,11 @@
import { createApp } from "vue";
import "./style.css";
import App from "./App.vue";
import router from "./router";
import { createPinia } from "pinia";
import piniaPluginPersistedstate from "pinia-plugin-persistedstate";
const pinia = createPinia();
pinia.use(piniaPluginPersistedstate);
createApp(App).use(router).use(pinia).mount("#app");

19
src/pages/AboutPage.vue Normal file
View File

@@ -0,0 +1,19 @@
<script lang="ts" setup>
// About page component
</script>
<template>
<div class="hero min-h-screen bg-base-200">
<div class="hero-content text-center">
<div class="max-w-md">
<h1 class="text-5xl font-bold">关于我们</h1>
<p class="py-6">这是一个自动路由注册的示例页面</p>
<p>路径将自动转换为 /about</p>
</div>
</div>
</div>
</template>
<style scoped>
/* Component styles */
</style>

77
src/pages/HelpPage.vue Normal file
View File

@@ -0,0 +1,77 @@
<script lang="ts" setup>
import { range } from "radash";
import { usePagination } from "@/utils/pagination";
const merchandise = ref([
{
id: 1,
name: "商品1",
},
]);
const { currentPage, changePage, pageNumbers, totalPages, getPaginatedData } =
usePagination(() => merchandise.value);
const handlePageChange = (page: number) => {
changePage(page);
};
onMounted(async () => {
await new Promise(() => {
setTimeout(() => {
merchandise.value = Array.from(range(1, 50)).map((item) => ({
id: item,
name: `商品${item}`,
}));
}, 1000);
});
});
</script>
<template>
<div class="hero min-h-screen bg-base-200">
<div class="hero-content text-center">
<div class="max-w-md">
<h1 class="text-5xl font-bold">帮助</h1>
</div>
<div>
<ul>
<li v-for="item in getPaginatedData" :key="item.id">
{{ item.name }}
</li>
</ul>
</div>
<button
class="join-item btn btn-sm"
:disabled="currentPage === 1"
@click="handlePageChange(currentPage - 1)"
>
«
</button>
<button
v-for="page in pageNumbers"
:key="page"
class="join-item btn btn-sm"
:class="{
'btn-active': page === currentPage,
'btn-disabled': page === '...',
}"
@click="page !== '...' && handlePageChange(Number(page))"
>
{{ page }}
</button>
<button
class="join-item btn btn-sm"
:disabled="currentPage === totalPages"
@click="handlePageChange(currentPage + 1)"
>
»
</button>
</div>
</div>
</template>
<style scoped>
/* Component styles */
</style>

18
src/pages/HomePage.vue Normal file
View File

@@ -0,0 +1,18 @@
<script lang="ts" setup>
// Home page component
</script>
<template>
<div class="hero min-h-screen bg-base-200">
<div class="hero-content text-center">
<div class="max-w-md">
<h1 class="text-5xl font-bold">首页</h1>
<p class="py-6">欢迎使用自动路由系统</p>
</div>
</div>
</div>
</template>
<style scoped>
/* Component styles */
</style>

View File

@@ -0,0 +1,15 @@
<script lang="ts" setup></script>
<template>
<div class="hero min-h-screen bg-base-200">
<div class="hero-content text-center">
<div class="max-w-md">
<h1 class="text-5xl font-bold">我的</h1>
<p class="py-6">这是一个自动路由注册的示例页面</p>
<p>路径将自动转换为 /panel/me</p>
</div>
</div>
</div>
</template>
<style scoped></style>

98
src/router/index.ts Normal file
View File

@@ -0,0 +1,98 @@
import {
createRouter,
createWebHashHistory,
type RouteRecordRaw,
} from "vue-router";
/**
* Convert PascalCase to kebab-case and remove 'Page' suffix
* @param name PascalCase name (e.g., AboutMePage)
* @returns kebab-case path (e.g., about-me)
*/
function formatPathFromComponentName(name: string): string {
// Remove .vue extension if present
const baseName = name.endsWith(".vue") ? name.slice(0, -4) : name;
// Remove Page suffix if present
const withoutPageSuffix = baseName.endsWith("Page")
? baseName.slice(0, -4)
: baseName;
// Convert PascalCase to kebab-case
return withoutPageSuffix
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
.replace(/([A-Z])([A-Z][a-z])/g, "$1-$2")
.toLowerCase();
}
/**
* Extract component name from file path
* @param filePath Path to the component file
* @returns Component name without extension
*/
function getComponentNameFromPath(filePath: string): string {
// Get the file name from the path
return filePath.split("/").pop() || "";
}
/**
* Generate route path from full file path, preserving directory structure
* @param filePath Full path to the component file (e.g., "@/pages/test/AbcPage.vue")
* @returns Route path (e.g., "/test/abc")
*/
function generateRoutePathFromFilePath(filePath: string): string {
// Remove the "@/pages" prefix and get the relative path
const relativePath = filePath.replace(/^@\/pages\//, "");
// Split into directory parts and filename
const pathParts = relativePath.split("/");
const fileName = pathParts.pop() || "";
const directories = pathParts.splice(3);
// Transform the filename using existing logic
const transformedFileName = formatPathFromComponentName(fileName);
// Combine directory path with transformed filename
const fullPath =
directories.length > 0
? `/${directories.join("/")}/${transformedFileName}`
: `/${transformedFileName}`;
return fullPath;
}
/**
* Generate routes from page components using Vite's import.meta.glob
* @returns Array of route configurations
*/
function generateRoutesFromPages(): RouteRecordRaw[] {
const routes: RouteRecordRaw[] = [];
const pages = import.meta.glob("@/pages/**/*.vue");
for (const path in pages) {
const componentName = getComponentNameFromPath(path);
const routePath = generateRoutePathFromFilePath(path);
// Special case for home page
const finalPath = routePath.toLowerCase() === "/home" ? "/" : routePath;
routes.push({
path: finalPath,
name: componentName,
component: pages[path],
});
}
return routes;
}
// Generate routes from pages directory
const routes: RouteRecordRaw[] = generateRoutesFromPages();
const router = createRouter({
history: createWebHashHistory(),
routes,
});
export default router;

21
src/stores/UserStore.ts Normal file
View File

@@ -0,0 +1,21 @@
import { ref } from "vue";
import { defineStore } from "pinia";
export const useUserStore = defineStore(
"user",
() => {
const token = ref("");
const setToken = (newToken: string) => {
token.value = newToken;
};
return {
token,
setToken,
};
},
{
persist: true,
},
);

29
src/style.css Normal file
View File

@@ -0,0 +1,29 @@
@import "tailwindcss";
@plugin "daisyui";
/* Custom Scrollbar Styles */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.05);
border-radius: 10px;
}
::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.2);
border-radius: 10px;
transition: background 0.2s ease;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.3);
}
/* Firefox scrollbar styles */
* {
scrollbar-width: thin;
scrollbar-color: rgba(0, 0, 0, 0.2) rgba(0, 0, 0, 0.05);
}

5
src/types/http.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
declare interface Result<T> {
code: number;
message: string;
data: T;
}

92
src/utils/confirm.tsx Normal file
View File

@@ -0,0 +1,92 @@
import { createApp, ref } from "vue";
import { AnimatePresence, motion } from "motion-v";
export interface ConfirmOptions {
title?: string;
message: string;
confirmText?: string;
cancelText?: string;
onConfirm?: () => void;
onCancel?: () => void;
}
export function confirm(options: ConfirmOptions): void {
const confirmHideFlag = ref(false);
const defaultOptions = {
title: "确认",
confirmText: "确定",
cancelText: "取消",
onConfirm: () => {},
onCancel: () => {},
...options,
};
const handleConfirm = () => {
confirmHideFlag.value = true;
defaultOptions.onConfirm?.();
setTimeout(() => {
confirmApp.unmount();
document.body.removeChild(confirmContainer);
}, 300); // 动画结束后移除
};
const handleCancel = () => {
confirmHideFlag.value = true;
defaultOptions.onCancel?.();
setTimeout(() => {
confirmApp.unmount();
document.body.removeChild(confirmContainer);
}, 300); // 动画结束后移除
};
const confirmInstance = () => (
<AnimatePresence>
{confirmHideFlag.value ? null : (
<div class="fixed inset-0 flex items-center justify-center z-50 bg-[#00000050]">
<motion.div
class="bg-white rounded-lg shadow-xl max-w-md w-full mx-4 px-2"
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.25 }}
exit={{ opacity: 0, scale: 0.95 }}
initial={{ opacity: 0, scale: 0.95 }}
>
{defaultOptions.title && (
<div class="px-6 py-4 border-b border-gray-200">
<h3 class="text-lg font-medium text-gray-900">
{defaultOptions.title}
</h3>
</div>
)}
<div class="px-6 py-4">
<p class="text-gray-700">{defaultOptions.message}</p>
</div>
<div class="px-6 py-3 flex justify-end space-x-3">
{defaultOptions.cancelText && (
<button
onClick={handleCancel}
class="px-4 py-2 bg-gray-100 text-gray-700 rounded hover:bg-gray-200 transition-colors"
>
{defaultOptions.cancelText}
</button>
)}
{defaultOptions.confirmText && (
<button
onClick={handleConfirm}
class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
>
{defaultOptions.confirmText}
</button>
)}
</div>
</motion.div>
</div>
)}
</AnimatePresence>
);
const confirmContainer = document.createElement("div");
document.body.appendChild(confirmContainer);
const confirmApp = createApp(confirmInstance);
confirmApp.mount(confirmContainer);
}

26
src/utils/http.ts Normal file
View File

@@ -0,0 +1,26 @@
import axios, { type AxiosRequestConfig } from "axios";
import { useUserStore } from "@/stores/UserStore.ts";
const instance = axios.create({
baseURL: import.meta.env.VITE_SERVER,
});
instance.interceptors.request.use((config) => {
const store = useUserStore();
if (!store.token) {
return config;
}
const token = store.token;
if (!token) {
return config;
}
config.headers["token"] = token;
return config;
});
const http = async <T>(config: AxiosRequestConfig): Promise<Result<T>> => {
const { data } = await instance.request<Result<T>>(config);
return data;
};
export default http;

158
src/utils/pagination.ts Normal file
View File

@@ -0,0 +1,158 @@
import { ref, computed, ComputedRef, Ref } from "vue";
export interface PaginationOptions {
pageSize?: number;
initialPage?: number;
maxVisiblePages?: number;
}
export interface PaginationResult<T> {
// Pagination state
currentPage: Ref<number>;
pageSize: Ref<number>;
totalPages: ComputedRef<number>;
pageNumbers: ComputedRef<(number | string)[]>;
// Pagination methods
changePage: (page: number) => void;
nextPage: () => void;
prevPage: () => void;
// Data methods
getPaginatedData: ComputedRef<T[]>;
setTotalItems: (count: number) => void;
}
/**
* Create pagination functionality for a list of items
*
* @param data Ref to the full data array or a function that returns the full data
* @param options Pagination options
* @returns Pagination state and methods
*/
export function usePagination<T>(
data: Ref<T[]> | (() => T[]),
options: PaginationOptions = {},
): PaginationResult<T> {
// Initialize pagination state
const currentPage = ref(options.initialPage || 1);
const pageSize = ref(options.pageSize || 10);
const maxVisiblePages = options.maxVisiblePages || 5;
// Calculate if data is a ref or a function
const isDataRef = "value" in data;
// Calculate total items
const totalItems = computed(() => {
if (isDataRef) {
return (data as Ref<T[]>).value.length;
} else {
return (data as () => T[])().length;
}
});
// Calculate total pages
const totalPages = computed(() => {
return Math.ceil(totalItems.value / pageSize.value);
});
// Get paginated data
const getPaginatedData = computed(() => {
const start = (currentPage.value - 1) * pageSize.value;
const end = start + pageSize.value;
if (isDataRef) {
return (data as Ref<T[]>).value.slice(start, end);
} else {
const fullData = (data as () => T[])();
return fullData.slice(start, end);
}
});
// Generate array of page numbers for pagination
const pageNumbers = computed(() => {
const pages: (number | string)[] = [];
if (totalPages.value <= maxVisiblePages) {
// Show all pages if total pages are less than max visible
for (let i = 1; i <= totalPages.value; i++) {
pages.push(i);
}
} else {
// Always show first page
pages.push(1);
// Calculate start and end of visible page range
let startPage = Math.max(2, currentPage.value - 1);
let endPage = Math.min(totalPages.value - 1, currentPage.value + 1);
// Adjust if we're near the beginning
if (currentPage.value <= 3) {
endPage = Math.min(totalPages.value - 1, 4);
}
// Adjust if we're near the end
if (currentPage.value >= totalPages.value - 2) {
startPage = Math.max(2, totalPages.value - 3);
}
// Add ellipsis if needed before visible pages
if (startPage > 2) {
pages.push("...");
}
// Add visible page numbers
for (let i = startPage; i <= endPage; i++) {
pages.push(i);
}
// Add ellipsis if needed after visible pages
if (endPage < totalPages.value - 1) {
pages.push("...");
}
// Always show last page
pages.push(totalPages.value);
}
return pages;
});
// Change page
const changePage = (page: number) => {
if (page >= 1 && page <= totalPages.value) {
currentPage.value = page;
}
};
// Next page
const nextPage = () => {
if (currentPage.value < totalPages.value) {
currentPage.value++;
}
};
// Previous page
const prevPage = () => {
if (currentPage.value > 1) {
currentPage.value--;
}
};
// Set total items (useful for API pagination)
const setTotalItems = (count: number) => {
totalItems.value = count;
};
return {
currentPage,
pageSize,
totalPages,
pageNumbers,
changePage,
nextPage,
prevPage,
getPaginatedData,
setTotalItems,
};
}

50
src/utils/toast.tsx Normal file
View File

@@ -0,0 +1,50 @@
import { createApp, ref } from "vue";
import { AnimatePresence, motion } from "motion-v";
export interface ToastOptions {
message: string;
duration?: number;
}
export function toast(options: ToastOptions): void {
const toastHideFlag = ref(false);
const toastInstance = () => (
<AnimatePresence>
{toastHideFlag.value ? null : (
<motion.div
class="toast toast-top toast-end bg-transparent"
animate={{ opacity: 1 }}
transition={{ duration: 0.25 }}
exit={{ opacity: 0 }}
initial={{ opacity: 0 }}
>
<div class="alert">
<span>{options.message}</span>
</div>
</motion.div>
)}
</AnimatePresence>
);
const toastContainer = document.createElement("div");
document.body.appendChild(toastContainer);
const toastApp = createApp(toastInstance);
toastApp.mount(toastContainer);
setTimeout(
() => {
toastHideFlag.value = true;
},
(options.duration || 3000) + 250, // 250ms for start animation
);
setTimeout(
() => {
toastApp.unmount();
document.body.removeChild(toastContainer);
},
(options.duration || 3000) + 500, // 500ms for end animation
);
}

9
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_SERVER: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

19
tsconfig.app.json Normal file
View File

@@ -0,0 +1,19 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"verbatimModuleSyntax": false,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "auto-imports.d.ts"]
}

7
tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

26
tsconfig.node.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"types": ["node"],
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts", "scripts/**/*.ts", "auto-imports.d.ts"]
}

24
vite.config.ts Normal file
View File

@@ -0,0 +1,24 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import { fileURLToPath, URL } from "node:url";
import tailwindcss from "@tailwindcss/vite";
import vueJsx from "@vitejs/plugin-vue-jsx";
import AutoImport from "unplugin-auto-import/vite";
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
tailwindcss(),
vueJsx(),
AutoImport({
imports: ["vue", "vue-router"],
dts: true,
}),
],
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
});