Browse Source

init

tags/只集成了数据库的提交
npmrun 3 months ago
commit
58aacb079f
  1. 11
      .vscode/settings.json
  2. 1
      .env.example
  3. 24
      .gitignore
  4. 17
      README.md
  5. 6
      app/app.vue
  6. 2
      app/assets/css/main.css
  7. 76
      app/layouts/default.vue
  8. 36
      app/pages/index/index.vue
  9. 64
      app/utils/http.ts
  10. 34
      app/utils/http/factory.ts
  11. 50
      build-files/migrate/migrate-pg.js
  12. 51
      build-files/migrate/migrate-sqlite.js
  13. 8
      build-files/run.sh
  14. 2370
      bun.lock
  15. 25
      nuxt.config.ts
  16. 49
      package.json
  17. 11
      packages/drizzle-pkg/database/pg/db.ts
  18. 16
      packages/drizzle-pkg/database/pg/schema/auth.ts
  19. 10
      packages/drizzle-pkg/database/sqlite/db.ts
  20. 11
      packages/drizzle-pkg/drizzle.config.ts
  21. 4
      packages/drizzle-pkg/env.ts
  22. 1
      packages/drizzle-pkg/lib/db.ts
  23. 1
      packages/drizzle-pkg/lib/schema/auth.ts
  24. 11
      packages/drizzle-pkg/migrations/0000_init.sql
  25. 90
      packages/drizzle-pkg/migrations/meta/0000_snapshot.json
  26. 13
      packages/drizzle-pkg/migrations/meta/_journal.json
  27. 9
      packages/drizzle-pkg/package.json
  28. 7
      packages/drizzle-pkg/scripts/mv.sh
  29. 25
      packages/drizzle-pkg/seed.ts
  30. 13
      packages/drizzle-pkg/tsconfig.json
  31. 38
      packages/logger/index.ts
  32. 4
      packages/logger/package.json
  33. BIN
      public/favicon.ico
  34. 2
      public/robots.txt
  35. 7
      scripts/migrate-test.sh
  36. 12
      server/api/[...slug].ts
  37. 86
      server/api/file/upload.post.ts
  38. 14
      server/api/hello.get.ts
  39. 56
      server/api/pic/random.get.ts
  40. 88
      server/middleware/00.public.ts
  41. 8
      server/plugins/00.global.ts
  42. 36
      server/plugins/01.req-time.ts
  43. 14
      server/plugins/02.well-known-ignore.ts
  44. 43
      server/plugins/03.error-logger.ts
  45. 12
      server/service/auth/index.ts
  46. 0
      server/types/index.d.ts
  47. 38
      server/utils/handler.ts
  48. 24
      server/utils/response.ts
  49. 18
      tsconfig.json

11
.vscode/settings.json

@ -0,0 +1,11 @@
{
"files.associations": {
"*.css": "tailwindcss"
},
"editor.quickSuggestions": {
"strings": "on"
},
"tailwindCSS.classAttributes": ["class", "ui"],
"tailwindCSS.classFunctions": ["defineAppConfig"]
}

1
.env.example

@ -0,0 +1 @@
DATABASE_URL=postgresql://postgres:xxxxxx@localhost:6666/postgres

24
.gitignore

@ -0,0 +1,24 @@
# Nuxt dev/build outputs
.output
.data
.nuxt
.nitro
.cache
dist
# Node dependencies
node_modules
# Logs
logs
*.log
# Misc
.DS_Store
.fleet
.idea
# Local env files
.env
.env.*
!.env.example

17
README.md

@ -0,0 +1,17 @@
## 文档
- [nuxt4目录结构](https://nuxt.com/docs/4.x/directory-structure/app/layouts)
- [nuxt4 API](https://nuxt.com/docs/4.x/api/nuxt-config#modulesdir)
- [nitro 文档](https://nitro.build/docs/plugins)
- [drizzle 文档](https://orm.drizzle.org.cn/docs/select)
## 开发与部署
用 Linux 开发与部署,包管理器采用 bun@1.3.11。数据库为 **postgres**(通过 `DATABASE_URL` 连接;本地可参考 `.env.example` 复制为 `.env.local`)。部署时可直接打包 `.output` 目录,在服务器环境执行迁移命令,省时省力。
## 计划
- [ ] 支持定时任务
- [ ]

6
app/app.vue

@ -0,0 +1,6 @@
<template>
<NuxtLayout>
<NuxtRouteAnnouncer atomic />
<NuxtPage />
</NuxtLayout>
</template>

2
app/assets/css/main.css

@ -0,0 +1,2 @@
@import "tailwindcss";
@import "@nuxt/ui";

76
app/layouts/default.vue

@ -0,0 +1,76 @@
<template>
<UApp>
<div class="min-h-screen bg-default text-default flex flex-col">
<header class="border-b border-default">
<UContainer class="h-14 flex items-center justify-between">
<div class="flex items-center gap-2">
<NuxtLink to="/" class="font-semibold tracking-tight">
Nuxt4 Demo
</NuxtLink>
<UDropdownMenu :items="menuItems" :content="{ align: 'end' }">
<UButton color="neutral" variant="ghost" label="菜单" icon="i-lucide-menu" />
</UDropdownMenu>
</div>
<div>
<UButton color="neutral" variant="ghost" label="登录 / 注册" />
</div>
</UContainer>
</header>
<main class="flex-1">
<UContainer class="py-8">
<NuxtPage />
</UContainer>
</main>
<footer class="border-t border-default">
<UContainer class="h-12 flex items-center text-sm text-muted">
Built with Nuxt + Nuxt UI
</UContainer>
</footer>
</div>
</UApp>
</template>
<script setup lang="ts">
const menuItems = [
[
{
label: "首页",
icon: "i-lucide-house",
to: "/",
},
{
label: "文档",
icon: "i-lucide-book-open",
children: [
{
label: "Nuxt",
to: "https://nuxt.com/docs",
target: "_blank",
},
{
label: "Nuxt UI",
to: "https://ui.nuxt.com/getting-started",
target: "_blank",
},
],
},
{
label: "示例",
icon: "i-lucide-layout-grid",
children: [
{
label: "Hello API",
to: "/api/hello",
target: "_blank",
},
{
label: "首页页面",
to: "/index",
},
],
},
],
]
</script>

36
app/pages/index/index.vue

@ -0,0 +1,36 @@
<script setup lang="ts">
const { data, pending, error, refresh } = await useHttpFetch('/api/hello')
const userCount = computed(() => data.value?.users?.length ?? 0)
</script>
<template>
<h1>Person Panel</h1>
<UAlert title="Heads up!" />
<p v-if="pending">加载中...</p>
<div v-else-if="error">
<p>接口请求失败{{ error.message }}</p>
<button type="button" @click="refresh()">重试</button>
</div>
<section v-else>
<p>hello: {{ data?.hello }}</p>
<p>users count: {{ userCount }}</p>
<button type="button" @click="refresh()">刷新数据</button>
<div v-if="Array.isArray(data?.users)">
<ul>
<li v-for="user in data.users" :key="user.id">
<span v-if="user.name">姓名{{ user.name }}</span>
<span v-if="user.email" style="margin-left: 1em;">邮箱{{ user.email }}</span>
<span v-if="user.age !== undefined" style="margin-left: 1em;">年龄{{ user.age }}</span>
</li>
</ul>
</div>
<div v-else>
<p>暂无用户信息</p>
</div>
</section>
</template>

64
app/utils/http.ts

@ -0,0 +1,64 @@
import type { AsyncData, UseFetchOptions } from '#app'
import type { KeysOf, PickFrom } from '#app/composables/asyncData'
import type { NitroFetchRequest, TypedInternalResponse, AvailableRouterMethod } from 'nitropack/types'
import type { FetchError } from 'ofetch'
import type { Ref } from 'vue'
import { _useHttpFetch, _useLazyHttpFetch, type UnwrapApiResponse } from './http/factory'
type DefaultMethod<ReqT extends NitroFetchRequest> = 'get' extends AvailableRouterMethod<ReqT>
? 'get'
: AvailableRouterMethod<ReqT>
type HttpFetchOptions<
_ResT,
DataT,
PickKeys extends KeysOf<DataT>,
DefaultT,
ReqT extends NitroFetchRequest,
Method extends AvailableRouterMethod<ReqT>,
> = Omit<UseFetchOptions<_ResT, DataT, PickKeys, DefaultT, ReqT, Method>, 'transform'>
/**
* `useFetch` `data` `{ code, data, message }` `data`
*
* Nuxt的 `createUseFetch` `transform` `AsyncData['data']`
* `DataT` `UnwrapApiResponse`
*/
export function useHttpFetch<
ResT = void,
ErrorT = FetchError,
ReqT extends NitroFetchRequest = NitroFetchRequest,
Method extends AvailableRouterMethod<ReqT> = ResT extends void
? DefaultMethod<ReqT>
: AvailableRouterMethod<ReqT>,
_ResT = ResT extends void ? TypedInternalResponse<ReqT, unknown, Lowercase<Method>> : ResT,
DataT = UnwrapApiResponse<_ResT>,
PickKeys extends KeysOf<DataT> = KeysOf<DataT>,
DefaultT = undefined,
>(
url: ReqT | Ref<ReqT, ReqT> | (() => ReqT),
opts?: HttpFetchOptions<_ResT, DataT, PickKeys, DefaultT, ReqT, Method>,
): AsyncData<DefaultT | PickFrom<DataT, PickKeys>, ErrorT | undefined> {
return _useHttpFetch(url, opts) as AsyncData<DefaultT | PickFrom<DataT, PickKeys>, ErrorT | undefined>
}
export function useLazyHttpFetch<
ResT = void,
ErrorT = FetchError,
ReqT extends NitroFetchRequest = NitroFetchRequest,
Method extends AvailableRouterMethod<ReqT> = ResT extends void
? DefaultMethod<ReqT>
: AvailableRouterMethod<ReqT>,
_ResT = ResT extends void ? TypedInternalResponse<ReqT, unknown, Lowercase<Method>> : ResT,
DataT = UnwrapApiResponse<_ResT>,
PickKeys extends KeysOf<DataT> = KeysOf<DataT>,
DefaultT = undefined,
>(
url: ReqT | Ref<ReqT, ReqT> | (() => ReqT),
opts?: HttpFetchOptions<_ResT, DataT, PickKeys, DefaultT, ReqT, Method>,
): AsyncData<DefaultT | PickFrom<DataT, PickKeys>, ErrorT | undefined> {
return _useLazyHttpFetch(url, opts) as AsyncData<
DefaultT | PickFrom<DataT, PickKeys>,
ErrorT | undefined
>
}

34
app/utils/http/factory.ts

@ -0,0 +1,34 @@
import { createUseFetch } from '#imports'
/** 与 `R.success` / `R.error` 返回结构对齐 */
export type ApiResponse<T = unknown> = {
code: number
message: string
data: T
}
/** 从 Nitro 推断的响应体上剥离一层 `ApiResponse`,得到 `data` 字段类型 */
export type UnwrapApiResponse<T> = T extends ApiResponse<infer D> ? D : T
export function unwrapApiBody<T>(payload: ApiResponse<T>): T {
if (payload.code !== 0) {
throw new Error(payload.message)
}
return payload.data
}
export const request = $fetch.create({})
const httpFetchDefaults = {
retry: 0,
$fetch: request,
transform: unwrapApiBody,
}
export const _useHttpFetch = createUseFetch(httpFetchDefaults)
export const _useLazyHttpFetch = createUseFetch({
...httpFetchDefaults,
lazy: true,
})

50
build-files/migrate/migrate-pg.js

@ -0,0 +1,50 @@
import { drizzle } from 'drizzle-orm/node-postgres'
import { migrate } from 'drizzle-orm/node-postgres/migrator'
import { Pool } from 'pg'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const argv = process.argv.slice(2)
const migrationsFolderRelative = argv[0]
if (!migrationsFolderRelative) {
throw new Error('migrations 文件夹未设置')
}
export async function runMigrations() {
const databaseUrl = process.env.DATABASE_URL
if (!databaseUrl) {
throw new Error('DATABASE_URL 未设置')
}
const pool = new Pool({ connectionString: databaseUrl })
const db = drizzle(pool)
try {
console.log('🚀 开始执行 PostgreSQL 迁移...')
const migrationsFolder = path.resolve(process.cwd(), migrationsFolderRelative)
await migrate(db, {
migrationsFolder,
})
console.log('✅ PostgreSQL 迁移完成!')
} catch (err) {
console.log('❌ 迁移失败:', err)
throw err
} finally {
await pool.end()
}
}
const isMain =
process.argv[1] &&
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
if (isMain) {
runMigrations().catch((err) => {
console.error(err)
process.exit(1)
})
}

51
build-files/migrate/migrate-sqlite.js

@ -0,0 +1,51 @@
import { drizzle } from 'drizzle-orm/better-sqlite3'
import { migrate } from 'drizzle-orm/better-sqlite3/migrator'
import Database from 'better-sqlite3'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const argv = process.argv.slice(2)
const migrationsFolderRelative = argv[0]
if (!migrationsFolderRelative) {
throw new Error('migrations 文件夹未设置')
}
export async function runMigrations() {
const dbUrl = process.env.DATABASE_URL || ''
const sqlitePath = dbUrl.startsWith('file:') ? dbUrl.slice(5) : dbUrl
if (!sqlitePath) {
throw new Error('DATABASE_URL 未设置,且未提供有效的 SQLite 文件路径')
}
const sqlite = new Database(sqlitePath)
const db = drizzle(sqlite)
try {
console.log(`🚀 开始执行 SQLite 迁移... (${sqlitePath})`)
const migrationsFolder = path.resolve(process.cwd(), migrationsFolderRelative)
await migrate(db, {
migrationsFolder,
})
console.log('✅ SQLite 迁移完成!')
} catch (err) {
console.log('❌ 迁移失败:', err)
throw err
} finally {
sqlite.close()
}
}
const isMain =
process.argv[1] &&
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
if (isMain) {
runMigrations().catch((err) => {
console.error(err)
process.exit(1)
})
}

8
build-files/run.sh

@ -0,0 +1,8 @@
cd "$(dirname "$0")"
if [ -f .env ]; then
export $(grep -v '^#' .env | xargs)
fi
node server/migrate-pg.js migrations
node server/index.mjs

2370
bun.lock

File diff suppressed because it is too large

25
nuxt.config.ts

@ -0,0 +1,25 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
compatibilityDate: '2025-07-15',
modules: ['@nuxt/ui'],
css: ['~/assets/css/main.css'],
ui: {
fonts: false
},
devtools: { enabled: true },
nitro: {
typescript: {
tsConfig: {
compilerOptions: {
resolvePackageJsonExports: true,
resolvePackageJsonImports: true,
baseUrl: './',
paths: {
'drizzle-pkg': ['./packages/drizzle-pkg/lib'],
'logger': ['./packages/logger/lib']
},
}
},
}
},
})

49
package.json

@ -0,0 +1,49 @@
{
"name": "person-panel",
"type": "module",
"packageManager": "bun@1.3.11",
"workspaces": [
"packages/*"
],
"private": true,
"scripts": {
"build": "nuxt build && bun run cp:db && bun --elide-lines=0 --filter drizzle-pkg build",
"dev": "nuxt dev",
"cp:db": "cp build-files/run.sh .output/run.sh && cp .env.example .output/.env && cp -r build-files/migrate/* .output/server/",
"migrate:test": "sh scripts/migrate-test.sh",
"db:migrate": "bun --elide-lines=0 --filter drizzle-pkg migrate",
"db:generate": "bun --elide-lines=0 --filter drizzle-pkg generate --name",
"db:seed": "bun --elide-lines=0 --filter drizzle-pkg seed",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare"
},
"dependencies": {
"@nuxt/ui": "^4.6.1",
"better-sqlite3": "^12.9.0",
"dotenv": "17.4.1",
"drizzle-orm": "0.45.2",
"drizzle-pkg": "workspace:*",
"drizzle-seed": "0.3.1",
"drizzle-zod": "0.8.3",
"log4js": "6.9.1",
"logger": "workspace:*",
"mime": "4.1.0",
"multer": "2.1.1",
"nuxt": "4.4.2",
"pg": "8.20.0",
"tailwindcss": "^4.2.2",
"ufo": "1.6.3",
"vue": "3.5.32",
"vue-router": "5.0.4",
"zod": "4.3.6"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/multer": "2.1.0",
"@types/pg": "8.20.0",
"drizzle-kit": "0.31.10",
"tsx": "4.21.0",
"typescript": "6.0.2"
}
}

11
packages/drizzle-pkg/database/pg/db.ts

@ -0,0 +1,11 @@
import { drizzle } from "drizzle-orm/node-postgres";
if (process.env.NODE_ENV === 'production') {
// 打包时需要保证migrator被引入
import('drizzle-orm/node-postgres/migrator')
}
const _db = drizzle(process.env.DATABASE_URL!);
export { _db as dbGlobal }

16
packages/drizzle-pkg/database/pg/schema/auth.ts

@ -0,0 +1,16 @@
import { sql } from "drizzle-orm";
import { integer, pgTable, timestamp, varchar } from "drizzle-orm/pg-core";
export const users = pgTable("users", {
id: integer().primaryKey(),
username: varchar().notNull().unique(),
email: varchar(),
nickname: varchar(),
password: varchar().notNull(),
avatar: varchar(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at')
.defaultNow()
.$onUpdate(() => sql`CURRENT_TIMESTAMP`)
.notNull(),
});

10
packages/drizzle-pkg/database/sqlite/db.ts

@ -0,0 +1,10 @@
import { drizzle } from 'drizzle-orm/libsql';
if (process.env.NODE_ENV === 'production') {
// 打包时需要保证migrator被引入
import('drizzle-orm/better-sqlite3/migrator')
}
const _db = drizzle(process.env.DATABASE_URL!);
export { _db as dbGlobal }

11
packages/drizzle-pkg/drizzle.config.ts

@ -0,0 +1,11 @@
import './env';
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
out: './migrations',
schema: './database/pg/schema/*',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL!
},
});

4
packages/drizzle-pkg/env.ts

@ -0,0 +1,4 @@
import { config } from 'dotenv';
config({ path: '../../.env' });

1
packages/drizzle-pkg/lib/db.ts

@ -0,0 +1 @@
export { dbGlobal } from '../database/pg/db'

1
packages/drizzle-pkg/lib/schema/auth.ts

@ -0,0 +1 @@
export { users } from '../../database/pg/schema/auth'

11
packages/drizzle-pkg/migrations/0000_init.sql

@ -0,0 +1,11 @@
CREATE TABLE "users" (
"id" integer PRIMARY KEY NOT NULL,
"username" varchar NOT NULL,
"email" varchar,
"nickname" varchar,
"password" varchar NOT NULL,
"avatar" varchar,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "users_username_unique" UNIQUE("username")
);

90
packages/drizzle-pkg/migrations/meta/0000_snapshot.json

@ -0,0 +1,90 @@
{
"id": "25840823-aa2a-4e32-a6b6-70bb1e27348e",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true
},
"username": {
"name": "username",
"type": "varchar",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "varchar",
"primaryKey": false,
"notNull": false
},
"nickname": {
"name": "nickname",
"type": "varchar",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "varchar",
"primaryKey": false,
"notNull": true
},
"avatar": {
"name": "avatar",
"type": "varchar",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"users_username_unique": {
"name": "users_username_unique",
"nullsNotDistinct": false,
"columns": [
"username"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

13
packages/drizzle-pkg/migrations/meta/_journal.json

@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1776329125490,
"tag": "0000_init",
"breakpoints": true
}
]
}

9
packages/drizzle-pkg/package.json

@ -0,0 +1,9 @@
{
"name": "drizzle-pkg",
"scripts": {
"migrate": "drizzle-kit migrate",
"generate": "drizzle-kit generate",
"build": "sh scripts/mv.sh",
"seed": "bun run seed.ts"
}
}

7
packages/drizzle-pkg/scripts/mv.sh

@ -0,0 +1,7 @@
if [ -d "./migrations" ]; then
cp -r ./migrations ../../.output/
else
echo "migrations directory not found"
exit 1
fi

25
packages/drizzle-pkg/seed.ts

@ -0,0 +1,25 @@
import './env';
import { seed } from "drizzle-seed";
import { usersTable } from "./lib/schema/auth";
import { dbGlobal } from "./lib/db";
async function main() {
await seed(dbGlobal, { usersTable }).refine((f) => ({
usersTable: {
columns: {
name: f.fullName(),
age: f.int({ minValue: 18, maxValue: 60 }),
email: f.email(),
},
count: 10,
},
}));
console.log('Seed complete!');
process.exit(0);
}
main().catch(e => {
console.error(e);
process.exit(1);
});

13
packages/drizzle-pkg/tsconfig.json

@ -0,0 +1,13 @@
{
"compilerOptions": {
"forceConsistentCasingInFileNames": true,
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
}
}

38
packages/logger/index.ts

@ -0,0 +1,38 @@
import path from "node:path";
import log4js from "log4js";
import fs from "node:fs";
const logDir = path.resolve(process.cwd(), "logs");
const pathLog = path.resolve(logDir, "running.log");
if (!fs.existsSync(logDir)) {
fs.mkdirSync(logDir, { recursive: true });
}
const configureLogger = () => {
const log4jsConfig = function () {
return {
appenders: {
file: {
type: "file",
filename: pathLog,
},
console: {
type: "console",
},
},
categories: {
default: {
appenders: ["file", "console"],
level: "all",
}
},
};
};
log4js.configure(log4jsConfig());
return log4js;
}
export default configureLogger();

4
packages/logger/package.json

@ -0,0 +1,4 @@
{
"name": "logger",
"sideEffects": true
}

BIN
public/favicon.ico

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

2
public/robots.txt

@ -0,0 +1,2 @@
User-Agent: *
Disallow:

7
scripts/migrate-test.sh

@ -0,0 +1,7 @@
if [ -f .env ]; then
export $(grep -v '^#' .env | xargs)
fi
echo "DATABASE_URL: $DATABASE_URL"
node build-files/migrate/migrate-pg.js packages/drizzle-pkg/migrations

12
server/api/[...slug].ts

@ -0,0 +1,12 @@
import type { H3Event } from "h3";
const handler = eventHandler(async (event: H3Event) => {
event.node.res.statusCode = 404
return {
code: 0,
message: "该接口不存在"
}
});
export type HealthCheckData = Awaited<ReturnType<typeof handler>>;
export default handler;

86
server/api/file/upload.post.ts

@ -0,0 +1,86 @@
import multer from 'multer';
import fs from 'node:fs';
import path from 'node:path';
import { callNodeListener } from 'h3';
// 类型定义
interface IFile {
name: string;
url: string; // 前端可直接访问的 URL
path: string; // 服务器存储路径
mimeType: string;
size: number;
}
export default defineWrappedResponseHandler(async (event) => {
try {
// 存储目录
const uploadDir = path.join(process.cwd(), 'public/assets');
// 自动创建目录
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
// 配置存储
const storage = multer.diskStorage({
destination: uploadDir,
filename: (req, file, cb) => {
// 生成唯一文件名:时间戳 + 原始文件名(安全处理)
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
// 获取文件后缀
const ext = path.extname(file.originalname).toLowerCase();
// 干净的文件名(只保留字母数字)
const baseName = path.basename(file.originalname, ext).replace(/[^a-z0-9]/gi, '-');
// 最终文件名(带后缀)
const filename = `${uniqueSuffix}-${baseName}${ext}`;
cb(null, filename);
},
});
// 上传配置
const upload = multer({
storage,
limits: {
fileSize: 5 * 1024 * 1024, // 5MB 限制
},
fileFilter: (req, file, cb) => {
const allowedTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/webp'];
if (allowedTypes.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error('只支持 PNG/JPG/WebP 格式图片'));
}
},
});
// 执行上传(最多 10 个文件)
await callNodeListener(
// @ts-expect-error Nuxt 类型兼容
upload.array('file', 10),
event.node.req,
event.node.res
);
// 获取上传后的文件
// @ts-expect-error
const uploadedFiles = event.node.req.files || [];
// 格式化返回数据
const result: IFile[] = uploadedFiles.map((file: any) => ({
name: file.originalname,
url: `/public/assets/${file.filename}`, // ✅ 前端可直接访问
mimeType: file.mimetype,
size: file.size,
}));
return result;
} catch (err: any) {
console.error('上传失败:', err);
return createError({
statusCode: 400,
statusMessage: err.message || '上传失败',
});
}
});

14
server/api/hello.get.ts

@ -0,0 +1,14 @@
export default defineWrappedResponseHandler(async (event) => {
return R.success({
hello: "aa",
users: [
{
id: 1,
name: "aaa",
email: "aaa",
age: 23,
}
]
})
})

56
server/api/pic/random.get.ts

@ -0,0 +1,56 @@
import type { H3Event } from "h3";
import fs from "fs/promises";
import { resolve } from "node:path";
const handler = eventHandler(async (event: H3Event) => {
const query = getQuery(event);
if (Reflect.has(query, "auto")) {
try {
return await $fetch("https://api.miaomc.cn/image/get", { method: "get", mode: "cors" })
} catch (error) {}
try {
return await sendRedirect(
event,
encodeURI("https://api.r10086.com/樱道随机图片api接口.php?图片系列=动漫综合1"),
302
);
} catch (error) {}
return "error"
}
if (Reflect.has(query, "miaomc")) {
// return await $fetch("https://api.miaomc.cn/image/get", { method: "get", mode: "cors" })
event.node.res.statusCode = 302;
event.node.res.setHeader("location", "https://api.miaomc.cn/image/get");
return;
}
if (Reflect.has(query, "r10086")) {
// return `<!DOCTYPE html><html lang="zh"><head><meta charset="utf-8"><title>选择</title></head>
// <body><script>location.href="https://api.r10086.com/樱道随机图片api接口.php?图片系列=动漫综合1"</script></body>
// </html>`;
return await sendRedirect(
event,
encodeURI("https://api.r10086.com/樱道随机图片api接口.php?图片系列=动漫综合1"),
302
);
}
if (Reflect.has(query, "favicon")) {
const avatarPath = resolve("public", "favicon.ico")
event.node.res.setHeader("Content-Type", "image/jpeg");
return fs.readFile(avatarPath) //fs.createReadStream(avatarPath);
}
return `<!DOCTYPE html><html lang="zh"><head><meta charset="utf-8"><title>选择</title></head>
<body>
<h1></h1>
<ol>
<li><a href="/api/pic/random?auto">auto()</a></li>
<li><a href="/api/pic/random?miaomc">api.miaomc.cn</a></li>
<li><a href="/api/pic/random?r10086">r10086</a></li>
<li><a href="/api/pic/random?favicon">favicon</a></li>
</ol>
</body>
</html>`;
});
export type ReturnData = Awaited<ReturnType<typeof handler>>;
export default handler;

88
server/middleware/00.public.ts

@ -0,0 +1,88 @@
import { resolve, join, sep, extname } from "node:path";
import { promises as fsp } from "node:fs";
import {
decodePath,
withLeadingSlash,
withoutTrailingSlash,
parseURL,
} from "ufo";
import type { H3Event } from "h3";
import mime from "mime";
const METHODS = new Set(["HEAD", "GET"]);
const SAFE_BASE_DIR = resolve("public");
// 缓存配置
const CACHE_CONTROL = "public, max-age=31536000, immutable";
const NOT_MODIFIED = 304;
const FORBIDDEN = 403;
const NOT_FOUND = 404;
const SERVER_ERROR = 500;
export default eventHandler(async (event: H3Event) => {
if (!event.path.startsWith("/public")) return;
const { req, res } = event.node;
const method = req.method;
if (method && !METHODS.has(method)) return;
try {
// 安全解析路径
const url = event.path.replace(/^\/public/, "");
const pathname = decodePath(
withLeadingSlash(withoutTrailingSlash(parseURL(url).pathname))
);
const targetPath = join(SAFE_BASE_DIR, pathname);
const resolvedPath = resolve(targetPath);
// 安全校验
if (!resolvedPath.startsWith(SAFE_BASE_DIR + sep)) {
res.statusCode = FORBIDDEN;
return "Forbidden";
}
const stat = await fsp.stat(resolvedPath);
if (!stat.isFile()) {
res.statusCode = NOT_FOUND;
return "Not Found";
}
// ====================== 缓存逻辑 ======================
const mtime = stat.mtime.toUTCString();
const etag = `"${stat.mtime.getTime().toString(16)}-${stat.size.toString(16)}"`;
const contentType = mime.getType(resolvedPath) || "application/octet-stream";
// 设置缓存头
res.setHeader("Cache-Control", CACHE_CONTROL);
res.setHeader("ETag", etag);
res.setHeader("Last-Modified", mtime);
res.setHeader("Content-Type", contentType);
res.setHeader("Content-Length", stat.size);
// 禁用 keep-alive
res.setHeader("Connection", "close");
// 304 协商缓存
const ifNoneMatch = req.headers["if-none-match"];
const ifModifiedSince = req.headers["if-modified-since"];
if (ifNoneMatch === etag || (ifModifiedSince && ifModifiedSince === mtime)) {
res.statusCode = NOT_MODIFIED;
return "";
}
// ======================================================
if (method === "HEAD") return "";
return fsp.readFile(resolvedPath);
} catch (err: any) {
if (err.code === "ENOENT") {
res.statusCode = NOT_FOUND;
return "Not Found";
}
res.statusCode = SERVER_ERROR;
return "Server Error";
}
});

8
server/plugins/00.global.ts

@ -0,0 +1,8 @@
if (import.meta.dev) {
console.log("plugin: 00.global");
}
export default defineNitroPlugin(async () => {
})

36
server/plugins/01.req-time.ts

@ -0,0 +1,36 @@
import log4js from "logger";
import { randomUUID } from "crypto";
const logger = log4js.getLogger("APP")
if (import.meta.dev) {
console.log("plugin: 1.error-handler");
}
declare module "http" {
interface IncomingMessage {
$beginTime: number;
$requestId: string;
}
}
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('request', async (event) => {
const incoming = event.node.req.headers["x-request-id"];
const requestId =
typeof incoming === "string" && incoming.trim().length > 0
? incoming.trim()
: randomUUID();
event.node.req.$requestId = requestId;
event.node.req.$beginTime = new Date().getTime();
event.node.res.setHeader("X-Request-Id", requestId);
logger.info(`[${requestId}]`, `[${event.method}-${event.path}]`, "开始请求");
})
nitroApp.hooks.hook('afterResponse', async (event) => {
const requestId = event.node.req.$requestId ?? "(no-request-id)";
let curTime = new Date().getTime()
let offsetTime = curTime - event.node.req.$beginTime
logger.info(`[${requestId}]`, `[${event.method}-${event.path}]`, "请求结束,花费了", offsetTime, "ms");
})
})

14
server/plugins/02.well-known-ignore.ts

@ -0,0 +1,14 @@
if (import.meta.dev) {
console.log("plugin: 01.well-known-ignore");
}
export default defineNitroPlugin(() => {
const originalWarn = console.warn;
console.warn = (...args) => {
const msg = args.join(' ');
if (msg.includes('/.well-known/appspecific/com.chrome.devtools.json')) {
return;
}
originalWarn(...args);
};
});

43
server/plugins/03.error-logger.ts

@ -0,0 +1,43 @@
import log4js from "logger";
const logger = log4js.getLogger("ERROR");
const processHandlersKey = "__personPanelErrorLoggerProcessHandlers";
function installProcessErrorLogging() {
const g = globalThis as typeof globalThis & { [processHandlersKey]?: boolean };
if (g[processHandlersKey]) return;
g[processHandlersKey] = true;
process.on("unhandledRejection", (reason) => {
if (reason instanceof Error) {
logger.error("[unhandledRejection]", reason.message, reason.stack ?? "");
} else {
logger.error("[unhandledRejection]", String(reason));
}
});
process.on("uncaughtException", (err) => {
logger.error("[uncaughtException]", err.message, err.stack ?? "");
});
}
if (import.meta.dev) {
console.log("plugin: 03.error-logger");
}
export default defineNitroPlugin((nitroApp) => {
installProcessErrorLogging();
nitroApp.hooks.hook("error", (error, context) => {
const event = context.event;
const tags = Array.isArray(context.tags) ? (context.tags as string[]).join(",") : "";
logger.error(
event?.method ?? "",
event?.path ?? "(no request)",
tags ? `[${tags}]` : "",
"\n",
error
);
});
});

12
server/service/auth/index.ts

@ -0,0 +1,12 @@
import { dbGlobal } from "drizzle-pkg/lib/db";
import { usersTable } from "drizzle-pkg/lib/schema/schema";
import { eq } from "drizzle-orm";
import log4js from "logger";
const logger = log4js.getLogger("AUTH")
export async function getUsers(id: number) {
const users = await dbGlobal.select().from(usersTable)
logger.info("users (formatted): %s \n", JSON.stringify(users, null, 2));
return users;
}

0
server/types/index.d.ts

38
server/utils/handler.ts

@ -0,0 +1,38 @@
import log4js from "logger";
interface IConfig {
}
const defaultConfig: IConfig = {
}
const logger = log4js.getLogger("ERROR");
export const defineWrappedResponseHandler = <T extends EventHandlerRequest, D>(
handlerOrConfig?: EventHandler<T, D> | IConfig,
_handler?: EventHandler<T, D>,
): EventHandler<T, D> => {
const handler = typeof handlerOrConfig === 'function' ? handlerOrConfig : _handler;
if (!handler) {
throw new Error('handler or config is required');
}
const config = Object.assign({ ...defaultConfig }, typeof handlerOrConfig === 'object' ? handlerOrConfig : {});
return defineEventHandler<T>(async (event) => {
try {
const response = await handler(event)
return response
} catch (error) {
logger.error(
event?.method ?? "",
event?.path ?? "(no request)",
`[request]`,
"\n",
error
);
return error
}
})
}

24
server/utils/response.ts

@ -0,0 +1,24 @@
export const R = {
success: <T>(data: T) => {
return {
code: 0,
message: 'success',
data: data,
} as const
},
error: <T>(message: string, data: T) => {
return {
code: 1,
message: message,
data: null,
} as const
},
throwError: <T>(code: number, message: string, data: T) => {
throw createError({
statusCode: code,
statusMessage: message,
data: data,
}) as never
}
} as const

18
tsconfig.json

@ -0,0 +1,18 @@
{
// https://nuxt.com/docs/guide/concepts/typescript
"files": [],
"references": [
{
"path": "./.nuxt/tsconfig.app.json"
},
{
"path": "./.nuxt/tsconfig.server.json"
},
{
"path": "./.nuxt/tsconfig.shared.json"
},
{
"path": "./.nuxt/tsconfig.node.json"
}
]
}
Loading…
Cancel
Save