feat: 实现游戏核心功能与前端界面

- 添加游戏核心系统:战斗、任务、日志、随机数生成等
- 实现前端主界面、HUD、底部导航和各类卡片组件
- 添加用户认证系统与游戏存档持久化
- 配置项目基础架构与开发环境
- 补充文档说明与Docker部署支持
This commit is contained in:
2026-04-28 22:16:58 +08:00
parent 2e1b58bada
commit e783e6e533
124 changed files with 9759 additions and 1 deletions
Binary file not shown.
+36
View File
@@ -0,0 +1,36 @@
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
expires_at INTEGER NOT NULL,
created_at INTEGER NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS save_slots (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
slot_key TEXT NOT NULL,
label TEXT NOT NULL,
state_json TEXT NOT NULL,
revision INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
UNIQUE(user_id, slot_key),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_save_slots_user_id ON save_slots(user_id);
+31
View File
@@ -0,0 +1,31 @@
{
"name": "@tinywaste/api",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsup src/server.ts scripts/migrate.ts --format esm --sourcemap --clean --out-dir dist",
"start": "node dist/src/server.js",
"db:migrate:prod": "node dist/scripts/migrate.js",
"lint": "eslint src scripts --ext .ts",
"test": "vitest run",
"db:migrate": "tsx scripts/migrate.ts"
},
"dependencies": {
"@fastify/cookie": "^11.0.2",
"@fastify/cors": "^11.1.0",
"@fastify/static": "^9.1.3",
"@tinywaste/content": "workspace:*",
"@tinywaste/game-core": "workspace:*",
"bcryptjs": "^3.0.3",
"better-sqlite3": "^12.4.1",
"dotenv": "^17.2.3",
"drizzle-orm": "^0.44.7",
"fastify": "^5.6.1",
"zod": "^4.1.12"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13"
}
}
+54
View File
@@ -0,0 +1,54 @@
import { mkdirSync, readdirSync, readFileSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import Database from 'better-sqlite3';
import { config as loadDotenv } from 'dotenv';
loadDotenv({
path: [resolve(process.cwd(), '.env'), resolve(process.cwd(), '../../.env')],
});
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
throw new Error('DATABASE_URL is required.');
}
const dbPath = resolve(process.cwd(), databaseUrl);
mkdirSync(dirname(dbPath), { recursive: true });
const db = new Database(dbPath);
db.pragma('foreign_keys = ON');
db.exec(`
CREATE TABLE IF NOT EXISTS _migrations (
name TEXT PRIMARY KEY,
applied_at INTEGER NOT NULL
);
`);
const applied = new Set(
db
.prepare('SELECT name FROM _migrations')
.all()
.map((row) => String((row as { name: string }).name)),
);
const migrationsDir = resolve(process.cwd(), 'migrations');
const files = readdirSync(migrationsDir)
.filter((file) => file.endsWith('.sql'))
.sort();
for (const file of files) {
if (applied.has(file)) continue;
const sql = readFileSync(join(migrationsDir, file), 'utf8');
db.exec('BEGIN');
try {
db.exec(sql);
db.prepare('INSERT INTO _migrations (name, applied_at) VALUES (?, ?)').run(file, Date.now());
db.exec('COMMIT');
console.log(`Applied migration ${file}`);
} catch (error) {
db.exec('ROLLBACK');
throw error;
}
}
db.close();
@@ -0,0 +1,51 @@
import { and, eq, lt, or } from 'drizzle-orm';
import type { db as database } from '../../shared/database/client';
import { saveSlotsTable, sessionsTable, usersTable } from '../../shared/database/schema';
type DbClient = typeof database;
export class AuthRepository {
constructor(private readonly db: DbClient) {}
findUserByIdentifier(identifier: string) {
return this.db.query.usersTable.findFirst({
where: or(eq(usersTable.email, identifier), eq(usersTable.username, identifier)),
});
}
findUserById(userId: string) {
return this.db.query.usersTable.findFirst({
where: eq(usersTable.id, userId),
});
}
createUser(user: typeof usersTable.$inferInsert) {
this.db.insert(usersTable).values(user).run();
return user;
}
createSession(session: typeof sessionsTable.$inferInsert) {
this.db.insert(sessionsTable).values(session).run();
return session;
}
findSessionByTokenHash(tokenHash: string) {
return this.db.query.sessionsTable.findFirst({
where: eq(sessionsTable.tokenHash, tokenHash),
});
}
deleteSessionByTokenHash(tokenHash: string) {
this.db.delete(sessionsTable).where(eq(sessionsTable.tokenHash, tokenHash)).run();
}
deleteExpiredSessions(now: number) {
this.db.delete(sessionsTable).where(lt(sessionsTable.expiresAt, now)).run();
}
getMainSaveSlot(userId: string) {
return this.db.query.saveSlotsTable.findFirst({
where: and(eq(saveSlotsTable.userId, userId), eq(saveSlotsTable.slotKey, 'main')),
});
}
}
+28
View File
@@ -0,0 +1,28 @@
import type { FastifyInstance } from 'fastify';
import { loginSchema, registerSchema } from './auth.schemas';
import { AuthService } from './auth.service';
export const registerAuthRoutes = (app: FastifyInstance, authService: AuthService) => {
app.get('/api/auth/me', async (request) => {
const user = await authService.getUserFromRequest(request);
return { user };
});
app.post('/api/auth/register', async (request, reply) => {
const input = registerSchema.parse(request.body);
const user = await authService.register(input, reply);
return { user };
});
app.post('/api/auth/login', async (request, reply) => {
const input = loginSchema.parse(request.body);
const user = await authService.login(input, reply);
return { user };
});
app.post('/api/auth/logout', async (request, reply) => {
await authService.logout(request, reply);
return { ok: true };
});
};
@@ -0,0 +1,16 @@
import { z } from 'zod';
export const registerSchema = z.object({
email: z.string().email(),
username: z.string().trim().min(3).max(24),
password: z.string().min(8).max(128),
});
export const loginSchema = z.object({
identifier: z.string().trim().min(3),
password: z.string().min(8).max(128),
});
export type RegisterInput = z.infer<typeof registerSchema>;
export type LoginInput = z.infer<typeof loginSchema>;
+132
View File
@@ -0,0 +1,132 @@
import { createHash, randomBytes, randomUUID } from 'node:crypto';
import bcrypt from 'bcryptjs';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { AppError } from '../../shared/errors';
import { env } from '../../shared/env';
import { AuthRepository } from './auth.repository';
import type { LoginInput, RegisterInput } from './auth.schemas';
const SESSION_TTL_MS = 1000 * 60 * 60 * 24 * 14;
export interface AuthUser {
id: string;
email: string;
username: string;
}
const normalizeIdentifier = (value: string) => value.trim().toLowerCase();
const hashSessionToken = (token: string) => createHash('sha256').update(token).digest('hex');
export class AuthService {
constructor(private readonly repository: AuthRepository) {}
private sanitizeUser(user: { id: string; email: string; username: string }): AuthUser {
return {
id: user.id,
email: user.email,
username: user.username,
};
}
private issueSession(reply: FastifyReply, userId: string) {
this.repository.deleteExpiredSessions(Date.now());
const rawToken = randomBytes(32).toString('hex');
const tokenHash = hashSessionToken(rawToken);
const now = Date.now();
const session = {
id: randomUUID(),
userId,
tokenHash,
expiresAt: now + SESSION_TTL_MS,
createdAt: now,
};
this.repository.createSession(session);
reply.setCookie(env.sessionCookieName, rawToken, {
httpOnly: true,
sameSite: 'lax',
path: '/',
maxAge: SESSION_TTL_MS / 1000,
secure: false,
});
}
async register(input: RegisterInput, reply: FastifyReply) {
const email = normalizeIdentifier(input.email);
const username = input.username.trim();
const existing = await this.repository.findUserByIdentifier(email);
if (existing) {
throw new AppError(409, 'AUTH_EXISTS', '该邮箱或用户名已经被使用。');
}
const byUsername = await this.repository.findUserByIdentifier(username);
if (byUsername) {
throw new AppError(409, 'AUTH_EXISTS', '该邮箱或用户名已经被使用。');
}
const user = {
id: randomUUID(),
email,
username,
passwordHash: await bcrypt.hash(input.password, 10),
createdAt: Date.now(),
updatedAt: Date.now(),
};
this.repository.createUser(user);
this.issueSession(reply, user.id);
return this.sanitizeUser(user);
}
async login(input: LoginInput, reply: FastifyReply) {
const identifier = input.identifier.includes('@')
? normalizeIdentifier(input.identifier)
: input.identifier.trim();
const user = await this.repository.findUserByIdentifier(identifier);
if (!user) {
throw new AppError(401, 'AUTH_INVALID', '账号或密码不正确。');
}
const ok = await bcrypt.compare(input.password, user.passwordHash);
if (!ok) {
throw new AppError(401, 'AUTH_INVALID', '账号或密码不正确。');
}
this.issueSession(reply, user.id);
return this.sanitizeUser(user);
}
async getUserFromRequest(request: FastifyRequest) {
const rawToken = request.cookies[env.sessionCookieName];
if (!rawToken) return null;
const session = await this.repository.findSessionByTokenHash(hashSessionToken(rawToken));
if (!session) return null;
if (session.expiresAt < Date.now()) {
this.repository.deleteSessionByTokenHash(session.tokenHash);
return null;
}
const user = await this.repository.findUserById(session.userId);
return user ? this.sanitizeUser(user) : null;
}
async requireUser(request: FastifyRequest) {
const user = await this.getUserFromRequest(request);
if (!user) {
throw new AppError(401, 'AUTH_REQUIRED', '请先登录。');
}
return user;
}
async logout(request: FastifyRequest, reply: FastifyReply) {
const rawToken = request.cookies[env.sessionCookieName];
if (rawToken) {
this.repository.deleteSessionByTokenHash(hashSessionToken(rawToken));
}
reply.clearCookie(env.sessionCookieName, { path: '/' });
}
}
@@ -0,0 +1,48 @@
import { and, eq } from 'drizzle-orm';
import type { db as database } from '../../shared/database/client';
import { saveSlotsTable } from '../../shared/database/schema';
type DbClient = typeof database;
export class GameRepository {
constructor(private readonly db: DbClient) {}
getMainSlot(userId: string) {
return this.db.query.saveSlotsTable.findFirst({
where: and(eq(saveSlotsTable.userId, userId), eq(saveSlotsTable.slotKey, 'main')),
});
}
async upsertMainSlot(userId: string, stateJson: string, label: string) {
const existing = await this.getMainSlot(userId);
const now = Date.now();
if (existing) {
this.db
.update(saveSlotsTable)
.set({
stateJson,
label,
revision: existing.revision + 1,
updatedAt: now,
})
.where(eq(saveSlotsTable.id, existing.id))
.run();
return await this.getMainSlot(userId);
}
const created = {
id: crypto.randomUUID(),
userId,
slotKey: 'main',
label,
stateJson,
revision: 1,
createdAt: now,
updatedAt: now,
};
this.db.insert(saveSlotsTable).values(created).run();
return await this.getMainSlot(userId);
}
}
+27
View File
@@ -0,0 +1,27 @@
import type { FastifyInstance } from 'fastify';
import { AuthService } from '../auth/auth.service';
import { createGameSchema, gameActionSchema } from './game.schemas';
import { GameService } from './game.service';
export const registerGameRoutes = (
app: FastifyInstance,
authService: AuthService,
gameService: GameService,
) => {
app.get('/api/game/state', async (request) => {
const user = await authService.requireUser(request);
return await gameService.getState(user.id);
});
app.post('/api/game/new', async (request) => {
const user = await authService.requireUser(request);
const input = createGameSchema.parse(request.body);
return { view: await gameService.createGame(user.id, input) };
});
app.post('/api/game/action', async (request) => {
const user = await authService.requireUser(request);
const action = gameActionSchema.parse(request.body);
return { view: await gameService.applyAction(user.id, action) };
});
};
@@ -0,0 +1,25 @@
import { z } from 'zod';
export const createGameSchema = z.object({
playerName: z.string().trim().min(2).max(24),
});
export const gameActionSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('travel'), toPlaceId: z.string().min(1) }),
z.object({ type: z.literal('perform-action'), actionId: z.string().min(1) }),
z.object({ type: z.literal('select-event-option'), optionId: z.string().min(1) }),
z.object({
type: z.literal('combat'),
move: z.enum(['attack', 'defend', 'advance', 'retreat', 'escape']),
}),
z.object({ type: z.literal('craft'), recipeId: z.string().min(1) }),
z.object({ type: z.literal('use-item'), itemId: z.string().min(1) }),
z.object({ type: z.literal('equip-item'), itemId: z.string().min(1) }),
z.object({ type: z.literal('unequip-item'), slot: z.enum(['weapon', 'body', 'tool']) }),
z.object({ type: z.literal('trade'), offerId: z.string().min(1) }),
z.object({ type: z.literal('rest'), minutes: z.number().int().min(10).max(240) }),
]);
export type CreateGameInput = z.infer<typeof createGameSchema>;
export type GameActionInput = z.infer<typeof gameActionSchema>;
@@ -0,0 +1,49 @@
import {
applyGameAction,
buildGameView,
createNewGameState,
type GameState,
} from '@tinywaste/game-core';
import { gameContent } from '@tinywaste/content';
import { AppError } from '../../shared/errors';
import { GameRepository } from './game.repository';
import type { CreateGameInput, GameActionInput } from './game.schemas';
export class GameService {
constructor(private readonly repository: GameRepository) {}
async getState(userId: string) {
const slot = await this.repository.getMainSlot(userId);
if (!slot) {
return { hasSave: false as const, view: null };
}
const state = JSON.parse(slot.stateJson) as GameState;
return {
hasSave: true as const,
view: buildGameView(state, gameContent),
};
}
async createGame(userId: string, input: CreateGameInput) {
const state = createNewGameState(gameContent, input.playerName.trim());
await this.repository.upsertMainSlot(
userId,
JSON.stringify(state),
`${input.playerName.trim()} 的进度`,
);
return buildGameView(state, gameContent);
}
async applyAction(userId: string, action: GameActionInput) {
const slot = await this.repository.getMainSlot(userId);
if (!slot) {
throw new AppError(404, 'GAME_NOT_FOUND', '还没有创建游戏进度。');
}
const state = JSON.parse(slot.stateJson) as GameState;
const result = applyGameAction(state, gameContent, action);
await this.repository.upsertMainSlot(userId, JSON.stringify(result.state), slot.label);
return buildGameView(result.state, gameContent);
}
}
+120
View File
@@ -0,0 +1,120 @@
import { existsSync } from 'node:fs';
import Fastify from 'fastify';
import cookie from '@fastify/cookie';
import cors from '@fastify/cors';
import fastifyStatic from '@fastify/static';
import { ZodError } from 'zod';
import { db } from './shared/database/client';
import { env } from './shared/env';
import { formatZodError, isAppError } from './shared/errors';
import { AuthRepository } from './features/auth/auth.repository';
import { AuthService } from './features/auth/auth.service';
import { registerAuthRoutes } from './features/auth/auth.routes';
import { GameRepository } from './features/game/game.repository';
import { GameService } from './features/game/game.service';
import { registerGameRoutes } from './features/game/game.routes';
const app = Fastify({ logger: true });
await app.register(cookie, {
secret: env.sessionSecret,
});
await app.register(cors, {
origin: env.webOrigin,
credentials: true,
});
const authRepository = new AuthRepository(db);
const authService = new AuthService(authRepository);
const gameRepository = new GameRepository(db);
const gameService = new GameService(gameRepository);
app.get('/api/health', async () => ({
ok: true,
service: 'tinywaste-api',
}));
registerAuthRoutes(app, authService);
registerGameRoutes(app, authService, gameService);
const webDistAvailable = existsSync(env.webDistPath);
if (webDistAvailable) {
await app.register(fastifyStatic, {
root: env.webDistPath,
prefix: '/',
maxAge: '30d',
immutable: true,
});
app.get('/', async (_request, reply) =>
reply.sendFile('index.html', {
maxAge: 0,
immutable: false,
}),
);
app.setNotFoundHandler((request, reply) => {
const acceptsHtml = request.headers.accept?.includes('text/html');
if (request.method === 'GET' && acceptsHtml && !request.url.startsWith('/api/')) {
return reply.sendFile('index.html', {
maxAge: 0,
immutable: false,
});
}
return reply.status(404).send({
error: {
code: 'NOT_FOUND',
message: '请求的资源不存在。',
},
});
});
}
app.setErrorHandler((error, _request, reply) => {
if (isAppError(error)) {
reply.status(error.statusCode).send({
error: {
code: error.code,
message: error.message,
},
});
return;
}
if (error instanceof ZodError) {
reply.status(400).send({
error: {
code: 'VALIDATION_ERROR',
message: formatZodError(error),
},
});
return;
}
app.log.error(error);
reply.status(500).send({
error: {
code: 'INTERNAL_ERROR',
message: '服务器内部错误,请稍后再试。',
},
});
});
const start = async () => {
try {
await app.listen({ port: env.port, host: '0.0.0.0' });
app.log.info(`API running on http://localhost:${env.port}`);
if (webDistAvailable) {
app.log.info(`Web bundle served from ${env.webDistPath}`);
}
} catch (error) {
app.log.error(error);
process.exit(1);
}
};
await start();
+11
View File
@@ -0,0 +1,11 @@
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import { env } from '../env';
import * as schema from './schema';
const sqlite = new Database(env.databasePath);
sqlite.pragma('foreign_keys = ON');
export const db = drizzle(sqlite, { schema });
export { sqlite, schema };
+53
View File
@@ -0,0 +1,53 @@
import { integer, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core';
export const usersTable = sqliteTable(
'users',
{
id: text('id').primaryKey(),
email: text('email').notNull(),
username: text('username').notNull(),
passwordHash: text('password_hash').notNull(),
createdAt: integer('created_at', { mode: 'number' }).notNull(),
updatedAt: integer('updated_at', { mode: 'number' }).notNull(),
},
(table) => ({
usersEmailUnique: uniqueIndex('users_email_unique').on(table.email),
usersUsernameUnique: uniqueIndex('users_username_unique').on(table.username),
}),
);
export const sessionsTable = sqliteTable(
'sessions',
{
id: text('id').primaryKey(),
userId: text('user_id')
.notNull()
.references(() => usersTable.id, { onDelete: 'cascade' }),
tokenHash: text('token_hash').notNull(),
expiresAt: integer('expires_at', { mode: 'number' }).notNull(),
createdAt: integer('created_at', { mode: 'number' }).notNull(),
},
(table) => ({
sessionsTokenHashUnique: uniqueIndex('sessions_token_hash_unique').on(table.tokenHash),
}),
);
export const saveSlotsTable = sqliteTable(
'save_slots',
{
id: text('id').primaryKey(),
userId: text('user_id')
.notNull()
.references(() => usersTable.id, { onDelete: 'cascade' }),
slotKey: text('slot_key').notNull(),
label: text('label').notNull(),
stateJson: text('state_json').notNull(),
revision: integer('revision', { mode: 'number' }).notNull(),
createdAt: integer('created_at', { mode: 'number' }).notNull(),
updatedAt: integer('updated_at', { mode: 'number' }).notNull(),
},
(table) => ({
saveSlotsUserSlotUnique: uniqueIndex('save_slots_user_slot_unique').on(table.userId, table.slotKey),
}),
);
+35
View File
@@ -0,0 +1,35 @@
import { mkdirSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { config as loadDotenv } from 'dotenv';
import { z } from 'zod';
loadDotenv({
path: [resolve(process.cwd(), '.env'), resolve(process.cwd(), '../../.env')],
});
const envSchema = z.object({
API_PORT: z.coerce.number().int().positive().default(3001),
WEB_ORIGIN: z.string().url().default('http://localhost:5173'),
WEB_DIST_DIR: z.string().min(1).default('../web/dist'),
SESSION_COOKIE_NAME: z.string().min(3).default('tinywaste_session'),
SESSION_SECRET: z.string().min(16),
DATABASE_URL: z.string().min(1).default('./data/tinywaste.db'),
});
const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {
throw new Error(`Invalid environment: ${parsed.error.message}`);
}
const databasePath = resolve(process.cwd(), parsed.data.DATABASE_URL);
const webDistPath = resolve(process.cwd(), parsed.data.WEB_DIST_DIR);
mkdirSync(dirname(databasePath), { recursive: true });
export const env = {
port: parsed.data.API_PORT,
webOrigin: parsed.data.WEB_ORIGIN,
webDistPath,
sessionCookieName: parsed.data.SESSION_COOKIE_NAME,
sessionSecret: parsed.data.SESSION_SECRET,
databasePath,
};
+30
View File
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest';
import { loginSchema, registerSchema } from '../features/auth/auth.schemas';
import { formatZodError } from './errors';
describe('formatZodError', () => {
it('returns a friendly message for short passwords', () => {
const result = loginSchema.safeParse({
identifier: 'tester',
password: '123',
});
expect(result.success).toBe(false);
if (result.success) return;
expect(formatZodError(result.error)).toBe('密码 至少需要 8 个字符。');
});
it('returns a friendly message for invalid email input', () => {
const result = registerSchema.safeParse({
email: 'not-an-email',
username: 'tester',
password: '12345678',
});
expect(result.success).toBe(false);
if (result.success) return;
expect(formatZodError(result.error)).toBe('请输入有效的邮箱地址。');
});
});
+72
View File
@@ -0,0 +1,72 @@
import { ZodError } from 'zod';
export class AppError extends Error {
constructor(
public readonly statusCode: number,
public readonly code: string,
message: string,
) {
super(message);
this.name = 'AppError';
}
}
export const isAppError = (value: unknown): value is AppError => value instanceof AppError;
const FIELD_LABELS: Record<string, string> = {
email: '邮箱',
username: '用户名',
identifier: '邮箱或用户名',
password: '密码',
playerName: '幸存者代号',
actionId: '动作',
toPlaceId: '目标地点',
optionId: '事件选项',
recipeId: '配方',
itemId: '物品',
slot: '装备槽位',
offerId: '交易项',
minutes: '休息时长',
move: '战斗动作',
};
const resolveFieldLabel = (path: Array<string | number | symbol>) => {
if (path.length === 0) return '提交内容';
const key = String(path[path.length - 1]);
return FIELD_LABELS[key] ?? key;
};
export const formatZodError = (error: ZodError) => {
const issue = error.issues[0];
if (!issue) {
return '请求参数不合法。';
}
const fieldLabel = resolveFieldLabel(issue.path);
switch (issue.code) {
case 'invalid_type':
return `${fieldLabel} 类型不正确。`;
case 'too_small':
if (issue.origin === 'string') {
return `${fieldLabel} 至少需要 ${issue.minimum} 个字符。`;
}
return `${fieldLabel} 不能小于 ${issue.minimum}`;
case 'too_big':
if (issue.origin === 'string') {
return `${fieldLabel} 不能超过 ${issue.maximum} 个字符。`;
}
return `${fieldLabel} 不能大于 ${issue.maximum}`;
case 'invalid_format':
if (issue.format === 'email') {
return '请输入有效的邮箱地址。';
}
return `${fieldLabel} 格式不正确。`;
case 'invalid_value':
return `${fieldLabel} 的值不在允许范围内。`;
default:
return issue.message && issue.message !== 'Invalid input'
? `${fieldLabel}${issue.message}`
: `${fieldLabel} 不合法。`;
}
};
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"types": ["node"]
},
"include": ["src", "scripts"]
}