diff --git a/app/composables/useChat.ts b/app/composables/useChat.ts index 2f2ee08..e081d8c 100644 --- a/app/composables/useChat.ts +++ b/app/composables/useChat.ts @@ -8,6 +8,7 @@ */ import { request, unwrapApiBody, type ApiResponse } from "../utils/http/factory" import type { ChatMessage } from "~~/server/service/chat" +import { useAuthSession } from "./useAuthSession" // ══════ Shared SSE state (module-level singleton) ══════ const messages = ref([]) @@ -151,8 +152,14 @@ export function useChat() { } if (import.meta.client) { + const { loggedIn } = useAuthSession() onMounted(() => { - startSSE() + if (loggedIn.value) { + startSSE() + } + watch(loggedIn, (val) => { + if (val) startSSE() + }) loadHistory() }) } diff --git a/app/pages/index/index.vue b/app/pages/index/index.vue index 3e703bc..43fb41b 100644 --- a/app/pages/index/index.vue +++ b/app/pages/index/index.vue @@ -409,6 +409,59 @@ async function loadMore() { padding-bottom: 40px; } +/* ── Responsive ── */ +@media (max-width: 860px) { + .home-split { + flex-direction: column; + } + + .form-panel { + width: 100%; + border-right: none; + border-bottom: 1px solid var(--color-hairline, #e6dfd8); + } + + .form-sticky { + position: static; + padding: 28px 20px; + } + + .cards-panel { + padding: 28px 20px; + } + + .cards-grid { + column-count: 2; + column-gap: 16px; + } +} + +@media (max-width: 540px) { + .form-sticky { + padding: 24px 16px; + } + + .form-title { + font-size: 22px; + } + + .cards-panel { + padding: 20px 12px; + } + + .cards-grid { + column-count: 1; + } + + .platform-chips { + gap: 6px; + } + + .platform-chip { + padding: 5px 12px; + font-size: 12px; + } +} diff --git a/bun.lock b/bun.lock index ef939cb..8e945eb 100644 --- a/bun.lock +++ b/bun.lock @@ -22,6 +22,7 @@ "drizzle-pkg": "workspace:*", "drizzle-seed": "0.3.1", "drizzle-zod": "0.8.3", + "env": "workspace:*", "lodash-es": "4.18.1", "log4js": "6.9.1", "logger": "workspace:*", @@ -70,6 +71,14 @@ "packages/drizzle-pkg": { "name": "drizzle-pkg", }, + "packages/env": { + "name": "env", + "version": "0.1.0", + "dependencies": { + "dotenv": "17.4.1", + "zod": "4.3.6", + }, + }, "packages/logger": { "name": "logger", }, @@ -954,6 +963,8 @@ "entities": ["entities@7.0.1", "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], + "env": ["env@workspace:packages/env"], + "error-stack-parser-es": ["error-stack-parser-es@1.0.5", "https://registry.npmmirror.com/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="], "errx": ["errx@0.1.0", "https://registry.npmmirror.com/errx/-/errx-0.1.0.tgz", {}, "sha512-fZmsRiDNv07K6s2KkKFTiD2aIvECa7++PKyD5NC32tpRw46qZA3sOz+aM+/V9V0GDHxVTKLziveV4JhzBHDp9Q=="], diff --git a/nuxt.config.ts b/nuxt.config.ts index bbf69fa..9dd9447 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -54,7 +54,8 @@ export default defineNuxtConfig({ compilerOptions: { ignoreDeprecations: "6.0", paths: { - 'common': ['./packages/common'] + 'common': ['./packages/common'], + 'env': ['./packages/env'] }, } } @@ -70,7 +71,8 @@ export default defineNuxtConfig({ 'drizzle-pkg': ['./packages/drizzle-pkg'], 'cache': ['./packages/cache'], 'common': ['./packages/common'], - 'logger': ['./packages/logger'] + 'logger': ['./packages/logger'], + 'env': ['./packages/env'] }, } }, diff --git a/package.json b/package.json index 92f4c02..d47627e 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "drizzle-pkg": "workspace:*", "drizzle-seed": "0.3.1", "drizzle-zod": "0.8.3", + "env": "workspace:*", "lodash-es": "4.18.1", "log4js": "6.9.1", "logger": "workspace:*", diff --git a/packages/common/config/index.ts b/packages/common/config/index.ts index cf3cddb..53dd1f7 100644 --- a/packages/common/config/index.ts +++ b/packages/common/config/index.ts @@ -30,6 +30,8 @@ export const API_ALLOWLIST: RouteRule[] = [ { path: "/api/pic/random", methods: ["GET"] }, /** 聊天消息只读 — 公开 */ { path: "/api/chat/messages", methods: ["GET"] }, + /** 想法 — 公开读写(匿名发布) */ + { path: "/api/ideas", methods: ["GET", "POST"] }, ]; export const FRONTEND_LOGIN_PATH = "/auth/login" diff --git a/packages/drizzle-pkg/db.sqlite b/packages/drizzle-pkg/db.sqlite index 5106788..0f35a96 100644 Binary files a/packages/drizzle-pkg/db.sqlite and b/packages/drizzle-pkg/db.sqlite differ diff --git a/packages/drizzle-pkg/drizzle.config.ts b/packages/drizzle-pkg/drizzle.config.ts index 79cd1cd..12415a2 100644 --- a/packages/drizzle-pkg/drizzle.config.ts +++ b/packages/drizzle-pkg/drizzle.config.ts @@ -1,11 +1,11 @@ -import './env'; import { defineConfig } from 'drizzle-kit'; +import { env } from '../env'; export default defineConfig({ out: './migrations', schema: './lib/schema/*', dialect: 'sqlite', dbCredentials: { - url: process.env.DATABASE_URL! + url: env.DATABASE_URL, }, }); diff --git a/packages/drizzle-pkg/env.ts b/packages/drizzle-pkg/env.ts index 92a311f..6f66283 100644 --- a/packages/drizzle-pkg/env.ts +++ b/packages/drizzle-pkg/env.ts @@ -1,4 +1,4 @@ -import { config } from 'dotenv'; - -config({ path: '../../.env' }); +// Re-export env from the centralized env package. +// This file is kept for backward-compatible side-effect imports. +export { env } from '../env'; diff --git a/packages/drizzle-pkg/lib/db.ts b/packages/drizzle-pkg/lib/db.ts index 6c3809a..e279fda 100644 --- a/packages/drizzle-pkg/lib/db.ts +++ b/packages/drizzle-pkg/lib/db.ts @@ -1,17 +1,14 @@ import { drizzle } from "drizzle-orm/libsql"; import { createClient } from "@libsql/client"; import { resolveSqliteDatabaseUrl } from "./resolve-sqlite-url"; +import { env } from "../../env"; -if (process.env.NODE_ENV === "production") { +if (env.NODE_ENV === "production") { // 打包时需要保证migrator被引入 import("drizzle-orm/libsql/migrator"); } -const rawUrl = process.env.DATABASE_URL; -if (!rawUrl) { - throw new Error("DATABASE_URL 未设置"); -} -const resolvedUrl = resolveSqliteDatabaseUrl(rawUrl); +const resolvedUrl = resolveSqliteDatabaseUrl(env.DATABASE_URL); process.env.DATABASE_URL = resolvedUrl; const client = createClient({ url: resolvedUrl }); diff --git a/packages/env/index.ts b/packages/env/index.ts new file mode 100644 index 0000000..31d1442 --- /dev/null +++ b/packages/env/index.ts @@ -0,0 +1,36 @@ +import { config } from "dotenv"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { envSchema, type Env } from "./schema"; + +// ── Load .env from project root ── +// Resolve relative to this file: packages/env/ → ../../.env = project root +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const projectRoot = path.resolve(__dirname, "..", ".."); +config({ path: path.resolve(projectRoot, ".env") }); + +// ── Parse & validate ── +function parseEnv(): Env { + const raw: Record = {}; + + // Collect only the keys we care about from process.env + for (const key of Object.keys(envSchema.shape)) { + raw[key] = process.env[key]; + } + + const result = envSchema.safeParse(raw); + + if (!result.success) { + const errors = result.error.issues + .map((issue) => ` - ${issue.path.join(".")}: ${issue.message}`) + .join("\n"); + throw new Error(`Environment variable validation failed:\n${errors}`); + } + + return result.data; +} + +export const env: Readonly = parseEnv(); + +// Re-export the type for consumers +export type { Env } from "./schema"; diff --git a/packages/env/package.json b/packages/env/package.json new file mode 100644 index 0000000..8cef2b1 --- /dev/null +++ b/packages/env/package.json @@ -0,0 +1,12 @@ +{ + "name": "env", + "version": "0.1.0", + "type": "module", + "exports": { + ".": "./index.ts" + }, + "dependencies": { + "dotenv": "17.4.1", + "zod": "4.3.6" + } +} diff --git a/packages/env/schema.ts b/packages/env/schema.ts new file mode 100644 index 0000000..6b97c1d --- /dev/null +++ b/packages/env/schema.ts @@ -0,0 +1,54 @@ +import { z } from "zod"; + +/** + * Zod schema for all environment variables used across the project. + * + * Every `process.env.X` access should be replaced by importing `env` from + * this package and using the typed, validated value. + * + * To add a new env var: + * 1. Add it to this schema with the correct type, default, and validation. + * 2. The typed `env` object is auto-generated from the schema's output. + */ + +export const envSchema = z.object({ + // ── Database ── + DATABASE_URL: z.string().min(1, "DATABASE_URL is required"), + + // ── Node / Runtime ── + NODE_ENV: z + .enum(["development", "production", "test"]) + .default("development"), + + // ── Logging (logger package) ── + LOG_MAX_SIZE: z.coerce.number().int().positive().default(10 * 1024 * 1024), // 10 MB + LOG_MAX_BACKUPS: z.coerce.number().int().nonnegative().default(5), + + // ── Static file serving ── + STATIC_DIR: z.string().default("static"), + UPLOAD_SUBDIR: z.string().default("upload"), + + // ── Scheduler ── + SCHEDULER_MAX_CONCURRENCY: z.coerce.number().int().positive().default(5), + SCHEDULER_LOG_RETENTION_DAYS: z.coerce.number().int().positive().default(30), + + // ── App ── + APP_URL: z.string().min(1, "APP_URL is required"), + NITRO_PORT: z.coerce.number().int().positive(), + + // ── OAuth: GitHub ── + GITHUB_CLIENT_ID: z.string().default(""), + GITHUB_CLIENT_SECRET: z.string().default(""), + + // ── OAuth: Gitea ── + GITEA_CLIENT_ID: z.string().default(""), + GITEA_CLIENT_SECRET: z.string().default(""), + GITEA_URL: z.string().default(""), + + // ── Bootstrap (seed) ── + BOOTSTRAP_ADMIN_USERNAME: z.string().optional(), + BOOTSTRAP_ADMIN_PASSWORD: z.string().optional(), +}); + +/** Inferred type of the parsed env object. */ +export type Env = z.infer; diff --git a/packages/logger/index.ts b/packages/logger/index.ts index d038c7d..889b703 100644 --- a/packages/logger/index.ts +++ b/packages/logger/index.ts @@ -1,11 +1,12 @@ import path from "node:path"; import log4js from "log4js"; import fs from "node:fs"; +import { env } from "env"; const logDir = path.resolve(process.cwd(), "logs"); const pathLog = path.resolve(logDir, "running.log"); -const maxLogSize = Number(process.env.LOG_MAX_SIZE ?? 10 * 1024 * 1024); // 10MB -const maxBackups = Number(process.env.LOG_MAX_BACKUPS ?? 5); +const maxLogSize = env.LOG_MAX_SIZE; +const maxBackups = env.LOG_MAX_BACKUPS; if (!fs.existsSync(logDir)) { fs.mkdirSync(logDir, { recursive: true }); diff --git a/server/constants/upload.ts b/server/constants/upload.ts index 6fee1b9..f7f85fc 100644 --- a/server/constants/upload.ts +++ b/server/constants/upload.ts @@ -1,4 +1,5 @@ import path from "node:path"; +import { env } from "env"; function trimSlashes(input: string): string { return input.trim().replace(/^\/+|\/+$/g, ""); @@ -40,14 +41,10 @@ function ensureSafeSubdir(input: string, fallback: string, envName: string): str export const STATIC_PUBLIC_PREFIX = "/static"; /** 静态资源根目录(相对项目根或绝对路径),默认 `static` */ -export const STATIC_DIR = ensureConfigurableDir(process.env.STATIC_DIR ?? "static", "static", "STATIC_DIR"); +export const STATIC_DIR = ensureConfigurableDir(env.STATIC_DIR, "static", "STATIC_DIR"); /** 媒体上传子目录(相对 STATIC_DIR),默认 `upload` */ -export const UPLOAD_SUBDIR = ensureSafeSubdir( - process.env.UPLOAD_SUBDIR ?? "upload", - "upload", - "UPLOAD_SUBDIR", -); +export const UPLOAD_SUBDIR = ensureSafeSubdir(env.UPLOAD_SUBDIR, "upload", "UPLOAD_SUBDIR"); /** 媒体上传目录(与 STATIC_DIR 同为相对或绝对),默认 `static/upload` */ export const RELATIVE_ASSETS_DIR = path.join(STATIC_DIR, UPLOAD_SUBDIR); diff --git a/server/plugins/30.scheduler.ts b/server/plugins/30.scheduler.ts index 4098b32..7283edc 100644 --- a/server/plugins/30.scheduler.ts +++ b/server/plugins/30.scheduler.ts @@ -1,9 +1,10 @@ import { start, stop } from "../scheduler/engine"; import { registerTask } from "../scheduler/registry"; import cleanupLogs from "../scheduler/tasks/cleanup-logs"; +import { env } from "env"; -const MAX_CONCURRENCY = Number(process.env.SCHEDULER_MAX_CONCURRENCY) || 5; -const LOG_RETENTION_DAYS = Number(process.env.SCHEDULER_LOG_RETENTION_DAYS) || 30; +const MAX_CONCURRENCY = env.SCHEDULER_MAX_CONCURRENCY; +const LOG_RETENTION_DAYS = env.SCHEDULER_LOG_RETENTION_DAYS; if (import.meta.dev) { console.log("plugin: 03.scheduler"); diff --git a/server/service/auth/cookie.ts b/server/service/auth/cookie.ts index 56d63fa..1ba0c9a 100644 --- a/server/service/auth/cookie.ts +++ b/server/service/auth/cookie.ts @@ -5,6 +5,7 @@ import { SESSION_COOKIE_PATH, SESSION_MAX_AGE_SECONDS, } from "#server/constants/auth"; +import { env } from "env"; export function getSessionId(event: H3Event) { return getCookie(event, SESSION_COOKIE_NAME); @@ -20,7 +21,7 @@ export function setSessionCookie(event: H3Event, sessionId: string) { setCookie(event, SESSION_COOKIE_NAME, sessionId, { httpOnly: true, sameSite: "lax", - secure: process.env.NODE_ENV === "production", + secure: env.NODE_ENV === "production", path: SESSION_COOKIE_PATH, maxAge: SESSION_MAX_AGE_SECONDS, }); diff --git a/server/service/chat/index.ts b/server/service/chat/index.ts index d8ffdce..a04d860 100644 --- a/server/service/chat/index.ts +++ b/server/service/chat/index.ts @@ -9,6 +9,7 @@ import { sendMail } from "#server/service/email" import { dbGlobal } from "drizzle-pkg/lib/db" import { chatMessages } from "drizzle-pkg/lib/schema/content" import { desc, sql } from "drizzle-orm" +import { env } from "env" // ── Types ── export interface ChatMessage { @@ -188,7 +189,7 @@ export async function sendMessage( sendMail({ to: email, subject: `[聊天室] @${mention} — 来自 ${displayNickname} 的消息`, - text: `有人在聊天室 @${mention} 了你:\n\n"${trimmed}"\n\n—— ${displayNickname}\n时间:${new Date(now).toLocaleString('zh-CN')}\n\n网站:${process.env.APP_URL || '(未配置)'}`, + text: `有人在聊天室 @${mention} 了你:\n\n"${trimmed}"\n\n—— ${displayNickname}\n时间:${new Date(now).toLocaleString('zh-CN')}\n\n网站:${env.APP_URL || '(未配置)'}`, }).catch(err => { console.error('Failed to send @mention email:', err) }) diff --git a/server/service/oauth/oauth-manager.ts b/server/service/oauth/oauth-manager.ts index b5562ec..c310a98 100644 --- a/server/service/oauth/oauth-manager.ts +++ b/server/service/oauth/oauth-manager.ts @@ -7,6 +7,7 @@ import { OAuthError, OAuthErrorCodes } from './oauth-error'; import { registerUser, createSession } from '#server/service/auth'; import type { MinimalUser } from '#server/service/auth'; import { FRONTEND_LOGIN_PATH } from "common/config" +import { env } from "env"; const logger = log4js.getLogger("OAUTH"); @@ -57,13 +58,13 @@ const providers: Record = { github: { name: 'github', icon: 'github', - clientId: process.env.GITHUB_CLIENT_ID || '', - clientSecret: process.env.GITHUB_CLIENT_SECRET || '', + clientId: env.GITHUB_CLIENT_ID, + clientSecret: env.GITHUB_CLIENT_SECRET, authorizeUrl: 'https://github.com/login/oauth/authorize', tokenUrl: 'https://github.com/login/oauth/access_token', userInfoUrl: 'https://api.github.com/user', scopes: ['read:user', 'user:email'], - redirectUri: `${process.env.APP_URL}/api/auth/oauth/github/callback`, + redirectUri: `${env.APP_URL}/api/auth/oauth/github/callback`, userInfoMapping: { providerUserId: 'id', username: 'login', @@ -74,15 +75,15 @@ const providers: Record = { gitea: { name: 'gitea', icon: 'gitea', // 或者使用自定义图标 - clientId: process.env.GITEA_CLIENT_ID || '', - clientSecret: process.env.GITEA_CLIENT_SECRET || '', - authorizeUrl: `${process.env.GITEA_URL}/login/oauth/authorize`, - tokenUrl: `${process.env.GITEA_URL}/login/oauth/access_token`, - userInfoUrl: `${process.env.GITEA_URL}/api/v1/user`, + clientId: env.GITEA_CLIENT_ID, + clientSecret: env.GITEA_CLIENT_SECRET, + authorizeUrl: `${env.GITEA_URL}/login/oauth/authorize`, + tokenUrl: `${env.GITEA_URL}/login/oauth/access_token`, + userInfoUrl: `${env.GITEA_URL}/api/v1/user`, scopes: ['read:user', 'user:email'], grant_type: "authorization_code", responseType: "code", - redirectUri: `${process.env.APP_URL}/api/auth/oauth/gitea/callback`, + redirectUri: `${env.APP_URL}/api/auth/oauth/gitea/callback`, userInfoMapping: { providerUserId: 'id', username: 'login',