commit
58aacb079f
49 changed files with 3546 additions and 0 deletions
@ -0,0 +1,11 @@ |
|||
{ |
|||
"files.associations": { |
|||
"*.css": "tailwindcss" |
|||
}, |
|||
"editor.quickSuggestions": { |
|||
"strings": "on" |
|||
}, |
|||
"tailwindCSS.classAttributes": ["class", "ui"], |
|||
"tailwindCSS.classFunctions": ["defineAppConfig"] |
|||
} |
|||
|
|||
@ -0,0 +1 @@ |
|||
DATABASE_URL=postgresql://postgres:xxxxxx@localhost:6666/postgres |
|||
@ -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 |
|||
@ -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` 目录,在服务器环境执行迁移命令,省时省力。 |
|||
|
|||
## 计划 |
|||
|
|||
- [ ] 支持定时任务 |
|||
- [ ] |
|||
@ -0,0 +1,6 @@ |
|||
<template> |
|||
<NuxtLayout> |
|||
<NuxtRouteAnnouncer atomic /> |
|||
<NuxtPage /> |
|||
</NuxtLayout> |
|||
</template> |
|||
@ -0,0 +1,2 @@ |
|||
@import "tailwindcss"; |
|||
@import "@nuxt/ui"; |
|||
@ -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> |
|||
@ -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> |
|||
@ -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 |
|||
> |
|||
} |
|||
@ -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, |
|||
}) |
|||
@ -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) |
|||
}) |
|||
} |
|||
@ -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) |
|||
}) |
|||
} |
|||
@ -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 |
|||
File diff suppressed because it is too large
@ -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'] |
|||
}, |
|||
} |
|||
}, |
|||
} |
|||
}, |
|||
}) |
|||
@ -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" |
|||
} |
|||
} |
|||
@ -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 } |
|||
@ -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(), |
|||
}); |
|||
@ -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 } |
|||
@ -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! |
|||
}, |
|||
}); |
|||
@ -0,0 +1,4 @@ |
|||
|
|||
import { config } from 'dotenv'; |
|||
|
|||
config({ path: '../../.env' }); |
|||
@ -0,0 +1 @@ |
|||
export { dbGlobal } from '../database/pg/db' |
|||
@ -0,0 +1 @@ |
|||
export { users } from '../../database/pg/schema/auth' |
|||
@ -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") |
|||
); |
|||
@ -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": {} |
|||
} |
|||
} |
|||
@ -0,0 +1,13 @@ |
|||
{ |
|||
"version": "7", |
|||
"dialect": "postgresql", |
|||
"entries": [ |
|||
{ |
|||
"idx": 0, |
|||
"version": "7", |
|||
"when": 1776329125490, |
|||
"tag": "0000_init", |
|||
"breakpoints": true |
|||
} |
|||
] |
|||
} |
|||
@ -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" |
|||
} |
|||
} |
|||
@ -0,0 +1,7 @@ |
|||
|
|||
if [ -d "./migrations" ]; then |
|||
cp -r ./migrations ../../.output/ |
|||
else |
|||
echo "migrations directory not found" |
|||
exit 1 |
|||
fi |
|||
@ -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); |
|||
}); |
|||
|
|||
@ -0,0 +1,13 @@ |
|||
{ |
|||
"compilerOptions": { |
|||
"forceConsistentCasingInFileNames": true, |
|||
"strict": true, |
|||
"noEmit": true, |
|||
"skipLibCheck": true, |
|||
"target": "ESNext", |
|||
"module": "ESNext", |
|||
"moduleResolution": "Bundler", |
|||
"resolveJsonModule": true, |
|||
"allowSyntheticDefaultImports": true, |
|||
} |
|||
} |
|||
@ -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(); |
|||
@ -0,0 +1,4 @@ |
|||
{ |
|||
"name": "logger", |
|||
"sideEffects": true |
|||
} |
|||
|
After Width: | Height: | Size: 4.2 KiB |
@ -0,0 +1,2 @@ |
|||
User-Agent: * |
|||
Disallow: |
|||
@ -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 |
|||
@ -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; |
|||
@ -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 || '上传失败', |
|||
}); |
|||
} |
|||
}); |
|||
@ -0,0 +1,14 @@ |
|||
|
|||
export default defineWrappedResponseHandler(async (event) => { |
|||
return R.success({ |
|||
hello: "aa", |
|||
users: [ |
|||
{ |
|||
id: 1, |
|||
name: "aaa", |
|||
email: "aaa", |
|||
age: 23, |
|||
} |
|||
] |
|||
}) |
|||
}) |
|||
@ -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; |
|||
@ -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"; |
|||
} |
|||
}); |
|||
@ -0,0 +1,8 @@ |
|||
|
|||
if (import.meta.dev) { |
|||
console.log("plugin: 00.global"); |
|||
} |
|||
|
|||
export default defineNitroPlugin(async () => { |
|||
|
|||
}) |
|||
@ -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"); |
|||
}) |
|||
}) |
|||
@ -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); |
|||
}; |
|||
}); |
|||
@ -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 |
|||
); |
|||
}); |
|||
}); |
|||
@ -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,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 |
|||
} |
|||
}) |
|||
} |
|||
@ -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 |
|||
@ -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…
Reference in new issue