feat:初始化

This commit is contained in:
Your Name 2026-06-22 10:26:29 +08:00
parent b26bb4a639
commit f33db22e2f
91 changed files with 9494 additions and 0 deletions

15
.env Normal file
View File

@ -0,0 +1,15 @@
# 服务端口
PORT=3000
# MySQL数据库配置
DB_HOST=localhost
DB_PORT=3306
DB_USER=root
DB_PASSWORD=123456
DB_NAME=corp_website
# JWT鉴权固定配置
JWT_SECRET=corp_website_secret_key_2026ai
JWT_EXPIRES_IN=7d
# 文件上传目录
UPLOAD_ROOT=./uploads
# 运行环境
NODE_ENV=development

11
.env.example Normal file
View File

@ -0,0 +1,11 @@
# 复制为 .env 并按需修改
PORT=3001
DB_HOST=127.0.0.1
DB_PORT=3306
DB_USER=root
DB_PASSWORD=root
DB_NAME=corp_website
JWT_SECRET=corp_website_secret_key_2026ai
JWT_EXPIRES_IN=7d
UPLOAD_ROOT=./uploads
NODE_ENV=development

108
.gitea/workflows/main.yml Normal file
View File

@ -0,0 +1,108 @@
name: main
on:
push:
branches: ["main"]
workflow_dispatch:
jobs:
build-and-push:
name: Build and push to Aliyun ACR
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: https://gitee.com/zsqai/checkout@v4
# 准备环境变量文件:将 .env.production 复制为 .env
- name: Prepare .env file for production
run: cp .env.production .env
- name: Set up Docker Buildx
uses: https://gitee.com/zsqai/setup-buildx-action@v3
- name: Login to Aliyun Container Registry
uses: https://gitee.com/zsqai/login-action@v3
with:
registry: ${{ vars.ALIYUN_REGISTRY }}
username: ${{ vars.ALIYUN_USERNAME }}
password: ${{ secrets.ALIYUN_PASSWORD }}
- name: Build and push Docker image
uses: https://gitee.com/zsqai/build-push-action@v5
with:
context: .
push: true
tags: |
${{ vars.ALIYUN_REGISTRY }}/${{ vars.ALIYUN_NAMESPACE }}/${{ vars.ALIYUN_REPO }}:latest
${{ vars.ALIYUN_REGISTRY }}/${{ vars.ALIYUN_NAMESPACE }}/${{ vars.ALIYUN_REPO }}:${{ github.sha }}
deploy:
name: Deploy to server
runs-on: ubuntu-latest
needs: build-and-push
environment:
name: production
url: http://${{ vars.HOST }}:8084
steps:
- name: Deploy via SSH
uses: https://gitee.com/zsqai/ssh-action@v1.0.3
with:
host: ${{ vars.HOST }}
username: root
password: ${{ secrets.DEV_HOST_PASSWORD }}
port: 22
script_stop: true
script: |
set -e
echo "=== 开始部署 $(date) ==="
# 登录阿里云镜像仓库
docker login --username=${{ vars.ALIYUN_USERNAME }} \
--password=${{ secrets.ALIYUN_PASSWORD }} \
${{ vars.ALIYUN_REGISTRY }}
# 拉取最新镜像
docker pull ${{ vars.ALIYUN_REGISTRY }}/${{ vars.ALIYUN_NAMESPACE }}/${{ vars.ALIYUN_REPO }}:latest
# 停止并删除旧容器
docker stop ai-personage-api 2>/dev/null || true
docker rm ai-personage-api 2>/dev/null || true
# 启动新容器(不再需要 -e 参数,因为环境变量已打包在镜像内)
docker run -d \
--name ai-personage-api \
--restart always \
-p 8084:3002 \
${{ vars.ALIYUN_REGISTRY }}/${{ vars.ALIYUN_NAMESPACE }}/${{ vars.ALIYUN_REPO }}:latest
# 清理旧镜像
docker image prune -f
# 检查容器状态
sleep 5
if docker ps --format '{{.Names}}' | grep -q "^ai-personage-api$"; then
echo "✓ 容器启动成功"
docker logs --tail 20 ai-personage-api
else
echo "✗ 容器启动失败"
docker logs ai-personage-api
exit 1
fi
notify:
name: Send notification
runs-on: ubuntu-latest
needs: [build-and-push, deploy]
if: always()
steps:
- name: Deployment result
run: |
if [ "${{ needs.deploy.result }}" == "success" ]; then
echo "✓ 部署成功"
echo "分支: ${{ github.ref_name }}"
echo "提交: ${{ github.sha }}"
echo "时间: $(date)"
else
echo "✗ 部署失败,请检查 CI 最终日志"
exit 1
fi

44
.gitignore vendored Normal file
View File

@ -0,0 +1,44 @@
# ---------- 系统 / 编辑器 ----------
.DS_Store
Thumbs.db
.idea/
.vscode/
*.swp
*.swo
# ---------- Node / pnpm ----------
node_modules/
.pnpm-store/
pnpm-debug.log*
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnp.*
.pnp.js
# ---------- 构建产物 ----------
dist/
build/
out/
.next/
.turbo/
*.tsbuildinfo
# ---------- 环境变量(敏感信息不入库) ----------
# 后端主配置文件按文档要求生成 .env但强烈建议提交时改为 .env.example
server/.env
client/.env.local
# ---------- 上传文件(运行时产物) ----------
server/uploads/
# ---------- 日志 ----------
*.log
logs/
# ---------- 测试覆盖率 ----------
coverage/
# ---------- 缓存 ----------
.cache/
.eslintcache

7
.prettierrc Normal file
View File

@ -0,0 +1,7 @@
{
"singleQuote": true,
"trailingComma": "all",
"semi": true,
"printWidth": 100,
"tabWidth": 2
}

47
Dockerfile Normal file
View File

@ -0,0 +1,47 @@
# 构建阶段
FROM crpi-jlsdxetsdmy4ckxh.cn-shenzhen.personal.cr.aliyuncs.com/zsq_proxy/node:20-alpine AS build
WORKDIR /app
# 添加构建参数
ARG BUILD_VERSION
ARG BUILD_TIME
ARG CACHE_BUST
ENV BUILD_VERSION=$BUILD_VERSION
ENV BUILD_TIME=$BUILD_TIME
# 强制打破 Docker 缓存
RUN echo "Cache bust: ${CACHE_BUST} - ${BUILD_VERSION} - ${BUILD_TIME}"
# 安装 pnpm
RUN npm install -g pnpm && \
pnpm config set registry https://registry.npmmirror.com
COPY package*.json ./
# 如果项目有 pnpm-lock.yaml取消注释下面一行
# COPY pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
# 强制清理 .next
RUN rm -rf .next
# 构建
RUN pnpm build
# 部署阶段
FROM crpi-jlsdxetsdmy4ckxh.cn-shenzhen.personal.cr.aliyuncs.com/zsq_proxy/node:20-alpine AS app
WORKDIR /app
ENV NODE_ENV=production
ENV NODE_OPTIONS="--max-old-space-size=4096"
COPY --from=build /app/.next ./.next
COPY --from=build /app/public ./public
COPY --from=build /app/package.json ./package.json
COPY --from=build /app/node_modules ./node_modules
EXPOSE 3003
CMD ["pnpm", "start"]

8
nest-cli.json Normal file
View File

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

59
package.json Normal file
View File

@ -0,0 +1,59 @@
{
"name": "corp-official-website-server",
"version": "1.0.0",
"description": "corp-official-website NestJS 10 backend",
"private": true,
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix"
},
"dependencies": {
"@nestjs/common": "^10.2.10",
"@nestjs/config": "^3.1.1",
"@nestjs/core": "^10.2.10",
"@nestjs/jwt": "^10.2.0",
"@nestjs/passport": "^10.0.2",
"@nestjs/platform-express": "^10.2.10",
"@nestjs/serve-static": "^4.0.0",
"@nestjs/swagger": "^7.1.16",
"@nestjs/typeorm": "^10.0.1",
"bcrypt": "^5.1.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"multer": "^1.4.5-lts.1",
"mysql2": "^3.6.5",
"nest-winston": "^1.9.4",
"passport": "^0.6.0",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.8.1",
"typeorm": "^0.3.17",
"winston": "^3.11.0"
},
"devDependencies": {
"@nestjs/cli": "^10.2.1",
"@nestjs/schematics": "^10.0.2",
"@nestjs/testing": "^10.2.10",
"@types/bcrypt": "^5.0.2",
"@types/express": "^4.17.21",
"@types/multer": "^1.4.11",
"@types/node": "^20.9.2",
"@types/passport-jwt": "^3.0.13",
"@typescript-eslint/eslint-plugin": "^6.14.0",
"@typescript-eslint/parser": "^6.14.0",
"eslint": "^8.54.0",
"prettier": "^3.1.0",
"source-map-support": "^0.5.21",
"ts-loader": "^9.5.1",
"ts-node": "^10.9.1",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.2.2"
},
"pnpm": {
"onlyBuiltDependencies": ["bcrypt", "@nestjs/core"]
}
}

5175
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

117
src/app.module.ts Normal file
View File

@ -0,0 +1,117 @@
import { MiddlewareConsumer, Module, NestModule, RequestMethod } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { APP_GUARD } from '@nestjs/core';
import { JwtModule } from '@nestjs/jwt';
import { ServeStaticModule } from '@nestjs/serve-static';
import configuration from './config/configuration';
import { JwtAuthGuard, RolesGuard } from './common/guards';
import { UploadMiddleware } from './common/middlewares';
import { Admin } from './entities/admin.entity';
import { Banner } from './entities/banner.entity';
import { Manual } from './entities/manual.entity';
import { Message } from './entities/message.entity';
import { NewsCategory } from './entities/news-category.entity';
import { News } from './entities/news.entity';
import { ProductCategory } from './entities/product-category.entity';
import { Product } from './entities/product.entity';
import { SiteConfig } from './entities/site-config.entity';
import { Team } from './entities/team.entity';
import { AuthModule } from './modules/auth/auth.module';
import { AdminUserModule } from './modules/admin-user/admin-user.module';
import { BannerModule } from './modules/banner/banner.module';
import { ManualModule } from './modules/manual/manual.module';
import { MessageModule } from './modules/message/message.module';
import { NewsCategoryModule } from './modules/news-category/news-category.module';
import { NewsModule } from './modules/news/news.module';
import { ProductCategoryModule } from './modules/product-category/product-category.module';
import { ProductModule } from './modules/product/product.module';
import { SiteConfigModule } from './modules/site-config/site-config.module';
import { TeamModule } from './modules/team/team.module';
import { UploadModule } from './modules/upload/upload.module';
import path from 'path';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [configuration],
}),
TypeOrmModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
type: 'mysql',
host: config.get<string>('database.host'),
port: config.get<number>('database.port'),
username: config.get<string>('database.user'),
password: config.get<string>('database.password'),
database: config.get<string>('database.name'),
autoLoadEntities: true,
synchronize: false,
timezone: '+08:00',
charset: 'utf8mb4',
logging: config.get<string>('nodeEnv') === 'development',
}),
}),
JwtModule.registerAsync({
global: true,
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.get<string>('jwt.secret'),
signOptions: { expiresIn: config.get<string>('jwt.expiresIn') },
}),
}),
ServeStaticModule.forRoot({
rootPath: path.resolve(
process.cwd(),
(process.env.UPLOAD_ROOT ?? './uploads').replace(/^\.\/?/, ''),
),
serveRoot: '/uploads',
serveStaticOptions: { index: false },
}),
TypeOrmModule.forFeature([
Admin,
Banner,
Manual,
Message,
NewsCategory,
News,
ProductCategory,
Product,
SiteConfig,
Team,
]),
AuthModule,
AdminUserModule,
BannerModule,
ManualModule,
MessageModule,
NewsCategoryModule,
NewsModule,
ProductCategoryModule,
ProductModule,
SiteConfigModule,
TeamModule,
UploadModule,
],
providers: [
// JwtAuthGuard 依赖 JwtService + Reflector必须用 DI 注册
{ provide: APP_GUARD, useClass: JwtAuthGuard },
// RolesGuard 读取 @Roles() 元数据校验角色权限
{ provide: APP_GUARD, useClass: RolesGuard },
],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer): void {
// middleware 在 global prefix 应用之前注册,路径用不带前缀的内部路径
consumer.apply(UploadMiddleware).forRoutes({
path: 'admin/upload',
method: RequestMethod.POST,
});
}
}

View File

@ -0,0 +1,22 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
/**
* JwtAuthGuard req.admin
*/
export interface CurrentAdminPayload {
id: number;
username: string;
nickname: string;
role: 'super_admin' | 'normal';
}
export const CurrentAdmin = createParamDecorator(
(data: keyof CurrentAdminPayload | undefined, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
const admin: CurrentAdminPayload | undefined = request.admin;
if (!admin) {
return undefined;
}
return data ? admin[data] : admin;
},
);

View File

@ -0,0 +1,3 @@
export * from './current-admin.decorator';
export * from './public.decorator';
export * from './roles.decorator';

View File

@ -0,0 +1,8 @@
import { SetMetadata } from '@nestjs/common';
/**
* JWT
* JwtAuthGuard IS_PUBLIC
*/
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

View File

@ -0,0 +1,13 @@
import { SetMetadata } from '@nestjs/common';
/** 管理员角色类型 */
export type AdminRole = 'super_admin' | 'normal';
/** 元数据 key标记接口所需角色 */
export const ROLES_KEY = 'roles';
/**
* RolesGuard 使
* @Roles('super_admin') 访
*/
export const Roles = (...roles: AdminRole[]) => SetMetadata(ROLES_KEY, roles);

View File

@ -0,0 +1,39 @@
/**
*
* { code:200, msg:'操作成功', data:{} }
* { code:401/400/403/500, msg:'错误提示', data:null }
*/
export class ApiResponse<T> {
code: number;
msg: string;
data: T | null;
constructor(code: number, msg: string, data: T | null = null) {
this.code = code;
this.msg = msg;
this.data = data;
}
static success<T>(data: T, msg = '操作成功'): ApiResponse<T> {
return new ApiResponse<T>(200, msg, data);
}
static error(code: number, msg: string): ApiResponse<null> {
return new ApiResponse<null>(code, msg, null);
}
}
/** 分页查询结果通用结构 */
export interface PaginatedResult<T> {
list: T[];
total: number;
page: number;
pageSize: number;
}
/** 通用分页 DTO 基类 */
export class PaginationDto {
page: number;
pageSize: number;
keyword?: string;
}

View File

@ -0,0 +1,101 @@
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
import { Request, Response } from 'express';
import { QueryFailedError } from 'typeorm';
/** MySQL 驱动抛出的错误对象结构(部分字段) */
interface MysqlDriverError {
code?: string;
errno?: number;
sqlMessage?: string;
sqlState?: string;
}
/**
* MySQL
* SQL /
*/
function describeDbError(err: MysqlDriverError): string {
switch (err.code) {
case 'ER_BAD_FIELD_ERROR':
return '数据库字段缺失,请联系管理员执行最新的数据库升级脚本';
case 'ER_NO_SUCH_TABLE':
return '数据库表不存在,请联系管理员核对数据库结构';
case 'ER_DUP_ENTRY':
return '数据重复,请检查唯一字段(如登录账号)';
case 'ER_ROW_IS_REFERENCED_2':
case 'ER_ROW_IS_REFERENCED':
return '该记录被其他数据引用,无法删除或修改';
case 'ER_DATA_TOO_LONG':
return '输入内容过长,请精简后重试';
case 'ER_BAD_NULL_ERROR':
return '缺少必填字段,请补全后重试';
case 'ER_LOCK_WAIT_TIMEOUT':
case 'ER_LOCK_DEADLOCK':
return '数据库繁忙,请稍后重试';
default:
return '服务器内部错误,请稍后重试';
}
}
/**
*
* { code, msg, data:null }
*
*/
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
private readonly logger = new Logger(AllExceptionsFilter.name);
catch(exception: unknown, host: ArgumentsHost): void {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const isHttp = exception instanceof HttpException;
const status = isHttp
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
let message = '服务器内部错误';
if (isHttp) {
const res = exception.getResponse();
if (typeof res === 'string') {
message = res;
} else if (typeof res === 'object' && res !== null) {
const r = res as { message?: unknown };
message = Array.isArray(r.message)
? r.message.join(';')
: String(r.message ?? message);
}
} else if (exception instanceof QueryFailedError) {
// TypeORM 查询失败:底层 MySQL 错误统一翻译为中文
const driverErr = (
exception as { driverError?: MysqlDriverError }
).driverError;
message = describeDbError(
driverErr ?? (exception as unknown as MysqlDriverError),
);
this.logger.error(exception.stack);
} else if (exception instanceof Error) {
// 其他未识别错误:不直接暴露英文 message统一中文兜底
message = '服务器内部错误,请稍后重试';
this.logger.error(exception.stack);
}
const code = status;
response.status(status).json({
code,
msg: message,
data: null,
path: request.url,
timestamp: new Date().toISOString(),
});
}
}

View File

@ -0,0 +1 @@
export * from './all-exceptions.filter';

View File

@ -0,0 +1,2 @@
export * from './jwt-auth.guard';
export * from './roles.guard';

View File

@ -0,0 +1,65 @@
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { JwtService } from '@nestjs/jwt';
import { Request } from 'express';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
const DEFAULT_JWT_SECRET = 'corp_website_secret_key_2026ai';
/**
* JWT
* - @Public()
* - Authorization: Bearer <token>
*/
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly jwtService: JwtService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true;
}
const request = context.switchToHttp().getRequest<Request>();
const token = this.extractToken(request);
if (!token) {
throw new UnauthorizedException('未登录或登录已过期');
}
try {
const payload = await this.jwtService.verifyAsync<{
id: number;
username: string;
nickname: string;
role: 'super_admin' | 'normal';
}>(token, {
secret: process.env.JWT_SECRET ?? DEFAULT_JWT_SECRET,
});
request.admin = payload;
} catch {
throw new UnauthorizedException('登录凭证无效');
}
return true;
}
private extractToken(request: Request): string | null {
const authHeader = request.headers.authorization ?? '';
const [type, token] = authHeader.split(' ');
if (type === 'Bearer' && token) {
return token.trim();
}
return null;
}
}

View File

@ -0,0 +1,35 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ROLES_KEY, type AdminRole } from '../decorators/roles.decorator';
/**
* @Roles()
* - @Roles()
* - @Roles('super_admin') request.admin.role === 'super_admin'
*/
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const required = this.reflector.getAllAndOverride<AdminRole[] | undefined>(
ROLES_KEY,
[context.getHandler(), context.getClass()],
);
if (!required || required.length === 0) {
return true;
}
const request = context.switchToHttp().getRequest();
const role: AdminRole | undefined = request.admin?.role;
if (!role || !required.includes(role)) {
throw new ForbiddenException('权限不足,需要超级管理员账号');
}
return true;
}
}

View File

@ -0,0 +1 @@
export * from './transform.interceptor';

View File

@ -0,0 +1,33 @@
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { ApiResponse } from '../dto/api-response.dto';
/**
*
* controller { code, msg, data }
*/
@Injectable()
export class TransformInterceptor<T>
implements NestInterceptor<T, ApiResponse<T>>
{
intercept(
context: ExecutionContext,
next: CallHandler<T>,
): Observable<ApiResponse<T>> {
return next.handle().pipe(
map((data) => {
// 如果已经是 ApiResponse则原样返回
if (data instanceof ApiResponse) {
return data;
}
return ApiResponse.success(data) as ApiResponse<T>;
}),
);
}
}

View File

@ -0,0 +1 @@
export * from './upload.middleware';

View File

@ -0,0 +1,63 @@
import {
Injectable,
NestMiddleware,
BadRequestException,
} from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import multer, { Multer } from 'multer';
import path from 'path';
import fs from 'fs';
const ALLOWED_MIME = ['image/jpeg', 'image/png', 'image/webp'];
const MAX_SIZE = 2 * 1024 * 1024; // 2M
/**
*
* file
*/
@Injectable()
export class UploadMiddleware implements NestMiddleware {
private readonly upload: Multer;
constructor() {
const root = process.env.UPLOAD_ROOT ?? './uploads';
this.upload = multer({
storage: multer.diskStorage({
destination: (_req, _file, cb) => {
const date = new Date();
const dir = path.join(
process.cwd(),
root.replace(/^\.\/?/, ''),
`${date.getFullYear()}/${String(date.getMonth() + 1).padStart(2, '0')}`,
);
fs.mkdirSync(dir, { recursive: true });
cb(null, dir);
},
filename: (_req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
const name = `${Date.now()}_${Math.random().toString(36).slice(2, 8)}${ext}`;
cb(null, name);
},
}),
fileFilter: (_req, file, cb) => {
if (ALLOWED_MIME.includes(file.mimetype)) {
cb(null, true);
} else {
// FileFilterCallback 重载:错误时只传 Error不传 acceptFile
cb(new Error('仅支持 jpg/png/jpeg/webp 格式图片'));
}
},
limits: { fileSize: MAX_SIZE },
});
}
use(req: Request, _res: Response, next: NextFunction): void {
this.upload.single('file')(req, _res, (err) => {
if (err) {
next(new BadRequestException(err.message ?? '文件上传失败'));
return;
}
next();
});
}
}

View File

@ -0,0 +1,47 @@
/**
*
* process.env
*/
export interface AppConfig {
/** 服务端口 */
port: number;
/** 运行环境 */
nodeEnv: string;
/** 文件上传根目录 */
uploadRoot: string;
/** 数据库配置 */
database: {
host: string;
port: number;
user: string;
password: string;
name: string;
};
/** JWT 配置 */
jwt: {
secret: string;
expiresIn: string;
};
}
export default (): AppConfig => {
const port = parseInt(process.env.PORT ?? '3001', 10);
const dbPort = parseInt(process.env.DB_PORT ?? '3306', 10);
return {
port: Number.isNaN(port) ? 3001 : port,
nodeEnv: process.env.NODE_ENV ?? 'development',
uploadRoot: process.env.UPLOAD_ROOT ?? './uploads',
database: {
host: process.env.DB_HOST ?? '127.0.0.1',
port: Number.isNaN(dbPort) ? 3306 : dbPort,
user: process.env.DB_USER ?? 'root',
password: process.env.DB_PASSWORD ?? '',
name: process.env.DB_NAME ?? 'corp_website',
},
jwt: {
secret: process.env.JWT_SECRET ?? 'corp_website_secret_key_2026ai',
expiresIn: process.env.JWT_EXPIRES_IN ?? '7d',
},
};
};

View File

@ -0,0 +1,22 @@
import { DocumentBuilder } from '@nestjs/swagger';
/**
* Swagger
* 访http://localhost:3001/api-docs
*/
export const swaggerOptions = new DocumentBuilder()
.setTitle('企业官方网站 接口文档')
.setDescription('Next14 + Nest10 + JWT + MySQL8 全栈项目后端 API')
.setVersion('1.0.0')
.addBearerAuth(
{
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
name: 'Authorization',
description: '请输入 JWT Token格式Bearer xxxxx',
in: 'header',
},
'admin-token',
)
.build();

View File

@ -0,0 +1,43 @@
import {
Column,
CreateDateColumn,
Entity,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
/**
* admin
*/
@Entity('admin')
export class Admin {
/** 主键ID */
@PrimaryGeneratedColumn({ type: 'int', unsigned: true })
id: number;
/** 登录账号 */
@Column({ type: 'varchar', length: 50, comment: '登录账号' })
username: string;
/** 加密密码bcrypt */
@Column({ type: 'varchar', length: 100, comment: '加密密码', select: false })
password: string;
/** 管理员名称 */
@Column({ type: 'varchar', length: 50, comment: '管理员名称' })
nickname: string;
/** 头像地址 */
@Column({ type: 'varchar', length: 255, default: '', comment: '头像地址' })
avatar: string;
/** 角色super_admin 超级管理员 / normal 普通管理员 */
@Column({ type: 'varchar', length: 20, default: 'normal', comment: '角色' })
role: 'super_admin' | 'normal';
@CreateDateColumn({ name: 'created_at', type: 'datetime' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'datetime' })
updatedAt: Date;
}

View File

@ -0,0 +1,42 @@
import {
Column,
CreateDateColumn,
Entity,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
/**
* banner
*/
@Entity('banner')
export class Banner {
@PrimaryGeneratedColumn({ type: 'int', unsigned: true })
id: number;
/** 轮播标题 */
@Column({ type: 'varchar', length: 100, comment: '轮播标题' })
title: string;
/** 图片地址 */
@Column({ type: 'varchar', length: 255, comment: '图片地址' })
image: string;
/** 跳转链接 */
@Column({ type: 'varchar', length: 255, default: '', comment: '跳转链接' })
link: string;
/** 排序值(越小越靠前) */
@Column({ type: 'int', default: 0, comment: '排序值' })
sort: number;
/** 是否展示 1是 0否 */
@Column({ type: 'tinyint', name: 'is_show', default: 1, comment: '是否展示 1是0否' })
isShow: number;
@CreateDateColumn({ name: 'created_at', type: 'datetime' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'datetime' })
updatedAt: Date;
}

9
src/entities/index.ts Normal file
View File

@ -0,0 +1,9 @@
export * from './admin.entity';
export * from './banner.entity';
export * from './message.entity';
export * from './news-category.entity';
export * from './news.entity';
export * from './product-category.entity';
export * from './product.entity';
export * from './site-config.entity';
export * from './team.entity';

View File

@ -0,0 +1,78 @@
import {
Column,
CreateDateColumn,
Entity,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
/**
* 使 manual
* - parentId
* - type=0 contenttype=1 content
*/
@Entity('manual')
export class Manual {
/** 主键ID */
@PrimaryGeneratedColumn({ type: 'int', unsigned: true })
id: number;
/** 父节点IDNULL 表示根节点 */
@Column({
name: 'parent_id',
type: 'int',
unsigned: true,
nullable: true,
comment: '父节点IDNULL=根节点',
})
parentId: number | null;
/** 标题 */
@Column({ type: 'varchar', length: 200, comment: '标题' })
title: string;
/** 节点类型0=目录 1=文档 */
@Column({
type: 'tinyint',
default: 1,
comment: '类型0=目录节点 1=文档节点',
})
type: number;
/** 文档正文(富文本 HTML 或 Markdown目录节点为 null */
@Column({
type: 'longtext',
nullable: true,
comment: '文档正文(目录节点为空)',
})
content: string | null;
/** 正文格式html=富文本 markdown=Markdown */
@Column({
name: 'content_format',
type: 'varchar',
length: 10,
default: 'html',
comment: '正文格式html=富文本 markdown=Markdown',
})
contentFormat: 'html' | 'markdown';
/** 排序值,越小越靠前 */
@Column({ type: 'int', default: 0, comment: '排序值' })
sort: number;
/** 是否显示1显示 0隐藏 */
@Column({
name: 'is_show',
type: 'tinyint',
default: 1,
comment: '是否显示 1是0否',
})
isShow: number;
@CreateDateColumn({ name: 'created_at', type: 'datetime' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'datetime' })
updatedAt: Date;
}

View File

@ -0,0 +1,38 @@
import {
Column,
CreateDateColumn,
Entity,
PrimaryGeneratedColumn,
} from 'typeorm';
/**
* message
*/
@Entity('message')
export class Message {
@PrimaryGeneratedColumn({ type: 'int', unsigned: true })
id: number;
/** 访客姓名 */
@Column({ type: 'varchar', length: 50, comment: '访客姓名' })
name: string;
/** 联系电话 */
@Column({ type: 'varchar', length: 20, comment: '联系电话' })
phone: string;
/** 邮箱 */
@Column({ type: 'varchar', length: 100, default: '', comment: '邮箱' })
email: string;
/** 留言内容 */
@Column({ type: 'text', comment: '留言内容' })
content: string;
/** 0未读 1已读 */
@Column({ type: 'tinyint', name: 'is_read', default: 0, comment: '0未读1已读' })
isRead: number;
@CreateDateColumn({ name: 'created_at', type: 'datetime' })
createdAt: Date;
}

View File

@ -0,0 +1,31 @@
import {
Column,
CreateDateColumn,
Entity,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
/**
* news_category
*/
@Entity('news_category')
export class NewsCategory {
@PrimaryGeneratedColumn({ type: 'int', unsigned: true })
id: number;
@Column({ type: 'varchar', length: 100, comment: '分类名称' })
name: string;
@Column({ type: 'int', default: 0, comment: '排序' })
sort: number;
@Column({ type: 'tinyint', name: 'is_show', default: 1 })
isShow: number;
@CreateDateColumn({ name: 'created_at', type: 'datetime' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'datetime' })
updatedAt: Date;
}

View File

@ -0,0 +1,58 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { NewsCategory } from './news-category.entity';
/**
* news
*/
@Entity('news')
@Index('idx_category', ['categoryId'])
export class News {
@PrimaryGeneratedColumn({ type: 'int', unsigned: true })
id: number;
@Column({ type: 'int', unsigned: true, name: 'category_id' })
categoryId: number;
@ManyToOne(() => NewsCategory)
@JoinColumn({ name: 'category_id', referencedColumnName: 'id' })
category?: NewsCategory;
/** 新闻标题 */
@Column({ type: 'varchar', length: 200, comment: '新闻标题' })
title: string;
/** 封面图 */
@Column({ type: 'varchar', length: 255, default: '', comment: '封面图' })
cover: string;
/** 简介 */
@Column({ type: 'varchar', length: 500, default: '', comment: '简介' })
intro: string;
/** 新闻正文富文本 */
@Column({ type: 'longtext', comment: '新闻正文富文本' })
content: string;
/** 是否置顶 */
@Column({ type: 'tinyint', name: 'is_top', default: 0, comment: '是否置顶' })
isTop: number;
/** 状态1发布 0草稿 */
@Column({ type: 'tinyint', default: 1, comment: '1发布 0草稿' })
status: number;
@CreateDateColumn({ name: 'created_at', type: 'datetime' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'datetime' })
updatedAt: Date;
}

View File

@ -0,0 +1,34 @@
import {
Column,
CreateDateColumn,
Entity,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
/**
* product_category
*/
@Entity('product_category')
export class ProductCategory {
@PrimaryGeneratedColumn({ type: 'int', unsigned: true })
id: number;
/** 分类名称 */
@Column({ type: 'varchar', length: 100, comment: '分类名称' })
name: string;
/** 排序值 */
@Column({ type: 'int', default: 0, comment: '排序' })
sort: number;
/** 是否展示 */
@Column({ type: 'tinyint', name: 'is_show', default: 1 })
isShow: number;
@CreateDateColumn({ name: 'created_at', type: 'datetime' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'datetime' })
updatedAt: Date;
}

View File

@ -0,0 +1,60 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { ProductCategory } from './product-category.entity';
/**
* product
*/
@Entity('product')
@Index('idx_category', ['categoryId'])
export class Product {
@PrimaryGeneratedColumn({ type: 'int', unsigned: true })
id: number;
/** 分类ID */
@Column({ type: 'int', unsigned: true, name: 'category_id', comment: '分类ID' })
categoryId: number;
/** 所属分类(仅用于查询关联,不映射物理外键) */
@ManyToOne(() => ProductCategory)
@JoinColumn({ name: 'category_id', referencedColumnName: 'id' })
category?: ProductCategory;
/** 产品名称 */
@Column({ type: 'varchar', length: 100, comment: '产品名称' })
name: string;
/** 封面图 */
@Column({ type: 'varchar', length: 255, comment: '封面图' })
cover: string;
/** 简短描述 */
@Column({ type: 'text', nullable: true, comment: '简短描述' })
desc: string | null;
/** 详情富文本 */
@Column({ type: 'longtext', nullable: true, comment: '详情富文本' })
content: string | null;
/** 排序值 */
@Column({ type: 'int', default: 0 })
sort: number;
/** 上下架 1上架 0下架 */
@Column({ type: 'tinyint', name: 'is_show', default: 1, comment: '上下架' })
isShow: number;
@CreateDateColumn({ name: 'created_at', type: 'datetime' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'datetime' })
updatedAt: Date;
}

View File

@ -0,0 +1,58 @@
import {
Column,
CreateDateColumn,
Entity,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
/**
* site_config id=1
*/
@Entity('site_config')
export class SiteConfig {
@PrimaryGeneratedColumn({ type: 'int', unsigned: true })
id: number;
/** 网站名称 */
@Column({ name: 'site_name', type: 'varchar', length: 100, comment: '网站名称' })
siteName: string;
/** logo 图片 */
@Column({ type: 'varchar', length: 255, default: '', comment: 'logo图片' })
logo: string;
/** 联系电话 */
@Column({ type: 'varchar', length: 50, default: '', comment: '联系电话' })
tel: string;
/** 公司地址 */
@Column({ type: 'varchar', length: 255, default: '', comment: '公司地址' })
address: string;
/** 商务邮箱 */
@Column({ type: 'varchar', length: 100, default: '', comment: '商务邮箱' })
email: string;
/** 底部版权 */
@Column({ type: 'varchar', length: 255, default: '', comment: '底部版权' })
copyright: string;
/** 备案号 */
@Column({ type: 'varchar', length: 100, default: '', comment: '备案号' })
icp: string;
/** 企业简介标题 */
@Column({ name: 'about_title', type: 'varchar', length: 100, default: '', comment: '企业简介标题' })
aboutTitle: string;
/** 企业简介正文 */
@Column({ name: 'about_content', type: 'longtext', nullable: true, comment: '企业简介正文' })
aboutContent: string | null;
@CreateDateColumn({ name: 'created_at', type: 'datetime' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'datetime' })
updatedAt: Date;
}

View File

@ -0,0 +1,44 @@
import {
Column,
CreateDateColumn,
Entity,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
/**
* team
*/
@Entity('team')
export class Team {
@PrimaryGeneratedColumn({ type: 'int', unsigned: true })
id: number;
/** 姓名 */
@Column({ type: 'varchar', length: 50, comment: '姓名' })
name: string;
/** 职位 */
@Column({ type: 'varchar', length: 100, comment: '职位' })
position: string;
/** 头像 */
@Column({ type: 'varchar', length: 255, comment: '头像' })
avatar: string;
/** 个人简介 */
@Column({ type: 'text', nullable: true, comment: '个人简介' })
desc: string | null;
@Column({ type: 'int', default: 0 })
sort: number;
@Column({ type: 'tinyint', name: 'is_show', default: 1 })
isShow: number;
@CreateDateColumn({ name: 'created_at', type: 'datetime' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'datetime' })
updatedAt: Date;
}

167
src/main.ts Normal file
View File

@ -0,0 +1,167 @@
import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import { ValidationPipe, Logger, type INestApplication } from '@nestjs/common';
import { SwaggerModule } from '@nestjs/swagger';
import { WinstonModule } from 'nest-winston';
import * as winston from 'winston';
import { DataSource } from 'typeorm';
import { AppModule } from './app.module';
import { swaggerOptions } from './config/swagger.config';
import { AllExceptionsFilter } from './common/filters';
import { TransformInterceptor } from './common/interceptors';
/**
* admin role
* - information_schema ALTER TABLE
* - 'normal' id=1 'super_admin'
* - role
*/
async function ensureAdminRoleColumn(app: INestApplication): Promise<void> {
const dataSource = app.get(DataSource);
const rows = (await dataSource.query(
`SELECT COUNT(*) AS cnt
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'admin'
AND COLUMN_NAME = 'role'`,
)) as Array<{ cnt: number | string }>;
const exists = Number(rows?.[0]?.cnt ?? 0) > 0;
if (exists) return;
Logger.warn('检测到 admin 表缺少 role 字段,正在自动升级数据库...', 'Migration');
await dataSource.query(
"ALTER TABLE `admin` ADD COLUMN `role` varchar(20) NOT NULL DEFAULT 'normal' " +
"COMMENT '角色super_admin 超级管理员 / normal 普通管理员' AFTER `avatar`",
);
await dataSource.query(
"UPDATE `admin` SET `role` = 'super_admin' WHERE `id` = 1",
);
Logger.log('admin 表 role 字段已自动升级完成,初始 admin 已置为超级管理员', 'Migration');
}
/**
* manual 使
* - CREATE TABLE
* -
*/
async function ensureManualTable(app: INestApplication): Promise<void> {
const dataSource = app.get(DataSource);
const rows = (await dataSource.query(
`SELECT COUNT(*) AS cnt
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'manual'`,
)) as Array<{ cnt: number | string }>;
const exists = Number(rows?.[0]?.cnt ?? 0) > 0;
if (exists) return;
Logger.warn('检测到 manual 表不存在,正在自动创建...', 'Migration');
await dataSource.query(
`CREATE TABLE \`manual\` (
\`id\` int unsigned NOT NULL AUTO_INCREMENT,
\`parent_id\` int unsigned DEFAULT NULL COMMENT '父节点IDNULL=根节点',
\`title\` varchar(200) NOT NULL COMMENT '标题',
\`type\` tinyint(1) NOT NULL DEFAULT 1 COMMENT '类型0=目录节点 1=文档节点',
\`content\` longtext COMMENT '文档正文(目录节点为空)',
\`content_format\` varchar(10) NOT NULL DEFAULT 'html' COMMENT '正文格式html=富文本 markdown=Markdown',
\`sort\` int NOT NULL DEFAULT 0 COMMENT '排序值',
\`is_show\` tinyint(1) NOT NULL DEFAULT 1 COMMENT '是否显示 1是0否',
\`created_at\` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
\`updated_at\` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (\`id\`),
KEY \`idx_parent\` (\`parent_id\`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='使用手册'`,
);
Logger.log('manual 表已自动创建完成', 'Migration');
}
/**
* manual content_format
* - manual
* -
*/
async function ensureManualContentFormatColumn(
app: INestApplication,
): Promise<void> {
const dataSource = app.get(DataSource);
const rows = (await dataSource.query(
`SELECT COUNT(*) AS cnt
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'manual'
AND COLUMN_NAME = 'content_format'`,
)) as Array<{ cnt: number | string }>;
const exists = Number(rows?.[0]?.cnt ?? 0) > 0;
if (exists) return;
Logger.warn(
'检测到 manual 表缺少 content_format 字段,正在自动升级数据库...',
'Migration',
);
await dataSource.query(
"ALTER TABLE `manual` ADD COLUMN `content_format` varchar(10) NOT NULL DEFAULT 'html' " +
"COMMENT '正文格式html=富文本 markdown=Markdown' AFTER `content`",
);
Logger.log(
'manual 表 content_format 字段已自动升级完成',
'Migration',
);
}
async function bootstrap(): Promise<void> {
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
logger: WinstonModule.createLogger({
transports: [
new winston.transports.Console({
format: winston.format.combine(
winston.format.timestamp(),
winston.format.colorize(),
winston.format.printf(({ level, message, timestamp }) => {
return `${timestamp} ${level}: ${message}`;
}),
),
}),
],
}),
});
app.setGlobalPrefix('api');
// 全局管道DTO + class-validator
// 注意app.module.ts 的 APP_PIPE/APP_FILTER/APP_INTERCEPTOR 已删除,
// 这里统一注册避免重复执行APP_GUARDJwtAuthGuard 需要 DI保留在 app.module.ts。
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: false,
}),
);
// 全局过滤器 + 响应拦截器
app.useGlobalFilters(new AllExceptionsFilter());
app.useGlobalInterceptors(new TransformInterceptor());
// 跨域放行(前端 3000 -> 后端 3001
app.enableCors({
origin: true,
credentials: true,
});
// Swagger 文档
const document = SwaggerModule.createDocument(app, swaggerOptions);
SwaggerModule.setup('api-docs', app, document);
// 启动期自动迁移:补齐 admin.role 字段、确保 manual 表存在
await ensureAdminRoleColumn(app);
await ensureManualTable(app);
await ensureManualContentFormatColumn(app);
const port = parseInt(process.env.PORT ?? '3001', 10);
await app.listen(port);
Logger.log(`🚀 后端服务已启动: http://localhost:${port}`, 'Bootstrap');
Logger.log(`📘 Swagger 文档地址: http://localhost:${port}/api-docs`, 'Bootstrap');
}
void bootstrap();

View File

@ -0,0 +1,87 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseIntPipe,
Post,
Put,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { PaginatedResult } from '@/common/dto/api-response.dto';
import { CurrentAdmin, Roles } from '@/common/decorators';
import { Admin } from '@/entities/admin.entity';
import { AdminUserService } from './admin-user.service';
import {
CreateAdminUserDto,
QueryAdminUserDto,
ResetPasswordDto,
UpdateAdminUserDto,
} from './dto/admin-user.dto';
@ApiTags('管理员账号 AdminUser')
@Controller()
export class AdminUserController {
constructor(private readonly service: AdminUserService) {}
@ApiBearerAuth('admin-token')
@Get('admin/admin-user')
@ApiOperation({ summary: '后台-管理员账号分页' })
paginate(
@Query() query: QueryAdminUserDto,
): Promise<PaginatedResult<Admin>> {
return this.service.paginate(query);
}
@ApiBearerAuth('admin-token')
@Get('admin/admin-user/:id')
@ApiOperation({ summary: '后台-管理员账号详情' })
detail(@Param('id', ParseIntPipe) id: number): Promise<Admin> {
return this.service.detail(id);
}
@ApiBearerAuth('admin-token')
@Post('admin/admin-user')
@Roles('super_admin')
@ApiOperation({ summary: '后台-新增管理员账号(仅超管)' })
create(@Body() dto: CreateAdminUserDto): Promise<Admin> {
return this.service.create(dto);
}
@ApiBearerAuth('admin-token')
@Put('admin/admin-user/:id')
@Roles('super_admin')
@ApiOperation({ summary: '后台-修改管理员账号(名称/头像/角色,仅超管)' })
update(
@Param('id', ParseIntPipe) id: number,
@Body() dto: UpdateAdminUserDto,
@CurrentAdmin('id') currentAdminId: number,
): Promise<void> {
return this.service.update(id, dto, currentAdminId);
}
@ApiBearerAuth('admin-token')
@Put('admin/admin-user/:id/password')
@Roles('super_admin')
@ApiOperation({ summary: '后台-重置管理员密码(仅超管)' })
resetPassword(
@Param('id', ParseIntPipe) id: number,
@Body() dto: ResetPasswordDto,
): Promise<void> {
return this.service.resetPassword(id, dto);
}
@ApiBearerAuth('admin-token')
@Delete('admin/admin-user/:id')
@Roles('super_admin')
@ApiOperation({ summary: '后台-删除管理员账号(不能删自己,仅超管)' })
remove(
@Param('id', ParseIntPipe) id: number,
@CurrentAdmin('id') currentAdminId: number,
): Promise<void> {
return this.service.remove(id, currentAdminId);
}
}

View File

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Admin } from '@/entities/admin.entity';
import { AdminUserController } from './admin-user.controller';
import { AdminUserService } from './admin-user.service';
@Module({
imports: [TypeOrmModule.forFeature([Admin])],
controllers: [AdminUserController],
providers: [AdminUserService],
})
export class AdminUserModule {}

View File

@ -0,0 +1,107 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Admin } from '@/entities/admin.entity';
import { PaginatedResult } from '@/common/dto/api-response.dto';
import type { AdminRole } from '@/common/decorators';
import { hashPassword } from '@/utils/crypto.util';
import {
CreateAdminUserDto,
QueryAdminUserDto,
ResetPasswordDto,
UpdateAdminUserDto,
} from './dto/admin-user.dto';
@Injectable()
export class AdminUserService {
constructor(
@InjectRepository(Admin)
private readonly repo: Repository<Admin>,
) {}
async paginate(query: QueryAdminUserDto): Promise<PaginatedResult<Admin>> {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 10;
const qb = this.repo.createQueryBuilder('a').orderBy('a.id', 'DESC');
if (query.keyword) {
qb.andWhere('(a.username LIKE :kw OR a.nickname LIKE :kw)', {
kw: `%${query.keyword}%`,
});
}
const [list, total] = await qb
.skip((page - 1) * pageSize)
.take(pageSize)
.getManyAndCount();
return { list, total, page, pageSize };
}
async detail(id: number): Promise<Admin> {
const item = await this.repo.findOne({ where: { id } });
if (!item) throw new NotFoundException('管理员不存在');
return item;
}
async create(dto: CreateAdminUserDto): Promise<Admin> {
const exists = await this.repo.findOne({
where: { username: dto.username },
});
if (exists) {
throw new ConflictException('登录账号已存在');
}
const hashed = await hashPassword(dto.password);
const entity = this.repo.create({
username: dto.username,
password: hashed,
nickname: dto.nickname,
avatar: dto.avatar ?? '',
role: dto.role ?? 'normal',
});
return this.repo.save(entity);
}
/**
* //
* -
*/
async update(
id: number,
dto: UpdateAdminUserDto,
currentAdminId: number,
): Promise<void> {
const item = await this.detail(id);
item.nickname = dto.nickname;
item.avatar = dto.avatar ?? '';
if (dto.role && dto.role !== item.role) {
if (
id === currentAdminId &&
item.role === 'super_admin' &&
dto.role !== 'super_admin'
) {
throw new ForbiddenException('不能将自己降级为普通管理员');
}
item.role = dto.role;
}
await this.repo.save(item);
}
async resetPassword(id: number, dto: ResetPasswordDto): Promise<void> {
const item = await this.detail(id);
item.password = await hashPassword(dto.newPassword);
await this.repo.save(item);
}
async remove(id: number, currentAdminId: number): Promise<void> {
if (id === currentAdminId) {
throw new BadRequestException('不能删除当前登录的管理员账号');
}
await this.detail(id);
await this.repo.delete(id);
}
}

View File

@ -0,0 +1,107 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsIn,
IsInt,
IsNotEmpty,
IsOptional,
IsString,
Max,
MaxLength,
Min,
MinLength,
} from 'class-validator';
import type { AdminRole } from '@/common/decorators';
export class CreateAdminUserDto {
@ApiProperty({
description: '登录账号(手机号/邮箱/自定义)',
example: 'admin',
})
@IsString()
@IsNotEmpty({ message: '登录账号不能为空' })
@MinLength(2, { message: '账号至少 2 个字符' })
@MaxLength(50, { message: '账号最多 50 个字符' })
username: string;
@ApiProperty({ description: '初始密码(至少 6 位)', example: '123456' })
@IsString()
@IsNotEmpty({ message: '密码不能为空' })
@MinLength(6, { message: '密码至少 6 位' })
@MaxLength(50, { message: '密码最多 50 个字符' })
password: string;
@ApiProperty({ description: '管理员名称' })
@IsString()
@IsNotEmpty({ message: '名称不能为空' })
@MaxLength(50)
nickname: string;
@ApiPropertyOptional({ description: '头像地址' })
@IsString()
@IsOptional()
avatar?: string;
@ApiPropertyOptional({
description: '角色super_admin 超级管理员 / normal 普通管理员',
default: 'normal',
enum: ['super_admin', 'normal'],
})
@IsString()
@IsIn(['super_admin', 'normal'], { message: '角色取值不合法' })
@IsOptional()
role?: AdminRole;
}
export class UpdateAdminUserDto {
@ApiProperty({ description: '管理员名称' })
@IsString()
@IsNotEmpty({ message: '名称不能为空' })
@MaxLength(50)
nickname: string;
@ApiPropertyOptional({ description: '头像地址' })
@IsString()
@IsOptional()
avatar?: string;
@ApiPropertyOptional({
description: '角色super_admin 超级管理员 / normal 普通管理员',
enum: ['super_admin', 'normal'],
})
@IsString()
@IsIn(['super_admin', 'normal'], { message: '角色取值不合法' })
@IsOptional()
role?: AdminRole;
}
export class ResetPasswordDto {
@ApiProperty({ description: '新密码(至少 6 位)' })
@IsString()
@IsNotEmpty({ message: '新密码不能为空' })
@MinLength(6, { message: '新密码至少 6 位' })
@MaxLength(50, { message: '新密码最多 50 个字符' })
newPassword: string;
}
export class QueryAdminUserDto {
@ApiPropertyOptional({ description: '页码', default: 1 })
@IsInt()
@Min(1)
@IsOptional()
@Type(() => Number)
page?: number;
@ApiPropertyOptional({ description: '每页数量', default: 10 })
@IsInt()
@Min(1)
@Max(100)
@IsOptional()
@Type(() => Number)
pageSize?: number;
@ApiPropertyOptional({ description: '关键词(账号/名称)' })
@IsString()
@IsOptional()
keyword?: string;
}

View File

@ -0,0 +1,40 @@
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { AuthService } from './auth.service';
import {
ChangePasswordDto,
LoginDto,
AdminLoginResult,
} from './dto/login.dto';
import { CurrentAdmin, Public } from '@/common/decorators';
@ApiTags('管理员鉴权 Auth')
@Controller('admin/auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Public()
@Post('login')
@ApiOperation({ summary: '管理员登录', description: '无需 token返回 JWT' })
login(@Body() dto: LoginDto): Promise<AdminLoginResult> {
return this.authService.login(dto);
}
@ApiBearerAuth('admin-token')
@Get('profile')
@ApiOperation({ summary: '获取当前登录管理员信息' })
profile(@CurrentAdmin('id') adminId: number) {
return this.authService.getProfile(adminId);
}
@ApiBearerAuth('admin-token')
@Post('change-password')
@ApiOperation({ summary: '修改当前管理员密码' })
changePassword(
@CurrentAdmin('id') adminId: number,
@Body() dto: ChangePasswordDto,
): Promise<void> {
return this.authService.changePassword(adminId, dto);
}
}

View File

@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Admin } from '@/entities/admin.entity';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
@Module({
// JwtModule 已在 AppModule 以 global: true 注册,无需再 import
imports: [TypeOrmModule.forFeature([Admin])],
controllers: [AuthController],
providers: [AuthService],
exports: [AuthService],
})
export class AuthModule {}

View File

@ -0,0 +1,109 @@
import {
BadRequestException,
Injectable,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { JwtService } from '@nestjs/jwt';
import { Admin } from '@/entities/admin.entity';
import { comparePassword, hashPassword } from '@/utils/crypto.util';
import {
AdminLoginResult,
ChangePasswordDto,
LoginDto,
} from './dto/login.dto';
/** 默认初始密码init_db.sql 中管理员 password 为空,首次启动会自动加密写入此密码) */
const DEFAULT_INIT_PASSWORD = '123456';
@Injectable()
export class AuthService {
constructor(
@InjectRepository(Admin)
private readonly adminRepo: Repository<Admin>,
private readonly jwtService: JwtService,
) {}
/**
*
* init_db.sql bcrypt 123456
*/
async login(dto: LoginDto): Promise<AdminLoginResult> {
const admin = await this.adminRepo
.createQueryBuilder('a')
.addSelect('a.password')
.where('a.username = :username', { username: dto.username })
.getOne();
if (!admin) {
throw new UnauthorizedException('账号或密码错误');
}
// 初始空密码 → 自动加密默认密码
if (!admin.password) {
admin.password = await hashPassword(DEFAULT_INIT_PASSWORD);
await this.adminRepo.update(admin.id, { password: admin.password });
}
const ok = await comparePassword(dto.password, admin.password);
if (!ok) {
throw new UnauthorizedException('账号或密码错误');
}
const token = await this.jwtService.signAsync({
id: admin.id,
username: admin.username,
nickname: admin.nickname,
role: admin.role,
});
return {
token,
admin: {
id: admin.id,
username: admin.username,
nickname: admin.nickname,
avatar: admin.avatar ?? '',
role: admin.role,
},
};
}
/** 获取当前登录管理员信息 */
async getProfile(adminId: number): Promise<Admin | null> {
return this.adminRepo.findOne({ where: { id: adminId } });
}
/** 修改密码 */
async changePassword(
adminId: number,
dto: ChangePasswordDto,
): Promise<void> {
if (dto.oldPassword === dto.newPassword) {
throw new BadRequestException('新密码不能与原密码相同');
}
const admin = await this.adminRepo
.createQueryBuilder('a')
.addSelect('a.password')
.where('a.id = :id', { id: adminId })
.getOne();
if (!admin) {
throw new NotFoundException('管理员不存在');
}
if (!admin.password) {
admin.password = await hashPassword(DEFAULT_INIT_PASSWORD);
await this.adminRepo.update(admin.id, { password: admin.password });
}
const ok = await comparePassword(dto.oldPassword, admin.password);
if (!ok) {
throw new BadRequestException('原密码错误');
}
const hashed = await hashPassword(dto.newPassword);
await this.adminRepo.update(admin.id, { password: hashed });
}
}

View File

@ -0,0 +1,39 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, MinLength, MaxLength } from 'class-validator';
export class LoginDto {
@ApiProperty({ description: '登录账号', example: 'admin' })
@IsString()
@MinLength(2, { message: '账号至少 2 个字符' })
@MaxLength(50, { message: '账号最多 50 个字符' })
username: string;
@ApiProperty({ description: '登录密码', example: '123456' })
@IsString()
@MinLength(6, { message: '密码至少 6 个字符' })
@MaxLength(50, { message: '密码最多 50 个字符' })
password: string;
}
export class ChangePasswordDto {
@ApiProperty({ description: '原密码' })
@IsString()
@MinLength(6)
oldPassword: string;
@ApiProperty({ description: '新密码(至少 6 位)' })
@IsString()
@MinLength(6, { message: '新密码至少 6 位' })
newPassword: string;
}
export interface AdminLoginResult {
token: string;
admin: {
id: number;
username: string;
nickname: string;
avatar: string;
role: 'super_admin' | 'normal';
};
}

View File

@ -0,0 +1,76 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseIntPipe,
Post,
Put,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { BannerService } from './banner.service';
import {
CreateBannerDto,
QueryBannerDto,
UpdateBannerDto,
} from './dto/banner.dto';
import { Public, Roles } from '@/common/decorators';
import { PaginatedResult } from '@/common/dto/api-response.dto';
import { Banner } from '@/entities/banner.entity';
@ApiTags('轮播图 Banner')
@Controller()
export class BannerController {
constructor(private readonly service: BannerService) {}
// ---------- 前台公开 ----------
@Public()
@Get('public/banner')
@ApiOperation({ summary: '前台-轮播图列表' })
publicList(): Promise<Banner[]> {
return this.service.findPublicList();
}
// ---------- 后台 ----------
@ApiBearerAuth('admin-token')
@Get('admin/banner')
@ApiOperation({ summary: '后台-轮播图分页' })
paginate(@Query() query: QueryBannerDto): Promise<PaginatedResult<Banner>> {
return this.service.paginate(query);
}
@ApiBearerAuth('admin-token')
@Get('admin/banner/:id')
@ApiOperation({ summary: '后台-轮播图详情' })
detail(@Param('id', ParseIntPipe) id: number): Promise<Banner> {
return this.service.detail(id);
}
@ApiBearerAuth('admin-token')
@Post('admin/banner')
@ApiOperation({ summary: '后台-新增轮播图' })
create(@Body() dto: CreateBannerDto): Promise<Banner> {
return this.service.create(dto);
}
@ApiBearerAuth('admin-token')
@Put('admin/banner/:id')
@ApiOperation({ summary: '后台-修改轮播图' })
update(
@Param('id', ParseIntPipe) id: number,
@Body() dto: UpdateBannerDto,
): Promise<void> {
return this.service.update(id, dto);
}
@ApiBearerAuth('admin-token')
@Delete('admin/banner/:id')
@Roles('super_admin')
@ApiOperation({ summary: '后台-删除轮播图(仅超管)' })
remove(@Param('id', ParseIntPipe) id: number): Promise<void> {
return this.service.remove(id);
}
}

View File

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Banner } from '@/entities/banner.entity';
import { BannerController } from './banner.controller';
import { BannerService } from './banner.service';
@Module({
imports: [TypeOrmModule.forFeature([Banner])],
controllers: [BannerController],
providers: [BannerService],
})
export class BannerModule {}

View File

@ -0,0 +1,79 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Banner } from '@/entities/banner.entity';
import { PaginatedResult } from '@/common/dto/api-response.dto';
import {
CreateBannerDto,
QueryBannerDto,
UpdateBannerDto,
} from './dto/banner.dto';
@Injectable()
export class BannerService {
constructor(
@InjectRepository(Banner)
private readonly repo: Repository<Banner>,
) {}
// ---------- 前台公开 ----------
/** 前台展示列表(仅 is_show=1按 sort 升序) */
async findPublicList(): Promise<Banner[]> {
return this.repo.find({
where: { isShow: 1 },
order: { sort: 'ASC', id: 'DESC' },
});
}
// ---------- 后台 ----------
async paginate(query: QueryBannerDto): Promise<PaginatedResult<Banner>> {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 10;
const qb = this.repo.createQueryBuilder('b').orderBy('b.sort', 'ASC').addOrderBy('b.id', 'DESC');
if (query.keyword) {
qb.andWhere('b.title LIKE :kw', { kw: `%${query.keyword}%` });
}
const [list, total] = await qb
.skip((page - 1) * pageSize)
.take(pageSize)
.getManyAndCount();
return { list, total, page, pageSize };
}
async detail(id: number): Promise<Banner> {
const item = await this.repo.findOne({ where: { id } });
if (!item) {
throw new NotFoundException('轮播图不存在');
}
return item;
}
async create(dto: CreateBannerDto): Promise<Banner> {
const entity = this.repo.create({
title: dto.title,
image: dto.image,
link: dto.link ?? '',
sort: dto.sort ?? 0,
isShow: dto.isShow ?? 1,
});
return this.repo.save(entity);
}
async update(id: number, dto: UpdateBannerDto): Promise<void> {
const item = await this.detail(id);
Object.assign(item, {
title: dto.title,
image: dto.image,
link: dto.link ?? '',
sort: dto.sort ?? 0,
isShow: dto.isShow ?? 1,
});
await this.repo.save(item);
}
async remove(id: number): Promise<void> {
await this.detail(id);
await this.repo.delete(id);
}
}

View File

@ -0,0 +1,70 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsInt,
IsNotEmpty,
IsOptional,
IsString,
Max,
MaxLength,
Min,
} from 'class-validator';
export class CreateBannerDto {
@ApiProperty({ description: '轮播标题' })
@IsString()
@IsNotEmpty({ message: '标题不能为空' })
@MaxLength(100)
title: string;
@ApiProperty({ description: '图片地址' })
@IsString()
@IsNotEmpty({ message: '图片不能为空' })
image: string;
@ApiPropertyOptional({ description: '跳转链接', default: '' })
@IsString()
@IsOptional()
@MaxLength(255)
link?: string;
@ApiPropertyOptional({ description: '排序值', default: 0 })
@IsInt()
@Min(0)
@Max(99999)
@IsOptional()
@Type(() => Number)
sort?: number;
@ApiPropertyOptional({ description: '是否展示 1是0否', default: 1 })
@IsInt()
@Min(0)
@Max(1)
@IsOptional()
@Type(() => Number)
isShow?: number;
}
export class UpdateBannerDto extends CreateBannerDto {}
export class QueryBannerDto {
@ApiPropertyOptional({ description: '页码', default: 1 })
@IsInt()
@Min(1)
@IsOptional()
@Type(() => Number)
page?: number;
@ApiPropertyOptional({ description: '每页数量', default: 10 })
@IsInt()
@Min(1)
@Max(100)
@IsOptional()
@Type(() => Number)
pageSize?: number;
@ApiPropertyOptional({ description: '关键词(标题)' })
@IsString()
@IsOptional()
keyword?: string;
}

View File

@ -0,0 +1,76 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsIn,
IsInt,
IsNotEmpty,
IsOptional,
IsString,
Max,
MaxLength,
Min,
} from 'class-validator';
export class CreateManualDto {
@ApiPropertyOptional({ description: '父节点IDNULL=根节点' })
@IsInt()
@Min(1)
@IsOptional()
@Type(() => Number)
parentId?: number | null;
@ApiProperty({ description: '标题' })
@IsString()
@IsNotEmpty({ message: '标题不能为空' })
@MaxLength(200)
title: string;
@ApiPropertyOptional({
description: '类型0=目录节点 1=文档节点',
default: 1,
})
@IsInt()
@Min(0)
@Max(1)
@IsOptional()
@Type(() => Number)
type?: number;
@ApiPropertyOptional({ description: '正文(富文本/Markdown目录节点为空' })
@IsString()
@IsOptional()
content?: string | null;
@ApiPropertyOptional({
description: '正文格式html=富文本 markdown=Markdown',
default: 'html',
})
@IsIn(['html', 'markdown'])
@IsOptional()
contentFormat?: 'html' | 'markdown';
@ApiPropertyOptional({ description: '排序值', default: 0 })
@IsInt()
@Min(0)
@Max(99999)
@IsOptional()
@Type(() => Number)
sort?: number;
@ApiPropertyOptional({ description: '是否显示 1是0否', default: 1 })
@IsInt()
@Min(0)
@Max(1)
@IsOptional()
@Type(() => Number)
isShow?: number;
}
export class UpdateManualDto extends CreateManualDto {}
export class QueryManualDto {
@ApiPropertyOptional({ description: '关键词(标题)' })
@IsString()
@IsOptional()
keyword?: string;
}

View File

@ -0,0 +1,91 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseIntPipe,
Post,
Put,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Public, Roles } from '@/common/decorators';
import { Manual } from '@/entities/manual.entity';
import { ManualService, type ManualTreeNode } from './manual.service';
import {
CreateManualDto,
QueryManualDto,
UpdateManualDto,
} from './dto/manual.dto';
@ApiTags('使用手册 Manual')
@Controller()
export class ManualController {
constructor(private readonly service: ManualService) {}
// ---------- 前台 ----------
@Public()
@Get('public/manual/tree')
@ApiOperation({ summary: '前台-使用手册树形菜单' })
publicTree(): Promise<ManualTreeNode[]> {
return this.service.findPublicTree();
}
@Public()
@Get('public/manual/:id')
@ApiOperation({ summary: '前台-使用手册文档详情' })
publicDetail(
@Param('id', ParseIntPipe) id: number,
): Promise<Manual> {
return this.service.findPublicDetail(id);
}
// ---------- 后台 ----------
@ApiBearerAuth('admin-token')
@Get('admin/manual')
@ApiOperation({ summary: '后台-使用手册全部节点(扁平)' })
list(@Query() query: QueryManualDto): Promise<Manual[]> {
return this.service.findAll(query);
}
@ApiBearerAuth('admin-token')
@Get('admin/manual/tree')
@ApiOperation({ summary: '后台-使用手册树形结构' })
tree(): Promise<ManualTreeNode[]> {
return this.service.findAdminTree();
}
@ApiBearerAuth('admin-token')
@Get('admin/manual/:id')
@ApiOperation({ summary: '后台-使用手册节点详情' })
detail(@Param('id', ParseIntPipe) id: number): Promise<Manual> {
return this.service.detail(id);
}
@ApiBearerAuth('admin-token')
@Post('admin/manual')
@ApiOperation({ summary: '后台-新增使用手册节点' })
create(@Body() dto: CreateManualDto): Promise<Manual> {
return this.service.create(dto);
}
@ApiBearerAuth('admin-token')
@Put('admin/manual/:id')
@ApiOperation({ summary: '后台-修改使用手册节点' })
update(
@Param('id', ParseIntPipe) id: number,
@Body() dto: UpdateManualDto,
): Promise<void> {
return this.service.update(id, dto);
}
@ApiBearerAuth('admin-token')
@Delete('admin/manual/:id')
@Roles('super_admin')
@ApiOperation({ summary: '后台-删除使用手册节点(仅超管)' })
remove(@Param('id', ParseIntPipe) id: number): Promise<void> {
return this.service.remove(id);
}
}

View File

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Manual } from '@/entities/manual.entity';
import { ManualController } from './manual.controller';
import { ManualService } from './manual.service';
@Module({
imports: [TypeOrmModule.forFeature([Manual])],
controllers: [ManualController],
providers: [ManualService],
})
export class ManualModule {}

View File

@ -0,0 +1,200 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, Like } from 'typeorm';
import { Manual } from '@/entities/manual.entity';
import {
CreateManualDto,
QueryManualDto,
UpdateManualDto,
} from './dto/manual.dto';
/** 树节点(不含正文,用于菜单渲染) */
export interface ManualTreeNode {
id: number;
parentId: number | null;
title: string;
type: number;
sort: number;
isShow: number;
children: ManualTreeNode[];
}
@Injectable()
export class ManualService {
constructor(
@InjectRepository(Manual)
private readonly repo: Repository<Manual>,
) {}
// ---------- 前台 ----------
/** 前台-获取可见的树形菜单(不含正文) */
async findPublicTree(): Promise<ManualTreeNode[]> {
const nodes = await this.repo.find({
where: { isShow: 1 },
order: { sort: 'ASC', id: 'ASC' },
});
return this.buildTree(nodes);
}
/** 前台-获取文档详情(含正文) */
async findPublicDetail(id: number): Promise<Manual> {
const item = await this.repo.findOne({ where: { id, isShow: 1 } });
if (!item) throw new NotFoundException('文档不存在或已下架');
return item;
}
// ---------- 后台 ----------
/** 后台-全部节点(扁平,含 parent 信息,供列表/下拉用) */
async findAll(query?: QueryManualDto): Promise<Manual[]> {
const where = query?.keyword
? { title: Like(`%${query.keyword}%`) }
: undefined;
return this.repo.find({
where,
order: { sort: 'ASC', id: 'ASC' },
});
}
/** 后台-获取所有节点的树形结构 */
async findAdminTree(): Promise<ManualTreeNode[]> {
const nodes = await this.repo.find({
order: { sort: 'ASC', id: 'ASC' },
});
return this.buildTree(nodes);
}
async detail(id: number): Promise<Manual> {
const item = await this.repo.findOne({ where: { id } });
if (!item) throw new NotFoundException('节点不存在');
return item;
}
async create(dto: CreateManualDto): Promise<Manual> {
if (dto.parentId) {
const parent = await this.repo.findOne({
where: { id: dto.parentId },
});
if (!parent) {
throw new BadRequestException('父节点不存在');
}
}
const type = dto.type ?? 1;
return this.repo.save(
this.repo.create({
parentId: dto.parentId ?? null,
title: dto.title,
type,
content: type === 0 ? null : dto.content ?? null,
contentFormat:
type === 0 ? 'html' : (dto.contentFormat ?? 'html'),
sort: dto.sort ?? 0,
isShow: dto.isShow ?? 1,
}),
);
}
async update(id: number, dto: UpdateManualDto): Promise<void> {
const item = await this.detail(id);
// 校验父节点
let newParentId: number | null = dto.parentId ?? null;
if (newParentId) {
if (newParentId === id) {
throw new BadRequestException('不能将自身设为父节点');
}
const parent = await this.repo.findOne({
where: { id: newParentId },
});
if (!parent) {
throw new BadRequestException('父节点不存在');
}
// 简单环路检测:父节点不能是自身的子孙
if (await this.isDescendant(id, newParentId)) {
throw new BadRequestException('不能将节点移动到其子节点下');
}
}
const type = dto.type ?? item.type;
Object.assign(item, {
parentId: newParentId,
title: dto.title,
type,
content: type === 0 ? null : dto.content ?? null,
contentFormat:
type === 0
? 'html'
: (dto.contentFormat ?? item.contentFormat ?? 'html'),
sort: dto.sort ?? 0,
isShow: dto.isShow ?? 1,
});
await this.repo.save(item);
}
async remove(id: number): Promise<void> {
const item = await this.detail(id);
const childCount = await this.repo.count({
where: { parentId: id },
});
if (childCount > 0) {
throw new BadRequestException('该节点下还有子节点,请先删除子节点');
}
await this.repo.remove(item);
}
// ---------- 工具 ----------
/** 判断 targetId 是否是 nodeId 的子孙(用于环路检测) */
private async isDescendant(
nodeId: number,
targetId: number,
): Promise<boolean> {
const stack = [nodeId];
const visited = new Set<number>();
while (stack.length > 0) {
const cur = stack.pop()!;
if (visited.has(cur)) continue;
visited.add(cur);
const children = await this.repo.find({
where: { parentId: cur },
select: ['id'],
});
for (const c of children) {
if (c.id === targetId) return true;
stack.push(c.id);
}
}
return false;
}
/** 扁平节点列表构建为树 */
private buildTree(nodes: Manual[]): ManualTreeNode[] {
const map = new Map<number, ManualTreeNode>();
const roots: ManualTreeNode[] = [];
for (const n of nodes) {
map.set(n.id, {
id: n.id,
parentId: n.parentId,
title: n.title,
type: n.type,
sort: n.sort,
isShow: n.isShow,
children: [],
});
}
for (const n of nodes) {
const node = map.get(n.id)!;
if (n.parentId && map.has(n.parentId)) {
map.get(n.parentId)!.children.push(node);
} else {
roots.push(node);
}
}
return roots;
}
}

View File

@ -0,0 +1,67 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsInt,
IsNotEmpty,
IsOptional,
IsString,
IsEmail,
Max,
MaxLength,
Min,
} from 'class-validator';
export class CreateMessageDto {
@ApiProperty({ description: '访客姓名' })
@IsString()
@IsNotEmpty({ message: '姓名不能为空' })
@MaxLength(50)
name: string;
@ApiProperty({ description: '联系电话' })
@IsString()
@IsNotEmpty({ message: '电话不能为空' })
@MaxLength(20)
phone: string;
@ApiPropertyOptional({ description: '邮箱' })
@IsEmail({}, { message: '邮箱格式不正确' })
@IsOptional()
@MaxLength(100)
email?: string;
@ApiProperty({ description: '留言内容' })
@IsString()
@IsNotEmpty({ message: '留言内容不能为空' })
content: string;
}
export class QueryMessageDto {
@ApiPropertyOptional({ description: '页码', default: 1 })
@IsInt()
@Min(1)
@IsOptional()
@Type(() => Number)
page?: number;
@ApiPropertyOptional({ description: '每页数量', default: 10 })
@IsInt()
@Min(1)
@Max(100)
@IsOptional()
@Type(() => Number)
pageSize?: number;
@ApiPropertyOptional({ description: '是否已读 0未读 1已读' })
@IsInt()
@Min(0)
@Max(1)
@IsOptional()
@Type(() => Number)
isRead?: number;
@ApiPropertyOptional({ description: '关键词' })
@IsString()
@IsOptional()
keyword?: string;
}

View File

@ -0,0 +1,56 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseIntPipe,
Post,
Put,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Public, Roles } from '@/common/decorators';
import { PaginatedResult } from '@/common/dto/api-response.dto';
import { Message } from '@/entities/message.entity';
import { MessageService } from './message.service';
import {
CreateMessageDto,
QueryMessageDto,
} from './dto/message.dto';
@ApiTags('留言 Message')
@Controller()
export class MessageController {
constructor(private readonly service: MessageService) {}
@Public()
@Post('public/message')
@ApiOperation({ summary: '前台-访客提交留言' })
submit(@Body() dto: CreateMessageDto): Promise<Message> {
return this.service.submit(dto);
}
@ApiBearerAuth('admin-token')
@Get('admin/message')
@ApiOperation({ summary: '后台-留言分页' })
paginate(@Query() query: QueryMessageDto): Promise<PaginatedResult<Message>> {
return this.service.paginate(query);
}
@ApiBearerAuth('admin-token')
@Put('admin/message/:id/read')
@ApiOperation({ summary: '后台-留言标记已读' })
markRead(@Param('id', ParseIntPipe) id: number): Promise<void> {
return this.service.markRead(id);
}
@ApiBearerAuth('admin-token')
@Delete('admin/message/:id')
@Roles('super_admin')
@ApiOperation({ summary: '后台-删除留言(仅超管)' })
remove(@Param('id', ParseIntPipe) id: number): Promise<void> {
return this.service.remove(id);
}
}

View File

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Message } from '@/entities/message.entity';
import { MessageController } from './message.controller';
import { MessageService } from './message.service';
@Module({
imports: [TypeOrmModule.forFeature([Message])],
controllers: [MessageController],
providers: [MessageService],
})
export class MessageModule {}

View File

@ -0,0 +1,67 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Message } from '@/entities/message.entity';
import { PaginatedResult } from '@/common/dto/api-response.dto';
import {
CreateMessageDto,
QueryMessageDto,
} from './dto/message.dto';
@Injectable()
export class MessageService {
constructor(
@InjectRepository(Message)
private readonly repo: Repository<Message>,
) {}
/** 前台访客提交留言 */
async submit(dto: CreateMessageDto): Promise<Message> {
return this.repo.save(
this.repo.create({
name: dto.name,
phone: dto.phone,
email: dto.email ?? '',
content: dto.content,
}),
);
}
/** 后台分页 */
async paginate(query: QueryMessageDto): Promise<PaginatedResult<Message>> {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 10;
const qb = this.repo
.createQueryBuilder('m')
.orderBy('m.is_read', 'ASC')
.addOrderBy('m.id', 'DESC');
if (typeof query.isRead === 'number') {
qb.andWhere('m.is_read = :ir', { ir: query.isRead });
}
if (query.keyword) {
qb.andWhere(
'(m.name LIKE :kw OR m.phone LIKE :kw OR m.content LIKE :kw)',
{ kw: `%${query.keyword}%` },
);
}
const [list, total] = await qb
.skip((page - 1) * pageSize)
.take(pageSize)
.getManyAndCount();
return { list, total, page, pageSize };
}
async markRead(id: number): Promise<void> {
const item = await this.repo.findOne({ where: { id } });
if (!item) throw new NotFoundException('留言不存在');
item.isRead = 1;
await this.repo.save(item);
}
async remove(id: number): Promise<void> {
const item = await this.repo.findOne({ where: { id } });
if (!item) throw new NotFoundException('留言不存在');
await this.repo.delete(id);
}
}

View File

@ -0,0 +1,44 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsInt,
IsNotEmpty,
IsOptional,
IsString,
Max,
MaxLength,
Min,
} from 'class-validator';
export class CreateNewsCategoryDto {
@ApiProperty({ description: '分类名称' })
@IsString()
@IsNotEmpty({ message: '分类名称不能为空' })
@MaxLength(100)
name: string;
@ApiPropertyOptional({ description: '排序', default: 0 })
@IsInt()
@Min(0)
@Max(99999)
@IsOptional()
@Type(() => Number)
sort?: number;
@ApiPropertyOptional({ description: '是否展示 1是0否', default: 1 })
@IsInt()
@Min(0)
@Max(1)
@IsOptional()
@Type(() => Number)
isShow?: number;
}
export class UpdateNewsCategoryDto extends CreateNewsCategoryDto {}
export class QueryNewsCategoryDto {
@ApiPropertyOptional({ description: '关键词' })
@IsString()
@IsOptional()
keyword?: string;
}

View File

@ -0,0 +1,72 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseIntPipe,
Post,
Put,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Public, Roles } from '@/common/decorators';
import { NewsCategory } from '@/entities/news-category.entity';
import { NewsCategoryService } from './news-category.service';
import {
CreateNewsCategoryDto,
QueryNewsCategoryDto,
UpdateNewsCategoryDto,
} from './dto/news-category.dto';
@ApiTags('新闻分类 NewsCategory')
@Controller()
export class NewsCategoryController {
constructor(private readonly service: NewsCategoryService) {}
@Public()
@Get('public/news-category')
@ApiOperation({ summary: '前台-新闻分类列表' })
publicList(): Promise<NewsCategory[]> {
return this.service.findPublicList();
}
@ApiBearerAuth('admin-token')
@Get('admin/news-category')
@ApiOperation({ summary: '后台-新闻分类全部列表' })
list(@Query() query: QueryNewsCategoryDto): Promise<NewsCategory[]> {
return this.service.findAll(query);
}
@ApiBearerAuth('admin-token')
@Get('admin/news-category/:id')
@ApiOperation({ summary: '后台-新闻分类详情' })
detail(@Param('id', ParseIntPipe) id: number): Promise<NewsCategory> {
return this.service.detail(id);
}
@ApiBearerAuth('admin-token')
@Post('admin/news-category')
@ApiOperation({ summary: '后台-新增新闻分类' })
create(@Body() dto: CreateNewsCategoryDto): Promise<NewsCategory> {
return this.service.create(dto);
}
@ApiBearerAuth('admin-token')
@Put('admin/news-category/:id')
@ApiOperation({ summary: '后台-修改新闻分类' })
update(
@Param('id', ParseIntPipe) id: number,
@Body() dto: UpdateNewsCategoryDto,
): Promise<void> {
return this.service.update(id, dto);
}
@ApiBearerAuth('admin-token')
@Delete('admin/news-category/:id')
@Roles('super_admin')
@ApiOperation({ summary: '后台-删除新闻分类(仅超管)' })
remove(@Param('id', ParseIntPipe) id: number): Promise<void> {
return this.service.remove(id);
}
}

View File

@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { NewsCategory } from '@/entities/news-category.entity';
import { NewsCategoryController } from './news-category.controller';
import { NewsCategoryService } from './news-category.service';
@Module({
imports: [TypeOrmModule.forFeature([NewsCategory])],
controllers: [NewsCategoryController],
providers: [NewsCategoryService],
exports: [NewsCategoryService],
})
export class NewsCategoryModule {}

View File

@ -0,0 +1,66 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { NewsCategory } from '@/entities/news-category.entity';
import {
CreateNewsCategoryDto,
QueryNewsCategoryDto,
UpdateNewsCategoryDto,
} from './dto/news-category.dto';
@Injectable()
export class NewsCategoryService {
constructor(
@InjectRepository(NewsCategory)
private readonly repo: Repository<NewsCategory>,
) {}
async findPublicList(): Promise<NewsCategory[]> {
return this.repo.find({
where: { isShow: 1 },
order: { sort: 'ASC', id: 'DESC' },
});
}
async findAll(query?: QueryNewsCategoryDto): Promise<NewsCategory[]> {
const qb = this.repo
.createQueryBuilder('c')
.orderBy('c.sort', 'ASC')
.addOrderBy('c.id', 'DESC');
if (query?.keyword) {
qb.andWhere('c.name LIKE :kw', { kw: `%${query.keyword}%` });
}
return qb.getMany();
}
async detail(id: number): Promise<NewsCategory> {
const item = await this.repo.findOne({ where: { id } });
if (!item) throw new NotFoundException('分类不存在');
return item;
}
async create(dto: CreateNewsCategoryDto): Promise<NewsCategory> {
return this.repo.save(
this.repo.create({
name: dto.name,
sort: dto.sort ?? 0,
isShow: dto.isShow ?? 1,
}),
);
}
async update(id: number, dto: UpdateNewsCategoryDto): Promise<void> {
const item = await this.detail(id);
Object.assign(item, {
name: dto.name,
sort: dto.sort ?? 0,
isShow: dto.isShow ?? 1,
});
await this.repo.save(item);
}
async remove(id: number): Promise<void> {
await this.detail(id);
await this.repo.delete(id);
}
}

View File

@ -0,0 +1,87 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsInt,
IsNotEmpty,
IsOptional,
IsString,
Max,
MaxLength,
Min,
} from 'class-validator';
export class CreateNewsDto {
@ApiProperty({ description: '分类ID' })
@IsInt()
@Min(1)
@Type(() => Number)
categoryId: number;
@ApiProperty({ description: '新闻标题' })
@IsString()
@IsNotEmpty({ message: '标题不能为空' })
@MaxLength(200)
title: string;
@ApiPropertyOptional({ description: '封面图' })
@IsString()
@IsOptional()
cover?: string;
@ApiPropertyOptional({ description: '简介' })
@IsString()
@IsOptional()
@MaxLength(500)
intro?: string;
@ApiProperty({ description: '正文富文本' })
@IsString()
@IsNotEmpty({ message: '正文不能为空' })
content: string;
@ApiPropertyOptional({ description: '是否置顶 1是0否', default: 0 })
@IsInt()
@Min(0)
@Max(1)
@IsOptional()
@Type(() => Number)
isTop?: number;
@ApiPropertyOptional({ description: '状态 1发布 0草稿', default: 1 })
@IsInt()
@Min(0)
@Max(1)
@IsOptional()
@Type(() => Number)
status?: number;
}
export class UpdateNewsDto extends CreateNewsDto {}
export class QueryNewsDto {
@ApiPropertyOptional({ description: '页码', default: 1 })
@IsInt()
@Min(1)
@IsOptional()
@Type(() => Number)
page?: number;
@ApiPropertyOptional({ description: '每页数量', default: 10 })
@IsInt()
@Min(1)
@Max(100)
@IsOptional()
@Type(() => Number)
pageSize?: number;
@ApiPropertyOptional({ description: '分类ID' })
@IsInt()
@IsOptional()
@Type(() => Number)
categoryId?: number;
@ApiPropertyOptional({ description: '关键词(标题)' })
@IsString()
@IsOptional()
keyword?: string;
}

View File

@ -0,0 +1,77 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseIntPipe,
Post,
Put,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Public, Roles } from '@/common/decorators';
import { PaginatedResult } from '@/common/dto/api-response.dto';
import { News } from '@/entities/news.entity';
import { NewsService } from './news.service';
import { CreateNewsDto, QueryNewsDto, UpdateNewsDto } from './dto/news.dto';
@ApiTags('新闻 News')
@Controller()
export class NewsController {
constructor(private readonly service: NewsService) {}
@Public()
@Get('public/news')
@ApiOperation({ summary: '前台-新闻分页' })
publicPaginate(@Query() query: QueryNewsDto): Promise<PaginatedResult<News>> {
return this.service.publicPaginate(query);
}
@Public()
@Get('public/news/:id')
@ApiOperation({ summary: '前台-新闻详情' })
publicDetail(@Param('id', ParseIntPipe) id: number): Promise<News> {
return this.service.publicDetail(id);
}
@ApiBearerAuth('admin-token')
@Get('admin/news')
@ApiOperation({ summary: '后台-新闻分页' })
paginate(@Query() query: QueryNewsDto): Promise<PaginatedResult<News>> {
return this.service.paginate(query);
}
@ApiBearerAuth('admin-token')
@Get('admin/news/:id')
@ApiOperation({ summary: '后台-新闻详情' })
detail(@Param('id', ParseIntPipe) id: number): Promise<News> {
return this.service.detail(id);
}
@ApiBearerAuth('admin-token')
@Post('admin/news')
@ApiOperation({ summary: '后台-新增新闻' })
create(@Body() dto: CreateNewsDto): Promise<News> {
return this.service.create(dto);
}
@ApiBearerAuth('admin-token')
@Put('admin/news/:id')
@ApiOperation({ summary: '后台-修改新闻' })
update(
@Param('id', ParseIntPipe) id: number,
@Body() dto: UpdateNewsDto,
): Promise<void> {
return this.service.update(id, dto);
}
@ApiBearerAuth('admin-token')
@Delete('admin/news/:id')
@Roles('super_admin')
@ApiOperation({ summary: '后台-删除新闻(仅超管)' })
remove(@Param('id', ParseIntPipe) id: number): Promise<void> {
return this.service.remove(id);
}
}

View File

@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { News } from '@/entities/news.entity';
import { NewsCategory } from '@/entities/news-category.entity';
import { NewsController } from './news.controller';
import { NewsService } from './news.service';
@Module({
imports: [TypeOrmModule.forFeature([News, NewsCategory])],
controllers: [NewsController],
providers: [NewsService],
})
export class NewsModule {}

View File

@ -0,0 +1,105 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { News } from '@/entities/news.entity';
import { PaginatedResult } from '@/common/dto/api-response.dto';
import { CreateNewsDto, QueryNewsDto, UpdateNewsDto } from './dto/news.dto';
@Injectable()
export class NewsService {
constructor(
@InjectRepository(News)
private readonly repo: Repository<News>,
) {}
// ---------- 前台 ----------
async publicPaginate(query: QueryNewsDto): Promise<PaginatedResult<News>> {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 10;
const qb = this.buildQb(query).andWhere('n.status = 1');
const [list, total] = await qb
.skip((page - 1) * pageSize)
.take(pageSize)
.getManyAndCount();
return { list, total, page, pageSize };
}
async publicDetail(id: number): Promise<News> {
const item = await this.repo.findOne({
where: { id, status: 1 },
relations: ['category'],
});
if (!item) throw new NotFoundException('新闻不存在或未发布');
return item;
}
// ---------- 后台 ----------
async paginate(query: QueryNewsDto): Promise<PaginatedResult<News>> {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 10;
const qb = this.buildQb(query);
const [list, total] = await qb
.skip((page - 1) * pageSize)
.take(pageSize)
.getManyAndCount();
return { list, total, page, pageSize };
}
async detail(id: number): Promise<News> {
const item = await this.repo.findOne({
where: { id },
relations: ['category'],
});
if (!item) throw new NotFoundException('新闻不存在');
return item;
}
async create(dto: CreateNewsDto): Promise<News> {
return this.repo.save(
this.repo.create({
categoryId: dto.categoryId,
title: dto.title,
cover: dto.cover ?? '',
intro: dto.intro ?? '',
content: dto.content,
isTop: dto.isTop ?? 0,
status: dto.status ?? 1,
}),
);
}
async update(id: number, dto: UpdateNewsDto): Promise<void> {
const item = await this.detail(id);
Object.assign(item, {
categoryId: dto.categoryId,
title: dto.title,
cover: dto.cover ?? '',
intro: dto.intro ?? '',
content: dto.content,
isTop: dto.isTop ?? 0,
status: dto.status ?? 1,
});
await this.repo.save(item);
}
async remove(id: number): Promise<void> {
await this.detail(id);
await this.repo.delete(id);
}
private buildQb(query: QueryNewsDto) {
const qb = this.repo
.createQueryBuilder('n')
.leftJoinAndSelect('n.category', 'c')
.orderBy('n.isTop', 'DESC')
.addOrderBy('n.id', 'DESC');
if (query.categoryId) {
qb.andWhere('n.category_id = :cid', { cid: query.categoryId });
}
if (query.keyword) {
qb.andWhere('n.title LIKE :kw', { kw: `%${query.keyword}%` });
}
return qb;
}
}

View File

@ -0,0 +1,44 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsInt,
IsNotEmpty,
IsOptional,
IsString,
Max,
MaxLength,
Min,
} from 'class-validator';
export class CreateProductCategoryDto {
@ApiProperty({ description: '分类名称' })
@IsString()
@IsNotEmpty({ message: '分类名称不能为空' })
@MaxLength(100)
name: string;
@ApiPropertyOptional({ description: '排序', default: 0 })
@IsInt()
@Min(0)
@Max(99999)
@IsOptional()
@Type(() => Number)
sort?: number;
@ApiPropertyOptional({ description: '是否展示 1是0否', default: 1 })
@IsInt()
@Min(0)
@Max(1)
@IsOptional()
@Type(() => Number)
isShow?: number;
}
export class UpdateProductCategoryDto extends CreateProductCategoryDto {}
export class QueryProductCategoryDto {
@ApiPropertyOptional({ description: '关键词' })
@IsString()
@IsOptional()
keyword?: string;
}

View File

@ -0,0 +1,72 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseIntPipe,
Post,
Put,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Public, Roles } from '@/common/decorators';
import { ProductCategory } from '@/entities/product-category.entity';
import { ProductCategoryService } from './product-category.service';
import {
CreateProductCategoryDto,
QueryProductCategoryDto,
UpdateProductCategoryDto,
} from './dto/product-category.dto';
@ApiTags('产品分类 ProductCategory')
@Controller()
export class ProductCategoryController {
constructor(private readonly service: ProductCategoryService) {}
@Public()
@Get('public/product-category')
@ApiOperation({ summary: '前台-产品分类列表' })
publicList(): Promise<ProductCategory[]> {
return this.service.findPublicList();
}
@ApiBearerAuth('admin-token')
@Get('admin/product-category')
@ApiOperation({ summary: '后台-产品分类全部列表' })
list(@Query() query: QueryProductCategoryDto): Promise<ProductCategory[]> {
return this.service.findAll(query);
}
@ApiBearerAuth('admin-token')
@Get('admin/product-category/:id')
@ApiOperation({ summary: '后台-产品分类详情' })
detail(@Param('id', ParseIntPipe) id: number): Promise<ProductCategory> {
return this.service.detail(id);
}
@ApiBearerAuth('admin-token')
@Post('admin/product-category')
@ApiOperation({ summary: '后台-新增产品分类' })
create(@Body() dto: CreateProductCategoryDto): Promise<ProductCategory> {
return this.service.create(dto);
}
@ApiBearerAuth('admin-token')
@Put('admin/product-category/:id')
@ApiOperation({ summary: '后台-修改产品分类' })
update(
@Param('id', ParseIntPipe) id: number,
@Body() dto: UpdateProductCategoryDto,
): Promise<void> {
return this.service.update(id, dto);
}
@ApiBearerAuth('admin-token')
@Delete('admin/product-category/:id')
@Roles('super_admin')
@ApiOperation({ summary: '后台-删除产品分类(仅超管)' })
remove(@Param('id', ParseIntPipe) id: number): Promise<void> {
return this.service.remove(id);
}
}

View File

@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ProductCategory } from '@/entities/product-category.entity';
import { ProductCategoryController } from './product-category.controller';
import { ProductCategoryService } from './product-category.service';
@Module({
imports: [TypeOrmModule.forFeature([ProductCategory])],
controllers: [ProductCategoryController],
providers: [ProductCategoryService],
exports: [ProductCategoryService],
})
export class ProductCategoryModule {}

View File

@ -0,0 +1,65 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ProductCategory } from '@/entities/product-category.entity';
import {
CreateProductCategoryDto,
QueryProductCategoryDto,
UpdateProductCategoryDto,
} from './dto/product-category.dto';
@Injectable()
export class ProductCategoryService {
constructor(
@InjectRepository(ProductCategory)
private readonly repo: Repository<ProductCategory>,
) {}
async findPublicList(): Promise<ProductCategory[]> {
return this.repo.find({
where: { isShow: 1 },
order: { sort: 'ASC', id: 'DESC' },
});
}
async findAll(query?: QueryProductCategoryDto): Promise<ProductCategory[]> {
const qb = this.repo
.createQueryBuilder('c')
.orderBy('c.sort', 'ASC')
.addOrderBy('c.id', 'DESC');
if (query?.keyword) {
qb.andWhere('c.name LIKE :kw', { kw: `%${query.keyword}%` });
}
return qb.getMany();
}
async detail(id: number): Promise<ProductCategory> {
const item = await this.repo.findOne({ where: { id } });
if (!item) throw new NotFoundException('分类不存在');
return item;
}
async create(dto: CreateProductCategoryDto): Promise<ProductCategory> {
const entity = this.repo.create({
name: dto.name,
sort: dto.sort ?? 0,
isShow: dto.isShow ?? 1,
});
return this.repo.save(entity);
}
async update(id: number, dto: UpdateProductCategoryDto): Promise<void> {
const item = await this.detail(id);
Object.assign(item, {
name: dto.name,
sort: dto.sort ?? 0,
isShow: dto.isShow ?? 1,
});
await this.repo.save(item);
}
async remove(id: number): Promise<void> {
await this.detail(id);
await this.repo.delete(id);
}
}

View File

@ -0,0 +1,86 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsInt,
IsNotEmpty,
IsOptional,
IsString,
Max,
MaxLength,
Min,
} from 'class-validator';
export class CreateProductDto {
@ApiProperty({ description: '分类ID' })
@IsInt()
@Min(1)
@Type(() => Number)
categoryId: number;
@ApiProperty({ description: '产品名称' })
@IsString()
@IsNotEmpty({ message: '产品名称不能为空' })
@MaxLength(100)
name: string;
@ApiProperty({ description: '封面图' })
@IsString()
@IsNotEmpty({ message: '封面图不能为空' })
cover: string;
@ApiPropertyOptional({ description: '简短描述' })
@IsString()
@IsOptional()
desc?: string;
@ApiPropertyOptional({ description: '详情富文本' })
@IsString()
@IsOptional()
content?: string;
@ApiPropertyOptional({ description: '排序', default: 0 })
@IsInt()
@Min(0)
@Max(99999)
@IsOptional()
@Type(() => Number)
sort?: number;
@ApiPropertyOptional({ description: '上下架 1是0否', default: 1 })
@IsInt()
@Min(0)
@Max(1)
@IsOptional()
@Type(() => Number)
isShow?: number;
}
export class UpdateProductDto extends CreateProductDto {}
export class QueryProductDto {
@ApiPropertyOptional({ description: '页码', default: 1 })
@IsInt()
@Min(1)
@IsOptional()
@Type(() => Number)
page?: number;
@ApiPropertyOptional({ description: '每页数量', default: 10 })
@IsInt()
@Min(1)
@Max(100)
@IsOptional()
@Type(() => Number)
pageSize?: number;
@ApiPropertyOptional({ description: '分类ID' })
@IsInt()
@IsOptional()
@Type(() => Number)
categoryId?: number;
@ApiPropertyOptional({ description: '关键词(名称)' })
@IsString()
@IsOptional()
keyword?: string;
}

View File

@ -0,0 +1,83 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseIntPipe,
Post,
Put,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Public, Roles } from '@/common/decorators';
import { PaginatedResult } from '@/common/dto/api-response.dto';
import { Product } from '@/entities/product.entity';
import { ProductService } from './product.service';
import {
CreateProductDto,
QueryProductDto,
UpdateProductDto,
} from './dto/product.dto';
@ApiTags('产品 Product')
@Controller()
export class ProductController {
constructor(private readonly service: ProductService) {}
// ---------- 前台 ----------
@Public()
@Get('public/product')
@ApiOperation({ summary: '前台-产品分页' })
publicPaginate(@Query() query: QueryProductDto): Promise<PaginatedResult<Product>> {
return this.service.publicPaginate(query);
}
@Public()
@Get('public/product/:id')
@ApiOperation({ summary: '前台-产品详情' })
publicDetail(@Param('id', ParseIntPipe) id: number): Promise<Product> {
return this.service.publicDetail(id);
}
// ---------- 后台 ----------
@ApiBearerAuth('admin-token')
@Get('admin/product')
@ApiOperation({ summary: '后台-产品分页' })
paginate(@Query() query: QueryProductDto): Promise<PaginatedResult<Product>> {
return this.service.paginate(query);
}
@ApiBearerAuth('admin-token')
@Get('admin/product/:id')
@ApiOperation({ summary: '后台-产品详情' })
detail(@Param('id', ParseIntPipe) id: number): Promise<Product> {
return this.service.detail(id);
}
@ApiBearerAuth('admin-token')
@Post('admin/product')
@ApiOperation({ summary: '后台-新增产品' })
create(@Body() dto: CreateProductDto): Promise<Product> {
return this.service.create(dto);
}
@ApiBearerAuth('admin-token')
@Put('admin/product/:id')
@ApiOperation({ summary: '后台-修改产品' })
update(
@Param('id', ParseIntPipe) id: number,
@Body() dto: UpdateProductDto,
): Promise<void> {
return this.service.update(id, dto);
}
@ApiBearerAuth('admin-token')
@Delete('admin/product/:id')
@Roles('super_admin')
@ApiOperation({ summary: '后台-删除产品(仅超管)' })
remove(@Param('id', ParseIntPipe) id: number): Promise<void> {
return this.service.remove(id);
}
}

View File

@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Product } from '@/entities/product.entity';
import { ProductCategory } from '@/entities/product-category.entity';
import { ProductController } from './product.controller';
import { ProductService } from './product.service';
@Module({
imports: [TypeOrmModule.forFeature([Product, ProductCategory])],
controllers: [ProductController],
providers: [ProductService],
})
export class ProductModule {}

View File

@ -0,0 +1,114 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Product } from '@/entities/product.entity';
import { PaginatedResult } from '@/common/dto/api-response.dto';
import {
CreateProductDto,
QueryProductDto,
UpdateProductDto,
} from './dto/product.dto';
@Injectable()
export class ProductService {
constructor(
@InjectRepository(Product)
private readonly repo: Repository<Product>,
) {}
// ---------- 前台 ----------
async publicPaginate(query: QueryProductDto): Promise<PaginatedResult<Product>> {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 12;
const qb = this.repo
.createQueryBuilder('p')
.leftJoinAndSelect('p.category', 'c')
.where('p.is_show = 1')
.orderBy('p.sort', 'ASC')
.addOrderBy('p.id', 'DESC');
if (query.categoryId) {
qb.andWhere('p.category_id = :cid', { cid: query.categoryId });
}
if (query.keyword) {
qb.andWhere('p.name LIKE :kw', { kw: `%${query.keyword}%` });
}
const [list, total] = await qb
.skip((page - 1) * pageSize)
.take(pageSize)
.getManyAndCount();
return { list, total, page, pageSize };
}
async publicDetail(id: number): Promise<Product> {
const item = await this.repo.findOne({
where: { id, isShow: 1 },
relations: ['category'],
});
if (!item) throw new NotFoundException('产品不存在或已下架');
return item;
}
// ---------- 后台 ----------
async paginate(query: QueryProductDto): Promise<PaginatedResult<Product>> {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 10;
const qb = this.repo
.createQueryBuilder('p')
.leftJoinAndSelect('p.category', 'c')
.orderBy('p.sort', 'ASC')
.addOrderBy('p.id', 'DESC');
if (query.categoryId) {
qb.andWhere('p.category_id = :cid', { cid: query.categoryId });
}
if (query.keyword) {
qb.andWhere('p.name LIKE :kw', { kw: `%${query.keyword}%` });
}
const [list, total] = await qb
.skip((page - 1) * pageSize)
.take(pageSize)
.getManyAndCount();
return { list, total, page, pageSize };
}
async detail(id: number): Promise<Product> {
const item = await this.repo.findOne({
where: { id },
relations: ['category'],
});
if (!item) throw new NotFoundException('产品不存在');
return item;
}
async create(dto: CreateProductDto): Promise<Product> {
const entity = this.repo.create({
categoryId: dto.categoryId,
name: dto.name,
cover: dto.cover,
desc: dto.desc ?? null,
content: dto.content ?? null,
sort: dto.sort ?? 0,
isShow: dto.isShow ?? 1,
});
return this.repo.save(entity);
}
async update(id: number, dto: UpdateProductDto): Promise<void> {
const item = await this.detail(id);
Object.assign(item, {
categoryId: dto.categoryId,
name: dto.name,
cover: dto.cover,
desc: dto.desc ?? null,
content: dto.content ?? null,
sort: dto.sort ?? 0,
isShow: dto.isShow ?? 1,
});
await this.repo.save(item);
}
async remove(id: number): Promise<void> {
await this.detail(id);
await this.repo.delete(id);
}
}

View File

@ -0,0 +1,61 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import {
IsOptional,
IsString,
MaxLength,
} from 'class-validator';
export class UpdateSiteConfigDto {
@ApiPropertyOptional({ description: '网站名称' })
@IsString()
@IsOptional()
@MaxLength(100)
siteName?: string;
@ApiPropertyOptional({ description: 'logo 图片地址' })
@IsString()
@IsOptional()
@MaxLength(255)
logo?: string;
@ApiPropertyOptional({ description: '联系电话' })
@IsString()
@IsOptional()
@MaxLength(50)
tel?: string;
@ApiPropertyOptional({ description: '公司地址' })
@IsString()
@IsOptional()
@MaxLength(255)
address?: string;
@ApiPropertyOptional({ description: '商务邮箱' })
@IsString()
@IsOptional()
@MaxLength(100)
email?: string;
@ApiPropertyOptional({ description: '底部版权' })
@IsString()
@IsOptional()
@MaxLength(255)
copyright?: string;
@ApiPropertyOptional({ description: '备案号' })
@IsString()
@IsOptional()
@MaxLength(100)
icp?: string;
@ApiPropertyOptional({ description: '企业简介标题' })
@IsString()
@IsOptional()
@MaxLength(100)
aboutTitle?: string;
@ApiPropertyOptional({ description: '企业简介正文(富文本)' })
@IsString()
@IsOptional()
aboutContent?: string;
}

View File

@ -0,0 +1,33 @@
import { Body, Controller, Get, Put } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Public } from '@/common/decorators';
import { SiteConfig } from '@/entities/site-config.entity';
import { SiteConfigService } from './site-config.service';
import { UpdateSiteConfigDto } from './dto/site-config.dto';
@ApiTags('网站配置 SiteConfig')
@Controller()
export class SiteConfigController {
constructor(private readonly service: SiteConfigService) {}
@Public()
@Get('public/site-config')
@ApiOperation({ summary: '前台-获取网站基础配置' })
get(): Promise<SiteConfig> {
return this.service.get();
}
@ApiBearerAuth('admin-token')
@Get('admin/site-config')
@ApiOperation({ summary: '后台-获取网站配置' })
adminGet(): Promise<SiteConfig> {
return this.service.get();
}
@ApiBearerAuth('admin-token')
@Put('admin/site-config')
@ApiOperation({ summary: '后台-修改网站配置' })
update(@Body() dto: UpdateSiteConfigDto): Promise<SiteConfig> {
return this.service.update(dto);
}
}

View File

@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { SiteConfig } from '@/entities/site-config.entity';
import { SiteConfigController } from './site-config.controller';
import { SiteConfigService } from './site-config.service';
@Module({
imports: [TypeOrmModule.forFeature([SiteConfig])],
controllers: [SiteConfigController],
providers: [SiteConfigService],
exports: [SiteConfigService],
})
export class SiteConfigModule {}

View File

@ -0,0 +1,44 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { SiteConfig } from '@/entities/site-config.entity';
import { UpdateSiteConfigDto } from './dto/site-config.dto';
const SINGLETON_ID = 1;
@Injectable()
export class SiteConfigService {
constructor(
@InjectRepository(SiteConfig)
private readonly repo: Repository<SiteConfig>,
) {}
/**
*
*/
async get(): Promise<SiteConfig> {
let one = await this.repo.findOne({ where: { id: SINGLETON_ID } });
if (!one) {
one = this.repo.create({
id: SINGLETON_ID,
siteName: '企业官方网站',
logo: '',
tel: '',
address: '',
email: '',
copyright: '',
icp: '',
aboutTitle: '',
aboutContent: null,
});
one = await this.repo.save(one);
}
return one;
}
async update(dto: UpdateSiteConfigDto): Promise<SiteConfig> {
const one = await this.get();
Object.assign(one, dto);
return this.repo.save(one);
}
}

View File

@ -0,0 +1,60 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsInt,
IsNotEmpty,
IsOptional,
IsString,
Max,
MaxLength,
Min,
} from 'class-validator';
export class CreateTeamDto {
@ApiProperty({ description: '姓名' })
@IsString()
@IsNotEmpty()
@MaxLength(50)
name: string;
@ApiProperty({ description: '职位' })
@IsString()
@IsNotEmpty()
@MaxLength(100)
position: string;
@ApiProperty({ description: '头像' })
@IsString()
@IsNotEmpty()
avatar: string;
@ApiPropertyOptional({ description: '个人简介' })
@IsString()
@IsOptional()
desc?: string;
@ApiPropertyOptional({ description: '排序', default: 0 })
@IsInt()
@Min(0)
@Max(99999)
@IsOptional()
@Type(() => Number)
sort?: number;
@ApiPropertyOptional({ description: '是否展示 1是0否', default: 1 })
@IsInt()
@Min(0)
@Max(1)
@IsOptional()
@Type(() => Number)
isShow?: number;
}
export class UpdateTeamDto extends CreateTeamDto {}
export class QueryTeamDto {
@ApiPropertyOptional({ description: '关键词(姓名/职位)' })
@IsString()
@IsOptional()
keyword?: string;
}

View File

@ -0,0 +1,72 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseIntPipe,
Post,
Put,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Public, Roles } from '@/common/decorators';
import { Team } from '@/entities/team.entity';
import { TeamService } from './team.service';
import {
CreateTeamDto,
QueryTeamDto,
UpdateTeamDto,
} from './dto/team.dto';
@ApiTags('团队 Team')
@Controller()
export class TeamController {
constructor(private readonly service: TeamService) {}
@Public()
@Get('public/team')
@ApiOperation({ summary: '前台-团队成员列表' })
publicList(): Promise<Team[]> {
return this.service.findPublicList();
}
@ApiBearerAuth('admin-token')
@Get('admin/team')
@ApiOperation({ summary: '后台-团队成员列表' })
list(@Query() query: QueryTeamDto): Promise<Team[]> {
return this.service.findAll(query);
}
@ApiBearerAuth('admin-token')
@Get('admin/team/:id')
@ApiOperation({ summary: '后台-团队成员详情' })
detail(@Param('id', ParseIntPipe) id: number): Promise<Team> {
return this.service.detail(id);
}
@ApiBearerAuth('admin-token')
@Post('admin/team')
@ApiOperation({ summary: '后台-新增团队成员' })
create(@Body() dto: CreateTeamDto): Promise<Team> {
return this.service.create(dto);
}
@ApiBearerAuth('admin-token')
@Put('admin/team/:id')
@ApiOperation({ summary: '后台-修改团队成员' })
update(
@Param('id', ParseIntPipe) id: number,
@Body() dto: UpdateTeamDto,
): Promise<void> {
return this.service.update(id, dto);
}
@ApiBearerAuth('admin-token')
@Delete('admin/team/:id')
@Roles('super_admin')
@ApiOperation({ summary: '后台-删除团队成员(仅超管)' })
remove(@Param('id', ParseIntPipe) id: number): Promise<void> {
return this.service.remove(id);
}
}

View File

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Team } from '@/entities/team.entity';
import { TeamController } from './team.controller';
import { TeamService } from './team.service';
@Module({
imports: [TypeOrmModule.forFeature([Team])],
controllers: [TeamController],
providers: [TeamService],
})
export class TeamModule {}

View File

@ -0,0 +1,74 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, Like } from 'typeorm';
import { Team } from '@/entities/team.entity';
import {
CreateTeamDto,
QueryTeamDto,
UpdateTeamDto,
} from './dto/team.dto';
@Injectable()
export class TeamService {
constructor(
@InjectRepository(Team)
private readonly repo: Repository<Team>,
) {}
async findPublicList(): Promise<Team[]> {
return this.repo.find({
where: { isShow: 1 },
order: { sort: 'ASC', id: 'DESC' },
});
}
async findAll(query?: QueryTeamDto): Promise<Team[]> {
const where = query?.keyword
? [
{ name: Like(`%${query.keyword}%`) },
{ position: Like(`%${query.keyword}%`) },
]
: undefined;
return this.repo.find({
where,
order: { sort: 'ASC', id: 'DESC' },
});
}
async detail(id: number): Promise<Team> {
const item = await this.repo.findOne({ where: { id } });
if (!item) throw new NotFoundException('成员不存在');
return item;
}
async create(dto: CreateTeamDto): Promise<Team> {
return this.repo.save(
this.repo.create({
name: dto.name,
position: dto.position,
avatar: dto.avatar,
desc: dto.desc ?? null,
sort: dto.sort ?? 0,
isShow: dto.isShow ?? 1,
}),
);
}
async update(id: number, dto: UpdateTeamDto): Promise<void> {
const item = await this.detail(id);
Object.assign(item, {
name: dto.name,
position: dto.position,
avatar: dto.avatar,
desc: dto.desc ?? null,
sort: dto.sort ?? 0,
isShow: dto.isShow ?? 1,
});
await this.repo.save(item);
}
async remove(id: number): Promise<void> {
await this.detail(id);
await this.repo.delete(id);
}
}

View File

@ -0,0 +1,36 @@
import {
Controller,
Post,
Req,
BadRequestException,
} from '@nestjs/common';
import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Request } from 'express';
import { filePathToUrl } from '@/utils/file.util';
export interface UploadResult {
url: string;
filename: string;
}
@ApiBearerAuth('admin-token')
@ApiTags('文件上传 Upload')
@Controller('admin/upload')
export class UploadController {
@Post()
@ApiConsumes('multipart/form-data')
@ApiOperation({
summary: '上传图片',
description: '仅支持 jpg/png/jpeg/webp单图 2M 上限',
})
upload(@Req() req: Request): UploadResult {
const file = (req as Request & { file?: Express.Multer.File }).file;
if (!file) {
throw new BadRequestException('请上传文件');
}
return {
url: filePathToUrl(file.path),
filename: file.filename,
};
}
}

View File

@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { UploadController } from './upload.controller';
@Module({
controllers: [UploadController],
})
export class UploadModule {}

10
src/types/express.d.ts vendored Normal file
View File

@ -0,0 +1,10 @@
import { CurrentAdminPayload } from '@/common/decorators/current-admin.decorator';
declare module 'express' {
interface Request {
/** JWT 守卫解析后挂载的当前管理员信息 */
admin?: CurrentAdminPayload;
/** multer 上传后的文件(仅 /admin/upload 路由) */
file?: Express.Multer.File;
}
}

16
src/utils/crypto.util.ts Normal file
View File

@ -0,0 +1,16 @@
import * as bcrypt from 'bcrypt';
const SALT_ROUNDS = 10;
/** bcrypt 加密明文密码 */
export async function hashPassword(plain: string): Promise<string> {
return bcrypt.hash(plain, SALT_ROUNDS);
}
/** bcrypt 校验密码 */
export async function comparePassword(
plain: string,
hashed: string,
): Promise<boolean> {
return bcrypt.compare(plain, hashed);
}

14
src/utils/file.util.ts Normal file
View File

@ -0,0 +1,14 @@
import path from 'path';
/**
* 访 URL
* /Users/xxx/server/uploads/2026/01/abc.jpg -> /uploads/2026/01/abc.jpg
*/
export function filePathToUrl(absPath: string): string {
const root = (process.env.UPLOAD_ROOT ?? './uploads').replace(/^\.\/?/, '');
const idx = absPath.replace(/\\/g, '/').indexOf(root);
if (idx >= 0) {
return '/' + absPath.replace(/\\/g, '/').slice(idx).replace(/^\/+/, '/');
}
return '/' + path.basename(absPath);
}

2
src/utils/index.ts Normal file
View File

@ -0,0 +1,2 @@
export * from './crypto.util';
export * from './file.util';

4
tsconfig.build.json Normal file
View File

@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

27
tsconfig.json Normal file
View File

@ -0,0 +1,27 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"skipLibCheck": true,
"strictNullChecks": true,
"noImplicitAny": true,
"strict": true,
"strictPropertyInitialization": false,
"noEmitOnError": false,
"esModuleInterop": true,
"resolveJsonModule": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "test"]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 333 KiB