Merge branch 'main' of http://113.45.157.195:3003/insiinc/re-mooc
This commit is contained in:
commit
4f7bb01e86
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"marscode.chatLanguage": "cn"
|
||||||
|
}
|
42
Dockerfile
42
Dockerfile
|
@ -1,7 +1,21 @@
|
||||||
# 基础镜像
|
# 基础镜像
|
||||||
FROM node:20-alpine as base
|
FROM node:18.17-alpine as base
|
||||||
# 更改 apk 镜像源为阿里云
|
# 更改 apk 镜像源为阿里云
|
||||||
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
|
# RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
|
||||||
|
|
||||||
|
# 使用阿里云镜像源 + 完整仓库声明
|
||||||
|
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories && \
|
||||||
|
echo "https://mirrors.aliyun.com/alpine/v3.18/community" >> /etc/apk/repositories
|
||||||
|
|
||||||
|
# 安装最新稳定版 PostgreSQL 客户端(15.11)
|
||||||
|
RUN apk update --no-cache && \
|
||||||
|
apk add --no-cache \
|
||||||
|
postgresql15-client \
|
||||||
|
libpq \
|
||||||
|
readline
|
||||||
|
|
||||||
|
RUN apk add --no-cache ffmpeg
|
||||||
|
|
||||||
# 设置 npm 镜像源
|
# 设置 npm 镜像源
|
||||||
RUN yarn config set registry https://registry.npmmirror.com
|
RUN yarn config set registry https://registry.npmmirror.com
|
||||||
|
|
||||||
|
@ -17,24 +31,30 @@ COPY pnpm-workspace.yaml ./
|
||||||
# 首先复制 package.json, package-lock.json 和 pnpm-lock.yaml 文件
|
# 首先复制 package.json, package-lock.json 和 pnpm-lock.yaml 文件
|
||||||
COPY package*.json pnpm-lock.yaml* ./
|
COPY package*.json pnpm-lock.yaml* ./
|
||||||
|
|
||||||
COPY tsconfig.json .
|
COPY tsconfig.base.json .
|
||||||
# 利用 Docker 缓存机制,如果依赖没有改变则不会重新执行 pnpm install
|
# 利用 Docker 缓存机制,如果依赖没有改变则不会重新执行 pnpm install
|
||||||
#100-500 5-40
|
#100-500 5-40
|
||||||
|
|
||||||
FROM base As server-build
|
FROM base As server-build
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY packages/common /app/packages/common
|
COPY packages/common /app/packages/common
|
||||||
|
COPY packages/tus /app/packages/tus
|
||||||
COPY apps/server /app/apps/server
|
COPY apps/server /app/apps/server
|
||||||
RUN pnpm install --filter server
|
RUN pnpm config set registry https://registry.npmmirror.com/
|
||||||
RUN pnpm install --filter common
|
RUN pnpm install --filter common
|
||||||
|
RUN pnpm install --filter tus
|
||||||
|
RUN pnpm install --filter server
|
||||||
RUN pnpm --filter common generate && pnpm --filter common build:cjs
|
RUN pnpm --filter common generate && pnpm --filter common build:cjs
|
||||||
|
RUN pnpm --filter tus build
|
||||||
RUN pnpm --filter server build
|
RUN pnpm --filter server build
|
||||||
|
|
||||||
FROM base As server-prod-dep
|
FROM base As server-prod-dep
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY packages/common /app/packages/common
|
COPY packages/common /app/packages/common
|
||||||
|
COPY packages/tus /app/packages/tus
|
||||||
COPY apps/server /app/apps/server
|
COPY apps/server /app/apps/server
|
||||||
RUN pnpm install --filter common --prod
|
RUN pnpm install --filter common --prod
|
||||||
|
RUN pnpm install --filter tus --prod
|
||||||
RUN pnpm install --filter server --prod
|
RUN pnpm install --filter server --prod
|
||||||
|
|
||||||
|
|
||||||
|
@ -43,6 +63,7 @@ FROM server-prod-dep as server
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV NODE_ENV production
|
ENV NODE_ENV production
|
||||||
COPY --from=server-build /app/packages/common/dist ./packages/common/dist
|
COPY --from=server-build /app/packages/common/dist ./packages/common/dist
|
||||||
|
COPY --from=server-build /app/packages/tus/dist ./packages/tus/dist
|
||||||
COPY --from=server-build /app/apps/server/dist ./apps/server/dist
|
COPY --from=server-build /app/apps/server/dist ./apps/server/dist
|
||||||
COPY apps/server/entrypoint.sh ./apps/server/entrypoint.sh
|
COPY apps/server/entrypoint.sh ./apps/server/entrypoint.sh
|
||||||
|
|
||||||
|
@ -85,7 +106,10 @@ EXPOSE 80
|
||||||
CMD ["/usr/bin/entrypoint.sh"]
|
CMD ["/usr/bin/entrypoint.sh"]
|
||||||
|
|
||||||
|
|
||||||
|
# 使用 Nginx 的 Alpine 版本作为基础镜像
|
||||||
FROM nginx:stable-alpine as nginx
|
FROM nginx:stable-alpine as nginx
|
||||||
|
|
||||||
|
# 替换 Alpine 的软件源为阿里云镜像
|
||||||
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
|
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
|
||||||
|
|
||||||
# 设置工作目录
|
# 设置工作目录
|
||||||
|
@ -94,9 +118,11 @@ WORKDIR /usr/share/nginx/html
|
||||||
# 设置环境变量
|
# 设置环境变量
|
||||||
ENV NODE_ENV production
|
ENV NODE_ENV production
|
||||||
|
|
||||||
# 安装 envsubst 以支持环境变量替换
|
# 安装 envsubst 和 inotify-tools
|
||||||
RUN apk add --no-cache gettext
|
RUN apk add --no-cache gettext inotify-tools
|
||||||
|
|
||||||
|
# 创建 /data/uploads 目录
|
||||||
|
RUN mkdir -p /data/uploads
|
||||||
|
|
||||||
# 暴露 80 端口
|
# 暴露 80 端口
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|
|
@ -110,4 +110,4 @@
|
||||||
"coverageDirectory": "../coverage",
|
"coverageDirectory": "../coverage",
|
||||||
"testEnvironment": "node"
|
"testEnvironment": "node"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,11 +0,0 @@
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
export const EnrollSchema = z.object({
|
|
||||||
studentId: z.string(),
|
|
||||||
postId: z.string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const UnenrollSchema = z.object({
|
|
||||||
studentId: z.string(),
|
|
||||||
postId: z.string(),
|
|
||||||
});
|
|
|
@ -1,9 +0,0 @@
|
||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { EnrollmentRouter } from './enrollment.router';
|
|
||||||
import { EnrollmentService } from './enrollment.service';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
exports: [EnrollmentRouter, EnrollmentService],
|
|
||||||
providers: [EnrollmentRouter, EnrollmentService],
|
|
||||||
})
|
|
||||||
export class EnrollmentModule {}
|
|
|
@ -1,54 +0,0 @@
|
||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { TrpcService } from '@server/trpc/trpc.service';
|
|
||||||
import { Prisma, UpdateOrderSchema } from '@nice/common';
|
|
||||||
import { EnrollmentService } from './enrollment.service';
|
|
||||||
import { z, ZodType } from 'zod';
|
|
||||||
import { EnrollSchema, UnenrollSchema } from './enroll.schema';
|
|
||||||
const EnrollmentCreateArgsSchema: ZodType<Prisma.EnrollmentCreateArgs> = z.any()
|
|
||||||
const EnrollmentCreateManyInputSchema: ZodType<Prisma.EnrollmentCreateManyInput> = z.any()
|
|
||||||
const EnrollmentDeleteManyArgsSchema: ZodType<Prisma.EnrollmentDeleteManyArgs> = z.any()
|
|
||||||
const EnrollmentFindManyArgsSchema: ZodType<Prisma.EnrollmentFindManyArgs> = z.any()
|
|
||||||
const EnrollmentFindFirstArgsSchema: ZodType<Prisma.EnrollmentFindFirstArgs> = z.any()
|
|
||||||
const EnrollmentWhereInputSchema: ZodType<Prisma.EnrollmentWhereInput> = z.any()
|
|
||||||
const EnrollmentSelectSchema: ZodType<Prisma.EnrollmentSelect> = z.any()
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class EnrollmentRouter {
|
|
||||||
constructor(
|
|
||||||
private readonly trpc: TrpcService,
|
|
||||||
private readonly enrollmentService: EnrollmentService,
|
|
||||||
) { }
|
|
||||||
router = this.trpc.router({
|
|
||||||
findFirst: this.trpc.procedure
|
|
||||||
.input(EnrollmentFindFirstArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
|
|
||||||
.query(async ({ input }) => {
|
|
||||||
return await this.enrollmentService.findFirst(input);
|
|
||||||
}),
|
|
||||||
findMany: this.trpc.procedure
|
|
||||||
.input(EnrollmentFindManyArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
|
|
||||||
.query(async ({ input }) => {
|
|
||||||
return await this.enrollmentService.findMany(input);
|
|
||||||
}),
|
|
||||||
findManyWithCursor: this.trpc.protectProcedure
|
|
||||||
.input(z.object({
|
|
||||||
cursor: z.any().nullish(),
|
|
||||||
take: z.number().nullish(),
|
|
||||||
where: EnrollmentWhereInputSchema.nullish(),
|
|
||||||
select: EnrollmentSelectSchema.nullish()
|
|
||||||
}))
|
|
||||||
.query(async ({ ctx, input }) => {
|
|
||||||
const { staff } = ctx;
|
|
||||||
return await this.enrollmentService.findManyWithCursor(input);
|
|
||||||
}),
|
|
||||||
enroll: this.trpc.protectProcedure
|
|
||||||
.input(EnrollSchema)
|
|
||||||
.mutation(async ({ input }) => {
|
|
||||||
return await this.enrollmentService.enroll(input);
|
|
||||||
}),
|
|
||||||
unenroll: this.trpc.protectProcedure
|
|
||||||
.input(UnenrollSchema)
|
|
||||||
.mutation(async ({ input }) => {
|
|
||||||
return await this.enrollmentService.unenroll(input);
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
}
|
|
|
@ -1,78 +0,0 @@
|
||||||
import {
|
|
||||||
ConflictException,
|
|
||||||
Injectable,
|
|
||||||
NotFoundException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { BaseService } from '../base/base.service';
|
|
||||||
import {
|
|
||||||
UserProfile,
|
|
||||||
db,
|
|
||||||
ObjectType,
|
|
||||||
Prisma,
|
|
||||||
EnrollmentStatus,
|
|
||||||
} from '@nice/common';
|
|
||||||
import { z } from 'zod';
|
|
||||||
import { EnrollSchema, UnenrollSchema } from './enroll.schema';
|
|
||||||
import EventBus, { CrudOperation } from '@server/utils/event-bus';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class EnrollmentService extends BaseService<Prisma.EnrollmentDelegate> {
|
|
||||||
constructor() {
|
|
||||||
super(db, ObjectType.COURSE);
|
|
||||||
}
|
|
||||||
async enroll(params: z.infer<typeof EnrollSchema>) {
|
|
||||||
const { studentId, postId } = params;
|
|
||||||
const result = await db.$transaction(async (tx) => {
|
|
||||||
// 检查是否已经报名
|
|
||||||
const existing = await tx.enrollment.findUnique({
|
|
||||||
where: {
|
|
||||||
studentId_postId: {
|
|
||||||
studentId: studentId,
|
|
||||||
postId: postId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (existing) {
|
|
||||||
throw new ConflictException('Already enrolled in this post');
|
|
||||||
}
|
|
||||||
// 创建报名记录
|
|
||||||
const enrollment = await tx.enrollment.create({
|
|
||||||
data: {
|
|
||||||
studentId: studentId,
|
|
||||||
postId: postId,
|
|
||||||
status: EnrollmentStatus.ACTIVE,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return enrollment;
|
|
||||||
});
|
|
||||||
|
|
||||||
EventBus.emit('dataChanged', {
|
|
||||||
type: ObjectType.ENROLLMENT,
|
|
||||||
operation: CrudOperation.CREATED,
|
|
||||||
data: result,
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
async unenroll(params: z.infer<typeof UnenrollSchema>) {
|
|
||||||
const { studentId, postId } = params;
|
|
||||||
const result = await db.enrollment.update({
|
|
||||||
where: {
|
|
||||||
studentId_postId: {
|
|
||||||
studentId,
|
|
||||||
postId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
status: EnrollmentStatus.CANCELLED,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
EventBus.emit('dataChanged', {
|
|
||||||
type: ObjectType.ENROLLMENT,
|
|
||||||
operation: CrudOperation.UPDATED,
|
|
||||||
data: result,
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -15,11 +15,10 @@ import {
|
||||||
import { MessageService } from '../message/message.service';
|
import { MessageService } from '../message/message.service';
|
||||||
import { BaseService } from '../base/base.service';
|
import { BaseService } from '../base/base.service';
|
||||||
import { DepartmentService } from '../department/department.service';
|
import { DepartmentService } from '../department/department.service';
|
||||||
import { setCourseInfo, setPostRelation } from './utils';
|
import { setPostInfo, setPostRelation } from './utils';
|
||||||
import EventBus, { CrudOperation } from '@server/utils/event-bus';
|
import EventBus, { CrudOperation } from '@server/utils/event-bus';
|
||||||
import { BaseTreeService } from '../base/base.tree.service';
|
import { BaseTreeService } from '../base/base.tree.service';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { DefaultArgs } from '@prisma/client/runtime/library';
|
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { OrderByArgs } from '../base/base.type';
|
import { OrderByArgs } from '../base/base.type';
|
||||||
|
|
||||||
|
@ -155,7 +154,7 @@ export class PostService extends BaseTreeService<Prisma.PostDelegate> {
|
||||||
if (result) {
|
if (result) {
|
||||||
await setPostRelation({ data: result, staff });
|
await setPostRelation({ data: result, staff });
|
||||||
await this.setPerms(result, staff);
|
await this.setPerms(result, staff);
|
||||||
await setCourseInfo({ data: result });
|
await setPostInfo({ data: result });
|
||||||
}
|
}
|
||||||
// console.log(result);
|
// console.log(result);
|
||||||
return result;
|
return result;
|
||||||
|
@ -183,7 +182,7 @@ export class PostService extends BaseTreeService<Prisma.PostDelegate> {
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
where?: Prisma.PostWhereInput;
|
where?: Prisma.PostWhereInput;
|
||||||
orderBy?: OrderByArgs<(typeof db.post)['findMany']>;
|
orderBy?: OrderByArgs<(typeof db.post)['findMany']>;
|
||||||
select?: Prisma.PostSelect<DefaultArgs>;
|
select?: Prisma.PostSelect;
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
items: {
|
items: {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
|
@ -126,7 +126,7 @@ export async function updateCourseEnrollmentStats(courseId: string) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setCourseInfo({ data }: { data: Post }) {
|
export async function setPostInfo({ data }: { data: Post }) {
|
||||||
// await db.term
|
// await db.term
|
||||||
if (data?.type === PostType.COURSE) {
|
if (data?.type === PostType.COURSE) {
|
||||||
const ancestries = await db.postAncestry.findMany({
|
const ancestries = await db.postAncestry.findMany({
|
||||||
|
@ -169,20 +169,36 @@ export async function setCourseInfo({ data }: { data: Post }) {
|
||||||
) as any as Lecture[];
|
) as any as Lecture[];
|
||||||
});
|
});
|
||||||
|
|
||||||
const students = await db.staff.findMany({
|
Object.assign(data, { sections, lectureCount });
|
||||||
|
}
|
||||||
|
if (data?.type === PostType.LECTURE || data?.type === PostType.SECTION) {
|
||||||
|
const ancestry = await db.postAncestry.findFirst({
|
||||||
where: {
|
where: {
|
||||||
learningPosts: {
|
descendantId: data?.id,
|
||||||
some: {
|
ancestor: {
|
||||||
id: data.id,
|
type: PostType.COURSE,
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
ancestor: { select: { id: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const courseId = ancestry.ancestor.id;
|
||||||
const studentIds = (students || []).map((student) => student?.id);
|
Object.assign(data, { courseId });
|
||||||
Object.assign(data, { sections, lectureCount, studentIds });
|
|
||||||
}
|
}
|
||||||
|
const students = await db.staff.findMany({
|
||||||
|
where: {
|
||||||
|
learningPosts: {
|
||||||
|
some: {
|
||||||
|
id: data.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const studentIds = (students || []).map((student) => student?.id);
|
||||||
|
Object.assign(data, { studentIds });
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { PrismaClient, Resource } from '@prisma/client';
|
import { Resource } from '@nice/common';
|
||||||
import { ProcessResult, ResourceProcessor } from '../types';
|
import { ProcessResult, ResourceProcessor } from '../types';
|
||||||
import { db, ResourceStatus } from '@nice/common';
|
import { db, ResourceStatus } from '@nice/common';
|
||||||
import { Logger } from '@nestjs/common';
|
import { Logger } from '@nestjs/common';
|
||||||
|
|
|
@ -12,6 +12,7 @@ export class VideoProcessor extends BaseProcessor {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
async process(resource: Resource): Promise<Resource> {
|
async process(resource: Resource): Promise<Resource> {
|
||||||
|
console.log('process');
|
||||||
const { url } = resource;
|
const { url } = resource;
|
||||||
const filepath = getUploadFilePath(url);
|
const filepath = getUploadFilePath(url);
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
|
@ -114,6 +115,7 @@ export class VideoProcessor extends BaseProcessor {
|
||||||
filepath: string,
|
filepath: string,
|
||||||
outputDir: string,
|
outputDir: string,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
|
console.log('outputDir', outputDir);
|
||||||
const m3u8Path = path.join(outputDir, 'index.m3u8');
|
const m3u8Path = path.join(outputDir, 'index.m3u8');
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Generating M3U8 stream for video: ${filepath}, Output Dir: ${outputDir}`,
|
`Generating M3U8 stream for video: ${filepath}, Output Dir: ${outputDir}`,
|
||||||
|
|
|
@ -3,68 +3,76 @@ import { TrpcService } from '@server/trpc/trpc.service';
|
||||||
import { Prisma, UpdateOrderSchema } from '@nice/common';
|
import { Prisma, UpdateOrderSchema } from '@nice/common';
|
||||||
import { ResourceService } from './resource.service';
|
import { ResourceService } from './resource.service';
|
||||||
import { z, ZodType } from 'zod';
|
import { z, ZodType } from 'zod';
|
||||||
const ResourceCreateArgsSchema: ZodType<Prisma.ResourceCreateArgs> = z.any()
|
|
||||||
const ResourceCreateManyInputSchema: ZodType<Prisma.ResourceCreateManyInput> = z.any()
|
const ResourceCreateArgsSchema: ZodType<Prisma.ResourceCreateArgs> = z.any();
|
||||||
const ResourceDeleteManyArgsSchema: ZodType<Prisma.ResourceDeleteManyArgs> = z.any()
|
const ResourceCreateManyInputSchema: ZodType<Prisma.ResourceCreateManyInput> =
|
||||||
const ResourceFindManyArgsSchema: ZodType<Prisma.ResourceFindManyArgs> = z.any()
|
z.any();
|
||||||
const ResourceFindFirstArgsSchema: ZodType<Prisma.ResourceFindFirstArgs> = z.any()
|
const ResourceDeleteManyArgsSchema: ZodType<Prisma.ResourceDeleteManyArgs> =
|
||||||
const ResourceWhereInputSchema: ZodType<Prisma.ResourceWhereInput> = z.any()
|
z.any();
|
||||||
const ResourceSelectSchema: ZodType<Prisma.ResourceSelect> = z.any()
|
const ResourceFindManyArgsSchema: ZodType<Prisma.ResourceFindManyArgs> =
|
||||||
|
z.any();
|
||||||
|
const ResourceFindFirstArgsSchema: ZodType<Prisma.ResourceFindFirstArgs> =
|
||||||
|
z.any();
|
||||||
|
const ResourceWhereInputSchema: ZodType<Prisma.ResourceWhereInput> = z.any();
|
||||||
|
const ResourceSelectSchema: ZodType<Prisma.ResourceSelect> = z.any();
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ResourceRouter {
|
export class ResourceRouter {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly trpc: TrpcService,
|
private readonly trpc: TrpcService,
|
||||||
private readonly resourceService: ResourceService,
|
private readonly resourceService: ResourceService,
|
||||||
) { }
|
) {}
|
||||||
router = this.trpc.router({
|
router = this.trpc.router({
|
||||||
create: this.trpc.protectProcedure
|
create: this.trpc.protectProcedure
|
||||||
.input(ResourceCreateArgsSchema)
|
.input(ResourceCreateArgsSchema)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const { staff } = ctx;
|
const { staff } = ctx;
|
||||||
return await this.resourceService.create(input, { staff });
|
return await this.resourceService.create(input, { staff });
|
||||||
}),
|
}),
|
||||||
createMany: this.trpc.protectProcedure.input(z.array(ResourceCreateManyInputSchema))
|
createMany: this.trpc.protectProcedure
|
||||||
.mutation(async ({ ctx, input }) => {
|
.input(z.array(ResourceCreateManyInputSchema))
|
||||||
const { staff } = ctx;
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const { staff } = ctx;
|
||||||
|
|
||||||
return await this.resourceService.createMany({ data: input }, staff);
|
return await this.resourceService.createMany({ data: input }, staff);
|
||||||
}),
|
}),
|
||||||
deleteMany: this.trpc.procedure
|
deleteMany: this.trpc.procedure
|
||||||
.input(ResourceDeleteManyArgsSchema)
|
.input(ResourceDeleteManyArgsSchema)
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
return await this.resourceService.deleteMany(input);
|
return await this.resourceService.deleteMany(input);
|
||||||
}),
|
}),
|
||||||
findFirst: this.trpc.procedure
|
findFirst: this.trpc.procedure
|
||||||
.input(ResourceFindFirstArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
|
.input(ResourceFindFirstArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
|
||||||
.query(async ({ input }) => {
|
.query(async ({ input }) => {
|
||||||
return await this.resourceService.findFirst(input);
|
return await this.resourceService.findFirst(input);
|
||||||
}),
|
}),
|
||||||
softDeleteByIds: this.trpc.protectProcedure
|
softDeleteByIds: this.trpc.protectProcedure
|
||||||
.input(z.object({ ids: z.array(z.string()) })) // expect input according to the schema
|
.input(z.object({ ids: z.array(z.string()) })) // expect input according to the schema
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
return this.resourceService.softDeleteByIds(input.ids);
|
return this.resourceService.softDeleteByIds(input.ids);
|
||||||
}),
|
}),
|
||||||
updateOrder: this.trpc.protectProcedure
|
updateOrder: this.trpc.protectProcedure
|
||||||
.input(UpdateOrderSchema)
|
.input(UpdateOrderSchema)
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
return this.resourceService.updateOrder(input);
|
return this.resourceService.updateOrder(input);
|
||||||
}),
|
}),
|
||||||
findMany: this.trpc.procedure
|
findMany: this.trpc.procedure
|
||||||
.input(ResourceFindManyArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
|
.input(ResourceFindManyArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
|
||||||
.query(async ({ input }) => {
|
.query(async ({ input }) => {
|
||||||
return await this.resourceService.findMany(input);
|
return await this.resourceService.findMany(input);
|
||||||
}),
|
}),
|
||||||
findManyWithCursor: this.trpc.protectProcedure
|
findManyWithCursor: this.trpc.protectProcedure
|
||||||
.input(z.object({
|
.input(
|
||||||
cursor: z.any().nullish(),
|
z.object({
|
||||||
take: z.number().nullish(),
|
cursor: z.any().nullish(),
|
||||||
where: ResourceWhereInputSchema.nullish(),
|
take: z.number().nullish(),
|
||||||
select: ResourceSelectSchema.nullish()
|
where: ResourceWhereInputSchema.nullish(),
|
||||||
}))
|
select: ResourceSelectSchema.nullish(),
|
||||||
.query(async ({ ctx, input }) => {
|
}),
|
||||||
const { staff } = ctx;
|
)
|
||||||
return await this.resourceService.findManyWithCursor(input);
|
.query(async ({ ctx, input }) => {
|
||||||
}),
|
const { staff } = ctx;
|
||||||
});
|
return await this.resourceService.findManyWithCursor(input);
|
||||||
|
}),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,13 +9,13 @@ export class VisitService extends BaseService<Prisma.VisitDelegate> {
|
||||||
}
|
}
|
||||||
async create(args: Prisma.VisitCreateArgs, staff?: UserProfile) {
|
async create(args: Prisma.VisitCreateArgs, staff?: UserProfile) {
|
||||||
const { postId, lectureId, messageId } = args.data;
|
const { postId, lectureId, messageId } = args.data;
|
||||||
const visitorId = args.data.visitorId || staff?.id;
|
const visitorId = args.data?.visitorId || staff?.id;
|
||||||
let result;
|
let result;
|
||||||
const existingVisit = await db.visit.findFirst({
|
const existingVisit = await db.visit.findFirst({
|
||||||
where: {
|
where: {
|
||||||
type: args.data.type,
|
type: args.data.type,
|
||||||
visitorId,
|
// visitorId: visitorId ? visitorId : null,
|
||||||
OR: [{ postId }, { lectureId }, { messageId }],
|
OR: [{ postId }, { messageId }],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!existingVisit) {
|
if (!existingVisit) {
|
||||||
|
|
|
@ -66,8 +66,6 @@ export async function updatePostViewCount(id: string, type: VisitType) {
|
||||||
where: { id },
|
where: { id },
|
||||||
select: { id: true, meta: true, type: true },
|
select: { id: true, meta: true, type: true },
|
||||||
});
|
});
|
||||||
console.log(post?.type);
|
|
||||||
console.log('updatePostViewCount');
|
|
||||||
const metaFieldMap = {
|
const metaFieldMap = {
|
||||||
[VisitType.READED]: 'views',
|
[VisitType.READED]: 'views',
|
||||||
[VisitType.LIKE]: 'likes',
|
[VisitType.LIKE]: 'likes',
|
||||||
|
@ -105,7 +103,6 @@ export async function updatePostViewCount(id: string, type: VisitType) {
|
||||||
type: type,
|
type: type,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
console.log(courseViews);
|
|
||||||
await db.post.update({
|
await db.post.update({
|
||||||
where: { id: course.id },
|
where: { id: course.id },
|
||||||
data: {
|
data: {
|
||||||
|
@ -126,7 +123,6 @@ export async function updatePostViewCount(id: string, type: VisitType) {
|
||||||
type: type,
|
type: type,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
console.log('totalViews', totalViews);
|
|
||||||
await db.post.update({
|
await db.post.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
|
|
|
@ -31,6 +31,7 @@ export class GenDevService {
|
||||||
domainDepts: Record<string, Department[]> = {};
|
domainDepts: Record<string, Department[]> = {};
|
||||||
staffs: Staff[] = [];
|
staffs: Staff[] = [];
|
||||||
deptGeneratedCount = 0;
|
deptGeneratedCount = 0;
|
||||||
|
courseGeneratedCount = 1;
|
||||||
constructor(
|
constructor(
|
||||||
private readonly appConfigService: AppConfigService,
|
private readonly appConfigService: AppConfigService,
|
||||||
|
|
||||||
|
@ -194,8 +195,9 @@ export class GenDevService {
|
||||||
cate.id,
|
cate.id,
|
||||||
randomLevelId,
|
randomLevelId,
|
||||||
);
|
);
|
||||||
|
this.courseGeneratedCount++;
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Generated ${this.deptGeneratedCount}/${total} departments`,
|
`Generated ${this.courseGeneratedCount}/${total} course`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -128,6 +128,7 @@ export class TusService implements OnModuleInit {
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleTus(req: Request, res: Response) {
|
async handleTus(req: Request, res: Response) {
|
||||||
|
// console.log(req)
|
||||||
return this.tusServer.handle(req, res);
|
return this.tusServer.handle(req, res);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,7 +12,6 @@
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"incremental": true,
|
"incremental": true
|
||||||
// "skipLibCheck": true,
|
}
|
||||||
},
|
}
|
||||||
}
|
|
||||||
|
|
|
@ -12,7 +12,7 @@
|
||||||
VITE_APP_VERSION: "$VITE_APP_VERSION",
|
VITE_APP_VERSION: "$VITE_APP_VERSION",
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
<title>fhmooc</title>
|
<title>%VITE_APP_APP_NAME%</title>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|
|
@ -34,9 +34,8 @@
|
||||||
"@nice/common": "workspace:^",
|
"@nice/common": "workspace:^",
|
||||||
"@nice/config": "workspace:^",
|
"@nice/config": "workspace:^",
|
||||||
"@nice/iconer": "workspace:^",
|
"@nice/iconer": "workspace:^",
|
||||||
"@nice/utils": "workspace:^",
|
|
||||||
"mind-elixir": "workspace:^",
|
|
||||||
"@nice/ui": "workspace:^",
|
"@nice/ui": "workspace:^",
|
||||||
|
"@nice/utils": "workspace:^",
|
||||||
"@tanstack/query-async-storage-persister": "^5.51.9",
|
"@tanstack/query-async-storage-persister": "^5.51.9",
|
||||||
"@tanstack/react-query": "^5.51.21",
|
"@tanstack/react-query": "^5.51.21",
|
||||||
"@tanstack/react-query-persist-client": "^5.51.9",
|
"@tanstack/react-query-persist-client": "^5.51.9",
|
||||||
|
@ -59,6 +58,7 @@
|
||||||
"framer-motion": "^11.15.0",
|
"framer-motion": "^11.15.0",
|
||||||
"hls.js": "^1.5.18",
|
"hls.js": "^1.5.18",
|
||||||
"idb-keyval": "^6.2.1",
|
"idb-keyval": "^6.2.1",
|
||||||
|
"mind-elixir": "workspace:^",
|
||||||
"mitt": "^3.0.1",
|
"mitt": "^3.0.1",
|
||||||
"quill": "2.0.3",
|
"quill": "2.0.3",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
|
@ -69,6 +69,7 @@
|
||||||
"react-router-dom": "^6.24.1",
|
"react-router-dom": "^6.24.1",
|
||||||
"superjson": "^2.2.1",
|
"superjson": "^2.2.1",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
|
"use-debounce": "^10.0.4",
|
||||||
"uuid": "^10.0.0",
|
"uuid": "^10.0.0",
|
||||||
"yjs": "^13.6.20",
|
"yjs": "^13.6.20",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
|
@ -89,4 +90,4 @@
|
||||||
"typescript-eslint": "^8.0.1",
|
"typescript-eslint": "^8.0.1",
|
||||||
"vite": "^5.4.1"
|
"vite": "^5.4.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,11 +11,12 @@ import dayjs from "dayjs";
|
||||||
import "dayjs/locale/zh-cn";
|
import "dayjs/locale/zh-cn";
|
||||||
import { AuthProvider } from './providers/auth-provider';
|
import { AuthProvider } from './providers/auth-provider';
|
||||||
import { Toaster } from 'react-hot-toast';
|
import { Toaster } from 'react-hot-toast';
|
||||||
|
// import PostForm from './components/models/post/PostSelect/PostSelect';
|
||||||
dayjs.locale("zh-cn");
|
dayjs.locale("zh-cn");
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{/* <PostForm/> */}
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<QueryProvider>
|
<QueryProvider>
|
||||||
<ConfigProvider
|
<ConfigProvider
|
||||||
|
@ -35,7 +36,8 @@ function App() {
|
||||||
</ConfigProvider>
|
</ConfigProvider>
|
||||||
</QueryProvider>
|
</QueryProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,6 +9,7 @@ import FixedHeader from "@web/src/components/layout/fix-header";
|
||||||
import { useForm } from "antd/es/form/Form";
|
import { useForm } from "antd/es/form/Form";
|
||||||
import { api } from "@nice/client";
|
import { api } from "@nice/client";
|
||||||
import { MainLayoutContext } from "../layout";
|
import { MainLayoutContext } from "../layout";
|
||||||
|
import CarouselUrlInput from "@web/src/components/common/input/CarouselUrlInput";
|
||||||
|
|
||||||
export default function BaseSettingPage() {
|
export default function BaseSettingPage() {
|
||||||
const { update, baseSetting } = useAppConfig();
|
const { update, baseSetting } = useAppConfig();
|
||||||
|
@ -134,6 +135,13 @@ export default function BaseSettingPage() {
|
||||||
<MultiAvatarUploader></MultiAvatarUploader>
|
<MultiAvatarUploader></MultiAvatarUploader>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="p-2 grid grid-cols-4 gap-2 border-b">
|
||||||
|
<Form.Item
|
||||||
|
label="首页轮播图链接"
|
||||||
|
name={["appConfig", "slideLinks"]}>
|
||||||
|
<CarouselUrlInput ></CarouselUrlInput>
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
{/* <div
|
{/* <div
|
||||||
className="p-2 border-b flex items-center justify-between"
|
className="p-2 border-b flex items-center justify-between"
|
||||||
style={{
|
style={{
|
||||||
|
|
|
@ -7,11 +7,7 @@ import SineWave from "../components/animation/sine-wave";
|
||||||
const LoginPage: React.FC = () => {
|
const LoginPage: React.FC = () => {
|
||||||
const [showLogin, setShowLogin] = useState(true);
|
const [showLogin, setShowLogin] = useState(true);
|
||||||
const [registerLoading, setRegisterLoading] = useState(false);
|
const [registerLoading, setRegisterLoading] = useState(false);
|
||||||
const {
|
const { login, isAuthenticated, signup } = useAuth();
|
||||||
login,
|
|
||||||
isAuthenticated,
|
|
||||||
signup
|
|
||||||
} = useAuth()
|
|
||||||
const loginFormRef = useRef<any>(null);
|
const loginFormRef = useRef<any>(null);
|
||||||
const registerFormRef = useRef<any>(null);
|
const registerFormRef = useRef<any>(null);
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
@ -21,7 +17,7 @@ const LoginPage: React.FC = () => {
|
||||||
const { username, password } = values;
|
const { username, password } = values;
|
||||||
await login(username, password);
|
await login(username, password);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
message.error(err?.response?.data?.message || "帐号或密码错误!");
|
message.error(err?.response?.data?.message || "账号或密码错误!");
|
||||||
console.error(err);
|
console.error(err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -52,10 +48,12 @@ const LoginPage: React.FC = () => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex justify-center items-center h-screen w-full bg-gray-200"
|
className="flex justify-center items-center h-screen w-full bg-gray-200"
|
||||||
style={{
|
style={
|
||||||
// backgroundImage: `url(${backgroundUrl})`,
|
{
|
||||||
// backgroundSize: "cover",
|
// backgroundImage: `url(${backgroundUrl})`,
|
||||||
}}>
|
// backgroundSize: "cover",
|
||||||
|
}
|
||||||
|
}>
|
||||||
<div
|
<div
|
||||||
className="flex items-center transition-all hover:bg-white overflow-hidden border-2 border-white bg-gray-50 shadow-elegant rounded-xl "
|
className="flex items-center transition-all hover:bg-white overflow-hidden border-2 border-white bg-gray-50 shadow-elegant rounded-xl "
|
||||||
style={{ width: 800, height: 600 }}>
|
style={{ width: 800, height: 600 }}>
|
||||||
|
@ -103,11 +101,11 @@ const LoginPage: React.FC = () => {
|
||||||
size="large">
|
size="large">
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="username"
|
name="username"
|
||||||
label="帐号"
|
label="账号"
|
||||||
rules={[
|
rules={[
|
||||||
{
|
{
|
||||||
required: true,
|
required: true,
|
||||||
message: "请输入帐号",
|
message: "请输入账号",
|
||||||
},
|
},
|
||||||
]}>
|
]}>
|
||||||
<Input />
|
<Input />
|
||||||
|
@ -158,17 +156,17 @@ const LoginPage: React.FC = () => {
|
||||||
<Col span={12}>
|
<Col span={12}>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="username"
|
name="username"
|
||||||
label="帐号"
|
label="账号"
|
||||||
rules={[
|
rules={[
|
||||||
{
|
{
|
||||||
required: true,
|
required: true,
|
||||||
message: "请输入帐号",
|
message: "请输入账号",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
min: 2,
|
min: 2,
|
||||||
max: 15,
|
max: 15,
|
||||||
message:
|
message:
|
||||||
"帐号长度为 2 到 15 个字符",
|
"账号长度为 2 到 15 个字符",
|
||||||
},
|
},
|
||||||
]}>
|
]}>
|
||||||
<Input />
|
<Input />
|
||||||
|
|
|
@ -35,6 +35,7 @@ const CategorySection = () => {
|
||||||
|
|
||||||
const handleMouseClick = useCallback((categoryId: string) => {
|
const handleMouseClick = useCallback((categoryId: string) => {
|
||||||
setSelectedTerms({
|
setSelectedTerms({
|
||||||
|
...selectedTerms,
|
||||||
[TaxonomySlug.CATEGORY]: [categoryId],
|
[TaxonomySlug.CATEGORY]: [categoryId],
|
||||||
});
|
});
|
||||||
navigate("/courses");
|
navigate("/courses");
|
||||||
|
@ -57,7 +58,7 @@ const CategorySection = () => {
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Skeleton paragraph={{ rows: 4 }}></Skeleton>
|
<Skeleton paragraph={{ rows: 4 }}></Skeleton>
|
||||||
) : (
|
) : (
|
||||||
courseCategoriesData?.map((category, index) => {
|
courseCategoriesData?.filter(c=>!c.deletedAt)?.map((category, index) => {
|
||||||
const categoryColor = stringToColor(category.name);
|
const categoryColor = stringToColor(category.name);
|
||||||
const isHovered = hoveredIndex === index;
|
const isHovered = hoveredIndex === index;
|
||||||
|
|
||||||
|
|
|
@ -23,7 +23,7 @@ function useGetTaxonomy({ type }): GetTaxonomyProps {
|
||||||
const categories = useMemo(() => {
|
const categories = useMemo(() => {
|
||||||
const allCategories = isLoading
|
const allCategories = isLoading
|
||||||
? []
|
? []
|
||||||
: data?.map((course) => course.name);
|
: data?.filter(c=>!c.deletedAt)?.map((course) => course.name);
|
||||||
return [...Array.from(new Set(allCategories))];
|
return [...Array.from(new Set(allCategories))];
|
||||||
}, [data]);
|
}, [data]);
|
||||||
return { categories, isLoading };
|
return { categories, isLoading };
|
||||||
|
|
|
@ -16,6 +16,7 @@ import {
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import type { CarouselRef } from "antd/es/carousel";
|
import type { CarouselRef } from "antd/es/carousel";
|
||||||
import { useAppConfig } from "@nice/client";
|
import { useAppConfig } from "@nice/client";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
@ -35,8 +36,9 @@ interface PlatformStat {
|
||||||
|
|
||||||
const HeroSection = () => {
|
const HeroSection = () => {
|
||||||
const carouselRef = useRef<CarouselRef>(null);
|
const carouselRef = useRef<CarouselRef>(null);
|
||||||
const { statistics, slides } = useAppConfig();
|
const { statistics, slides, slideLinks = [] } = useAppConfig();
|
||||||
const [countStatistics, setCountStatistics] = useState<number>(4);
|
const [countStatistics, setCountStatistics] = useState<number>(4);
|
||||||
|
const navigator = useNavigate()
|
||||||
const platformStats: PlatformStat[] = useMemo(() => {
|
const platformStats: PlatformStat[] = useMemo(() => {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
@ -91,7 +93,11 @@ const HeroSection = () => {
|
||||||
}}>
|
}}>
|
||||||
{Array.isArray(slides) ? (
|
{Array.isArray(slides) ? (
|
||||||
slides.map((item, index) => (
|
slides.map((item, index) => (
|
||||||
<div key={index} className="relative h-[600px]">
|
<div key={index} className="relative h-[600px] cursor-pointer"
|
||||||
|
onClick={()=>{
|
||||||
|
if(slideLinks?.[index])window.open(slideLinks?.[index],"_blank")
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 bg-cover bg-center transform transition-[transform,filter] duration-[2000ms] group-hover:scale-105 group-hover:brightness-110 will-change-[transform,filter]"
|
className="absolute inset-0 bg-cover bg-center transform transition-[transform,filter] duration-[2000ms] group-hover:scale-105 group-hover:brightness-110 will-change-[transform,filter]"
|
||||||
style={{
|
style={{
|
||||||
|
|
|
@ -24,10 +24,11 @@ export default function FilterSection() {
|
||||||
<div key={index}>
|
<div key={index}>
|
||||||
<h3 className="text-lg font-medium mb-4">
|
<h3 className="text-lg font-medium mb-4">
|
||||||
{tax?.name}
|
{tax?.name}
|
||||||
|
{/* {JSON.stringify(items)} */}
|
||||||
</h3>
|
</h3>
|
||||||
<TermParentSelector
|
<TermParentSelector
|
||||||
value={items}
|
value={items}
|
||||||
slug={tax?.slug}
|
// slug={tax?.slug}
|
||||||
className="w-70 max-h-[400px] overscroll-contain overflow-x-hidden"
|
className="w-70 max-h-[400px] overscroll-contain overflow-x-hidden"
|
||||||
onChange={(selected) =>
|
onChange={(selected) =>
|
||||||
handleTermChange(
|
handleTermChange(
|
||||||
|
|
|
@ -26,13 +26,13 @@ export function MainFooter() {
|
||||||
<div className="flex items-center justify-center space-x-2">
|
<div className="flex items-center justify-center space-x-2">
|
||||||
<PhoneOutlined className="text-gray-400" />
|
<PhoneOutlined className="text-gray-400" />
|
||||||
<span className="text-gray-300 text-xs">
|
<span className="text-gray-300 text-xs">
|
||||||
628118
|
628532
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-center space-x-2">
|
<div className="flex items-center justify-center space-x-2">
|
||||||
<MailOutlined className="text-gray-400" />
|
<MailOutlined className="text-gray-400" />
|
||||||
<span className="text-gray-300 text-xs">
|
<span className="text-gray-300 text-xs">
|
||||||
gcsjs6@tx3l.nb.kj
|
ruanjian1@tx3l.nb.kj
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,16 +1,11 @@
|
||||||
|
import { Input, Button } from "antd";
|
||||||
import { Input, Layout, Avatar, Button, Dropdown } from "antd";
|
import { PlusOutlined, SearchOutlined, UserOutlined } from "@ant-design/icons";
|
||||||
import {
|
|
||||||
EditFilled,
|
|
||||||
PlusOutlined,
|
|
||||||
SearchOutlined,
|
|
||||||
UserOutlined,
|
|
||||||
} from "@ant-design/icons";
|
|
||||||
import { useAuth } from "@web/src/providers/auth-provider";
|
import { useAuth } from "@web/src/providers/auth-provider";
|
||||||
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { UserMenu } from "./UserMenu/UserMenu";
|
import { UserMenu } from "./UserMenu/UserMenu";
|
||||||
import { NavigationMenu } from "./NavigationMenu";
|
import { NavigationMenu } from "./NavigationMenu";
|
||||||
import { useMainContext } from "./MainProvider";
|
import { useMainContext } from "./MainProvider";
|
||||||
|
import { env } from "@web/src/env";
|
||||||
export function MainHeader() {
|
export function MainHeader() {
|
||||||
const { isAuthenticated, user } = useAuth();
|
const { isAuthenticated, user } = useAuth();
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
|
@ -25,12 +20,11 @@ export function MainHeader() {
|
||||||
<div
|
<div
|
||||||
onClick={() => navigate("/")}
|
onClick={() => navigate("/")}
|
||||||
className="text-2xl font-bold bg-gradient-to-r from-primary-600 via-primary-500 to-primary-400 bg-clip-text text-transparent hover:scale-105 transition-transform cursor-pointer whitespace-nowrap">
|
className="text-2xl font-bold bg-gradient-to-r from-primary-600 via-primary-500 to-primary-400 bg-clip-text text-transparent hover:scale-105 transition-transform cursor-pointer whitespace-nowrap">
|
||||||
烽火慕课
|
{env.APP_NAME}
|
||||||
</div>
|
</div>
|
||||||
<NavigationMenu />
|
<NavigationMenu />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
{/* 右侧区域 - 可以灵活收缩 */}
|
{/* 右侧区域 - 可以灵活收缩 */}
|
||||||
<div className="flex justify-end gap-2 md:gap-4 flex-shrink">
|
<div className="flex justify-end gap-2 md:gap-4 flex-shrink">
|
||||||
<div className="flex items-center gap-2 md:gap-4">
|
<div className="flex items-center gap-2 md:gap-4">
|
||||||
|
@ -43,7 +37,9 @@ export function MainHeader() {
|
||||||
className="w-full md:w-96 rounded-full"
|
className="w-full md:w-96 rounded-full"
|
||||||
value={searchValue}
|
value={searchValue}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (!window.location.pathname.startsWith("/search")) {
|
if (
|
||||||
|
!window.location.pathname.startsWith("/search")
|
||||||
|
) {
|
||||||
navigate(`/search`);
|
navigate(`/search`);
|
||||||
window.scrollTo({
|
window.scrollTo({
|
||||||
top: 0,
|
top: 0,
|
||||||
|
@ -53,7 +49,9 @@ export function MainHeader() {
|
||||||
}}
|
}}
|
||||||
onChange={(e) => setSearchValue(e.target.value)}
|
onChange={(e) => setSearchValue(e.target.value)}
|
||||||
onPressEnter={(e) => {
|
onPressEnter={(e) => {
|
||||||
if (!window.location.pathname.startsWith("/search")) {
|
if (
|
||||||
|
!window.location.pathname.startsWith("/search")
|
||||||
|
) {
|
||||||
navigate(`/search`);
|
navigate(`/search`);
|
||||||
window.scrollTo({
|
window.scrollTo({
|
||||||
top: 0,
|
top: 0,
|
||||||
|
@ -69,14 +67,11 @@ export function MainHeader() {
|
||||||
shape="round"
|
shape="round"
|
||||||
icon={<PlusOutlined></PlusOutlined>}
|
icon={<PlusOutlined></PlusOutlined>}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const url = id
|
const url = "/course/editor";
|
||||||
? `/course/${id}/editor`
|
|
||||||
: "/course/editor";
|
|
||||||
navigate(url);
|
navigate(url);
|
||||||
}}
|
}}
|
||||||
type="primary"
|
type="primary">
|
||||||
>
|
{"创建课程"}
|
||||||
{id ? "编辑课程" : "创建课程"}
|
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
@ -87,7 +82,8 @@ export function MainHeader() {
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
window.location.href = "/path/editor";
|
window.location.href = "/path/editor";
|
||||||
}}
|
}}
|
||||||
ghost type="primary"
|
ghost
|
||||||
|
type="primary"
|
||||||
icon={<PlusOutlined></PlusOutlined>}>
|
icon={<PlusOutlined></PlusOutlined>}>
|
||||||
创建思维导图
|
创建思维导图
|
||||||
</Button>
|
</Button>
|
||||||
|
@ -100,7 +96,6 @@ export function MainHeader() {
|
||||||
size="large"
|
size="large"
|
||||||
shape="round"
|
shape="round"
|
||||||
onClick={() => navigate("/login")}
|
onClick={() => navigate("/login")}
|
||||||
|
|
||||||
icon={<UserOutlined />}>
|
icon={<UserOutlined />}>
|
||||||
登录
|
登录
|
||||||
</Button>
|
</Button>
|
||||||
|
@ -110,4 +105,3 @@ export function MainHeader() {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -6,6 +6,7 @@ import React, {
|
||||||
useMemo,
|
useMemo,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import { useDebounce } from "use-debounce";
|
||||||
interface SelectedTerms {
|
interface SelectedTerms {
|
||||||
[key: string]: string[]; // 每个 slug 对应一个 string 数组
|
[key: string]: string[]; // 每个 slug 对应一个 string 数组
|
||||||
}
|
}
|
||||||
|
@ -35,7 +36,8 @@ export function MainProvider({ children }: MainProviderProps) {
|
||||||
PostType.COURSE | PostType.PATH | "both"
|
PostType.COURSE | PostType.PATH | "both"
|
||||||
>("both");
|
>("both");
|
||||||
const [showSearchMode, setShowSearchMode] = useState<boolean>(false);
|
const [showSearchMode, setShowSearchMode] = useState<boolean>(false);
|
||||||
const [searchValue, setSearchValue] = useState("");
|
const [searchValue, setSearchValue] = useState<string>("");
|
||||||
|
const [debouncedValue] = useDebounce<string>(searchValue, 500);
|
||||||
const [selectedTerms, setSelectedTerms] = useState<SelectedTerms>({}); // 初始化状态
|
const [selectedTerms, setSelectedTerms] = useState<SelectedTerms>({}); // 初始化状态
|
||||||
const termFilters = useMemo(() => {
|
const termFilters = useMemo(() => {
|
||||||
return Object.entries(selectedTerms)
|
return Object.entries(selectedTerms)
|
||||||
|
@ -60,10 +62,10 @@ export function MainProvider({ children }: MainProviderProps) {
|
||||||
}, [termFilters]);
|
}, [termFilters]);
|
||||||
const searchCondition: Prisma.PostWhereInput = useMemo(() => {
|
const searchCondition: Prisma.PostWhereInput = useMemo(() => {
|
||||||
const containTextCondition: Prisma.StringNullableFilter = {
|
const containTextCondition: Prisma.StringNullableFilter = {
|
||||||
contains: searchValue,
|
contains: debouncedValue,
|
||||||
mode: "insensitive" as Prisma.QueryMode, // 使用类型断言
|
mode: "insensitive" as Prisma.QueryMode, // 使用类型断言
|
||||||
};
|
};
|
||||||
return searchValue
|
return debouncedValue
|
||||||
? {
|
? {
|
||||||
OR: [
|
OR: [
|
||||||
{ title: containTextCondition },
|
{ title: containTextCondition },
|
||||||
|
@ -79,7 +81,7 @@ export function MainProvider({ children }: MainProviderProps) {
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
: {};
|
: {};
|
||||||
}, [searchValue]);
|
}, [debouncedValue]);
|
||||||
return (
|
return (
|
||||||
<MainContext.Provider
|
<MainContext.Provider
|
||||||
value={{
|
value={{
|
||||||
|
|
|
@ -28,8 +28,11 @@ export const NavigationMenu = () => {
|
||||||
}
|
}
|
||||||
}, [isAuthenticated]);
|
}, [isAuthenticated]);
|
||||||
|
|
||||||
const selectedKey =
|
const selectedKey = useMemo(() => {
|
||||||
menuItems.find((item) => item.path === pathname)?.key || "";
|
const normalizePath = (path: string): string => path.replace(/\/$/, "");
|
||||||
|
return pathname === '/' ? "home" : menuItems.find((item) => normalizePath(pathname) === item.path)?.key || "";
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu
|
<Menu
|
||||||
mode="horizontal"
|
mode="horizontal"
|
||||||
|
|
|
@ -4,7 +4,7 @@ import { useMainContext } from "../../layout/MainProvider";
|
||||||
import { PostType } from "@nice/common";
|
import { PostType } from "@nice/common";
|
||||||
import PathCard from "@web/src/components/models/post/SubPost/PathCard";
|
import PathCard from "@web/src/components/models/post/SubPost/PathCard";
|
||||||
|
|
||||||
export default function MyLearningListContainer() {
|
export default function MyDutyPathContainer() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { searchCondition, termsCondition } = useMainContext();
|
const { searchCondition, termsCondition } = useMainContext();
|
||||||
return (
|
return (
|
||||||
|
@ -15,11 +15,7 @@ export default function MyLearningListContainer() {
|
||||||
pageSize: 12,
|
pageSize: 12,
|
||||||
where: {
|
where: {
|
||||||
type: PostType.PATH,
|
type: PostType.PATH,
|
||||||
students: {
|
authorId: user?.id,
|
||||||
some: {
|
|
||||||
id: user?.id,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
...termsCondition,
|
...termsCondition,
|
||||||
...searchCondition,
|
...searchCondition,
|
||||||
},
|
},
|
||||||
|
|
|
@ -17,7 +17,12 @@ export default function MyPathListContainer() {
|
||||||
pageSize: 12,
|
pageSize: 12,
|
||||||
where: {
|
where: {
|
||||||
type: PostType.PATH,
|
type: PostType.PATH,
|
||||||
authorId: user.id,
|
students: {
|
||||||
|
some: {
|
||||||
|
id: user?.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
...termsCondition,
|
...termsCondition,
|
||||||
...searchCondition,
|
...searchCondition,
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,21 +1,21 @@
|
||||||
import { Tag } from "antd";
|
import { Tag } from "antd";
|
||||||
import { PostDto, TaxonomySlug } from "@nice/common";
|
import { PostDto, TaxonomySlug, TermDto } from "@nice/common";
|
||||||
|
|
||||||
const TermInfo = ({ post }: { post: PostDto }) => {
|
const TermInfo = ({ terms = [] }: { terms?: TermDto[] }) => {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{post?.terms && post?.terms?.length > 0 ? (
|
{terms && terms?.length > 0 ? (
|
||||||
<div className="flex gap-2 mb-4">
|
<div className="flex gap-2 mb-4">
|
||||||
{post?.terms?.map((term: any) => {
|
{terms?.map((term: any) => {
|
||||||
return (
|
return (
|
||||||
<Tag
|
<Tag
|
||||||
key={term.id}
|
key={term.id}
|
||||||
color={
|
color={
|
||||||
term?.taxonomy?.slug ===
|
term?.taxonomy?.slug ===
|
||||||
TaxonomySlug.CATEGORY
|
TaxonomySlug.CATEGORY
|
||||||
? "green"
|
? "green"
|
||||||
: term?.taxonomy?.slug ===
|
: term?.taxonomy?.slug ===
|
||||||
TaxonomySlug.LEVEL
|
TaxonomySlug.LEVEL
|
||||||
? "blue"
|
? "blue"
|
||||||
: "orange"
|
: "orange"
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,10 +1,12 @@
|
||||||
import MindEditor from "@web/src/components/common/editor/MindEditor";
|
import MindEditor from "@web/src/components/common/editor/MindEditor";
|
||||||
|
import { PostDetailProvider } from "@web/src/components/models/course/detail/PostDetailContext";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
|
|
||||||
export default function PathEditorPage() {
|
export default function PathEditorPage() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
|
return (
|
||||||
return <div className="">
|
<PostDetailProvider editId={id}>
|
||||||
<MindEditor id={id}></MindEditor>
|
<MindEditor id={id}></MindEditor>;
|
||||||
</div>
|
</PostDetailProvider>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,12 +3,15 @@ import BasePostLayout from "../layout/BasePost/BasePostLayout";
|
||||||
import { useMainContext } from "../layout/MainProvider";
|
import { useMainContext } from "../layout/MainProvider";
|
||||||
import PathListContainer from "./components/PathListContainer";
|
import PathListContainer from "./components/PathListContainer";
|
||||||
import { PostType } from "@nice/common";
|
import { PostType } from "@nice/common";
|
||||||
|
import { PostDetailProvider } from "@web/src/components/models/course/detail/PostDetailContext";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
|
||||||
export default function PathPage() {
|
export default function PathPage() {
|
||||||
const { setSearchMode } = useMainContext();
|
const { setSearchMode } = useMainContext();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSearchMode(PostType.PATH);
|
setSearchMode(PostType.PATH);
|
||||||
}, [setSearchMode]);
|
}, [setSearchMode]);
|
||||||
|
const { id } = useParams();
|
||||||
return (
|
return (
|
||||||
<BasePostLayout>
|
<BasePostLayout>
|
||||||
<PathListContainer></PathListContainer>
|
<PathListContainer></PathListContainer>
|
||||||
|
|
|
@ -12,7 +12,7 @@ import {
|
||||||
} from "@nice/common";
|
} from "@nice/common";
|
||||||
import TermSelect from "../../models/term/term-select";
|
import TermSelect from "../../models/term/term-select";
|
||||||
import DepartmentSelect from "../../models/department/department-select";
|
import DepartmentSelect from "../../models/department/department-select";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { MindElixirInstance } from "mind-elixir";
|
import { MindElixirInstance } from "mind-elixir";
|
||||||
import MindElixir from "mind-elixir";
|
import MindElixir from "mind-elixir";
|
||||||
|
@ -21,22 +21,35 @@ import { useNavigate } from "react-router-dom";
|
||||||
import { useAuth } from "@web/src/providers/auth-provider";
|
import { useAuth } from "@web/src/providers/auth-provider";
|
||||||
import { MIND_OPTIONS } from "./constant";
|
import { MIND_OPTIONS } from "./constant";
|
||||||
import { SaveOutlined } from "@ant-design/icons";
|
import { SaveOutlined } from "@ant-design/icons";
|
||||||
|
import JoinButton from "../../models/course/detail/CourseOperationBtns/JoinButton";
|
||||||
|
import { CourseDetailContext } from "../../models/course/detail/PostDetailContext";
|
||||||
export default function MindEditor({ id }: { id?: string }) {
|
export default function MindEditor({ id }: { id?: string }) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const {
|
||||||
|
post,
|
||||||
|
isLoading,
|
||||||
|
// userIsLearning,
|
||||||
|
// setUserIsLearning,
|
||||||
|
} = useContext(CourseDetailContext);
|
||||||
const [instance, setInstance] = useState<MindElixirInstance | null>(null);
|
const [instance, setInstance] = useState<MindElixirInstance | null>(null);
|
||||||
const { isAuthenticated, user, hasSomePermissions } = useAuth();
|
const { isAuthenticated, user, hasSomePermissions } = useAuth();
|
||||||
const { read } = useVisitor()
|
const { read } = useVisitor();
|
||||||
const { data: post, isLoading }: { data: PathDto; isLoading: boolean } =
|
// const { data: post, isLoading }: { data: PathDto; isLoading: boolean } =
|
||||||
api.post.findFirst.useQuery({
|
// api.post.findFirst.useQuery(
|
||||||
where: {
|
// {
|
||||||
id,
|
// where: {
|
||||||
},
|
// id,
|
||||||
select: postDetailSelect,
|
// },
|
||||||
}, { enabled: Boolean(id) });
|
// select: postDetailSelect,
|
||||||
|
// },
|
||||||
|
// { enabled: Boolean(id) }
|
||||||
|
// );
|
||||||
|
|
||||||
const canEdit: boolean = useMemo(() => {
|
const canEdit: boolean = useMemo(() => {
|
||||||
const isAuth = isAuthenticated && user?.id === post?.author?.id;
|
const isAuth = isAuthenticated && user?.id === post?.author?.id;
|
||||||
return !!id || isAuth || hasSomePermissions(RolePerms.MANAGE_ANY_POST);
|
return !id || isAuth || hasSomePermissions(RolePerms.MANAGE_ANY_POST);
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { create, update } = usePost();
|
const { create, update } = usePost();
|
||||||
const { data: taxonomies } = api.taxonomy.getAll.useQuery({
|
const { data: taxonomies } = api.taxonomy.getAll.useQuery({
|
||||||
|
@ -64,8 +77,8 @@ export default function MindEditor({ id }: { id?: string }) {
|
||||||
deptIds: deptIds,
|
deptIds: deptIds,
|
||||||
};
|
};
|
||||||
post.terms?.forEach((term) => {
|
post.terms?.forEach((term) => {
|
||||||
formData[term.taxonomyId] = term.id // 假设 taxonomyName是您在 Form.Item 中使用的name
|
formData[term.taxonomyId] = term.id; // 假设 taxonomyName是您在 Form.Item 中使用的name
|
||||||
})
|
});
|
||||||
form.setFieldsValue(formData);
|
form.setFieldsValue(formData);
|
||||||
}
|
}
|
||||||
}, [post, form, instance, id]);
|
}, [post, form, instance, id]);
|
||||||
|
@ -94,8 +107,8 @@ export default function MindEditor({ id }: { id?: string }) {
|
||||||
if ((!id || post) && instance) {
|
if ((!id || post) && instance) {
|
||||||
containerRef.current.hidden = false;
|
containerRef.current.hidden = false;
|
||||||
instance.toCenter();
|
instance.toCenter();
|
||||||
if (post?.meta?.nodeData) {
|
if ((post as any as PathDto)?.meta?.nodeData) {
|
||||||
instance.refresh(post?.meta);
|
instance.refresh((post as any as PathDto)?.meta);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [id, post, instance]);
|
}, [id, post, instance]);
|
||||||
|
@ -103,6 +116,7 @@ export default function MindEditor({ id }: { id?: string }) {
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!instance) return;
|
if (!instance) return;
|
||||||
const values = form.getFieldsValue();
|
const values = form.getFieldsValue();
|
||||||
|
//以图片格式导出思维导图以作为思维导图封面
|
||||||
const imgBlob = await instance?.exportPng();
|
const imgBlob = await instance?.exportPng();
|
||||||
handleFileUpload(
|
handleFileUpload(
|
||||||
imgBlob,
|
imgBlob,
|
||||||
|
@ -159,22 +173,20 @@ export default function MindEditor({ id }: { id?: string }) {
|
||||||
}
|
}
|
||||||
console.log(result);
|
console.log(result);
|
||||||
},
|
},
|
||||||
(error) => { },
|
(error) => {},
|
||||||
`mind-thumb-${new Date().toString()}`
|
`mind-thumb-${new Date().toString()}`
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
||||||
containerRef.current.style.height = `${Math.floor(window.innerHeight - 271)}px`;
|
containerRef.current.style.height = `${Math.floor(window.innerHeight - 271)}px`;
|
||||||
}, []);
|
}, []);
|
||||||
|
useEffect(()=>{
|
||||||
|
console.log(canEdit,user?.id,post?.author?.id)
|
||||||
|
})
|
||||||
return (
|
return (
|
||||||
|
|
||||||
<div className={` flex-col flex `}>
|
<div className={` flex-col flex `}>
|
||||||
{canEdit && taxonomies && (
|
{taxonomies && (
|
||||||
<Form
|
<Form form={form} className=" bg-white p-4 border-b">
|
||||||
form={form}
|
|
||||||
className=" bg-white p-4 border-b">
|
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
{taxonomies.map((tax, index) => (
|
{taxonomies.map((tax, index) => (
|
||||||
|
@ -202,15 +214,20 @@ export default function MindEditor({ id }: { id?: string }) {
|
||||||
multiple
|
multiple
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
{id ? <JoinButton></JoinButton> : <></>}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{canEdit && (
|
||||||
|
<Button
|
||||||
|
ghost
|
||||||
|
type="primary"
|
||||||
|
icon={<SaveOutlined></SaveOutlined>}
|
||||||
|
onSubmit={(e) => e.preventDefault()}
|
||||||
|
onClick={handleSave}>
|
||||||
|
{id ? "更新" : "保存"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{canEdit && <Button
|
|
||||||
ghost
|
|
||||||
type="primary"
|
|
||||||
icon={<SaveOutlined></SaveOutlined>}
|
|
||||||
onSubmit={(e) => e.preventDefault()}
|
|
||||||
onClick={handleSave}>
|
|
||||||
{id ? "更新" : "保存"}
|
|
||||||
</Button>}
|
|
||||||
</div>
|
</div>
|
||||||
</Form>
|
</Form>
|
||||||
)}
|
)}
|
||||||
|
@ -220,24 +237,20 @@ export default function MindEditor({ id }: { id?: string }) {
|
||||||
onContextMenu={(e) => e.preventDefault()}
|
onContextMenu={(e) => e.preventDefault()}
|
||||||
/>
|
/>
|
||||||
{canEdit && instance && <NodeMenu mind={instance} />}
|
{canEdit && instance && <NodeMenu mind={instance} />}
|
||||||
{
|
{isLoading && (
|
||||||
isLoading && (
|
<div
|
||||||
<div
|
className="py-64 justify-center flex"
|
||||||
className="py-64 justify-center flex"
|
style={{ height: "calc(100vh - 271px)" }}>
|
||||||
style={{ height: "calc(100vh - 271px)" }}>
|
<Spin size="large"></Spin>
|
||||||
<Spin size="large"></Spin>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)
|
{!post && id && !isLoading && (
|
||||||
}
|
<div
|
||||||
{
|
className="py-64"
|
||||||
!post && id && !isLoading && (
|
style={{ height: "calc(100vh - 271px)" }}>
|
||||||
<div
|
<Empty></Empty>
|
||||||
className="py-64"
|
</div>
|
||||||
style={{ height: "calc(100vh - 271px)" }}>
|
)}
|
||||||
<Empty></Empty>
|
</div>
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</div >
|
|
||||||
);
|
);
|
||||||
}
|
}
|
|
@ -1,190 +1,273 @@
|
||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef, useMemo } from "react";
|
||||||
import { Input, Button, ColorPicker, Select } from 'antd';
|
import { Input, Button, ColorPicker, Select } from "antd";
|
||||||
import {
|
import {
|
||||||
FontSizeOutlined,
|
FontSizeOutlined,
|
||||||
BoldOutlined,
|
BoldOutlined,
|
||||||
LinkOutlined,
|
LinkOutlined,
|
||||||
} from '@ant-design/icons';
|
GlobalOutlined,
|
||||||
import type { MindElixirInstance, NodeObj } from 'mind-elixir';
|
SwapOutlined,
|
||||||
|
} from "@ant-design/icons";
|
||||||
const xmindColorPresets = [
|
import type { MindElixirInstance, NodeObj } from "mind-elixir";
|
||||||
// 经典16色
|
import PostSelect from "../../models/post/PostSelect/PostSelect";
|
||||||
'#FFFFFF', '#F5F5F5', // 白色系
|
import { Lecture, PostType } from "@nice/common";
|
||||||
'#2196F3', '#1976D2', // 蓝色系
|
import { xmindColorPresets } from "./constant";
|
||||||
'#4CAF50', '#388E3C', // 绿色系
|
import { api } from "@nice/client";
|
||||||
'#FF9800', '#F57C00', // 橙色系
|
import { env } from "@web/src/env";
|
||||||
'#F44336', '#D32F2F', // 红色系
|
|
||||||
'#9C27B0', '#7B1FA2', // 紫色系
|
|
||||||
'#424242', '#757575', // 灰色系
|
|
||||||
'#FFEB3B', '#FBC02D' // 黄色系
|
|
||||||
];
|
|
||||||
|
|
||||||
interface NodeMenuProps {
|
interface NodeMenuProps {
|
||||||
mind: MindElixirInstance;
|
mind: MindElixirInstance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//管理节点样式状态
|
||||||
const NodeMenu: React.FC<NodeMenuProps> = ({ mind }) => {
|
const NodeMenu: React.FC<NodeMenuProps> = ({ mind }) => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [selectedFontColor, setSelectedFontColor] = useState<string>('');
|
const [selectedFontColor, setSelectedFontColor] = useState<string>("");
|
||||||
const [selectedBgColor, setSelectedBgColor] = useState<string>('');
|
const [selectedBgColor, setSelectedBgColor] = useState<string>("");
|
||||||
const [selectedSize, setSelectedSize] = useState<string>('');
|
const [selectedSize, setSelectedSize] = useState<string>("");
|
||||||
const [isBold, setIsBold] = useState(false);
|
const [isBold, setIsBold] = useState(false);
|
||||||
const [url, setUrl] = useState<string>('');
|
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const [urlMode, setUrlMode] = useState<"URL" | "POSTURL">("POSTURL");
|
||||||
const handleSelectNode = (nodeObj: NodeObj) => {
|
const [url, setUrl] = useState<string>("");
|
||||||
setIsOpen(true);
|
const [postId, setPostId] = useState<string>("");
|
||||||
const style = nodeObj.style || {};
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
setSelectedFontColor(style.color || '');
|
const { data: lecture, isLoading }: { data: Lecture; isLoading: boolean } =
|
||||||
setSelectedBgColor(style.background || '');
|
api.post.findFirst.useQuery(
|
||||||
setSelectedSize(style.fontSize || '24');
|
{
|
||||||
setIsBold(style.fontWeight === 'bold');
|
where: { id: postId },
|
||||||
setUrl(nodeObj.hyperLink || '');
|
},
|
||||||
};
|
{ enabled: !!postId }
|
||||||
const handleUnselectNode = () => {
|
);
|
||||||
setIsOpen(false);
|
useEffect(() => {
|
||||||
};
|
{
|
||||||
mind.bus.addListener('selectNode', handleSelectNode);
|
if(lecture?.courseId && lecture?.id){
|
||||||
mind.bus.addListener('unselectNode', handleUnselectNode);
|
if (urlMode === "POSTURL"){
|
||||||
}, [mind]);
|
setUrl(`/course/${lecture?.courseId}/detail/${lecture?.id}`);
|
||||||
|
}
|
||||||
|
mind.reshapeNode(mind.currentNode, {
|
||||||
|
hyperLink: `/course/${lecture?.courseId}/detail/${lecture?.id}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [postId, lecture, isLoading, urlMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
//监听思维导图节点选择事件,更新节点菜单状态
|
||||||
if (containerRef.current && mind.container) {
|
useEffect(() => {
|
||||||
mind.container.appendChild(containerRef.current);
|
const handleSelectNode = (nodeObj: NodeObj) => {
|
||||||
}
|
setIsOpen(true);
|
||||||
|
const style = nodeObj.style || {};
|
||||||
|
setSelectedFontColor(style.color || "");
|
||||||
|
setSelectedBgColor(style.background || "");
|
||||||
|
setSelectedSize(style.fontSize || "24");
|
||||||
|
setIsBold(style.fontWeight === "bold");
|
||||||
|
setUrl(nodeObj.hyperLink || "");
|
||||||
|
};
|
||||||
|
const handleUnselectNode = () => {
|
||||||
|
setIsOpen(false);
|
||||||
|
};
|
||||||
|
mind.bus.addListener("selectNode", handleSelectNode);
|
||||||
|
mind.bus.addListener("unselectNode", handleUnselectNode);
|
||||||
|
}, [mind]);
|
||||||
|
|
||||||
}, [mind.container]);
|
useEffect(() => {
|
||||||
|
const handleSelectNode = (nodeObj: NodeObj) => {
|
||||||
|
setIsOpen(true);
|
||||||
|
const style = nodeObj.style || {};
|
||||||
|
setSelectedFontColor(style.color || "");
|
||||||
|
setSelectedBgColor(style.background || "");
|
||||||
|
setSelectedSize(style.fontSize || "24");
|
||||||
|
setIsBold(style.fontWeight === "bold");
|
||||||
|
setUrl(nodeObj.hyperLink || "");
|
||||||
|
};
|
||||||
|
const handleUnselectNode = () => {
|
||||||
|
setIsOpen(false);
|
||||||
|
};
|
||||||
|
mind.bus.addListener("selectNode", handleSelectNode);
|
||||||
|
mind.bus.addListener("unselectNode", handleUnselectNode);
|
||||||
|
}, [mind]);
|
||||||
|
|
||||||
const handleColorChange = (type: "font" | "background", color: string) => {
|
useEffect(() => {
|
||||||
if (type === 'font') {
|
if (containerRef.current && mind.container) {
|
||||||
setSelectedFontColor(color);
|
mind.container.appendChild(containerRef.current);
|
||||||
} else {
|
}
|
||||||
setSelectedBgColor(color);
|
}, [mind.container]);
|
||||||
}
|
|
||||||
const patch = { style: {} as any };
|
|
||||||
if (type === 'font') {
|
|
||||||
patch.style.color = color;
|
|
||||||
} else {
|
|
||||||
patch.style.background = color;
|
|
||||||
}
|
|
||||||
mind.reshapeNode(mind.currentNode, patch);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSizeChange = (size: string) => {
|
const handleColorChange = (type: "font" | "background", color: string) => {
|
||||||
setSelectedSize(size);
|
if (type === "font") {
|
||||||
mind.reshapeNode(mind.currentNode, { style: { fontSize: size } });
|
setSelectedFontColor(color);
|
||||||
};
|
} else {
|
||||||
|
setSelectedBgColor(color);
|
||||||
|
}
|
||||||
|
const patch = { style: {} as any };
|
||||||
|
if (type === "font") {
|
||||||
|
patch.style.color = color;
|
||||||
|
} else {
|
||||||
|
patch.style.background = color;
|
||||||
|
}
|
||||||
|
mind.reshapeNode(mind.currentNode, patch);
|
||||||
|
};
|
||||||
|
|
||||||
const handleBoldToggle = () => {
|
const handleSizeChange = (size: string) => {
|
||||||
const fontWeight = isBold ? '' : 'bold';
|
setSelectedSize(size);
|
||||||
setIsBold(!isBold);
|
mind.reshapeNode(mind.currentNode, { style: { fontSize: size } });
|
||||||
mind.reshapeNode(mind.currentNode, { style: { fontWeight } });
|
};
|
||||||
};
|
|
||||||
|
|
||||||
const handleUrlChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleBoldToggle = () => {
|
||||||
const value = e.target.value;
|
const fontWeight = isBold ? "" : "bold";
|
||||||
setUrl(value);
|
setIsBold(!isBold);
|
||||||
mind.reshapeNode(mind.currentNode, { hyperLink: value });
|
mind.reshapeNode(mind.currentNode, { style: { fontWeight } });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const handleUrlChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
<div
|
const value = e.target.value;
|
||||||
className={`node-menu-container absolute right-2 top-2 rounded-lg bg-slate-200 shadow-xl ring-2 ring-white transition-all duration-300 ${isOpen ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4 pointer-events-none'
|
setUrl(value);
|
||||||
}`}
|
mind.reshapeNode(mind.currentNode, {
|
||||||
ref={containerRef}
|
hyperLink: value,
|
||||||
>
|
});
|
||||||
<div className="p-5 space-y-6">
|
};
|
||||||
{/* Font Size Selector */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h3 className="text-sm font-medium text-gray-600">文字样式</h3>
|
|
||||||
<div className="flex gap-3 items-center justify-between">
|
|
||||||
<Select
|
|
||||||
value={selectedSize}
|
|
||||||
onChange={handleSizeChange}
|
|
||||||
prefix={<FontSizeOutlined className='mr-2' />}
|
|
||||||
className="w-1/2"
|
|
||||||
options={[
|
|
||||||
{ value: '12', label: '12' },
|
|
||||||
{ value: '14', label: '14' },
|
|
||||||
{ value: '16', label: '16' },
|
|
||||||
{ value: '18', label: '18' },
|
|
||||||
{ value: '20', label: '20' },
|
|
||||||
{ value: '24', label: '24' },
|
|
||||||
{ value: '28', label: '28' },
|
|
||||||
{ value: '32', label: '32' }
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type={isBold ? "primary" : "default"}
|
|
||||||
onClick={handleBoldToggle}
|
|
||||||
className='w-1/2'
|
|
||||||
icon={<BoldOutlined />}
|
|
||||||
>
|
|
||||||
|
|
||||||
加粗
|
return (
|
||||||
</Button>
|
<div
|
||||||
</div>
|
className={`node-menu-container absolute right-2 top-2 rounded-lg bg-slate-200 shadow-xl ring-2 ring-white transition-all duration-300 ${
|
||||||
</div>
|
isOpen
|
||||||
|
? "opacity-100 translate-y-0"
|
||||||
|
: "opacity-0 translate-y-4 pointer-events-none"
|
||||||
|
}`}
|
||||||
|
ref={containerRef}>
|
||||||
|
<div className="p-5 space-y-6">
|
||||||
|
{/* Font Size Selector */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-sm font-medium text-gray-600">
|
||||||
|
文字样式
|
||||||
|
</h3>
|
||||||
|
<div className="flex gap-3 items-center justify-between">
|
||||||
|
<Select
|
||||||
|
value={selectedSize}
|
||||||
|
onChange={handleSizeChange}
|
||||||
|
prefix={<FontSizeOutlined className="mr-2" />}
|
||||||
|
className="w-1/2"
|
||||||
|
options={[
|
||||||
|
{ value: "12", label: "12" },
|
||||||
|
{ value: "14", label: "14" },
|
||||||
|
{ value: "16", label: "16" },
|
||||||
|
{ value: "18", label: "18" },
|
||||||
|
{ value: "20", label: "20" },
|
||||||
|
{ value: "24", label: "24" },
|
||||||
|
{ value: "28", label: "28" },
|
||||||
|
{ value: "32", label: "32" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type={isBold ? "primary" : "default"}
|
||||||
|
onClick={handleBoldToggle}
|
||||||
|
className="w-1/2"
|
||||||
|
icon={<BoldOutlined />}>
|
||||||
|
加粗
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Color Picker */}
|
{/* Color Picker */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h3 className="text-sm font-medium text-gray-600">颜色设置</h3>
|
<h3 className="text-sm font-medium text-gray-600">
|
||||||
|
颜色设置
|
||||||
|
</h3>
|
||||||
|
|
||||||
{/* Font Color Picker */}
|
{/* Font Color Picker */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h4 className="text-xs font-medium text-gray-500">文字颜色</h4>
|
<h4 className="text-xs font-medium text-gray-500">
|
||||||
<div className="grid grid-cols-8 gap-2 p-2 bg-gray-50 rounded-lg">
|
文字颜色
|
||||||
{xmindColorPresets.map((color) => (
|
</h4>
|
||||||
<div
|
<div className="grid grid-cols-8 gap-2 p-2 bg-gray-50 rounded-lg">
|
||||||
key={`font-${color}`}
|
{xmindColorPresets.map((color) => (
|
||||||
className={`w-6 h-6 rounded-full cursor-pointer hover:scale-105 transition-transform outline outline-2 outline-offset-1 ${selectedFontColor === color ? 'outline-blue-500' : 'outline-transparent'
|
<div
|
||||||
}`}
|
key={`font-${color}`}
|
||||||
style={{ backgroundColor: color }}
|
className={`w-6 h-6 rounded-full cursor-pointer hover:scale-105 transition-transform outline outline-2 outline-offset-1 ${
|
||||||
onClick={() => {
|
selectedFontColor === color
|
||||||
|
? "outline-blue-500"
|
||||||
|
: "outline-transparent"
|
||||||
|
}`}
|
||||||
|
style={{ backgroundColor: color }}
|
||||||
|
onClick={() => {
|
||||||
|
handleColorChange("font", color);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
handleColorChange('font', color);
|
{/* Background Color Picker */}
|
||||||
}}
|
<div className="space-y-2">
|
||||||
/>
|
<h4 className="text-xs font-medium text-gray-500">
|
||||||
))}
|
背景颜色
|
||||||
</div>
|
</h4>
|
||||||
</div>
|
<div className="grid grid-cols-8 gap-2 p-2 bg-gray-50 rounded-lg">
|
||||||
|
{xmindColorPresets.map((color) => (
|
||||||
|
<div
|
||||||
|
key={`bg-${color}`}
|
||||||
|
className={`w-6 h-6 rounded-full cursor-pointer hover:scale-105 transition-transform outline outline-2 outline-offset-1 ${
|
||||||
|
selectedBgColor === color
|
||||||
|
? "outline-blue-500"
|
||||||
|
: "outline-transparent"
|
||||||
|
}`}
|
||||||
|
style={{ backgroundColor: color }}
|
||||||
|
onClick={() => {
|
||||||
|
handleColorChange("background", color);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Background Color Picker */}
|
<div className="text-sm font-medium text-gray-600 flex items-center gap-2">
|
||||||
<div className="space-y-2">
|
{urlMode === "URL" ? "关联链接" : "关联课时"}
|
||||||
<h4 className="text-xs font-medium text-gray-500">背景颜色</h4>
|
<Button
|
||||||
<div className="grid grid-cols-8 gap-2 p-2 bg-gray-50 rounded-lg">
|
type="text"
|
||||||
{xmindColorPresets.map((color) => (
|
className=" hover:bg-gray-400 active:bg-gray-300 rounded-md text-gray-600 border transition-colors"
|
||||||
<div
|
size="small"
|
||||||
key={`bg-${color}`}
|
icon={<SwapOutlined />}
|
||||||
className={`w-6 h-6 rounded-full cursor-pointer hover:scale-105 transition-transform outline outline-2 outline-offset-1 ${selectedBgColor === color ? 'outline-blue-500' : 'outline-transparent'
|
onClick={() =>
|
||||||
}`}
|
setUrlMode((prev) =>
|
||||||
style={{ backgroundColor: color }}
|
prev === "POSTURL" ? "URL" : "POSTURL"
|
||||||
onClick={() => {
|
)
|
||||||
handleColorChange('background', color);
|
}
|
||||||
}}
|
/>
|
||||||
/>
|
</div>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3 className="text-sm font-medium text-gray-600">关联链接</h3>
|
<div className="space-y-1">
|
||||||
{/* URL Input */}
|
{urlMode === "POSTURL" ? (
|
||||||
<div className="space-y-1">
|
<PostSelect
|
||||||
<Input
|
onChange={(value) => {
|
||||||
placeholder="例如:https://example.com"
|
if (typeof value === "string") {
|
||||||
value={url}
|
setPostId(value);
|
||||||
onChange={handleUrlChange}
|
}
|
||||||
addonBefore={<LinkOutlined />}
|
}}
|
||||||
/>
|
params={{
|
||||||
{url && !/^https?:\/\/\S+$/.test(url) && (
|
where: {
|
||||||
<p className="text-xs text-red-500">请输入有效的URL地址</p>
|
type: PostType.LECTURE,
|
||||||
)}
|
},
|
||||||
</div>
|
}}
|
||||||
</div>
|
/>
|
||||||
</div>
|
) : (
|
||||||
);
|
<Input
|
||||||
|
placeholder="例如:https://example.com"
|
||||||
|
value={url}
|
||||||
|
onChange={handleUrlChange}
|
||||||
|
addonBefore={<LinkOutlined />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{urlMode === "URL" &&
|
||||||
|
url &&
|
||||||
|
!/^(https?:\/\/\S+|\/|\.\/|\.\.\/)?\S+$/.test(url) && (
|
||||||
|
<p className="text-xs text-red-500">
|
||||||
|
请输入有效的URL地址
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default NodeMenu;
|
export default NodeMenu;
|
||||||
|
|
|
@ -32,3 +32,23 @@ export const MIND_OPTIONS = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const xmindColorPresets = [
|
||||||
|
// 经典16色
|
||||||
|
"#FFFFFF",
|
||||||
|
"#F5F5F5", // 白色系
|
||||||
|
"#2196F3",
|
||||||
|
"#1976D2", // 蓝色系
|
||||||
|
"#4CAF50",
|
||||||
|
"#388E3C", // 绿色系
|
||||||
|
"#FF9800",
|
||||||
|
"#F57C00", // 橙色系
|
||||||
|
"#F44336",
|
||||||
|
"#D32F2F", // 红色系
|
||||||
|
"#9C27B0",
|
||||||
|
"#7B1FA2", // 紫色系
|
||||||
|
"#424242",
|
||||||
|
"#757575", // 灰色系
|
||||||
|
"#FFEB3B",
|
||||||
|
"#FBC02D", // 黄色系
|
||||||
|
];
|
||||||
|
|
|
@ -1,105 +0,0 @@
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
|
||||||
import { addEdge, ReactFlow, Background, Controls, Edge, Node, ReactFlowProvider, useEdgesState, useNodesState, MiniMap, Panel, BackgroundVariant, ControlButton, applyNodeChanges, applyEdgeChanges, SelectionMode, OnNodesChange, OnEdgesChange, useReactFlow, useOnSelectionChange, useNodesInitialized } from '@xyflow/react';
|
|
||||||
import { Button } from '../../element/Button';
|
|
||||||
import '@xyflow/react/dist/style.css';
|
|
||||||
import { edgeTypes, GraphState, nodeTypes } from './types';
|
|
||||||
import useGraphStore from './store';
|
|
||||||
import { shallow } from 'zustand/shallow';
|
|
||||||
import { useKeyboardCtrl } from './useKeyboardCtrl';
|
|
||||||
import { getMindMapLayout } from './layout';
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const selector = (store: GraphState) => ({
|
|
||||||
nodes: store.present.nodes,
|
|
||||||
edges: store.present.edges,
|
|
||||||
setNodes: store.setNodes,
|
|
||||||
setEdges: store.setEdges,
|
|
||||||
record: store.record,
|
|
||||||
onNodesChange: store.onNodesChange,
|
|
||||||
onEdgesChange: store.onEdgesChange,
|
|
||||||
});
|
|
||||||
|
|
||||||
const panOnDrag = [1, 2];
|
|
||||||
|
|
||||||
const Flow: React.FC = () => {
|
|
||||||
|
|
||||||
const store = useGraphStore(selector, shallow);
|
|
||||||
useKeyboardCtrl()
|
|
||||||
const nodesInitialized = useNodesInitialized();
|
|
||||||
const onLayout = useCallback(async () => {
|
|
||||||
const layouted = getMindMapLayout({ nodes: store.nodes, edges: store.edges })
|
|
||||||
store.setNodes(layouted.nodes)
|
|
||||||
store.setEdges(layouted.edges)
|
|
||||||
}, [store.nodes, store.edges]);
|
|
||||||
useEffect(() => {
|
|
||||||
if (nodesInitialized && store.nodes.length) {
|
|
||||||
console.log('layout')
|
|
||||||
onLayout()
|
|
||||||
}
|
|
||||||
}, [nodesInitialized, store.nodes.length]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ReactFlow
|
|
||||||
nodesDraggable={true}
|
|
||||||
nodes={store.nodes}
|
|
||||||
edges={store.edges}
|
|
||||||
|
|
||||||
onNodesChange={(changes) => {
|
|
||||||
const recordTypes = new Set(['remove', 'select']);
|
|
||||||
const undoChanges = changes.filter(change => recordTypes.has(change.type))
|
|
||||||
const otherChanges = changes.filter(change => !recordTypes.has(change.type))
|
|
||||||
if (undoChanges.length)
|
|
||||||
store.record(() => {
|
|
||||||
store.onNodesChange(undoChanges);
|
|
||||||
});
|
|
||||||
store.onNodesChange(otherChanges);
|
|
||||||
}}
|
|
||||||
onEdgesChange={(changes) => {
|
|
||||||
const recordTypes = new Set(['remove', 'select']);
|
|
||||||
changes.forEach((change) => {
|
|
||||||
if (recordTypes.has(change.type)) {
|
|
||||||
store.record(() => {
|
|
||||||
store.onEdgesChange([change]);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
store.onEdgesChange([change]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
selectionOnDrag
|
|
||||||
panOnDrag={panOnDrag}
|
|
||||||
nodeTypes={nodeTypes}
|
|
||||||
edgeTypes={edgeTypes}
|
|
||||||
selectionMode={SelectionMode.Partial}
|
|
||||||
fitView
|
|
||||||
minZoom={0.001}
|
|
||||||
maxZoom={1000}
|
|
||||||
>
|
|
||||||
<Panel position="top-right">
|
|
||||||
<div className='flex items-center gap-4'>
|
|
||||||
<Button onClick={onLayout}>自动布局</Button>
|
|
||||||
<span>节点个数{store.nodes.length}</span>
|
|
||||||
<span>边条数{store.edges.length}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</Panel>
|
|
||||||
<Background variant={BackgroundVariant.Dots} />
|
|
||||||
<Controls >
|
|
||||||
<ControlButton>测试</ControlButton>
|
|
||||||
</Controls>
|
|
||||||
<MiniMap pannable zoomable nodeStrokeWidth={3} position='bottom-right'></MiniMap>
|
|
||||||
</ReactFlow>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const GraphEditor: React.FC = () => {
|
|
||||||
return (
|
|
||||||
<ReactFlowProvider>
|
|
||||||
<Flow></Flow>
|
|
||||||
</ReactFlowProvider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default GraphEditor;
|
|
|
@ -1,57 +0,0 @@
|
||||||
import { MarkerType } from "@xyflow/react";
|
|
||||||
|
|
||||||
// 生成思维导图数据的函数
|
|
||||||
function generateMindMapData(levels: number, nodesPerLevel: number) {
|
|
||||||
const nodes = [];
|
|
||||||
const edges = [];
|
|
||||||
|
|
||||||
// 添加根节点
|
|
||||||
nodes.push({
|
|
||||||
id: 'root',
|
|
||||||
data: { label: '核心主题', level: 0 },
|
|
||||||
type: 'graph-node',
|
|
||||||
position: { x: 0, y: 0 }
|
|
||||||
});
|
|
||||||
|
|
||||||
// 为每一层生成节点
|
|
||||||
for (let level = 1; level <= levels; level++) {
|
|
||||||
const angleStep = (2 * Math.PI) / nodesPerLevel;
|
|
||||||
const radius = level * 200; // 每层的半径
|
|
||||||
|
|
||||||
for (let i = 0; i < nodesPerLevel; i++) {
|
|
||||||
const angle = i * angleStep;
|
|
||||||
const nodeId = `node-${level}-${i}`;
|
|
||||||
|
|
||||||
// 计算节点位置
|
|
||||||
const x = Math.cos(angle) * radius;
|
|
||||||
const y = Math.sin(angle) * radius;
|
|
||||||
|
|
||||||
// 添加节点
|
|
||||||
nodes.push({
|
|
||||||
id: nodeId,
|
|
||||||
data: { label: `主题${level}-${i}`, level },
|
|
||||||
type: 'graph-node',
|
|
||||||
position: { x, y }
|
|
||||||
});
|
|
||||||
|
|
||||||
// 添加边
|
|
||||||
// 第一层连接到根节点,其他层连接到上一层的节点
|
|
||||||
const sourceId = level === 1 ? 'root' : `node-${level - 1}-${Math.floor(i / 2)}`;
|
|
||||||
edges.push({
|
|
||||||
id: `edge-${level}-${i}`,
|
|
||||||
source: sourceId,
|
|
||||||
target: nodeId,
|
|
||||||
type: 'graph-edge',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { nodes, edges };
|
|
||||||
}
|
|
||||||
|
|
||||||
// 生成测试数据 - 可以调整参数来控制规模
|
|
||||||
// 参数1: 层级数量
|
|
||||||
// 参数2: 每层节点数量
|
|
||||||
const { nodes: initialNodes, edges: initialEdges } = generateMindMapData(2, 3);
|
|
||||||
|
|
||||||
export { initialNodes, initialEdges };
|
|
|
@ -1,83 +0,0 @@
|
||||||
import { BaseEdge, Node, Edge, EdgeLabelRenderer, EdgeProps, getBezierPath, getSmoothStepPath, getStraightPath, Position, useReactFlow, useInternalNode, InternalNode } from '@xyflow/react';
|
|
||||||
|
|
||||||
export type GraphEdge = Edge<{ text: string }, 'graph-edge'>;
|
|
||||||
|
|
||||||
function getEdgeParams(sourceNode: InternalNode, targetNode: InternalNode) {
|
|
||||||
console.log(sourceNode)
|
|
||||||
const sourceCenter = {
|
|
||||||
x: sourceNode.position.x + sourceNode.width / 2,
|
|
||||||
y: sourceNode.position.y + sourceNode.height / 2,
|
|
||||||
};
|
|
||||||
const targetCenter = {
|
|
||||||
x: targetNode.position.x + targetNode.width / 2,
|
|
||||||
y: targetNode.position.y + targetNode.height / 2,
|
|
||||||
};
|
|
||||||
|
|
||||||
const dx = targetCenter.x - sourceCenter.x;
|
|
||||||
|
|
||||||
// 简化连接逻辑:只基于x轴方向判断
|
|
||||||
let sourcePos: Position;
|
|
||||||
let targetPos: Position;
|
|
||||||
|
|
||||||
// 如果目标在源节点右边,源节点用右侧连接点,目标节点用左侧连接点
|
|
||||||
if (dx > 0) {
|
|
||||||
sourcePos = Position.Right;
|
|
||||||
targetPos = Position.Left;
|
|
||||||
} else {
|
|
||||||
// 如果目标在源节点左边,源节点用左侧连接点,目标节点用右侧连接点
|
|
||||||
sourcePos = Position.Left;
|
|
||||||
targetPos = Position.Right;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 使用节点中心的y坐标
|
|
||||||
return {
|
|
||||||
sourcePos,
|
|
||||||
targetPos,
|
|
||||||
sx: sourceCenter.x + sourceNode.measured.width / 2,
|
|
||||||
sy: sourceCenter.y,
|
|
||||||
tx: targetCenter.x - targetNode.measured.width / 2,
|
|
||||||
ty: targetCenter.y,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
export const GraphEdge = ({ id, source, target, sourceX, sourceY, targetX, targetY, data, ...props }: EdgeProps<GraphEdge>) => {
|
|
||||||
const sourceNode = useInternalNode(source);
|
|
||||||
const targetNode = useInternalNode(target);
|
|
||||||
const { sx, sy, tx, ty, targetPos, sourcePos } = getEdgeParams(sourceNode, targetNode)
|
|
||||||
const [edgePath, labelX, labelY] = getBezierPath({
|
|
||||||
sourceX: sx,
|
|
||||||
sourceY: sy,
|
|
||||||
targetX: tx,
|
|
||||||
targetY: ty,
|
|
||||||
sourcePosition: sourcePos,
|
|
||||||
targetPosition: targetPos,
|
|
||||||
curvature: 0.3,
|
|
||||||
|
|
||||||
});
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<BaseEdge
|
|
||||||
path={edgePath}
|
|
||||||
style={{
|
|
||||||
strokeWidth: 2,
|
|
||||||
stroke: '#b1b1b7',
|
|
||||||
transition: 'stroke 0.3s, stroke-width 0.3s',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<EdgeLabelRenderer>
|
|
||||||
{data?.text && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
|
|
||||||
fontSize: 12,
|
|
||||||
pointerEvents: 'all',
|
|
||||||
}}
|
|
||||||
className="nodrag nopan px-2 py-1 rounded bg-white/80 shadow-sm"
|
|
||||||
>
|
|
||||||
{data.text}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</EdgeLabelRenderer>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,219 +0,0 @@
|
||||||
import { areLinesReverseDirection, areLinesSameDirection } from "../edge";
|
|
||||||
import {
|
|
||||||
ControlPoint,
|
|
||||||
NodeRect,
|
|
||||||
isEqualPoint,
|
|
||||||
isSegmentCrossingRect,
|
|
||||||
} from "../point";
|
|
||||||
|
|
||||||
interface GetAStarPathParams {
|
|
||||||
/**
|
|
||||||
* Collection of potential control points between `sourceOffset` and `targetOffset`, excluding the `source` and `target` points.
|
|
||||||
*/
|
|
||||||
points: ControlPoint[];
|
|
||||||
source: ControlPoint;
|
|
||||||
target: ControlPoint;
|
|
||||||
/**
|
|
||||||
* Node size information for the `source` and `target`, used to optimize edge routing without intersecting nodes.
|
|
||||||
*/
|
|
||||||
sourceRect: NodeRect;
|
|
||||||
targetRect: NodeRect;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Utilizes the [A\* search algorithm](https://en.wikipedia.org/wiki/A*_search_algorithm) combined with
|
|
||||||
* [Manhattan Distance](https://simple.wikipedia.org/wiki/Manhattan_distance) to find the optimal path for edges.
|
|
||||||
*
|
|
||||||
* @returns Control points including sourceOffset and targetOffset (not including source and target points).
|
|
||||||
*/
|
|
||||||
export const getAStarPath = ({
|
|
||||||
points,
|
|
||||||
source,
|
|
||||||
target,
|
|
||||||
sourceRect,
|
|
||||||
targetRect,
|
|
||||||
}: GetAStarPathParams): ControlPoint[] => {
|
|
||||||
if (points.length < 3) {
|
|
||||||
return points;
|
|
||||||
}
|
|
||||||
const start = points[0];
|
|
||||||
const end = points[points.length - 1];
|
|
||||||
const openSet: ControlPoint[] = [start];
|
|
||||||
const closedSet: Set<ControlPoint> = new Set();
|
|
||||||
const cameFrom: Map<ControlPoint, ControlPoint> = new Map();
|
|
||||||
const gScore: Map<ControlPoint, number> = new Map().set(start, 0);
|
|
||||||
const fScore: Map<ControlPoint, number> = new Map().set(
|
|
||||||
start,
|
|
||||||
heuristicCostEstimate({
|
|
||||||
from: start,
|
|
||||||
to: start,
|
|
||||||
start,
|
|
||||||
end,
|
|
||||||
source,
|
|
||||||
target,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
while (openSet.length) {
|
|
||||||
let current;
|
|
||||||
let currentIdx;
|
|
||||||
let lowestFScore = Infinity;
|
|
||||||
openSet.forEach((p, idx) => {
|
|
||||||
const score = fScore.get(p) ?? 0;
|
|
||||||
if (score < lowestFScore) {
|
|
||||||
lowestFScore = score;
|
|
||||||
current = p;
|
|
||||||
currentIdx = idx;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!current) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (current === end) {
|
|
||||||
return buildPath(cameFrom, current);
|
|
||||||
}
|
|
||||||
|
|
||||||
openSet.splice(currentIdx!, 1);
|
|
||||||
closedSet.add(current);
|
|
||||||
|
|
||||||
const curFScore = fScore.get(current) ?? 0;
|
|
||||||
const previous = cameFrom.get(current);
|
|
||||||
const neighbors = getNextNeighborPoints({
|
|
||||||
points,
|
|
||||||
previous,
|
|
||||||
current,
|
|
||||||
sourceRect,
|
|
||||||
targetRect,
|
|
||||||
});
|
|
||||||
for (const neighbor of neighbors) {
|
|
||||||
if (closedSet.has(neighbor)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const neighborGScore = gScore.get(neighbor) ?? 0;
|
|
||||||
const tentativeGScore = curFScore + estimateDistance(current, neighbor);
|
|
||||||
if (openSet.includes(neighbor) && tentativeGScore >= neighborGScore) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
openSet.push(neighbor);
|
|
||||||
cameFrom.set(neighbor, current);
|
|
||||||
gScore.set(neighbor, tentativeGScore);
|
|
||||||
fScore.set(
|
|
||||||
neighbor,
|
|
||||||
neighborGScore +
|
|
||||||
heuristicCostEstimate({
|
|
||||||
from: current,
|
|
||||||
to: neighbor,
|
|
||||||
start,
|
|
||||||
end,
|
|
||||||
source,
|
|
||||||
target,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return [start, end];
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildPath = (
|
|
||||||
cameFrom: Map<ControlPoint, ControlPoint>,
|
|
||||||
current: ControlPoint
|
|
||||||
): ControlPoint[] => {
|
|
||||||
const path = [current];
|
|
||||||
|
|
||||||
let previous = cameFrom.get(current);
|
|
||||||
while (previous) {
|
|
||||||
path.push(previous);
|
|
||||||
previous = cameFrom.get(previous);
|
|
||||||
}
|
|
||||||
|
|
||||||
return path.reverse();
|
|
||||||
};
|
|
||||||
|
|
||||||
interface GetNextNeighborPointsParams {
|
|
||||||
points: ControlPoint[];
|
|
||||||
previous?: ControlPoint;
|
|
||||||
current: ControlPoint;
|
|
||||||
sourceRect: NodeRect;
|
|
||||||
targetRect: NodeRect;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the set of possible neighboring points for the current control point
|
|
||||||
*
|
|
||||||
* - The line is in a horizontal or vertical direction
|
|
||||||
* - The line does not intersect with the two end nodes
|
|
||||||
* - The line does not overlap with the previous line segment in reverse direction
|
|
||||||
*/
|
|
||||||
export const getNextNeighborPoints = ({
|
|
||||||
points,
|
|
||||||
previous,
|
|
||||||
current,
|
|
||||||
sourceRect,
|
|
||||||
targetRect,
|
|
||||||
}: GetNextNeighborPointsParams): ControlPoint[] => {
|
|
||||||
return points.filter((p) => {
|
|
||||||
if (p === current) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// The connection is in the horizontal or vertical direction
|
|
||||||
const rightDirection = p.x === current.x || p.y === current.y;
|
|
||||||
// Reverse direction with the previous line segment (overlap)
|
|
||||||
const reverseDirection = previous
|
|
||||||
? areLinesReverseDirection(previous, current, current, p)
|
|
||||||
: false;
|
|
||||||
return (
|
|
||||||
rightDirection && // The line is in a horizontal or vertical direction
|
|
||||||
!reverseDirection && // The line does not overlap with the previous line segment in reverse direction
|
|
||||||
!isSegmentCrossingRect(p, current, sourceRect) && // Does not intersect with sourceNode
|
|
||||||
!isSegmentCrossingRect(p, current, targetRect) // Does not intersect with targetNode
|
|
||||||
);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
interface HeuristicCostParams {
|
|
||||||
from: ControlPoint;
|
|
||||||
to: ControlPoint;
|
|
||||||
start: ControlPoint;
|
|
||||||
end: ControlPoint;
|
|
||||||
source: ControlPoint;
|
|
||||||
target: ControlPoint;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Connection point distance loss function
|
|
||||||
*
|
|
||||||
* - The smaller the sum of distances, the better
|
|
||||||
* - The closer the start and end line segments are in direction, the better
|
|
||||||
* - The better the inflection point is symmetric or centered in the line segment
|
|
||||||
*/
|
|
||||||
const heuristicCostEstimate = ({
|
|
||||||
from,
|
|
||||||
to,
|
|
||||||
start,
|
|
||||||
end,
|
|
||||||
source,
|
|
||||||
target,
|
|
||||||
}: HeuristicCostParams): number => {
|
|
||||||
const base = estimateDistance(to, start) + estimateDistance(to, end);
|
|
||||||
const startCost = isEqualPoint(from, start)
|
|
||||||
? areLinesSameDirection(from, to, source, start)
|
|
||||||
? -base / 2
|
|
||||||
: 0
|
|
||||||
: 0;
|
|
||||||
const endCost = isEqualPoint(to, end)
|
|
||||||
? areLinesSameDirection(from, to, end, target)
|
|
||||||
? -base / 2
|
|
||||||
: 0
|
|
||||||
: 0;
|
|
||||||
return base + startCost + endCost;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate the estimated distance between two points
|
|
||||||
*
|
|
||||||
* Manhattan distance: the sum of horizontal and vertical distances, faster calculation speed
|
|
||||||
*/
|
|
||||||
const estimateDistance = (p1: ControlPoint, p2: ControlPoint): number =>
|
|
||||||
Math.abs(p1.x - p2.x) + Math.abs(p1.y - p2.y);
|
|
|
@ -1,127 +0,0 @@
|
||||||
import { areLinesSameDirection, isHorizontalFromPosition } from "../edge";
|
|
||||||
import {
|
|
||||||
ControlPoint,
|
|
||||||
HandlePosition,
|
|
||||||
NodeRect,
|
|
||||||
getCenterPoints,
|
|
||||||
getExpandedRect,
|
|
||||||
getOffsetPoint,
|
|
||||||
getSidesFromPoints,
|
|
||||||
getVerticesFromRectVertex,
|
|
||||||
optimizeInputPoints,
|
|
||||||
reducePoints,
|
|
||||||
} from "../point";
|
|
||||||
import { getAStarPath } from "./a-star";
|
|
||||||
import { getSimplePath } from "./simple";
|
|
||||||
|
|
||||||
export interface GetControlPointsParams {
|
|
||||||
source: HandlePosition;
|
|
||||||
target: HandlePosition;
|
|
||||||
sourceRect: NodeRect;
|
|
||||||
targetRect: NodeRect;
|
|
||||||
/**
|
|
||||||
* Minimum spacing between edges and nodes
|
|
||||||
*/
|
|
||||||
offset: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate control points on the optimal path of an edge.
|
|
||||||
*
|
|
||||||
* Reference article: https://juejin.cn/post/6942727734518874142
|
|
||||||
*/
|
|
||||||
export const getControlPoints = ({
|
|
||||||
source: oldSource,
|
|
||||||
target: oldTarget,
|
|
||||||
sourceRect,
|
|
||||||
targetRect,
|
|
||||||
offset = 20,
|
|
||||||
}: GetControlPointsParams) => {
|
|
||||||
const source: ControlPoint = oldSource;
|
|
||||||
const target: ControlPoint = oldTarget;
|
|
||||||
let edgePoints: ControlPoint[] = [];
|
|
||||||
let optimized: ReturnType<typeof optimizeInputPoints>;
|
|
||||||
|
|
||||||
// 1. Find the starting and ending points after applying the offset
|
|
||||||
const sourceOffset = getOffsetPoint(oldSource, offset);
|
|
||||||
const targetOffset = getOffsetPoint(oldTarget, offset);
|
|
||||||
const expandedSource = getExpandedRect(sourceRect, offset);
|
|
||||||
const expandedTarget = getExpandedRect(targetRect, offset);
|
|
||||||
|
|
||||||
// 2. Determine if the two Rects are relatively close or should directly connected
|
|
||||||
const minOffset = 2 * offset + 10;
|
|
||||||
const isHorizontalLayout = isHorizontalFromPosition(oldSource.position);
|
|
||||||
const isSameDirection = areLinesSameDirection(
|
|
||||||
source,
|
|
||||||
sourceOffset,
|
|
||||||
targetOffset,
|
|
||||||
target
|
|
||||||
);
|
|
||||||
const sides = getSidesFromPoints([
|
|
||||||
source,
|
|
||||||
target,
|
|
||||||
sourceOffset,
|
|
||||||
targetOffset,
|
|
||||||
]);
|
|
||||||
const isTooClose = isHorizontalLayout
|
|
||||||
? sides.right - sides.left < minOffset
|
|
||||||
: sides.bottom - sides.top < minOffset;
|
|
||||||
const isDirectConnect = isHorizontalLayout
|
|
||||||
? isSameDirection && source.x < target.x
|
|
||||||
: isSameDirection && source.y < target.y;
|
|
||||||
|
|
||||||
if (isTooClose || isDirectConnect) {
|
|
||||||
// 3. If the two Rects are relatively close or directly connected, return a simple Path
|
|
||||||
edgePoints = getSimplePath({
|
|
||||||
source,
|
|
||||||
target,
|
|
||||||
sourceOffset,
|
|
||||||
targetOffset,
|
|
||||||
isDirectConnect,
|
|
||||||
});
|
|
||||||
optimized = optimizeInputPoints({
|
|
||||||
source: oldSource,
|
|
||||||
target: oldTarget,
|
|
||||||
sourceOffset,
|
|
||||||
targetOffset,
|
|
||||||
edgePoints,
|
|
||||||
});
|
|
||||||
edgePoints = optimized.edgePoints;
|
|
||||||
} else {
|
|
||||||
// 3. Find the vertices of the two expanded Rects
|
|
||||||
edgePoints = [
|
|
||||||
...getVerticesFromRectVertex(expandedSource, targetOffset),
|
|
||||||
...getVerticesFromRectVertex(expandedTarget, sourceOffset),
|
|
||||||
];
|
|
||||||
// 4. Find possible midpoints and intersections
|
|
||||||
edgePoints = edgePoints.concat(
|
|
||||||
getCenterPoints({
|
|
||||||
source: expandedSource,
|
|
||||||
target: expandedTarget,
|
|
||||||
sourceOffset,
|
|
||||||
targetOffset,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
// 5. Merge nearby coordinate points and remove duplicate coordinate points
|
|
||||||
optimized = optimizeInputPoints({
|
|
||||||
source: oldSource,
|
|
||||||
target: oldTarget,
|
|
||||||
sourceOffset,
|
|
||||||
targetOffset,
|
|
||||||
edgePoints,
|
|
||||||
});
|
|
||||||
// 6. Find the optimal path
|
|
||||||
edgePoints = getAStarPath({
|
|
||||||
points: optimized.edgePoints,
|
|
||||||
source: optimized.source,
|
|
||||||
target: optimized.target,
|
|
||||||
sourceRect: getExpandedRect(sourceRect, offset / 2),
|
|
||||||
targetRect: getExpandedRect(targetRect, offset / 2),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
points: reducePoints([optimized.source, ...edgePoints, optimized.target]),
|
|
||||||
inputPoints: optimized.edgePoints,
|
|
||||||
};
|
|
||||||
};
|
|
|
@ -1,113 +0,0 @@
|
||||||
import { uuid } from "@/utils/uuid";
|
|
||||||
|
|
||||||
import { LayoutDirection } from "../../node";
|
|
||||||
import { ControlPoint, isInLine, isOnLine } from "../point";
|
|
||||||
|
|
||||||
interface GetSimplePathParams {
|
|
||||||
isDirectConnect?: boolean;
|
|
||||||
source: ControlPoint;
|
|
||||||
target: ControlPoint;
|
|
||||||
sourceOffset: ControlPoint;
|
|
||||||
targetOffset: ControlPoint;
|
|
||||||
}
|
|
||||||
|
|
||||||
const getLineDirection = (
|
|
||||||
start: ControlPoint,
|
|
||||||
end: ControlPoint
|
|
||||||
): LayoutDirection => (start.x === end.x ? "vertical" : "horizontal");
|
|
||||||
|
|
||||||
/**
|
|
||||||
* When two nodes are too close, use the simple path
|
|
||||||
*
|
|
||||||
* @returns Control points including sourceOffset and targetOffset (not including source and target points).
|
|
||||||
*/
|
|
||||||
export const getSimplePath = ({
|
|
||||||
isDirectConnect,
|
|
||||||
source,
|
|
||||||
target,
|
|
||||||
sourceOffset,
|
|
||||||
targetOffset,
|
|
||||||
}: GetSimplePathParams): ControlPoint[] => {
|
|
||||||
const points: ControlPoint[] = [];
|
|
||||||
const sourceDirection = getLineDirection(source, sourceOffset);
|
|
||||||
const targetDirection = getLineDirection(target, targetOffset);
|
|
||||||
const isHorizontalLayout = sourceDirection === "horizontal";
|
|
||||||
if (isDirectConnect) {
|
|
||||||
// Direct connection, return a simple Path
|
|
||||||
if (isHorizontalLayout) {
|
|
||||||
if (sourceOffset.x <= targetOffset.x) {
|
|
||||||
const centerX = (sourceOffset.x + targetOffset.x) / 2;
|
|
||||||
return [
|
|
||||||
{ id: uuid(), x: centerX, y: sourceOffset.y },
|
|
||||||
{ id: uuid(), x: centerX, y: targetOffset.y },
|
|
||||||
];
|
|
||||||
} else {
|
|
||||||
const centerY = (sourceOffset.y + targetOffset.y) / 2;
|
|
||||||
return [
|
|
||||||
sourceOffset,
|
|
||||||
{ id: uuid(), x: sourceOffset.x, y: centerY },
|
|
||||||
{ id: uuid(), x: targetOffset.x, y: centerY },
|
|
||||||
targetOffset,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (sourceOffset.y <= targetOffset.y) {
|
|
||||||
const centerY = (sourceOffset.y + targetOffset.y) / 2;
|
|
||||||
return [
|
|
||||||
{ id: uuid(), x: sourceOffset.x, y: centerY },
|
|
||||||
{ id: uuid(), x: targetOffset.x, y: centerY },
|
|
||||||
];
|
|
||||||
} else {
|
|
||||||
const centerX = (sourceOffset.x + targetOffset.x) / 2;
|
|
||||||
return [
|
|
||||||
sourceOffset,
|
|
||||||
{ id: uuid(), x: centerX, y: sourceOffset.y },
|
|
||||||
{ id: uuid(), x: centerX, y: targetOffset.y },
|
|
||||||
targetOffset,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (sourceDirection === targetDirection) {
|
|
||||||
// Same direction, add two points, two endpoints of parallel lines at half the vertical distance
|
|
||||||
if (source.y === sourceOffset.y) {
|
|
||||||
points.push({
|
|
||||||
id: uuid(),
|
|
||||||
x: sourceOffset.x,
|
|
||||||
y: (sourceOffset.y + targetOffset.y) / 2,
|
|
||||||
});
|
|
||||||
points.push({
|
|
||||||
id: uuid(),
|
|
||||||
x: targetOffset.x,
|
|
||||||
y: (sourceOffset.y + targetOffset.y) / 2,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
points.push({
|
|
||||||
id: uuid(),
|
|
||||||
x: (sourceOffset.x + targetOffset.x) / 2,
|
|
||||||
y: sourceOffset.y,
|
|
||||||
});
|
|
||||||
points.push({
|
|
||||||
id: uuid(),
|
|
||||||
x: (sourceOffset.x + targetOffset.x) / 2,
|
|
||||||
y: targetOffset.y,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Different directions, add one point, ensure it's not on the current line segment (to avoid overlap), and there are no turns
|
|
||||||
let point = { id: uuid(), x: sourceOffset.x, y: targetOffset.y };
|
|
||||||
const inStart = isInLine(point, source, sourceOffset);
|
|
||||||
const inEnd = isInLine(point, target, targetOffset);
|
|
||||||
if (inStart || inEnd) {
|
|
||||||
point = { id: uuid(), x: targetOffset.x, y: sourceOffset.y };
|
|
||||||
} else {
|
|
||||||
const onStart = isOnLine(point, source, sourceOffset);
|
|
||||||
const onEnd = isOnLine(point, target, targetOffset);
|
|
||||||
if (onStart && onEnd) {
|
|
||||||
point = { id: uuid(), x: targetOffset.x, y: sourceOffset.y };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
points.push(point);
|
|
||||||
}
|
|
||||||
return [sourceOffset, ...points, targetOffset];
|
|
||||||
};
|
|
|
@ -1,26 +0,0 @@
|
||||||
import { LayoutOptions, LayoutStrategy, NodeWithLayout } from "./types";
|
|
||||||
import { Edge, Node } from "@xyflow/react";
|
|
||||||
// 抽象布局类,包含共用的工具方法
|
|
||||||
export abstract class BaseLayout implements LayoutStrategy {
|
|
||||||
protected buildNodeMap(nodes: Node[]): Map<string, NodeWithLayout> {
|
|
||||||
const nodeMap = new Map<string, NodeWithLayout>();
|
|
||||||
nodes.forEach(node => {
|
|
||||||
nodeMap.set(node.id, { ...node, children: [], width: 150, height: 40 });
|
|
||||||
});
|
|
||||||
return nodeMap;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected buildTreeStructure(nodeMap: Map<string, NodeWithLayout>, edges: Edge[]): NodeWithLayout | undefined {
|
|
||||||
edges.forEach(edge => {
|
|
||||||
const source = nodeMap.get(edge.source);
|
|
||||||
const target = nodeMap.get(edge.target);
|
|
||||||
if (source && target) {
|
|
||||||
source.children?.push(target);
|
|
||||||
target.parent = source;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return Array.from(nodeMap.values()).find(node => !node.parent);
|
|
||||||
}
|
|
||||||
|
|
||||||
abstract layout(options: LayoutOptions): { nodes: Node[], edges: Edge[] };
|
|
||||||
}
|
|
|
@ -1,87 +0,0 @@
|
||||||
import { Edge,Node } from "@xyflow/react";
|
|
||||||
import { BaseLayout } from "./BaseLayout";
|
|
||||||
import { LayoutOptions, NodeWithLayout } from "./types";
|
|
||||||
|
|
||||||
// 思维导图布局实现
|
|
||||||
export class MindMapLayout extends BaseLayout {
|
|
||||||
layout(options: LayoutOptions): { nodes: Node[], edges: Edge[] } {
|
|
||||||
const {
|
|
||||||
nodes,
|
|
||||||
edges,
|
|
||||||
levelSeparation = 200,
|
|
||||||
nodeSeparation = 60
|
|
||||||
} = options;
|
|
||||||
|
|
||||||
const nodeMap = this.buildNodeMap(nodes);
|
|
||||||
const rootNode = this.buildTreeStructure(nodeMap, edges);
|
|
||||||
if (!rootNode) return { nodes, edges };
|
|
||||||
|
|
||||||
this.assignSides(rootNode);
|
|
||||||
this.calculateSubtreeHeight(rootNode, nodeSeparation);
|
|
||||||
this.calculateLayout(rootNode, 0, 0, levelSeparation, nodeSeparation);
|
|
||||||
|
|
||||||
const layoutedNodes = Array.from(nodeMap.values()).map(node => ({
|
|
||||||
...node,
|
|
||||||
position: node.position,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return { nodes: layoutedNodes, edges };
|
|
||||||
}
|
|
||||||
|
|
||||||
private assignSides(node: NodeWithLayout, isRight: boolean = true): void {
|
|
||||||
if (!node.children?.length) return;
|
|
||||||
|
|
||||||
const len = node.children.length;
|
|
||||||
const midIndex = Math.floor(len / 2);
|
|
||||||
|
|
||||||
if (!node.parent) {
|
|
||||||
for (let i = 0; i < len; i++) {
|
|
||||||
const child = node.children[i];
|
|
||||||
this.assignSides(child, i < midIndex);
|
|
||||||
child.isRight = i < midIndex;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
node.children.forEach(child => {
|
|
||||||
this.assignSides(child, isRight);
|
|
||||||
child.isRight = isRight;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private calculateSubtreeHeight(node: NodeWithLayout, nodeSeparation: number): number {
|
|
||||||
if (!node.children?.length) {
|
|
||||||
node.subtreeHeight = node.height || 40;
|
|
||||||
return node.subtreeHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
const childrenHeight = node.children.reduce((sum, child) => {
|
|
||||||
return sum + this.calculateSubtreeHeight(child, nodeSeparation);
|
|
||||||
}, 0);
|
|
||||||
|
|
||||||
const totalGaps = (node.children.length - 1) * nodeSeparation;
|
|
||||||
node.subtreeHeight = Math.max(node.height || 40, childrenHeight + totalGaps);
|
|
||||||
return node.subtreeHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
private calculateLayout(
|
|
||||||
node: NodeWithLayout,
|
|
||||||
x: number,
|
|
||||||
y: number,
|
|
||||||
levelSeparation: number,
|
|
||||||
nodeSeparation: number
|
|
||||||
): void {
|
|
||||||
node.position = { x, y };
|
|
||||||
if (!node.children?.length) return;
|
|
||||||
|
|
||||||
let currentY = y - (node.subtreeHeight || 0) / 2;
|
|
||||||
|
|
||||||
node.children.forEach(child => {
|
|
||||||
const direction = child.isRight ? 1 : -1;
|
|
||||||
const childX = x + (levelSeparation * direction);
|
|
||||||
const childY = currentY + (child.subtreeHeight || 0) / 2;
|
|
||||||
|
|
||||||
this.calculateLayout(child, childX, childY, levelSeparation, nodeSeparation);
|
|
||||||
currentY += (child.subtreeHeight || 0) + nodeSeparation;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,127 +0,0 @@
|
||||||
import { Edge, Node } from "@xyflow/react";
|
|
||||||
import { BaseLayout } from "./BaseLayout";
|
|
||||||
import { LayoutOptions, NodeWithLayout } from "./types";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SingleMapLayout 类继承自 BaseLayout,用于实现单图布局。
|
|
||||||
* 该类主要负责将节点和边按照一定的规则进行布局,使得节点在视觉上呈现出层次分明、结构清晰的效果。
|
|
||||||
*/
|
|
||||||
export class SingleMapLayout extends BaseLayout {
|
|
||||||
/**
|
|
||||||
* 布局方法,根据提供的选项对节点和边进行布局。
|
|
||||||
* @param options 布局选项,包含节点、边、层级间距和节点间距等信息。
|
|
||||||
* @returns 返回布局后的节点和边。
|
|
||||||
*/
|
|
||||||
layout(options: LayoutOptions): { nodes: Node[], edges: Edge[] } {
|
|
||||||
const { nodes, edges, levelSeparation = 100, nodeSeparation = 30 } = options;
|
|
||||||
const nodeMap = this.buildNodeMap(nodes);
|
|
||||||
const root = this.buildTreeStructure(nodeMap, edges);
|
|
||||||
|
|
||||||
if (!root) {
|
|
||||||
return { nodes: [], edges: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
// 计算子树的尺寸
|
|
||||||
this.calculateSubtreeDimensions(root);
|
|
||||||
|
|
||||||
// 第一遍:分配垂直位置
|
|
||||||
this.assignInitialVerticalPositions(root, 0);
|
|
||||||
|
|
||||||
// 第二遍:使用平衡布局定位节点
|
|
||||||
this.positionNodes(root, 0, 0, levelSeparation, nodeSeparation, 'right');
|
|
||||||
|
|
||||||
return {
|
|
||||||
nodes: Array.from(nodeMap.values()),
|
|
||||||
edges
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 计算子树的尺寸,包括高度和宽度。
|
|
||||||
* @param node 当前节点。
|
|
||||||
*/
|
|
||||||
private calculateSubtreeDimensions(node: NodeWithLayout): void {
|
|
||||||
|
|
||||||
node.subtreeHeight = node.height || 40;
|
|
||||||
node.subtreeWidth = node.width || 150;
|
|
||||||
|
|
||||||
if (node.children && node.children.length > 0) {
|
|
||||||
// 首先计算所有子节点的尺寸
|
|
||||||
node.children.forEach(child => this.calculateSubtreeDimensions(child));
|
|
||||||
|
|
||||||
// 计算子节点所需的总高度,包括间距
|
|
||||||
const totalChildrenHeight = this.calculateTotalChildrenHeight(node.children, 30);
|
|
||||||
|
|
||||||
// 更新节点的子树尺寸
|
|
||||||
node.subtreeHeight = Math.max(node.subtreeHeight, totalChildrenHeight);
|
|
||||||
node.subtreeWidth += Math.max(...node.children.map(child => child.subtreeWidth || 0));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 计算子节点的总高度。
|
|
||||||
* @param children 子节点数组。
|
|
||||||
* @param spacing 子节点之间的间距。
|
|
||||||
* @returns 返回子节点的总高度。
|
|
||||||
*/
|
|
||||||
private calculateTotalChildrenHeight(children: NodeWithLayout[], spacing: number): number {
|
|
||||||
if (!children.length) return 0;
|
|
||||||
const totalHeight = children.reduce((sum, child) => sum + (child.subtreeHeight || 0), 0);
|
|
||||||
return totalHeight + (spacing * (children.length - 1));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 分配初始垂直位置。
|
|
||||||
* @param node 当前节点。
|
|
||||||
* @param level 当前层级。
|
|
||||||
*/
|
|
||||||
private assignInitialVerticalPositions(node: NodeWithLayout, level: number): void {
|
|
||||||
if (!node.children?.length) return;
|
|
||||||
const totalHeight = this.calculateTotalChildrenHeight(node.children, 30);
|
|
||||||
let currentY = -(totalHeight / 2);
|
|
||||||
node.children.forEach(child => {
|
|
||||||
const childHeight = child.subtreeHeight || 0;
|
|
||||||
child.verticalLevel = level + 1;
|
|
||||||
child.relativeY = currentY + (childHeight / 2);
|
|
||||||
this.assignInitialVerticalPositions(child, level + 1);
|
|
||||||
currentY += childHeight + 30; // 30 是垂直间距
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 定位节点。
|
|
||||||
* @param node 当前节点。
|
|
||||||
* @param x 当前节点的水平位置。
|
|
||||||
* @param y 当前节点的垂直位置。
|
|
||||||
* @param levelSeparation 层级间距。
|
|
||||||
* @param nodeSeparation 节点间距。
|
|
||||||
* @param direction 布局方向,'left' 或 'right'。
|
|
||||||
*/
|
|
||||||
private positionNodes(
|
|
||||||
node: NodeWithLayout,
|
|
||||||
x: number,
|
|
||||||
y: number,
|
|
||||||
levelSeparation: number,
|
|
||||||
nodeSeparation: number,
|
|
||||||
direction: 'left' | 'right'
|
|
||||||
): void {
|
|
||||||
node.position = { x, y };
|
|
||||||
if (!node.children?.length) return;
|
|
||||||
// 计算子节点的水平位置
|
|
||||||
const nextX = direction === 'right'
|
|
||||||
? x + (node.width || 0) + levelSeparation
|
|
||||||
: x - (node.width || 0) - levelSeparation;
|
|
||||||
// 定位每个子节点
|
|
||||||
node.children.forEach(child => {
|
|
||||||
const childY = y + (child.relativeY || 0);
|
|
||||||
this.positionNodes(
|
|
||||||
child,
|
|
||||||
nextX,
|
|
||||||
childY,
|
|
||||||
levelSeparation,
|
|
||||||
nodeSeparation,
|
|
||||||
direction
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,68 +0,0 @@
|
||||||
import { BaseLayout } from "./BaseLayout";
|
|
||||||
import { LayoutOptions, LayoutStrategy, NodeWithLayout } from "./types";
|
|
||||||
import { Edge, Node } from "@xyflow/react";
|
|
||||||
export class TreeLayout extends BaseLayout {
|
|
||||||
layout(options: LayoutOptions): { nodes: Node[], edges: Edge[] } {
|
|
||||||
const {
|
|
||||||
nodes,
|
|
||||||
edges,
|
|
||||||
levelSeparation = 100, // 层级间垂直距离
|
|
||||||
nodeSeparation = 50 // 节点间水平距离
|
|
||||||
} = options;
|
|
||||||
|
|
||||||
const nodeMap = this.buildNodeMap(nodes);
|
|
||||||
const rootNode = this.buildTreeStructure(nodeMap, edges);
|
|
||||||
if (!rootNode) return { nodes, edges };
|
|
||||||
// 计算每个节点的子树宽度
|
|
||||||
this.calculateSubtreeWidth(rootNode, nodeSeparation);
|
|
||||||
// 计算布局位置
|
|
||||||
this.calculateTreeLayout(rootNode, 0, 0, levelSeparation);
|
|
||||||
const layoutedNodes = Array.from(nodeMap.values()).map(node => ({
|
|
||||||
...node,
|
|
||||||
position: node.position,
|
|
||||||
}));
|
|
||||||
return { nodes: layoutedNodes, edges };
|
|
||||||
}
|
|
||||||
|
|
||||||
private calculateSubtreeWidth(node: NodeWithLayout, nodeSeparation: number): number {
|
|
||||||
if (!node.children?.length) {
|
|
||||||
node.subtreeWidth = node.width || 150;
|
|
||||||
return node.subtreeWidth;
|
|
||||||
}
|
|
||||||
|
|
||||||
const childrenWidth = node.children.reduce((sum, child) => {
|
|
||||||
return sum + this.calculateSubtreeWidth(child, nodeSeparation);
|
|
||||||
}, 0);
|
|
||||||
|
|
||||||
const totalGaps = (node.children.length - 1) * nodeSeparation;
|
|
||||||
node.subtreeWidth = Math.max(node.width || 150, childrenWidth + totalGaps);
|
|
||||||
return node.subtreeWidth;
|
|
||||||
}
|
|
||||||
|
|
||||||
private calculateTreeLayout(
|
|
||||||
node: NodeWithLayout,
|
|
||||||
x: number,
|
|
||||||
y: number,
|
|
||||||
levelSeparation: number
|
|
||||||
): void {
|
|
||||||
node.position = { x, y };
|
|
||||||
|
|
||||||
if (!node.children?.length) return;
|
|
||||||
|
|
||||||
const totalChildrenWidth = node.children.reduce((sum, child) =>
|
|
||||||
sum + (child.subtreeWidth || 0), 0);
|
|
||||||
const totalGaps = (node.children.length - 1) * (node.width || 150);
|
|
||||||
|
|
||||||
// 计算最左侧子节点的起始x坐标
|
|
||||||
let startX = x - (totalChildrenWidth + totalGaps) / 2;
|
|
||||||
|
|
||||||
node.children.forEach(child => {
|
|
||||||
const childX = startX + (child.subtreeWidth || 0) / 2;
|
|
||||||
const childY = y + levelSeparation;
|
|
||||||
|
|
||||||
this.calculateTreeLayout(child, childX, childY, levelSeparation);
|
|
||||||
|
|
||||||
startX += (child.subtreeWidth || 0) + (node.width || 150);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,248 +0,0 @@
|
||||||
import { areLinesReverseDirection, areLinesSameDirection } from "../edge";
|
|
||||||
import {
|
|
||||||
ControlPoint,
|
|
||||||
NodeRect,
|
|
||||||
isEqualPoint,
|
|
||||||
isSegmentCrossingRect,
|
|
||||||
} from "../point";
|
|
||||||
|
|
||||||
interface GetAStarPathParams {
|
|
||||||
/**
|
|
||||||
* Collection of potential control points between `sourceOffset` and `targetOffset`, excluding the `source` and `target` points.
|
|
||||||
*/
|
|
||||||
points: ControlPoint[];
|
|
||||||
source: ControlPoint;
|
|
||||||
target: ControlPoint;
|
|
||||||
/**
|
|
||||||
* Node size information for the `source` and `target`, used to optimize edge routing without intersecting nodes.
|
|
||||||
*/
|
|
||||||
sourceRect: NodeRect;
|
|
||||||
targetRect: NodeRect;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Utilizes the [A\* search algorithm](https://en.wikipedia.org/wiki/A*_search_algorithm) combined with
|
|
||||||
* [Manhattan Distance](https://simple.wikipedia.org/wiki/Manhattan_distance) to find the optimal path for edges.
|
|
||||||
*
|
|
||||||
* @returns Control points including sourceOffset and targetOffset (not including source and target points).
|
|
||||||
*/
|
|
||||||
export const getAStarPath = ({
|
|
||||||
points,
|
|
||||||
source,
|
|
||||||
target,
|
|
||||||
sourceRect,
|
|
||||||
targetRect,
|
|
||||||
}: GetAStarPathParams): ControlPoint[] => {
|
|
||||||
if (points.length < 3) {
|
|
||||||
return points;
|
|
||||||
}
|
|
||||||
const start = points[0];
|
|
||||||
const end = points[points.length - 1];
|
|
||||||
const openSet: ControlPoint[] = [start];
|
|
||||||
const closedSet: Set<ControlPoint> = new Set();
|
|
||||||
const cameFrom: Map<ControlPoint, ControlPoint> = new Map();
|
|
||||||
const gScore: Map<ControlPoint, number> = new Map().set(start, 0);
|
|
||||||
const fScore: Map<ControlPoint, number> = new Map().set(
|
|
||||||
start,
|
|
||||||
heuristicCostEstimate({
|
|
||||||
from: start,
|
|
||||||
to: start,
|
|
||||||
start,
|
|
||||||
end,
|
|
||||||
source,
|
|
||||||
target,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
while (openSet.length) {
|
|
||||||
let current;
|
|
||||||
let currentIdx;
|
|
||||||
let lowestFScore = Infinity;
|
|
||||||
openSet.forEach((p, idx) => {
|
|
||||||
const score = fScore.get(p) ?? 0;
|
|
||||||
if (score < lowestFScore) {
|
|
||||||
lowestFScore = score;
|
|
||||||
current = p;
|
|
||||||
currentIdx = idx;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!current) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (current === end) {
|
|
||||||
return buildPath(cameFrom, current);
|
|
||||||
}
|
|
||||||
|
|
||||||
openSet.splice(currentIdx!, 1);
|
|
||||||
closedSet.add(current);
|
|
||||||
|
|
||||||
const curFScore = fScore.get(current) ?? 0;
|
|
||||||
const previous = cameFrom.get(current);
|
|
||||||
const neighbors = getNextNeighborPoints({
|
|
||||||
points,
|
|
||||||
previous,
|
|
||||||
current,
|
|
||||||
sourceRect,
|
|
||||||
targetRect,
|
|
||||||
});
|
|
||||||
for (const neighbor of neighbors) {
|
|
||||||
if (closedSet.has(neighbor)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const neighborGScore = gScore.get(neighbor) ?? 0;
|
|
||||||
const tentativeGScore = curFScore + estimateDistance(current, neighbor);
|
|
||||||
if (openSet.includes(neighbor) && tentativeGScore >= neighborGScore) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
openSet.push(neighbor);
|
|
||||||
cameFrom.set(neighbor, current);
|
|
||||||
gScore.set(neighbor, tentativeGScore);
|
|
||||||
fScore.set(
|
|
||||||
neighbor,
|
|
||||||
neighborGScore +
|
|
||||||
heuristicCostEstimate({
|
|
||||||
from: current,
|
|
||||||
to: neighbor,
|
|
||||||
start,
|
|
||||||
end,
|
|
||||||
source,
|
|
||||||
target,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return [start, end];
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildPath = (
|
|
||||||
cameFrom: Map<ControlPoint, ControlPoint>,
|
|
||||||
current: ControlPoint
|
|
||||||
): ControlPoint[] => {
|
|
||||||
const path = [current];
|
|
||||||
|
|
||||||
let previous = cameFrom.get(current);
|
|
||||||
while (previous) {
|
|
||||||
path.push(previous);
|
|
||||||
previous = cameFrom.get(previous);
|
|
||||||
}
|
|
||||||
|
|
||||||
return path.reverse();
|
|
||||||
};
|
|
||||||
|
|
||||||
interface GetNextNeighborPointsParams {
|
|
||||||
points: ControlPoint[];
|
|
||||||
previous?: ControlPoint;
|
|
||||||
current: ControlPoint;
|
|
||||||
sourceRect: NodeRect;
|
|
||||||
targetRect: NodeRect;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the set of possible neighboring points for the current control point
|
|
||||||
*
|
|
||||||
* - The line is in a horizontal or vertical direction
|
|
||||||
* - The line does not intersect with the two end nodes
|
|
||||||
* - The line does not overlap with the previous line segment in reverse direction
|
|
||||||
*/
|
|
||||||
export const getNextNeighborPoints = ({
|
|
||||||
points,
|
|
||||||
previous,
|
|
||||||
current,
|
|
||||||
sourceRect,
|
|
||||||
targetRect,
|
|
||||||
}: GetNextNeighborPointsParams): ControlPoint[] => {
|
|
||||||
return points.filter((p) => {
|
|
||||||
if (p === current) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// The connection is in the horizontal or vertical direction
|
|
||||||
const rightDirection = p.x === current.x || p.y === current.y;
|
|
||||||
// Reverse direction with the previous line segment (overlap)
|
|
||||||
const reverseDirection = previous
|
|
||||||
? areLinesReverseDirection(previous, current, current, p)
|
|
||||||
: false;
|
|
||||||
return (
|
|
||||||
rightDirection && // The line is in a horizontal or vertical direction
|
|
||||||
!reverseDirection && // The line does not overlap with the previous line segment in reverse direction
|
|
||||||
!isSegmentCrossingRect(p, current, sourceRect) && // Does not intersect with sourceNode
|
|
||||||
!isSegmentCrossingRect(p, current, targetRect) // Does not intersect with targetNode
|
|
||||||
);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 路径规划所需的启发式代价计算参数接口
|
|
||||||
* 包含了计算路径代价所需的所有控制点信息:
|
|
||||||
* - from/to: 当前路径段的起点和终点
|
|
||||||
* - start/end: 整条路径的起点和终点
|
|
||||||
* - source/target: 连接的源节点和目标节点位置
|
|
||||||
*/
|
|
||||||
interface HeuristicCostParams {
|
|
||||||
from: ControlPoint; // 当前路径段的起点
|
|
||||||
to: ControlPoint; // 当前路径段的终点
|
|
||||||
start: ControlPoint; // 整条路径的起始点
|
|
||||||
end: ControlPoint; // 整条路径的终点
|
|
||||||
source: ControlPoint; // 源节点的连接点
|
|
||||||
target: ControlPoint; // 目标节点的连接点
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 启发式路径代价估算函数
|
|
||||||
*
|
|
||||||
* 该函数通过多个因素综合评估路径的优劣程度:
|
|
||||||
* 1. 基础代价: 当前点到起点和终点的曼哈顿距离之和
|
|
||||||
* 2. 起点优化: 如果是起始段,判断方向一致性给予奖励
|
|
||||||
* 3. 终点优化: 如果是结束段,判断方向一致性给予奖励
|
|
||||||
*
|
|
||||||
* 优化目标:
|
|
||||||
* - 减少路径总长度
|
|
||||||
* - 保持路径走向的连续性
|
|
||||||
* - 使拐点在路径中更均匀分布
|
|
||||||
*
|
|
||||||
* @param params 包含所有必要控制点的参数对象
|
|
||||||
* @returns 计算得到的启发式代价值,值越小路径越优
|
|
||||||
*/
|
|
||||||
const heuristicCostEstimate = ({
|
|
||||||
from,
|
|
||||||
to,
|
|
||||||
start,
|
|
||||||
end,
|
|
||||||
source,
|
|
||||||
target,
|
|
||||||
}: HeuristicCostParams): number => {
|
|
||||||
// 计算基础代价 - 到起点和终点的距离之和
|
|
||||||
const base = estimateDistance(to, start) + estimateDistance(to, end);
|
|
||||||
|
|
||||||
// 起点方向优化 - 如果是起始段且方向一致,给予奖励
|
|
||||||
const startCost = isEqualPoint(from, start)
|
|
||||||
? areLinesSameDirection(from, to, source, start)
|
|
||||||
? -base / 2 // 方向一致时减少代价
|
|
||||||
: 0
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
// 终点方向优化 - 如果是结束段且方向一致,给予奖励
|
|
||||||
const endCost = isEqualPoint(to, end)
|
|
||||||
? areLinesSameDirection(from, to, end, target)
|
|
||||||
? -base / 2 // 方向一致时减少代价
|
|
||||||
: 0
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
return base + startCost + endCost;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 计算两点间的估计距离
|
|
||||||
*
|
|
||||||
* 采用曼哈顿距离(Manhattan distance)计算:
|
|
||||||
* - 只计算水平和垂直方向的距离之和
|
|
||||||
* - 避免使用欧几里得距离的开方运算
|
|
||||||
* - 在网格化的路径规划中性能更优
|
|
||||||
*
|
|
||||||
* @param p1 第一个控制点
|
|
||||||
* @param p2 第二个控制点
|
|
||||||
* @returns 两点间的曼哈顿距离
|
|
||||||
*/
|
|
||||||
const estimateDistance = (p1: ControlPoint, p2: ControlPoint): number =>
|
|
||||||
Math.abs(p1.x - p2.x) + Math.abs(p1.y - p2.y);
|
|
|
@ -1,142 +0,0 @@
|
||||||
import { areLinesSameDirection, isHorizontalFromPosition } from "../edge";
|
|
||||||
import {
|
|
||||||
ControlPoint,
|
|
||||||
HandlePosition,
|
|
||||||
NodeRect,
|
|
||||||
getCenterPoints,
|
|
||||||
getExpandedRect,
|
|
||||||
getOffsetPoint,
|
|
||||||
getSidesFromPoints,
|
|
||||||
getVerticesFromRectVertex,
|
|
||||||
optimizeInputPoints,
|
|
||||||
reducePoints,
|
|
||||||
} from "../point";
|
|
||||||
import { getAStarPath } from "./a-star";
|
|
||||||
import { getSimplePath } from "./simple";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 边缘控制点计算模块
|
|
||||||
* 用于计算图形边缘连接线的控制点,以实现平滑的连接效果
|
|
||||||
* 主要应用于流程图、思维导图等需要节点间连线的场景
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 控制点计算所需的输入参数接口
|
|
||||||
*/
|
|
||||||
export interface GetControlPointsParams {
|
|
||||||
source: HandlePosition; // 起始连接点位置
|
|
||||||
target: HandlePosition; // 目标连接点位置
|
|
||||||
sourceRect: NodeRect; // 起始节点的矩形区域
|
|
||||||
targetRect: NodeRect; // 目标节点的矩形区域
|
|
||||||
/**
|
|
||||||
* 边缘与节点之间的最小间距
|
|
||||||
* @default 20
|
|
||||||
*/
|
|
||||||
offset: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 计算两个节点之间连接线的控制点
|
|
||||||
* @param params 控制点计算参数
|
|
||||||
* @returns 返回优化后的路径点和输入点集合
|
|
||||||
*/
|
|
||||||
export const getControlPoints = ({
|
|
||||||
source: oldSource,
|
|
||||||
target: oldTarget,
|
|
||||||
sourceRect,
|
|
||||||
targetRect,
|
|
||||||
offset = 20,
|
|
||||||
}: GetControlPointsParams) => {
|
|
||||||
const source: ControlPoint = oldSource;
|
|
||||||
const target: ControlPoint = oldTarget;
|
|
||||||
let edgePoints: ControlPoint[] = [];
|
|
||||||
let optimized: ReturnType<typeof optimizeInputPoints>;
|
|
||||||
|
|
||||||
// 1. 计算考虑偏移量后的起始和结束点
|
|
||||||
const sourceOffset = getOffsetPoint(oldSource, offset);
|
|
||||||
const targetOffset = getOffsetPoint(oldTarget, offset);
|
|
||||||
const expandedSource = getExpandedRect(sourceRect, offset);
|
|
||||||
const expandedTarget = getExpandedRect(targetRect, offset);
|
|
||||||
|
|
||||||
// 2. 判断两个矩形是否靠得较近或应该直接连接
|
|
||||||
const minOffset = 2 * offset + 10; // 最小间距阈值
|
|
||||||
const isHorizontalLayout = isHorizontalFromPosition(oldSource.position); // 是否为水平布局
|
|
||||||
const isSameDirection = areLinesSameDirection(
|
|
||||||
source,
|
|
||||||
sourceOffset,
|
|
||||||
targetOffset,
|
|
||||||
target
|
|
||||||
); // 判断是否同向
|
|
||||||
const sides = getSidesFromPoints([
|
|
||||||
source,
|
|
||||||
target,
|
|
||||||
sourceOffset,
|
|
||||||
targetOffset,
|
|
||||||
]); // 获取边界信息
|
|
||||||
|
|
||||||
// 判断节点是否过近
|
|
||||||
const isTooClose = isHorizontalLayout
|
|
||||||
? sides.right - sides.left < minOffset
|
|
||||||
: sides.bottom - sides.top < minOffset;
|
|
||||||
// 判断是否可以直接连接
|
|
||||||
const isDirectConnect = isHorizontalLayout
|
|
||||||
? isSameDirection && source.x < target.x
|
|
||||||
: isSameDirection && source.y < target.y;
|
|
||||||
|
|
||||||
if (isTooClose || isDirectConnect) {
|
|
||||||
// 3. 如果节点较近或可直接连接,返回简单路径
|
|
||||||
edgePoints = getSimplePath({
|
|
||||||
source,
|
|
||||||
target,
|
|
||||||
sourceOffset,
|
|
||||||
targetOffset,
|
|
||||||
isDirectConnect,
|
|
||||||
});
|
|
||||||
// 优化输入点
|
|
||||||
optimized = optimizeInputPoints({
|
|
||||||
source: oldSource,
|
|
||||||
target: oldTarget,
|
|
||||||
sourceOffset,
|
|
||||||
targetOffset,
|
|
||||||
edgePoints,
|
|
||||||
});
|
|
||||||
edgePoints = optimized.edgePoints;
|
|
||||||
} else {
|
|
||||||
// 3. 获取两个扩展矩形的顶点
|
|
||||||
edgePoints = [
|
|
||||||
...getVerticesFromRectVertex(expandedSource, targetOffset),
|
|
||||||
...getVerticesFromRectVertex(expandedTarget, sourceOffset),
|
|
||||||
];
|
|
||||||
// 4. 计算可能的中点和交点
|
|
||||||
edgePoints = edgePoints.concat(
|
|
||||||
getCenterPoints({
|
|
||||||
source: expandedSource,
|
|
||||||
target: expandedTarget,
|
|
||||||
sourceOffset,
|
|
||||||
targetOffset,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
// 5. 合并临近坐标点并去除重复点
|
|
||||||
optimized = optimizeInputPoints({
|
|
||||||
source: oldSource,
|
|
||||||
target: oldTarget,
|
|
||||||
sourceOffset,
|
|
||||||
targetOffset,
|
|
||||||
edgePoints,
|
|
||||||
});
|
|
||||||
// 6. 使用A*算法寻找最优路径
|
|
||||||
edgePoints = getAStarPath({
|
|
||||||
points: optimized.edgePoints,
|
|
||||||
source: optimized.source,
|
|
||||||
target: optimized.target,
|
|
||||||
sourceRect: getExpandedRect(sourceRect, offset / 2),
|
|
||||||
targetRect: getExpandedRect(targetRect, offset / 2),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 返回简化后的路径点和输入点集合
|
|
||||||
return {
|
|
||||||
points: reducePoints([optimized.source, ...edgePoints, optimized.target]),
|
|
||||||
inputPoints: optimized.edgePoints,
|
|
||||||
};
|
|
||||||
};
|
|
|
@ -1,112 +0,0 @@
|
||||||
import { uuid } from "../../../utils/uuid";
|
|
||||||
import { LayoutDirection } from "../../node";
|
|
||||||
import { ControlPoint, isInLine, isOnLine } from "../point";
|
|
||||||
|
|
||||||
interface GetSimplePathParams {
|
|
||||||
isDirectConnect?: boolean;
|
|
||||||
source: ControlPoint;
|
|
||||||
target: ControlPoint;
|
|
||||||
sourceOffset: ControlPoint;
|
|
||||||
targetOffset: ControlPoint;
|
|
||||||
}
|
|
||||||
|
|
||||||
const getLineDirection = (
|
|
||||||
start: ControlPoint,
|
|
||||||
end: ControlPoint
|
|
||||||
): LayoutDirection => (start.x === end.x ? "vertical" : "horizontal");
|
|
||||||
|
|
||||||
/**
|
|
||||||
* When two nodes are too close, use the simple path
|
|
||||||
*
|
|
||||||
* @returns Control points including sourceOffset and targetOffset (not including source and target points).
|
|
||||||
*/
|
|
||||||
export const getSimplePath = ({
|
|
||||||
isDirectConnect,
|
|
||||||
source,
|
|
||||||
target,
|
|
||||||
sourceOffset,
|
|
||||||
targetOffset,
|
|
||||||
}: GetSimplePathParams): ControlPoint[] => {
|
|
||||||
const points: ControlPoint[] = [];
|
|
||||||
const sourceDirection = getLineDirection(source, sourceOffset);
|
|
||||||
const targetDirection = getLineDirection(target, targetOffset);
|
|
||||||
const isHorizontalLayout = sourceDirection === "horizontal";
|
|
||||||
if (isDirectConnect) {
|
|
||||||
// Direct connection, return a simple Path
|
|
||||||
if (isHorizontalLayout) {
|
|
||||||
if (sourceOffset.x <= targetOffset.x) {
|
|
||||||
const centerX = (sourceOffset.x + targetOffset.x) / 2;
|
|
||||||
return [
|
|
||||||
{ id: uuid(), x: centerX, y: sourceOffset.y },
|
|
||||||
{ id: uuid(), x: centerX, y: targetOffset.y },
|
|
||||||
];
|
|
||||||
} else {
|
|
||||||
const centerY = (sourceOffset.y + targetOffset.y) / 2;
|
|
||||||
return [
|
|
||||||
sourceOffset,
|
|
||||||
{ id: uuid(), x: sourceOffset.x, y: centerY },
|
|
||||||
{ id: uuid(), x: targetOffset.x, y: centerY },
|
|
||||||
targetOffset,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (sourceOffset.y <= targetOffset.y) {
|
|
||||||
const centerY = (sourceOffset.y + targetOffset.y) / 2;
|
|
||||||
return [
|
|
||||||
{ id: uuid(), x: sourceOffset.x, y: centerY },
|
|
||||||
{ id: uuid(), x: targetOffset.x, y: centerY },
|
|
||||||
];
|
|
||||||
} else {
|
|
||||||
const centerX = (sourceOffset.x + targetOffset.x) / 2;
|
|
||||||
return [
|
|
||||||
sourceOffset,
|
|
||||||
{ id: uuid(), x: centerX, y: sourceOffset.y },
|
|
||||||
{ id: uuid(), x: centerX, y: targetOffset.y },
|
|
||||||
targetOffset,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (sourceDirection === targetDirection) {
|
|
||||||
// Same direction, add two points, two endpoints of parallel lines at half the vertical distance
|
|
||||||
if (source.y === sourceOffset.y) {
|
|
||||||
points.push({
|
|
||||||
id: uuid(),
|
|
||||||
x: sourceOffset.x,
|
|
||||||
y: (sourceOffset.y + targetOffset.y) / 2,
|
|
||||||
});
|
|
||||||
points.push({
|
|
||||||
id: uuid(),
|
|
||||||
x: targetOffset.x,
|
|
||||||
y: (sourceOffset.y + targetOffset.y) / 2,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
points.push({
|
|
||||||
id: uuid(),
|
|
||||||
x: (sourceOffset.x + targetOffset.x) / 2,
|
|
||||||
y: sourceOffset.y,
|
|
||||||
});
|
|
||||||
points.push({
|
|
||||||
id: uuid(),
|
|
||||||
x: (sourceOffset.x + targetOffset.x) / 2,
|
|
||||||
y: targetOffset.y,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Different directions, add one point, ensure it's not on the current line segment (to avoid overlap), and there are no turns
|
|
||||||
let point = { id: uuid(), x: sourceOffset.x, y: targetOffset.y };
|
|
||||||
const inStart = isInLine(point, source, sourceOffset);
|
|
||||||
const inEnd = isInLine(point, target, targetOffset);
|
|
||||||
if (inStart || inEnd) {
|
|
||||||
point = { id: uuid(), x: targetOffset.x, y: sourceOffset.y };
|
|
||||||
} else {
|
|
||||||
const onStart = isOnLine(point, source, sourceOffset);
|
|
||||||
const onEnd = isOnLine(point, target, targetOffset);
|
|
||||||
if (onStart && onEnd) {
|
|
||||||
point = { id: uuid(), x: targetOffset.x, y: sourceOffset.y };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
points.push(point);
|
|
||||||
}
|
|
||||||
return [sourceOffset, ...points, targetOffset];
|
|
||||||
};
|
|
|
@ -1,389 +0,0 @@
|
||||||
import { Position, XYPosition } from "@xyflow/react";
|
|
||||||
import { ControlPoint, HandlePosition } from "./point";
|
|
||||||
import { uuid } from "../../utils/uuid";
|
|
||||||
|
|
||||||
export interface ILine {
|
|
||||||
start: ControlPoint;
|
|
||||||
end: ControlPoint;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 判断给定位置是否为水平方向
|
|
||||||
* @param position - 位置枚举值
|
|
||||||
* @returns 如果是左侧或右侧位置则返回true,否则返回false
|
|
||||||
*/
|
|
||||||
export const isHorizontalFromPosition = (position: Position) => {
|
|
||||||
return [Position.Left, Position.Right].includes(position);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 判断连接是否为反向
|
|
||||||
* 在图形布局中,通常希望连线从左到右或从上到下。当连线方向与此相反时,即为反向连接
|
|
||||||
* @param props - 包含源点和目标点位置信息的对象
|
|
||||||
* @param props.source - 源节点的位置信息
|
|
||||||
* @param props.target - 目标节点的位置信息
|
|
||||||
* @returns 如果是反向连接则返回true,否则返回false
|
|
||||||
*/
|
|
||||||
export const isConnectionBackward = (props: {
|
|
||||||
source: HandlePosition;
|
|
||||||
target: HandlePosition;
|
|
||||||
}) => {
|
|
||||||
const { source, target } = props;
|
|
||||||
// 判断是水平还是垂直方向的连接
|
|
||||||
const isHorizontal = isHorizontalFromPosition(source.position);
|
|
||||||
let isBackward = false;
|
|
||||||
|
|
||||||
// 水平方向时,如果源点x坐标大于目标点,则为反向
|
|
||||||
if (isHorizontal) {
|
|
||||||
if (source.x > target.x) {
|
|
||||||
isBackward = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 垂直方向时,如果源点y坐标大于目标点,则为反向
|
|
||||||
else {
|
|
||||||
if (source.y > target.y) {
|
|
||||||
isBackward = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return isBackward;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 计算两点之间的欧几里得距离
|
|
||||||
* 使用勾股定理(Math.hypot)计算两点间的直线距离
|
|
||||||
* @param p1 - 第一个控制点
|
|
||||||
* @param p2 - 第二个控制点
|
|
||||||
* @returns 两点间的距离
|
|
||||||
*/
|
|
||||||
export const distance = (p1: ControlPoint, p2: ControlPoint) => {
|
|
||||||
return Math.hypot(p2.x - p1.x, p2.y - p1.y);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 计算线段的中点坐标
|
|
||||||
* 通过取两端点坐标的算术平均值来确定中点位置
|
|
||||||
* @param p1 - 第一个控制点
|
|
||||||
* @param p2 - 第二个控制点
|
|
||||||
* @returns 包含中点坐标和唯一标识的控制点对象
|
|
||||||
*/
|
|
||||||
export const getLineCenter = (
|
|
||||||
p1: ControlPoint,
|
|
||||||
p2: ControlPoint
|
|
||||||
): ControlPoint => {
|
|
||||||
return {
|
|
||||||
id: uuid(),
|
|
||||||
x: (p1.x + p2.x) / 2, // x坐标取两端点x坐标的平均值
|
|
||||||
y: (p1.y + p2.y) / 2, // y坐标取两端点y坐标的平均值
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 判断点是否在线段上
|
|
||||||
*
|
|
||||||
* @description
|
|
||||||
* 该函数用于检测给定的点是否位于由两个控制点构成的线段上。
|
|
||||||
* 判断逻辑分为两种情况:
|
|
||||||
* 1. 垂直线段: 当起点和终点的x坐标相同时,判断目标点的x坐标是否等于线段x坐标,且y坐标在线段y坐标范围内
|
|
||||||
* 2. 水平线段: 当起点和终点的y坐标相同时,判断目标点的y坐标是否等于线段y坐标,且x坐标在线段x坐标范围内
|
|
||||||
*
|
|
||||||
* @param start - 线段起点坐标
|
|
||||||
* @param end - 线段终点坐标
|
|
||||||
* @param p - 待检测点的坐标
|
|
||||||
* @returns {boolean} 如果点在线段上返回true,否则返回false
|
|
||||||
*/
|
|
||||||
export const isLineContainsPoint = (
|
|
||||||
start: ControlPoint,
|
|
||||||
end: ControlPoint,
|
|
||||||
p: ControlPoint
|
|
||||||
) => {
|
|
||||||
return (
|
|
||||||
// 判断垂直线段
|
|
||||||
(start.x === end.x && // 起点终点x坐标相同
|
|
||||||
p.x === start.x && // 目标点x坐标与线段相同
|
|
||||||
p.y <= Math.max(start.y, end.y) && // 目标点y坐标不超过线段y坐标最大值
|
|
||||||
p.y >= Math.min(start.y, end.y)) || // 目标点y坐标不小于线段y坐标最小值
|
|
||||||
// 判断水平线段
|
|
||||||
(start.y === end.y && // 起点终点y坐标相同
|
|
||||||
p.y === start.y && // 目标点y坐标与线段相同
|
|
||||||
p.x <= Math.max(start.x, end.x) && // 目标点x坐标不超过线段x坐标最大值
|
|
||||||
p.x >= Math.min(start.x, end.x)) // 目标点x坐标不小于线段x坐标最小值
|
|
||||||
);
|
|
||||||
};
|
|
||||||
/**
|
|
||||||
/**
|
|
||||||
* 生成带圆角转角的SVG路径
|
|
||||||
*
|
|
||||||
* 该函数用于在图形编辑器中生成连接两点之间的边线路径。路径具有以下特点:
|
|
||||||
* 1. 两个控制点之间为直线段
|
|
||||||
* 2. 在转折点处生成圆角过渡
|
|
||||||
* 3. 支持垂直和水平方向的转角
|
|
||||||
*
|
|
||||||
* @param points 控制点数组,包含边的起点、终点和中间的转折点
|
|
||||||
* - 至少需要2个点(起点和终点)
|
|
||||||
* - 点的顺序应从输入端点开始到输出端点结束
|
|
||||||
* @param radius 转角处的圆角半径
|
|
||||||
* @returns 返回SVG路径字符串
|
|
||||||
* @throws 当points数组长度小于2时抛出错误
|
|
||||||
*/
|
|
||||||
export function getPathWithRoundCorners(
|
|
||||||
points: ControlPoint[],
|
|
||||||
radius: number
|
|
||||||
): string {
|
|
||||||
if (points.length < 2) {
|
|
||||||
throw new Error("At least 2 points are required.");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 计算两条线段交点处的圆角路径
|
|
||||||
* @param center 转折点坐标
|
|
||||||
* @param p1 前一个点的坐标
|
|
||||||
* @param p2 后一个点的坐标
|
|
||||||
* @param radius 圆角半径
|
|
||||||
* @returns SVG路径命令字符串
|
|
||||||
*/
|
|
||||||
function getRoundCorner(
|
|
||||||
center: ControlPoint,
|
|
||||||
p1: ControlPoint,
|
|
||||||
p2: ControlPoint,
|
|
||||||
radius: number
|
|
||||||
) {
|
|
||||||
const { x, y } = center;
|
|
||||||
|
|
||||||
// 如果两条线段不垂直,则直接返回直线路径
|
|
||||||
if (!areLinesPerpendicular(p1, center, center, p2)) {
|
|
||||||
return `L ${x} ${y}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 计算实际可用的圆角半径,取三个值中的最小值:
|
|
||||||
// 1. 与前一个点的距离的一半
|
|
||||||
// 2. 与后一个点的距离的一半
|
|
||||||
// 3. 传入的目标半径
|
|
||||||
const d1 = distance(center, p1);
|
|
||||||
const d2 = distance(center, p2);
|
|
||||||
radius = Math.min(d1 / 2, d2 / 2, radius);
|
|
||||||
|
|
||||||
// 判断第一条线段是否为水平线
|
|
||||||
const isHorizontal = p1.y === y;
|
|
||||||
|
|
||||||
// 根据点的相对位置确定圆角绘制方向
|
|
||||||
const xDir = isHorizontal ? (p1.x < p2.x ? -1 : 1) : p1.x < p2.x ? 1 : -1;
|
|
||||||
const yDir = isHorizontal ? (p1.y < p2.y ? 1 : -1) : p1.y < p2.y ? -1 : 1;
|
|
||||||
|
|
||||||
// 根据线段方向生成不同的圆角路径
|
|
||||||
if (isHorizontal) {
|
|
||||||
return `L ${x + radius * xDir},${y}Q ${x},${y} ${x},${y + radius * yDir}`;
|
|
||||||
}
|
|
||||||
return `L ${x},${y + radius * yDir}Q ${x},${y} ${x + radius * xDir},${y}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 构建完整的SVG路径
|
|
||||||
const path: string[] = [];
|
|
||||||
for (let i = 0; i < points.length; i++) {
|
|
||||||
if (i === 0) {
|
|
||||||
// 起点使用移动命令M
|
|
||||||
path.push(`M ${points[i].x} ${points[i].y}`);
|
|
||||||
} else if (i === points.length - 1) {
|
|
||||||
// 终点使用直线命令L
|
|
||||||
path.push(`L ${points[i].x} ${points[i].y}`);
|
|
||||||
} else {
|
|
||||||
// 中间点使用圆角转角
|
|
||||||
path.push(
|
|
||||||
getRoundCorner(points[i], points[i - 1], points[i + 1], radius)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 将所有路径命令连接成完整的路径字符串
|
|
||||||
return path.join(" ");
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* 获取折线中最长的线段
|
|
||||||
* @param points 控制点数组,每个点包含x和y坐标
|
|
||||||
* @returns 返回最长线段的起点和终点坐标
|
|
||||||
*
|
|
||||||
* 实现原理:
|
|
||||||
* 1. 初始化第一条线段为最长线段
|
|
||||||
* 2. 遍历所有相邻点对,计算线段长度
|
|
||||||
* 3. 如果找到更长的线段,则更新最长线段记录
|
|
||||||
* 4. 返回最长线段的两个端点
|
|
||||||
*/
|
|
||||||
export function getLongestLine(
|
|
||||||
points: ControlPoint[]
|
|
||||||
): [ControlPoint, ControlPoint] {
|
|
||||||
let longestLine: [ControlPoint, ControlPoint] = [points[0], points[1]];
|
|
||||||
let longestDistance = distance(...longestLine);
|
|
||||||
for (let i = 1; i < points.length - 1; i++) {
|
|
||||||
const _distance = distance(points[i], points[i + 1]);
|
|
||||||
if (_distance > longestDistance) {
|
|
||||||
longestDistance = _distance;
|
|
||||||
longestLine = [points[i], points[i + 1]];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return longestLine;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 计算折线上标签的位置
|
|
||||||
* @param points 控制点数组
|
|
||||||
* @param minGap 最小间隔距离,默认为20
|
|
||||||
* @returns 标签的坐标位置
|
|
||||||
*
|
|
||||||
* 计算逻辑:
|
|
||||||
* 1. 如果折线点数为偶数:
|
|
||||||
* - 取中间两个点
|
|
||||||
* - 如果这两点间距大于最小间隔,返回它们的中点
|
|
||||||
* 2. 如果折线点数为奇数或中间段太短:
|
|
||||||
* - 找出最长的线段
|
|
||||||
* - 返回最长线段的中点作为标签位置
|
|
||||||
*/
|
|
||||||
export function getLabelPosition(
|
|
||||||
points: ControlPoint[],
|
|
||||||
minGap = 20
|
|
||||||
): XYPosition {
|
|
||||||
if (points.length % 2 === 0) {
|
|
||||||
const middleP1 = points[points.length / 2 - 1];
|
|
||||||
const middleP2 = points[points.length / 2];
|
|
||||||
if (distance(middleP1, middleP2) > minGap) {
|
|
||||||
return getLineCenter(middleP1, middleP2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const [start, end] = getLongestLine(points);
|
|
||||||
return {
|
|
||||||
x: (start.x + end.x) / 2,
|
|
||||||
y: (start.y + end.y) / 2,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 判断两条线段是否垂直
|
|
||||||
* @param p1,p2 第一条线段的起点和终点
|
|
||||||
* @param p3,p4 第二条线段的起点和终点
|
|
||||||
* @returns 如果两线段垂直则返回true
|
|
||||||
*
|
|
||||||
* 判断依据:
|
|
||||||
* - 假设线段要么水平要么垂直
|
|
||||||
* - 当一条线段水平(y相等)而另一条垂直(x相等)时,两线段垂直
|
|
||||||
*/
|
|
||||||
export function areLinesPerpendicular(
|
|
||||||
p1: ControlPoint,
|
|
||||||
p2: ControlPoint,
|
|
||||||
p3: ControlPoint,
|
|
||||||
p4: ControlPoint
|
|
||||||
): boolean {
|
|
||||||
return (p1.x === p2.x && p3.y === p4.y) || (p1.y === p2.y && p3.x === p4.x);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 判断两条线段是否平行
|
|
||||||
* @param p1,p2 第一条线段的起点和终点
|
|
||||||
* @param p3,p4 第二条线段的起点和终点
|
|
||||||
* @returns 如果两线段平行则返回true
|
|
||||||
*
|
|
||||||
* 判断依据:
|
|
||||||
* - 假设线段要么水平要么垂直
|
|
||||||
* - 当两条线段都是水平的(x相等)或都是垂直的(y相等)时,两线段平行
|
|
||||||
*/
|
|
||||||
export function areLinesParallel(
|
|
||||||
p1: ControlPoint,
|
|
||||||
p2: ControlPoint,
|
|
||||||
p3: ControlPoint,
|
|
||||||
p4: ControlPoint
|
|
||||||
) {
|
|
||||||
return (p1.x === p2.x && p3.x === p4.x) || (p1.y === p2.y && p3.y === p4.y);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 判断两条线段是否同向
|
|
||||||
* @param p1 第一条线段的起点
|
|
||||||
* @param p2 第一条线段的终点
|
|
||||||
* @param p3 第二条线段的起点
|
|
||||||
* @param p4 第二条线段的终点
|
|
||||||
* @returns boolean 如果两线段同向返回true,否则返回false
|
|
||||||
*
|
|
||||||
* 判断逻辑:
|
|
||||||
* 1. 对于水平线段(y坐标相等),判断x方向的变化是否同向
|
|
||||||
* 2. 对于垂直线段(x坐标相等),判断y方向的变化是否同向
|
|
||||||
*/
|
|
||||||
export function areLinesSameDirection(
|
|
||||||
p1: ControlPoint,
|
|
||||||
p2: ControlPoint,
|
|
||||||
p3: ControlPoint,
|
|
||||||
p4: ControlPoint
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
// 判断垂直线段是否同向
|
|
||||||
(p1.x === p2.x && p3.x === p4.x && (p1.y - p2.y) * (p3.y - p4.y) > 0) ||
|
|
||||||
// 判断水平线段是否同向
|
|
||||||
(p1.y === p2.y && p3.y === p4.y && (p1.x - p2.x) * (p3.x - p4.x) > 0)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 判断两条线段是否反向
|
|
||||||
* @param p1 第一条线段的起点
|
|
||||||
* @param p2 第一条线段的终点
|
|
||||||
* @param p3 第二条线段的起点
|
|
||||||
* @param p4 第二条线段的终点
|
|
||||||
* @returns boolean 如果两线段反向返回true,否则返回false
|
|
||||||
*
|
|
||||||
* 判断逻辑:
|
|
||||||
* 1. 对于水平线段(y坐标相等),判断x方向的变化是否反向
|
|
||||||
* 2. 对于垂直线段(x坐标相等),判断y方向的变化是否反向
|
|
||||||
*/
|
|
||||||
export function areLinesReverseDirection(
|
|
||||||
p1: ControlPoint,
|
|
||||||
p2: ControlPoint,
|
|
||||||
p3: ControlPoint,
|
|
||||||
p4: ControlPoint
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
// 判断垂直线段是否反向
|
|
||||||
(p1.x === p2.x && p3.x === p4.x && (p1.y - p2.y) * (p3.y - p4.y) < 0) ||
|
|
||||||
// 判断水平线段是否反向
|
|
||||||
(p1.y === p2.y && p3.y === p4.y && (p1.x - p2.x) * (p3.x - p4.x) < 0)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 计算两条线段之间的夹角
|
|
||||||
* @param p1 第一条线段的起点
|
|
||||||
* @param p2 第一条线段的终点
|
|
||||||
* @param p3 第二条线段的起点
|
|
||||||
* @param p4 第二条线段的终点
|
|
||||||
* @returns number 两线段之间的夹角(单位:度)
|
|
||||||
*
|
|
||||||
* 计算步骤:
|
|
||||||
* 1. 计算两个向量
|
|
||||||
* 2. 计算向量的点积
|
|
||||||
* 3. 计算向量的模长
|
|
||||||
* 4. 使用反余弦函数计算弧度
|
|
||||||
* 5. 将弧度转换为角度
|
|
||||||
*/
|
|
||||||
export function getAngleBetweenLines(
|
|
||||||
p1: ControlPoint,
|
|
||||||
p2: ControlPoint,
|
|
||||||
p3: ControlPoint,
|
|
||||||
p4: ControlPoint
|
|
||||||
) {
|
|
||||||
// 计算两条线段对应的向量
|
|
||||||
const v1 = { x: p2.x - p1.x, y: p2.y - p1.y };
|
|
||||||
const v2 = { x: p4.x - p3.x, y: p4.y - p3.y };
|
|
||||||
|
|
||||||
// 计算向量的点积
|
|
||||||
const dotProduct = v1.x * v2.x + v1.y * v2.y;
|
|
||||||
|
|
||||||
// 计算两个向量的模长
|
|
||||||
const magnitude1 = Math.sqrt(v1.x ** 2 + v1.y ** 2);
|
|
||||||
const magnitude2 = Math.sqrt(v2.x ** 2 + v2.y ** 2);
|
|
||||||
|
|
||||||
// 计算夹角的余弦值
|
|
||||||
const cosine = dotProduct / (magnitude1 * magnitude2);
|
|
||||||
|
|
||||||
// 使用反余弦函数计算弧度
|
|
||||||
const angleInRadians = Math.acos(cosine);
|
|
||||||
|
|
||||||
// 将弧度转换为角度并返回
|
|
||||||
const angleInDegrees = (angleInRadians * 180) / Math.PI;
|
|
||||||
|
|
||||||
return angleInDegrees;
|
|
||||||
}
|
|
|
@ -1,72 +0,0 @@
|
||||||
import { EdgeLayout } from "../../types";
|
|
||||||
import { getControlPoints, GetControlPointsParams } from "./algorithms";
|
|
||||||
import { getLabelPosition, getPathWithRoundCorners } from "./edge";
|
|
||||||
import { InternalNode, Node } from "@xyflow/react"
|
|
||||||
interface GetBasePathParams extends GetControlPointsParams {
|
|
||||||
borderRadius: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getBasePath({
|
|
||||||
id,
|
|
||||||
offset,
|
|
||||||
borderRadius,
|
|
||||||
source,
|
|
||||||
target,
|
|
||||||
sourceX,
|
|
||||||
sourceY,
|
|
||||||
targetX,
|
|
||||||
targetY,
|
|
||||||
sourcePosition,
|
|
||||||
targetPosition,
|
|
||||||
}: any) {
|
|
||||||
const sourceNode: InternalNode =
|
|
||||||
kReactFlow.instance!.getNode(source)!;
|
|
||||||
const targetNode: InternalNode =
|
|
||||||
kReactFlow.instance!.getNode(target)!;
|
|
||||||
return getPathWithPoints({
|
|
||||||
offset,
|
|
||||||
borderRadius,
|
|
||||||
source: {
|
|
||||||
id: "source-" + id,
|
|
||||||
x: sourceX,
|
|
||||||
y: sourceY,
|
|
||||||
position: sourcePosition,
|
|
||||||
},
|
|
||||||
target: {
|
|
||||||
id: "target-" + id,
|
|
||||||
x: targetX,
|
|
||||||
y: targetY,
|
|
||||||
position: targetPosition,
|
|
||||||
},
|
|
||||||
sourceRect: {
|
|
||||||
...(sourceNode.internals.positionAbsolute || sourceNode.position),
|
|
||||||
width: sourceNode.width!,
|
|
||||||
height: sourceNode.height!,
|
|
||||||
},
|
|
||||||
targetRect: {
|
|
||||||
...(targetNode.internals.positionAbsolute || targetNode.position),
|
|
||||||
width: targetNode.width!,
|
|
||||||
height: targetNode.height!,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getPathWithPoints({
|
|
||||||
source,
|
|
||||||
target,
|
|
||||||
sourceRect,
|
|
||||||
targetRect,
|
|
||||||
offset = 20,
|
|
||||||
borderRadius = 16,
|
|
||||||
}: GetBasePathParams): EdgeLayout {
|
|
||||||
const { points, inputPoints } = getControlPoints({
|
|
||||||
source,
|
|
||||||
target,
|
|
||||||
offset,
|
|
||||||
sourceRect,
|
|
||||||
targetRect,
|
|
||||||
});
|
|
||||||
const labelPosition = getLabelPosition(points);
|
|
||||||
const path = getPathWithRoundCorners(points, borderRadius);
|
|
||||||
return { path, points, inputPoints, labelPosition };
|
|
||||||
}
|
|
|
@ -1,623 +0,0 @@
|
||||||
import { Position } from "@xyflow/react";
|
|
||||||
import { isHorizontalFromPosition } from "./edge";
|
|
||||||
import { uuid } from "../../utils/uuid";
|
|
||||||
|
|
||||||
export interface ControlPoint {
|
|
||||||
id: string;
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NodeRect {
|
|
||||||
x: number; // left
|
|
||||||
y: number; // top
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RectSides {
|
|
||||||
top: number;
|
|
||||||
right: number;
|
|
||||||
bottom: number;
|
|
||||||
left: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HandlePosition extends ControlPoint {
|
|
||||||
position: Position;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GetVerticesParams {
|
|
||||||
source: NodeRect;
|
|
||||||
target: NodeRect;
|
|
||||||
sourceOffset: ControlPoint;
|
|
||||||
targetOffset: ControlPoint;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 计算两个节点之间的控制点位置
|
|
||||||
*
|
|
||||||
* 该函数用于在图形编辑器中确定边的控制点,以实现更自然的边布局。
|
|
||||||
* 主要应用于:
|
|
||||||
* 1. 节点之间连线的路径规划
|
|
||||||
* 2. 边的弯曲程度控制
|
|
||||||
* 3. 避免边与节点重叠
|
|
||||||
*
|
|
||||||
* 实现原理:
|
|
||||||
* 1. 基于源节点和目标节点构建外部边界矩形
|
|
||||||
* 2. 基于偏移点构建内部边界矩形
|
|
||||||
* 3. 在两个矩形的边上生成候选控制点
|
|
||||||
* 4. 过滤掉无效的控制点
|
|
||||||
*
|
|
||||||
* @param {GetVerticesParams} params - 计算所需的参数
|
|
||||||
* @param {Rect} params.source - 源节点的矩形区域,包含x、y、width、height
|
|
||||||
* @param {Rect} params.target - 目标节点的矩形区域
|
|
||||||
* @param {Point} params.sourceOffset - 源节点上的连接点坐标
|
|
||||||
* @param {Point} params.targetOffset - 目标节点上的连接点坐标
|
|
||||||
* @returns {ControlPoint[]} 有效的控制点数组,每个点包含唯一ID和坐标
|
|
||||||
*/
|
|
||||||
export const getCenterPoints = ({
|
|
||||||
source,
|
|
||||||
target,
|
|
||||||
sourceOffset,
|
|
||||||
targetOffset,
|
|
||||||
}: GetVerticesParams): ControlPoint[] => {
|
|
||||||
// 特殊情况处理:当源点和目标点在同一直线上时,无法构成有效的控制区域
|
|
||||||
if (sourceOffset.x === targetOffset.x || sourceOffset.y === targetOffset.y) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// 步骤1: 获取外部边界
|
|
||||||
// 收集两个节点的所有顶点,用于构建外部最大矩形
|
|
||||||
const vertices = [...getRectVertices(source), ...getRectVertices(target)];
|
|
||||||
const outerSides = getSidesFromPoints(vertices);
|
|
||||||
|
|
||||||
// 步骤2: 获取内部边界
|
|
||||||
// 根据偏移点(实际连接点)计算内部矩形的四条边
|
|
||||||
const { left, right, top, bottom } = getSidesFromPoints([
|
|
||||||
sourceOffset,
|
|
||||||
targetOffset,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// 步骤3: 计算中心参考线
|
|
||||||
const centerX = (left + right) / 2; // 水平中心线
|
|
||||||
const centerY = (top + bottom) / 2; // 垂直中心线
|
|
||||||
|
|
||||||
// 步骤4: 生成候选控制点
|
|
||||||
// 在内外两个矩形的边上各生成4个控制点,共8个候选点
|
|
||||||
const points = [
|
|
||||||
{ id: uuid(), x: centerX, y: top }, // 内矩形-上
|
|
||||||
{ id: uuid(), x: right, y: centerY }, // 内矩形-右
|
|
||||||
{ id: uuid(), x: centerX, y: bottom }, // 内矩形-下
|
|
||||||
{ id: uuid(), x: left, y: centerY }, // 内矩形-左
|
|
||||||
{ id: uuid(), x: centerX, y: outerSides.top }, // 外矩形-上
|
|
||||||
{ id: uuid(), x: outerSides.right, y: centerY }, // 外矩形-右
|
|
||||||
{ id: uuid(), x: centerX, y: outerSides.bottom },// 外矩形-下
|
|
||||||
{ id: uuid(), x: outerSides.left, y: centerY }, // 外矩形-左
|
|
||||||
];
|
|
||||||
|
|
||||||
// 步骤5: 过滤无效控制点
|
|
||||||
// 移除落在源节点或目标节点内部的控制点,避免边穿过节点
|
|
||||||
return points.filter((p) => {
|
|
||||||
return !isPointInRect(p, source) && !isPointInRect(p, target);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 扩展矩形区域
|
|
||||||
* @param rect 原始矩形区域
|
|
||||||
* @param offset 扩展偏移量
|
|
||||||
* @returns 扩展后的新矩形区域
|
|
||||||
*
|
|
||||||
* 该函数将一个矩形区域向四周扩展指定的偏移量。
|
|
||||||
* 扩展规则:
|
|
||||||
* 1. x和y坐标各向外偏移offset距离
|
|
||||||
* 2. 宽度和高度各增加2*offset
|
|
||||||
*/
|
|
||||||
export const getExpandedRect = (rect: NodeRect, offset: number): NodeRect => {
|
|
||||||
return {
|
|
||||||
x: rect.x - offset,
|
|
||||||
y: rect.y - offset,
|
|
||||||
width: rect.width + 2 * offset,
|
|
||||||
height: rect.height + 2 * offset,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 检测两个矩形是否重叠
|
|
||||||
* @param rect1 第一个矩形
|
|
||||||
* @param rect2 第二个矩形
|
|
||||||
* @returns 布尔值,true表示重叠,false表示不重叠
|
|
||||||
*
|
|
||||||
* 使用AABB(轴对齐包围盒)碰撞检测算法:
|
|
||||||
* 1. 计算x轴投影是否重叠
|
|
||||||
* 2. 计算y轴投影是否重叠
|
|
||||||
* 两个轴向都重叠则矩形重叠
|
|
||||||
*/
|
|
||||||
export const isRectOverLapping = (rect1: NodeRect, rect2: NodeRect) => {
|
|
||||||
return (
|
|
||||||
Math.abs(rect1.x - rect2.x) < (rect1.width + rect2.width) / 2 &&
|
|
||||||
Math.abs(rect1.y - rect2.y) < (rect1.height + rect2.height) / 2
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 判断点是否在矩形内
|
|
||||||
* @param p 待检测的控制点
|
|
||||||
* @param box 矩形区域
|
|
||||||
* @returns 布尔值,true表示点在矩形内,false表示点在矩形外
|
|
||||||
*
|
|
||||||
* 点在矩形内的条件:
|
|
||||||
* 1. x坐标在矩形左右边界之间
|
|
||||||
* 2. y坐标在矩形上下边界之间
|
|
||||||
*/
|
|
||||||
export const isPointInRect = (p: ControlPoint, box: NodeRect) => {
|
|
||||||
const sides = getRectSides(box);
|
|
||||||
return (
|
|
||||||
p.x >= sides.left &&
|
|
||||||
p.x <= sides.right &&
|
|
||||||
p.y >= sides.top &&
|
|
||||||
p.y <= sides.bottom
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 矩形顶点计算模块
|
|
||||||
* 用于处理图形编辑器中矩形节点的顶点、边界等几何计算
|
|
||||||
* 主要应用于连线路径规划和节点定位
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 根据矩形和外部顶点计算包围矩形的顶点坐标
|
|
||||||
* @param box 原始矩形的位置和尺寸信息
|
|
||||||
* @param vertex 外部控制点
|
|
||||||
* @returns 包围矩形的四个顶点坐标
|
|
||||||
* 算法思路:
|
|
||||||
* 1. 合并外部顶点和原矩形的顶点
|
|
||||||
* 2. 计算所有点的边界范围
|
|
||||||
* 3. 根据边界生成新的矩形顶点
|
|
||||||
*/
|
|
||||||
export const getVerticesFromRectVertex = (
|
|
||||||
box: NodeRect,
|
|
||||||
vertex: ControlPoint
|
|
||||||
): ControlPoint[] => {
|
|
||||||
const points = [vertex, ...getRectVertices(box)];
|
|
||||||
const { top, right, bottom, left } = getSidesFromPoints(points);
|
|
||||||
return [
|
|
||||||
{ id: uuid(), x: left, y: top }, // 左上角顶点
|
|
||||||
{ id: uuid(), x: right, y: top }, // 右上角顶点
|
|
||||||
{ id: uuid(), x: right, y: bottom }, // 右下角顶点
|
|
||||||
{ id: uuid(), x: left, y: bottom }, // 左下角顶点
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 计算一组点的边界范围
|
|
||||||
* @param points 控制点数组
|
|
||||||
* @returns 返回边界的上下左右极值
|
|
||||||
* 实现方式:
|
|
||||||
* - 使用数组map和Math.min/max计算坐标的最值
|
|
||||||
*/
|
|
||||||
export const getSidesFromPoints = (points: ControlPoint[]) => {
|
|
||||||
const left = Math.min(...points.map((p) => p.x)); // 最左侧x坐标
|
|
||||||
const right = Math.max(...points.map((p) => p.x)); // 最右侧x坐标
|
|
||||||
const top = Math.min(...points.map((p) => p.y)); // 最上方y坐标
|
|
||||||
const bottom = Math.max(...points.map((p) => p.y)); // 最下方y坐标
|
|
||||||
return { top, right, bottom, left };
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取矩形的四条边界位置
|
|
||||||
* @param box 矩形的位置和尺寸信息
|
|
||||||
* @returns 矩形的上下左右边界坐标
|
|
||||||
* 计算方式:
|
|
||||||
* - 左边界 = x坐标
|
|
||||||
* - 右边界 = x + width
|
|
||||||
* - 上边界 = y坐标
|
|
||||||
* - 下边界 = y + height
|
|
||||||
*/
|
|
||||||
export const getRectSides = (box: NodeRect): RectSides => {
|
|
||||||
const { x: left, y: top, width, height } = box;
|
|
||||||
const right = left + width;
|
|
||||||
const bottom = top + height;
|
|
||||||
return { top, right, bottom, left };
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 根据边界信息生成矩形的四个顶点
|
|
||||||
* @param sides 矩形的上下左右边界坐标
|
|
||||||
* @returns 返回四个顶点的坐标信息
|
|
||||||
* 顶点顺序: 左上 -> 右上 -> 右下 -> 左下
|
|
||||||
*/
|
|
||||||
export const getRectVerticesFromSides = ({
|
|
||||||
top,
|
|
||||||
right,
|
|
||||||
bottom,
|
|
||||||
left,
|
|
||||||
}: RectSides): ControlPoint[] => {
|
|
||||||
return [
|
|
||||||
{ id: uuid(), x: left, y: top }, // 左上角顶点
|
|
||||||
{ id: uuid(), x: right, y: top }, // 右上角顶点
|
|
||||||
{ id: uuid(), x: right, y: bottom }, // 右下角顶点
|
|
||||||
{ id: uuid(), x: left, y: bottom }, // 左下角顶点
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取矩形的四个顶点坐标
|
|
||||||
* @param box 矩形的位置和尺寸信息
|
|
||||||
* @returns 返回矩形四个顶点的坐标
|
|
||||||
* 实现流程:
|
|
||||||
* 1. 先计算矩形的边界
|
|
||||||
* 2. 根据边界生成顶点
|
|
||||||
*/
|
|
||||||
export const getRectVertices = (box: NodeRect) => {
|
|
||||||
const sides = getRectSides(box);
|
|
||||||
return getRectVerticesFromSides(sides);
|
|
||||||
};
|
|
||||||
/**
|
|
||||||
* 合并多个矩形区域,返回一个能包含所有输入矩形的最小矩形
|
|
||||||
* @param boxes 需要合并的矩形数组,每个矩形包含 x,y 坐标和宽高信息
|
|
||||||
* @returns 合并后的最小包围矩形
|
|
||||||
*
|
|
||||||
* 实现原理:
|
|
||||||
* 1. 找出所有矩形中最左边的 x 坐标(left)和最右边的 x 坐标(right)
|
|
||||||
* 2. 找出所有矩形中最上边的 y 坐标(top)和最下边的 y 坐标(bottom)
|
|
||||||
* 3. 用这四个边界值构造出新的矩形
|
|
||||||
*/
|
|
||||||
export const mergeRects = (...boxes: NodeRect[]): NodeRect => {
|
|
||||||
// 计算所有矩形的最左边界
|
|
||||||
const left = Math.min(
|
|
||||||
...boxes.reduce((pre, e) => [...pre, e.x, e.x + e.width], [] as number[])
|
|
||||||
);
|
|
||||||
// 计算所有矩形的最右边界
|
|
||||||
const right = Math.max(
|
|
||||||
...boxes.reduce((pre, e) => [...pre, e.x, e.x + e.width], [] as number[])
|
|
||||||
);
|
|
||||||
// 计算所有矩形的最上边界
|
|
||||||
const top = Math.min(
|
|
||||||
...boxes.reduce((pre, e) => [...pre, e.y, e.y + e.height], [] as number[])
|
|
||||||
);
|
|
||||||
// 计算所有矩形的最下边界
|
|
||||||
const bottom = Math.max(
|
|
||||||
...boxes.reduce((pre, e) => [...pre, e.y, e.y + e.height], [] as number[])
|
|
||||||
);
|
|
||||||
|
|
||||||
// 返回能包含所有输入矩形的最小矩形
|
|
||||||
return {
|
|
||||||
x: left, // 左上角 x 坐标
|
|
||||||
y: top, // 左上角 y 坐标
|
|
||||||
width: right - left, // 宽度 = 最右边界 - 最左边界
|
|
||||||
height: bottom - top, // 高度 = 最下边界 - 最上边界
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 根据给定的位置和偏移量计算控制点坐标
|
|
||||||
* @param box - 起始位置信息,包含x、y坐标和位置类型(上下左右)
|
|
||||||
* @param offset - 偏移距离
|
|
||||||
* @returns 返回计算后的控制点对象,包含唯一id和新的x、y坐标
|
|
||||||
*/
|
|
||||||
export const getOffsetPoint = (
|
|
||||||
box: HandlePosition,
|
|
||||||
offset: number
|
|
||||||
): ControlPoint => {
|
|
||||||
// 根据不同的位置类型计算偏移后的坐标
|
|
||||||
switch (box.position) {
|
|
||||||
case Position.Top: // 顶部位置,y坐标向上偏移
|
|
||||||
return {
|
|
||||||
id: uuid(),
|
|
||||||
x: box.x,
|
|
||||||
y: box.y - offset,
|
|
||||||
};
|
|
||||||
case Position.Bottom: // 底部位置,y坐标向下偏移
|
|
||||||
return { id: uuid(), x: box.x, y: box.y + offset };
|
|
||||||
case Position.Left: // 左侧位置,x坐标向左偏移
|
|
||||||
return { id: uuid(), x: box.x - offset, y: box.y };
|
|
||||||
case Position.Right: // 右侧位置,x坐标向右偏移
|
|
||||||
return { id: uuid(), x: box.x + offset, y: box.y };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 判断一个点是否在线段上
|
|
||||||
* @param p - 待判断的点
|
|
||||||
* @param p1 - 线段起点
|
|
||||||
* @param p2 - 线段终点
|
|
||||||
* @returns 如果点在线段上返回true,否则返回false
|
|
||||||
*
|
|
||||||
* 判断逻辑:
|
|
||||||
* 1. 点必须在线段所在的直线上(x坐标相等或y坐标相等)
|
|
||||||
* 2. 点的坐标必须在线段两端点坐标范围内
|
|
||||||
*/
|
|
||||||
export const isInLine = (
|
|
||||||
p: ControlPoint,
|
|
||||||
p1: ControlPoint,
|
|
||||||
p2: ControlPoint
|
|
||||||
) => {
|
|
||||||
// 获取x坐标的范围区间[min, max]
|
|
||||||
const xPoints = p1.x < p2.x ? [p1.x, p2.x] : [p2.x, p1.x];
|
|
||||||
// 获取y坐标的范围区间[min, max]
|
|
||||||
const yPoints = p1.y < p2.y ? [p1.y, p2.y] : [p2.y, p1.y];
|
|
||||||
|
|
||||||
return (
|
|
||||||
// 垂直线段:三点x坐标相等,且待判断点的y坐标在范围内
|
|
||||||
(p1.x === p.x && p.x === p2.x && p.y >= yPoints[0] && p.y <= yPoints[1]) ||
|
|
||||||
// 水平线段:三点y坐标相等,且待判断点的x坐标在范围内
|
|
||||||
(p1.y === p.y && p.y === p2.y && p.x >= xPoints[0] && p.x <= xPoints[1])
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 判断一个点是否在直线上(不考虑线段端点限制)
|
|
||||||
* @param p - 待判断的点
|
|
||||||
* @param p1 - 直线上的点1
|
|
||||||
* @param p2 - 直线上的点2
|
|
||||||
* @returns 如果点在直线上返回true,否则返回false
|
|
||||||
*
|
|
||||||
* 判断逻辑:
|
|
||||||
* 仅判断点是否与直线上的两点共线(x坐标相等或y坐标相等)
|
|
||||||
*/
|
|
||||||
export const isOnLine = (
|
|
||||||
p: ControlPoint,
|
|
||||||
p1: ControlPoint,
|
|
||||||
p2: ControlPoint
|
|
||||||
) => {
|
|
||||||
return (p1.x === p.x && p.x === p2.x) || (p1.y === p.y && p.y === p2.y);
|
|
||||||
};
|
|
||||||
export interface OptimizePointsParams {
|
|
||||||
edgePoints: ControlPoint[];
|
|
||||||
source: HandlePosition;
|
|
||||||
target: HandlePosition;
|
|
||||||
sourceOffset: ControlPoint;
|
|
||||||
targetOffset: ControlPoint;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 优化边的控制点
|
|
||||||
*
|
|
||||||
* 主要功能:
|
|
||||||
* 1. 合并坐标相近的点
|
|
||||||
* 2. 删除重复的坐标点
|
|
||||||
* 3. 修正起点和终点的位置
|
|
||||||
*
|
|
||||||
* @param p 包含边的起点、终点、偏移点和中间控制点等信息的参数对象
|
|
||||||
* @returns 优化后的控制点信息,包含起点、终点、起点偏移、终点偏移和中间控制点
|
|
||||||
*/
|
|
||||||
export const optimizeInputPoints = (p: OptimizePointsParams) => {
|
|
||||||
// 合并坐标相近的点,将所有点放入一个数组进行处理
|
|
||||||
let edgePoints = mergeClosePoints([
|
|
||||||
p.source,
|
|
||||||
p.sourceOffset,
|
|
||||||
...p.edgePoints,
|
|
||||||
p.targetOffset,
|
|
||||||
p.target,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// 从合并后的点中提取起点和终点
|
|
||||||
const source = edgePoints.shift()!;
|
|
||||||
const target = edgePoints.pop()!;
|
|
||||||
const sourceOffset = edgePoints[0];
|
|
||||||
const targetOffset = edgePoints[edgePoints.length - 1];
|
|
||||||
|
|
||||||
// 根据起点和终点的位置类型修正其坐标
|
|
||||||
// 如果是水平方向,则保持x坐标不变;否则保持y坐标不变
|
|
||||||
if (isHorizontalFromPosition(p.source.position)) {
|
|
||||||
source.x = p.source.x;
|
|
||||||
} else {
|
|
||||||
source.y = p.source.y;
|
|
||||||
}
|
|
||||||
if (isHorizontalFromPosition(p.target.position)) {
|
|
||||||
target.x = p.target.x;
|
|
||||||
} else {
|
|
||||||
target.y = p.target.y;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 移除重复的坐标点,并为每个点分配唯一ID
|
|
||||||
edgePoints = removeRepeatPoints(edgePoints).map((p, idx) => ({
|
|
||||||
...p,
|
|
||||||
id: `${idx + 1}`,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return { source, target, sourceOffset, targetOffset, edgePoints };
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 简化边的控制点
|
|
||||||
*
|
|
||||||
* 主要功能:
|
|
||||||
* 1. 确保直线上只保留两个端点
|
|
||||||
* 2. 移除位于直线内部的控制点
|
|
||||||
*
|
|
||||||
* 实现原理:
|
|
||||||
* - 遍历所有中间点
|
|
||||||
* - 判断每个点是否在其相邻两点形成的直线上
|
|
||||||
* - 如果在直线上则移除该点
|
|
||||||
*
|
|
||||||
* @param points 原始控制点数组
|
|
||||||
* @returns 简化后的控制点数组
|
|
||||||
*/
|
|
||||||
export function reducePoints(points: ControlPoint[]): ControlPoint[] {
|
|
||||||
const optimizedPoints = [points[0]];
|
|
||||||
|
|
||||||
// 遍历除首尾点外的所有点
|
|
||||||
for (let i = 1; i < points.length - 1; i++) {
|
|
||||||
// 判断当前点是否在前后两点形成的直线上
|
|
||||||
const inSegment = isInLine(points[i], points[i - 1], points[i + 1]);
|
|
||||||
// 如果不在直线上,则保留该点
|
|
||||||
if (!inSegment) {
|
|
||||||
optimizedPoints.push(points[i]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
optimizedPoints.push(points[points.length - 1]);
|
|
||||||
return optimizedPoints;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* 坐标点处理工具函数集合
|
|
||||||
* 主要用于图形边缘控制点的坐标处理,包括合并、去重等操作
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 合并临近坐标点,同时将坐标值取整
|
|
||||||
* @param points 控制点数组
|
|
||||||
* @param threshold 合并阈值,默认为4个像素
|
|
||||||
* @returns 处理后的控制点数组
|
|
||||||
*
|
|
||||||
* 实现原理:
|
|
||||||
* 1. 分别记录x和y轴上的所有坐标值
|
|
||||||
* 2. 对每个新坐标,在阈值范围内查找已存在的相近值
|
|
||||||
* 3. 如果找到相近值则使用已存在值,否则添加新值
|
|
||||||
*/
|
|
||||||
export function mergeClosePoints(
|
|
||||||
points: ControlPoint[],
|
|
||||||
threshold = 4
|
|
||||||
): ControlPoint[] {
|
|
||||||
// 存储已处理的离散坐标值
|
|
||||||
const positions = { x: [] as number[], y: [] as number[] };
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 查找或添加坐标值
|
|
||||||
* @param axis 坐标轴('x'|'y')
|
|
||||||
* @param v 待处理的坐标值
|
|
||||||
* @returns 最终使用的坐标值
|
|
||||||
*/
|
|
||||||
const findPosition = (axis: "x" | "y", v: number) => {
|
|
||||||
// 向下取整,确保坐标为整数
|
|
||||||
v = Math.floor(v);
|
|
||||||
const ps = positions[axis];
|
|
||||||
// 在阈值范围内查找已存在的相近值
|
|
||||||
let p = ps.find((e) => Math.abs(v - e) < threshold);
|
|
||||||
// 如果没找到相近值,则添加新值
|
|
||||||
if (p == null) {
|
|
||||||
p = v;
|
|
||||||
positions[axis].push(v);
|
|
||||||
}
|
|
||||||
return p;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 处理每个控制点的坐标
|
|
||||||
const finalPoints = points.map((point) => {
|
|
||||||
return {
|
|
||||||
...point,
|
|
||||||
x: findPosition("x", point.x),
|
|
||||||
y: findPosition("y", point.y),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return finalPoints;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 判断两个控制点是否重合
|
|
||||||
* @param p1 控制点1
|
|
||||||
* @param p2 控制点2
|
|
||||||
* @returns 是否重合
|
|
||||||
*/
|
|
||||||
export function isEqualPoint(p1: ControlPoint, p2: ControlPoint) {
|
|
||||||
return p1.x === p2.x && p1.y === p2.y;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 移除重复的控制点,但保留起点和终点
|
|
||||||
* @param points 控制点数组
|
|
||||||
* @returns 去重后的控制点数组
|
|
||||||
*
|
|
||||||
* 实现思路:
|
|
||||||
* 1. 使用Set存储已处理的坐标字符串(格式:"x-y")
|
|
||||||
* 2. 保留最后一个点(终点)
|
|
||||||
* 3. 遍历时跳过重复坐标,但保留第一次出现的点
|
|
||||||
*/
|
|
||||||
export function removeRepeatPoints(points: ControlPoint[]): ControlPoint[] {
|
|
||||||
// 先添加终点坐标,确保终点被保留
|
|
||||||
const lastP = points[points.length - 1];
|
|
||||||
const uniquePoints = new Set([`${lastP.x}-${lastP.y}`]);
|
|
||||||
const finalPoints: ControlPoint[] = [];
|
|
||||||
|
|
||||||
points.forEach((p, idx) => {
|
|
||||||
// 处理终点
|
|
||||||
if (idx === points.length - 1) {
|
|
||||||
return finalPoints.push(p);
|
|
||||||
}
|
|
||||||
// 使用坐标字符串作为唯一标识
|
|
||||||
const key = `${p.x}-${p.y}`;
|
|
||||||
if (!uniquePoints.has(key)) {
|
|
||||||
uniquePoints.add(key);
|
|
||||||
finalPoints.push(p);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return finalPoints;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* 判断两条线段是否相交
|
|
||||||
* @param p0 第一条线段的起点
|
|
||||||
* @param p1 第一条线段的终点
|
|
||||||
* @param p2 第二条线段的起点
|
|
||||||
* @param p3 第二条线段的终点
|
|
||||||
* @returns 如果两线段相交返回true,否则返回false
|
|
||||||
*
|
|
||||||
* 实现原理:
|
|
||||||
* 1. 使用向量叉积判断两线段是否平行
|
|
||||||
* 2. 使用参数方程求解交点参数s和t
|
|
||||||
* 3. 判断参数是否在[0,1]区间内来确定是否相交
|
|
||||||
*/
|
|
||||||
const isSegmentsIntersected = (
|
|
||||||
p0: ControlPoint,
|
|
||||||
p1: ControlPoint,
|
|
||||||
p2: ControlPoint,
|
|
||||||
p3: ControlPoint
|
|
||||||
): boolean => {
|
|
||||||
// 计算两条线段的方向向量
|
|
||||||
const s1x = p1.x - p0.x;
|
|
||||||
const s1y = p1.y - p0.y;
|
|
||||||
const s2x = p3.x - p2.x;
|
|
||||||
const s2y = p3.y - p2.y;
|
|
||||||
|
|
||||||
// 使用向量叉积判断两线段是否平行
|
|
||||||
if (s1x * s2y - s1y * s2x === 0) {
|
|
||||||
// 平行线段必不相交
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 求解参数方程,获取交点参数s和t
|
|
||||||
const denominator = -s2x * s1y + s1x * s2y;
|
|
||||||
const s = (s1y * (p2.x - p0.x) - s1x * (p2.y - p0.y)) / denominator;
|
|
||||||
const t = (s2x * (p0.y - p2.y) - s2y * (p0.x - p2.x)) / denominator;
|
|
||||||
|
|
||||||
// 当且仅当s和t都在[0,1]区间内时,两线段相交
|
|
||||||
return s >= 0 && s <= 1 && t >= 0 && t <= 1;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 判断线段是否与矩形相交
|
|
||||||
* @param p1 线段起点
|
|
||||||
* @param p2 线段终点
|
|
||||||
* @param box 矩形区域
|
|
||||||
* @returns 如果线段与矩形有交点返回true,否则返回false
|
|
||||||
*
|
|
||||||
* 实现思路:
|
|
||||||
* 1. 首先处理特殊情况:矩形退化为点时必不相交
|
|
||||||
* 2. 将矩形分解为四条边
|
|
||||||
* 3. 判断线段是否与任意一条矩形边相交
|
|
||||||
* 4. 只要与任意一边相交,则与矩形相交
|
|
||||||
*/
|
|
||||||
export const isSegmentCrossingRect = (
|
|
||||||
p1: ControlPoint,
|
|
||||||
p2: ControlPoint,
|
|
||||||
box: NodeRect
|
|
||||||
): boolean => {
|
|
||||||
// 处理特殊情况:矩形退化为点
|
|
||||||
if (box.width === 0 && box.height === 0) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取矩形的四个顶点
|
|
||||||
const [topLeft, topRight, bottomRight, bottomLeft] = getRectVertices(box);
|
|
||||||
|
|
||||||
// 判断线段是否与矩形的任意一条边相交
|
|
||||||
return (
|
|
||||||
isSegmentsIntersected(p1, p2, topLeft, topRight) || // 上边
|
|
||||||
isSegmentsIntersected(p1, p2, topRight, bottomRight) || // 右边
|
|
||||||
isSegmentsIntersected(p1, p2, bottomRight, bottomLeft) || // 下边
|
|
||||||
isSegmentsIntersected(p1, p2, bottomLeft, topLeft) // 左边
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,200 +0,0 @@
|
||||||
import { deepClone, lastOf } from "@/utils/base";
|
|
||||||
import { Position, getBezierPath } from "reactflow";
|
|
||||||
|
|
||||||
import { getBasePath } from ".";
|
|
||||||
import {
|
|
||||||
kBaseMarkerColor,
|
|
||||||
kBaseMarkerColors,
|
|
||||||
kNoMarkerColor,
|
|
||||||
kYesMarkerColor,
|
|
||||||
} from "../../components/Edges/Marker";
|
|
||||||
import { isEqual } from "../../utils/diff";
|
|
||||||
import { EdgeLayout, ReactFlowEdgeWithData } from "../../data/types";
|
|
||||||
import { kReactFlow } from "../../states/reactflow";
|
|
||||||
import { getPathWithRoundCorners } from "./edge";
|
|
||||||
|
|
||||||
interface EdgeStyle {
|
|
||||||
color: string;
|
|
||||||
edgeType: "solid" | "dashed";
|
|
||||||
pathType: "base" | "bezier";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the style of the connection line
|
|
||||||
*
|
|
||||||
* 1. When there are more than 3 edges connecting to both ends of the Node, use multiple colors to distinguish the edges.
|
|
||||||
* 2. When the connection line goes backward or connects to a hub Node, use dashed lines to distinguish the edges.
|
|
||||||
* 3. When the connection line goes from a hub to a Node, use bezier path.
|
|
||||||
*/
|
|
||||||
export const getEdgeStyles = (props: {
|
|
||||||
id: string;
|
|
||||||
isBackward: boolean;
|
|
||||||
}): EdgeStyle => {
|
|
||||||
const { id, isBackward } = props;
|
|
||||||
const idx = parseInt(lastOf(id.split("#")) ?? "0", 10);
|
|
||||||
if (isBackward) {
|
|
||||||
// Use dashed lines to distinguish the edges when the connection line goes backward or connects to a hub Node
|
|
||||||
return { color: kNoMarkerColor, edgeType: "dashed", pathType: "base" };
|
|
||||||
}
|
|
||||||
const edge: ReactFlowEdgeWithData = kReactFlow.instance!.getEdge(id)!;
|
|
||||||
if (edge.data!.targetPort.edges > 2) {
|
|
||||||
// Use dashed bezier path when the connection line connects to a hub Node
|
|
||||||
return {
|
|
||||||
color: kYesMarkerColor,
|
|
||||||
edgeType: "dashed",
|
|
||||||
pathType: "bezier",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (edge.data!.sourcePort.edges > 2) {
|
|
||||||
// Use multiple colors to distinguish the edges when there are more than 3 edges connecting to both ends of the Node
|
|
||||||
return {
|
|
||||||
color: kBaseMarkerColors[idx % kBaseMarkerColors.length],
|
|
||||||
edgeType: "solid",
|
|
||||||
pathType: "base",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { color: kBaseMarkerColor, edgeType: "solid", pathType: "base" };
|
|
||||||
};
|
|
||||||
|
|
||||||
interface ILayoutEdge {
|
|
||||||
id: string;
|
|
||||||
layout?: EdgeLayout;
|
|
||||||
offset: number;
|
|
||||||
borderRadius: number;
|
|
||||||
pathType: EdgeStyle["pathType"];
|
|
||||||
source: string;
|
|
||||||
target: string;
|
|
||||||
sourceX: number;
|
|
||||||
sourceY: number;
|
|
||||||
targetX: number;
|
|
||||||
targetY: number;
|
|
||||||
sourcePosition: Position;
|
|
||||||
targetPosition: Position;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function layoutEdge({
|
|
||||||
id,
|
|
||||||
layout,
|
|
||||||
offset,
|
|
||||||
borderRadius,
|
|
||||||
pathType,
|
|
||||||
source,
|
|
||||||
target,
|
|
||||||
sourceX,
|
|
||||||
sourceY,
|
|
||||||
targetX,
|
|
||||||
targetY,
|
|
||||||
sourcePosition,
|
|
||||||
targetPosition,
|
|
||||||
}: ILayoutEdge): EdgeLayout {
|
|
||||||
const relayoutDeps = [sourceX, sourceY, targetX, targetY];
|
|
||||||
const needRelayout = !isEqual(relayoutDeps, layout?.deps?.relayoutDeps);
|
|
||||||
const reBuildPathDeps = layout?.points;
|
|
||||||
const needReBuildPath = !isEqual(
|
|
||||||
reBuildPathDeps,
|
|
||||||
layout?.deps?.reBuildPathDeps
|
|
||||||
);
|
|
||||||
let newLayout = layout;
|
|
||||||
if (needRelayout) {
|
|
||||||
newLayout = _layoutEdge({
|
|
||||||
id,
|
|
||||||
offset,
|
|
||||||
borderRadius,
|
|
||||||
pathType,
|
|
||||||
source,
|
|
||||||
target,
|
|
||||||
sourceX,
|
|
||||||
sourceY,
|
|
||||||
targetX,
|
|
||||||
targetY,
|
|
||||||
sourcePosition,
|
|
||||||
targetPosition,
|
|
||||||
});
|
|
||||||
} else if (needReBuildPath) {
|
|
||||||
newLayout = _layoutEdge({
|
|
||||||
layout,
|
|
||||||
id,
|
|
||||||
offset,
|
|
||||||
borderRadius,
|
|
||||||
pathType,
|
|
||||||
source,
|
|
||||||
target,
|
|
||||||
sourceX,
|
|
||||||
sourceY,
|
|
||||||
targetX,
|
|
||||||
targetY,
|
|
||||||
sourcePosition,
|
|
||||||
targetPosition,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
newLayout!.deps = deepClone({ relayoutDeps, reBuildPathDeps });
|
|
||||||
return newLayout!;
|
|
||||||
}
|
|
||||||
|
|
||||||
function _layoutEdge({
|
|
||||||
id,
|
|
||||||
layout,
|
|
||||||
offset,
|
|
||||||
borderRadius,
|
|
||||||
pathType,
|
|
||||||
source,
|
|
||||||
target,
|
|
||||||
sourceX,
|
|
||||||
sourceY,
|
|
||||||
targetX,
|
|
||||||
targetY,
|
|
||||||
sourcePosition,
|
|
||||||
targetPosition,
|
|
||||||
}: ILayoutEdge): EdgeLayout {
|
|
||||||
const _pathType: EdgeStyle["pathType"] = pathType;
|
|
||||||
if (_pathType === "bezier") {
|
|
||||||
const [path, labelX, labelY] = getBezierPath({
|
|
||||||
sourceX,
|
|
||||||
sourceY,
|
|
||||||
targetX,
|
|
||||||
targetY,
|
|
||||||
sourcePosition,
|
|
||||||
targetPosition,
|
|
||||||
});
|
|
||||||
const points = [
|
|
||||||
{
|
|
||||||
id: "source-" + id,
|
|
||||||
x: sourceX,
|
|
||||||
y: sourceY,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "target-" + id,
|
|
||||||
x: targetX,
|
|
||||||
y: targetY,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
return {
|
|
||||||
path,
|
|
||||||
points,
|
|
||||||
inputPoints: points,
|
|
||||||
labelPosition: {
|
|
||||||
x: labelX,
|
|
||||||
y: labelY,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((layout?.points?.length ?? 0) > 1) {
|
|
||||||
layout!.path = getPathWithRoundCorners(layout!.points, borderRadius);
|
|
||||||
return layout!;
|
|
||||||
}
|
|
||||||
|
|
||||||
return getBasePath({
|
|
||||||
id,
|
|
||||||
offset,
|
|
||||||
borderRadius,
|
|
||||||
source,
|
|
||||||
target,
|
|
||||||
sourceX,
|
|
||||||
sourceY,
|
|
||||||
targetX,
|
|
||||||
targetY,
|
|
||||||
sourcePosition,
|
|
||||||
targetPosition,
|
|
||||||
});
|
|
||||||
}
|
|
|
@ -1,34 +0,0 @@
|
||||||
import { Node, Edge } from "@xyflow/react";
|
|
||||||
import { LayoutOptions, LayoutStrategy } from "./types";
|
|
||||||
import { TreeLayout } from "./TreeLayout";
|
|
||||||
import { MindMapLayout } from "./MindMapLayout";
|
|
||||||
import { SingleMapLayout } from "./SingleMapLayout";
|
|
||||||
|
|
||||||
// 布局工厂类
|
|
||||||
class LayoutFactory {
|
|
||||||
static createLayout(type: 'mindmap' | 'tree' | 'force' | 'single'): LayoutStrategy {
|
|
||||||
switch (type) {
|
|
||||||
case 'mindmap':
|
|
||||||
return new MindMapLayout();
|
|
||||||
case 'tree':
|
|
||||||
return new TreeLayout();
|
|
||||||
case 'single':
|
|
||||||
return new SingleMapLayout()
|
|
||||||
case 'force':
|
|
||||||
// return new ForceLayout(); // 待实现
|
|
||||||
default:
|
|
||||||
return new MindMapLayout();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 导出布局函数
|
|
||||||
export function getLayout(type: 'mindmap' | 'tree' | 'force' | 'single', options: LayoutOptions) {
|
|
||||||
const layoutStrategy = LayoutFactory.createLayout(type);
|
|
||||||
return layoutStrategy.layout(options);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 为了保持向后兼容,保留原有的导出
|
|
||||||
export function getMindMapLayout(options: LayoutOptions) {
|
|
||||||
return getLayout("single", options);
|
|
||||||
}
|
|
|
@ -1,148 +0,0 @@
|
||||||
import { MarkerType, Position, useInternalNode, Node, Edge } from "@xyflow/react";
|
|
||||||
import { LayoutDirection, LayoutVisibility } from "./node";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取流程图中的根节点
|
|
||||||
* @param nodes - 所有节点数组
|
|
||||||
* @param edges - 所有边的数组
|
|
||||||
* @returns 根节点数组(没有入边的节点)
|
|
||||||
*/
|
|
||||||
export const getRootNodes = (nodes: Node[], edges: Edge[]): Node[] => {
|
|
||||||
// 创建一个Set来存储所有有入边的节点ID
|
|
||||||
const nodesWithIncoming = new Set(
|
|
||||||
edges.map((edge) => edge.target)
|
|
||||||
);
|
|
||||||
|
|
||||||
// 过滤出没有入边的节点
|
|
||||||
const rootNodes = nodes.filter(
|
|
||||||
(node) => !nodesWithIncoming.has(node.id)
|
|
||||||
);
|
|
||||||
|
|
||||||
return rootNodes;
|
|
||||||
};
|
|
||||||
/**
|
|
||||||
* 获取节点尺寸信息的工具函数
|
|
||||||
* @param node 需要获取尺寸的节点对象
|
|
||||||
* @param defaultSize 默认尺寸配置,包含默认宽度(150px)和高度(36px)
|
|
||||||
* @returns 返回节点的尺寸信息对象,包含:
|
|
||||||
* - hasDimension: 是否已设置实际尺寸
|
|
||||||
* - width: 节点实际宽度
|
|
||||||
* - height: 节点实际高度
|
|
||||||
* - widthWithDefault: 实际宽度或默认宽度
|
|
||||||
* - heightWithDefault: 实际高度或默认高度
|
|
||||||
*/
|
|
||||||
export const getNodeSize = (
|
|
||||||
node: Node,
|
|
||||||
defaultSize = { width: 150, height: 36 }
|
|
||||||
) => {
|
|
||||||
// 获取节点的实际宽高
|
|
||||||
const nodeWith = node?.width;
|
|
||||||
const nodeHeight = node?.height;
|
|
||||||
// 检查节点是否同时设置了宽度和高度
|
|
||||||
const hasDimension = [nodeWith, nodeHeight].every((e) => e != null);
|
|
||||||
|
|
||||||
// 返回包含完整尺寸信息的对象
|
|
||||||
// 使用空值合并运算符(??)在实际尺寸未设置时使用默认值
|
|
||||||
return {
|
|
||||||
hasDimension,
|
|
||||||
width: nodeWith,
|
|
||||||
height: nodeHeight,
|
|
||||||
widthWithDefault: nodeWith ?? defaultSize.width,
|
|
||||||
heightWithDefault: nodeHeight ?? defaultSize.height,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export type IFixPosition = (pros: {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
}) => {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
};
|
|
||||||
/**
|
|
||||||
* 节点布局计算函数
|
|
||||||
* @description 根据给定的节点信息和布局参数,计算节点的最终布局属性
|
|
||||||
* @param props 布局参数对象
|
|
||||||
* @param props.node 需要布局的节点对象
|
|
||||||
* @param props.position 节点的初始位置坐标
|
|
||||||
* @param props.direction 布局方向,'horizontal'表示水平布局,'vertical'表示垂直布局
|
|
||||||
* @param props.visibility 节点可见性,'visible'表示可见,其他值表示隐藏
|
|
||||||
* @param props.fixPosition 可选的位置修正函数,用于调整最终位置
|
|
||||||
* @returns 返回计算好布局属性的节点对象
|
|
||||||
*/
|
|
||||||
export const getNodeLayouted = (props: {
|
|
||||||
node: Node;
|
|
||||||
position: { x: number; y: number };
|
|
||||||
direction: LayoutDirection;
|
|
||||||
visibility: LayoutVisibility;
|
|
||||||
fixPosition?: IFixPosition;
|
|
||||||
}) => {
|
|
||||||
// 解构布局参数,设置位置修正函数的默认值
|
|
||||||
const {
|
|
||||||
node,
|
|
||||||
position,
|
|
||||||
direction,
|
|
||||||
visibility,
|
|
||||||
fixPosition = (p) => ({ x: p.x, y: p.y }),
|
|
||||||
} = props;
|
|
||||||
|
|
||||||
// 计算节点的显示状态和布局方向
|
|
||||||
const hidden = visibility !== "visible";
|
|
||||||
const isHorizontal = direction === "horizontal";
|
|
||||||
|
|
||||||
// 获取节点尺寸信息
|
|
||||||
const { width, height, widthWithDefault, heightWithDefault } =
|
|
||||||
getNodeSize(node);
|
|
||||||
|
|
||||||
// 根据布局方向设置节点的连接点位置
|
|
||||||
node.targetPosition = isHorizontal ? Position.Left : Position.Top;
|
|
||||||
node.sourcePosition = isHorizontal ? Position.Right : Position.Bottom;
|
|
||||||
|
|
||||||
// 返回带有完整布局属性的节点对象
|
|
||||||
return {
|
|
||||||
...node,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
hidden,
|
|
||||||
position: fixPosition({
|
|
||||||
...position,
|
|
||||||
width: widthWithDefault,
|
|
||||||
height: heightWithDefault,
|
|
||||||
}),
|
|
||||||
style: {
|
|
||||||
...node.style,
|
|
||||||
opacity: hidden ? 0 : 1,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 边布局计算函数
|
|
||||||
* @description 根据给定的边信息和可见性参数,计算边的最终布局属性
|
|
||||||
* @param props 布局参数对象
|
|
||||||
* @param props.edge 需要布局的边对象
|
|
||||||
* @param props.visibility 边的可见性,'visible'表示可见,其他值表示隐藏
|
|
||||||
* @returns 返回计算好布局属性的边对象
|
|
||||||
*/
|
|
||||||
export const getEdgeLayouted = (props: {
|
|
||||||
edge: Edge;
|
|
||||||
visibility: LayoutVisibility;
|
|
||||||
}) => {
|
|
||||||
const { edge, visibility } = props;
|
|
||||||
const hidden = visibility !== "visible";
|
|
||||||
|
|
||||||
// 返回带有完整布局属性的边对象
|
|
||||||
return {
|
|
||||||
...edge,
|
|
||||||
hidden,
|
|
||||||
markerEnd: {
|
|
||||||
type: MarkerType.ArrowClosed, // 设置箭头样式为闭合箭头
|
|
||||||
},
|
|
||||||
style: {
|
|
||||||
...edge.style,
|
|
||||||
opacity: hidden ? 0 : 1,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
|
@ -1,121 +0,0 @@
|
||||||
import { graphStratify, sugiyama } from "d3-dag";
|
|
||||||
import { getIncomers, type Node } from "@xyflow/react";
|
|
||||||
import { getEdgeLayouted, getNodeLayouted, getNodeSize } from "../../metadata";
|
|
||||||
import { LayoutAlgorithm, LayoutAlgorithmProps } from "..";
|
|
||||||
type NodeWithPosition = Node & { x: number; y: number };
|
|
||||||
|
|
||||||
// Since d3-dag layout algorithm does not support multiple root nodes,
|
|
||||||
// we attach the sub-workflows to the global rootNode.
|
|
||||||
const rootNode: NodeWithPosition = {
|
|
||||||
id: "#root",
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
position: { x: 0, y: 0 },
|
|
||||||
data: {} as any,
|
|
||||||
};
|
|
||||||
|
|
||||||
const algorithms = {
|
|
||||||
"d3-dag": "d3-dag",
|
|
||||||
"ds-dag(s)": "ds-dag(s)",
|
|
||||||
};
|
|
||||||
|
|
||||||
export type D3DAGLayoutAlgorithms = "d3-dag" | "ds-dag(s)";
|
|
||||||
|
|
||||||
export const layoutD3DAG = async (
|
|
||||||
props: LayoutAlgorithmProps & { algorithm?: D3DAGLayoutAlgorithms }
|
|
||||||
) => {
|
|
||||||
const {
|
|
||||||
nodes,
|
|
||||||
edges,
|
|
||||||
direction,
|
|
||||||
visibility,
|
|
||||||
spacing,
|
|
||||||
algorithm = "d3-dag",
|
|
||||||
} = props;
|
|
||||||
const isHorizontal = direction === "horizontal";
|
|
||||||
|
|
||||||
const initialNodes = [] as NodeWithPosition[];
|
|
||||||
let maxNodeWidth = 0;
|
|
||||||
let maxNodeHeight = 0;
|
|
||||||
for (const node of nodes) {
|
|
||||||
const { widthWithDefault, heightWithDefault } = getNodeSize(node);
|
|
||||||
initialNodes.push({
|
|
||||||
...node,
|
|
||||||
...node.position,
|
|
||||||
width: widthWithDefault,
|
|
||||||
height: heightWithDefault,
|
|
||||||
});
|
|
||||||
maxNodeWidth = Math.max(maxNodeWidth, widthWithDefault);
|
|
||||||
maxNodeHeight = Math.max(maxNodeHeight, heightWithDefault);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Since d3-dag does not support horizontal layout,
|
|
||||||
// we swap the width and height of nodes and interchange x and y mappings based on the layout direction.
|
|
||||||
const nodeSize: any = isHorizontal
|
|
||||||
? [maxNodeHeight + spacing.y, maxNodeWidth + spacing.x]
|
|
||||||
: [maxNodeWidth + spacing.x, maxNodeHeight + spacing.y];
|
|
||||||
|
|
||||||
const getParentIds = (node: Node) => {
|
|
||||||
if (node.id === rootNode.id) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
// Node without input is the root node of sub-workflow, and we should connect it to the rootNode
|
|
||||||
const incomers = getIncomers(node, nodes, edges);
|
|
||||||
if (incomers.length < 1) {
|
|
||||||
return [rootNode.id];
|
|
||||||
}
|
|
||||||
return algorithm === "d3-dag"
|
|
||||||
? [incomers[0]?.id]
|
|
||||||
: incomers.map((e) => e.id);
|
|
||||||
};
|
|
||||||
|
|
||||||
const stratify = graphStratify();
|
|
||||||
const dag = stratify(
|
|
||||||
[rootNode, ...initialNodes].map((node) => {
|
|
||||||
return {
|
|
||||||
id: node.id,
|
|
||||||
parentIds: getParentIds(node),
|
|
||||||
};
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const layout = sugiyama().nodeSize(nodeSize);
|
|
||||||
layout(dag);
|
|
||||||
|
|
||||||
const layoutNodes = new Map<string, any>();
|
|
||||||
for (const node of dag.nodes()) {
|
|
||||||
layoutNodes.set(node.data.id, node);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
nodes: nodes.map((node) => {
|
|
||||||
const { x, y } = layoutNodes.get(node.id);
|
|
||||||
// Interchange x and y mappings based on the layout direction.
|
|
||||||
const position = isHorizontal ? { x: y, y: x } : { x, y };
|
|
||||||
return getNodeLayouted({
|
|
||||||
node,
|
|
||||||
position,
|
|
||||||
direction,
|
|
||||||
visibility,
|
|
||||||
fixPosition: ({ x, y, width, height }) => {
|
|
||||||
// This algorithm uses the center coordinate of the node as the reference point,
|
|
||||||
// which needs adjustment for ReactFlow's topLeft coordinate system.
|
|
||||||
return {
|
|
||||||
x: x - width / 2,
|
|
||||||
y: y - height / 2,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
edges: edges.map((edge) => getEdgeLayouted({ edge, visibility })),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const kD3DAGAlgorithms: Record<string, LayoutAlgorithm> = Object.keys(
|
|
||||||
algorithms
|
|
||||||
).reduce((pre, algorithm) => {
|
|
||||||
pre[algorithm] = (props: any) => {
|
|
||||||
return layoutD3DAG({ ...props, algorithm });
|
|
||||||
};
|
|
||||||
return pre;
|
|
||||||
}, {} as any);
|
|
|
@ -1,89 +0,0 @@
|
||||||
// Based on: https://github.com/flanksource/flanksource-ui/blob/75b35591d3bbc7d446fa326d0ca7536790f38d88/src/ui/Graphs/Layouts/algorithms/d3-hierarchy.ts
|
|
||||||
|
|
||||||
import { stratify, tree, type HierarchyPointNode } from "d3-hierarchy";
|
|
||||||
import {getIncomers, Node} from "@xyflow/react"
|
|
||||||
import { LayoutAlgorithm } from "..";
|
|
||||||
import { getEdgeLayouted, getNodeLayouted, getNodeSize } from "../../metadata";
|
|
||||||
type NodeWithPosition = Node & { x: number; y: number };
|
|
||||||
|
|
||||||
const layout = tree<NodeWithPosition>().separation(() => 1);
|
|
||||||
|
|
||||||
// Since d3-hierarchy layout algorithm does not support multiple root nodes,
|
|
||||||
// we attach the sub-workflows to the global rootNode.
|
|
||||||
const rootNode: NodeWithPosition = {
|
|
||||||
id: "#root",
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
position: { x: 0, y: 0 },
|
|
||||||
data: {} as any,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const layoutD3Hierarchy: LayoutAlgorithm = async (props) => {
|
|
||||||
const { nodes, edges, direction, visibility, spacing } = props;
|
|
||||||
const isHorizontal = direction === "horizontal";
|
|
||||||
|
|
||||||
const initialNodes = [] as NodeWithPosition[];
|
|
||||||
let maxNodeWidth = 0;
|
|
||||||
let maxNodeHeight = 0;
|
|
||||||
for (const node of nodes) {
|
|
||||||
const { widthWithDefault, heightWithDefault } = getNodeSize(node);
|
|
||||||
initialNodes.push({
|
|
||||||
...node,
|
|
||||||
...node.position,
|
|
||||||
width: widthWithDefault,
|
|
||||||
height: heightWithDefault,
|
|
||||||
});
|
|
||||||
maxNodeWidth = Math.max(maxNodeWidth, widthWithDefault);
|
|
||||||
maxNodeHeight = Math.max(maxNodeHeight, heightWithDefault);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Since d3-hierarchy does not support horizontal layout,
|
|
||||||
// we swap the width and height of nodes and interchange x and y mappings based on the layout direction.
|
|
||||||
const nodeSize: [number, number] = isHorizontal
|
|
||||||
? [maxNodeHeight + spacing.y, maxNodeWidth + spacing.x]
|
|
||||||
: [maxNodeWidth + spacing.x, maxNodeHeight + spacing.y];
|
|
||||||
|
|
||||||
layout.nodeSize(nodeSize);
|
|
||||||
|
|
||||||
const getParentId = (node: Node) => {
|
|
||||||
if (node.id === rootNode.id) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
// Node without input is the root node of sub-workflow, and we should connect it to the rootNode
|
|
||||||
const incomers = getIncomers(node, nodes, edges);
|
|
||||||
return incomers[0]?.id || rootNode.id;
|
|
||||||
};
|
|
||||||
|
|
||||||
const hierarchy = stratify<NodeWithPosition>()
|
|
||||||
.id((d) => d.id)
|
|
||||||
.parentId(getParentId)([rootNode, ...initialNodes]);
|
|
||||||
|
|
||||||
const root = layout(hierarchy);
|
|
||||||
const layoutNodes = new Map<string, HierarchyPointNode<NodeWithPosition>>();
|
|
||||||
for (const node of root) {
|
|
||||||
layoutNodes.set(node.id!, node);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
nodes: nodes.map((node) => {
|
|
||||||
const { x, y } = layoutNodes.get(node.id)!;
|
|
||||||
// Interchange x and y mappings based on the layout direction.
|
|
||||||
const position = isHorizontal ? { x: y, y: x } : { x, y };
|
|
||||||
return getNodeLayouted({
|
|
||||||
node,
|
|
||||||
position,
|
|
||||||
direction,
|
|
||||||
visibility,
|
|
||||||
fixPosition: ({ x, y, width, height }) => {
|
|
||||||
// This algorithm uses the center coordinate of the node as the reference point,
|
|
||||||
// which needs adjustment for ReactFlow's topLeft coordinate system.
|
|
||||||
return {
|
|
||||||
x: x - width / 2,
|
|
||||||
y: y - height / 2,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
edges: edges.map((edge) => getEdgeLayouted({ edge, visibility })),
|
|
||||||
};
|
|
||||||
};
|
|
|
@ -1,122 +0,0 @@
|
||||||
import dagre from "@dagrejs/dagre";
|
|
||||||
import { LayoutAlgorithm } from "..";
|
|
||||||
import { getIncomers, Node } from "@xyflow/react";
|
|
||||||
import { getEdgeLayouted, getNodeLayouted, getNodeSize } from "../../metadata";
|
|
||||||
import { randomInt } from "../../../utils/base";
|
|
||||||
|
|
||||||
// 布局配置常量
|
|
||||||
const LAYOUT_CONFIG = {
|
|
||||||
VIRTUAL_ROOT_ID: '#root',
|
|
||||||
VIRTUAL_NODE_SIZE: 1,
|
|
||||||
RANKER: 'tight-tree',
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
// 创建并配置 dagre 图实例
|
|
||||||
const createDagreGraph = () => {
|
|
||||||
const graph = new dagre.graphlib.Graph();
|
|
||||||
graph.setDefaultEdgeLabel(() => ({}));
|
|
||||||
return graph;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 获取布局方向配置
|
|
||||||
const getLayoutConfig = (
|
|
||||||
direction: 'horizontal' | 'vertical',
|
|
||||||
spacing: { x: number, y: number },
|
|
||||||
graph: dagre.graphlib.Graph
|
|
||||||
) => ({
|
|
||||||
nodesep: direction === 'horizontal' ? spacing.y : spacing.x,
|
|
||||||
ranksep: direction === 'horizontal' ? spacing.x : spacing.y,
|
|
||||||
ranker: LAYOUT_CONFIG.RANKER,
|
|
||||||
rankdir: direction === 'horizontal' ? 'LR' : 'TB',
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
// 查找根节点
|
|
||||||
const findRootNodes = (nodes: Node[], edges: any[]): Node[] =>
|
|
||||||
nodes.filter(node => getIncomers(node, nodes, edges).length < 1);
|
|
||||||
|
|
||||||
// 计算节点边界
|
|
||||||
const calculateBounds = (nodes: Node[], graph: dagre.graphlib.Graph) => {
|
|
||||||
const bounds = {
|
|
||||||
minX: Number.POSITIVE_INFINITY,
|
|
||||||
minY: Number.POSITIVE_INFINITY,
|
|
||||||
maxX: Number.NEGATIVE_INFINITY,
|
|
||||||
maxY: Number.NEGATIVE_INFINITY,
|
|
||||||
};
|
|
||||||
|
|
||||||
nodes.forEach(node => {
|
|
||||||
const pos = graph.node(node.id);
|
|
||||||
if (pos) {
|
|
||||||
bounds.minX = Math.min(bounds.minX, pos.x);
|
|
||||||
bounds.minY = Math.min(bounds.minY, pos.y);
|
|
||||||
bounds.maxX = Math.max(bounds.maxX, pos.x);
|
|
||||||
bounds.maxY = Math.max(bounds.maxY, pos.y);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return bounds;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const layoutDagreTree: LayoutAlgorithm = async ({
|
|
||||||
nodes,
|
|
||||||
edges,
|
|
||||||
direction,
|
|
||||||
visibility,
|
|
||||||
spacing
|
|
||||||
}) => {
|
|
||||||
const dagreGraph = createDagreGraph();
|
|
||||||
|
|
||||||
// 设置图的布局参数
|
|
||||||
dagreGraph.setGraph(getLayoutConfig(direction, spacing, dagreGraph));
|
|
||||||
|
|
||||||
// 添加节点
|
|
||||||
nodes.forEach((node) => {
|
|
||||||
const { widthWithDefault, heightWithDefault } = getNodeSize(node);
|
|
||||||
dagreGraph.setNode(node.id, {
|
|
||||||
width: widthWithDefault,
|
|
||||||
height: heightWithDefault,
|
|
||||||
order: randomInt(0, 10)
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// 添加边
|
|
||||||
edges.forEach(edge => dagreGraph.setEdge(edge.source, edge.target));
|
|
||||||
|
|
||||||
// 处理多个子工作流的情况
|
|
||||||
const rootNodes = findRootNodes(nodes, edges);
|
|
||||||
if (rootNodes.length > 1) {
|
|
||||||
dagreGraph.setNode(LAYOUT_CONFIG.VIRTUAL_ROOT_ID, {
|
|
||||||
width: LAYOUT_CONFIG.VIRTUAL_NODE_SIZE,
|
|
||||||
height: LAYOUT_CONFIG.VIRTUAL_NODE_SIZE,
|
|
||||||
rank: -1 // 确保虚拟根节点排在最前面
|
|
||||||
});
|
|
||||||
rootNodes.forEach(node =>
|
|
||||||
dagreGraph.setEdge(LAYOUT_CONFIG.VIRTUAL_ROOT_ID, node.id)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 执行布局
|
|
||||||
dagre.layout(dagreGraph);
|
|
||||||
|
|
||||||
// 移除虚拟根节点
|
|
||||||
if (rootNodes.length > 1) {
|
|
||||||
dagreGraph.removeNode(LAYOUT_CONFIG.VIRTUAL_ROOT_ID);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 计算边界并返回布局结果
|
|
||||||
const bounds = calculateBounds(nodes, dagreGraph);
|
|
||||||
|
|
||||||
return {
|
|
||||||
nodes: nodes.map(node => getNodeLayouted({
|
|
||||||
node,
|
|
||||||
position: dagreGraph.node(node.id),
|
|
||||||
direction,
|
|
||||||
visibility,
|
|
||||||
fixPosition: ({ x, y, width, height }) => ({
|
|
||||||
x: x - width / 2 - bounds.minX,
|
|
||||||
y: y - height / 2 - bounds.minY,
|
|
||||||
}),
|
|
||||||
})),
|
|
||||||
edges: edges.map(edge => getEdgeLayouted({ edge, visibility })),
|
|
||||||
};
|
|
||||||
};
|
|
|
@ -1,128 +0,0 @@
|
||||||
import ELK, { ElkNode } from "elkjs/lib/elk.bundled.js";
|
|
||||||
import { getIncomers,Node } from "@xyflow/react";
|
|
||||||
import { LayoutAlgorithm, LayoutAlgorithmProps } from "..";
|
|
||||||
import { getEdgeLayouted, getNodeLayouted, getNodeSize } from "../../metadata";
|
|
||||||
|
|
||||||
const algorithms = {
|
|
||||||
"elk-layered": "layered",
|
|
||||||
"elk-mr-tree": "mrtree",
|
|
||||||
};
|
|
||||||
|
|
||||||
const elk = new ELK({ algorithms: Object.values(algorithms) });
|
|
||||||
|
|
||||||
export type ELKLayoutAlgorithms = "elk-layered" | "elk-mr-tree";
|
|
||||||
|
|
||||||
export const layoutELK = async (
|
|
||||||
props: LayoutAlgorithmProps & { algorithm?: ELKLayoutAlgorithms }
|
|
||||||
) => {
|
|
||||||
const {
|
|
||||||
nodes,
|
|
||||||
edges,
|
|
||||||
direction,
|
|
||||||
visibility,
|
|
||||||
spacing,
|
|
||||||
algorithm = "elk-mr-tree",
|
|
||||||
} = props;
|
|
||||||
const isHorizontal = direction === "horizontal";
|
|
||||||
|
|
||||||
const subWorkflowRootNodes: Node[] = [];
|
|
||||||
const layoutNodes = nodes.map((node) => {
|
|
||||||
const incomers = getIncomers(node, nodes, edges);
|
|
||||||
if (incomers.length < 1) {
|
|
||||||
// Node without input is the root node of sub-workflow
|
|
||||||
subWorkflowRootNodes.push(node);
|
|
||||||
}
|
|
||||||
const { widthWithDefault, heightWithDefault } = getNodeSize(node);
|
|
||||||
const sourcePorts = node.data.sourceHandles.map((id) => ({
|
|
||||||
id,
|
|
||||||
properties: {
|
|
||||||
side: isHorizontal ? "EAST" : "SOUTH",
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
const targetPorts = node.data.targetHandles.map((id) => ({
|
|
||||||
id,
|
|
||||||
properties: {
|
|
||||||
side: isHorizontal ? "WEST" : "NORTH",
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
return {
|
|
||||||
id: node.id,
|
|
||||||
width: widthWithDefault,
|
|
||||||
height: heightWithDefault,
|
|
||||||
ports: [...targetPorts, ...sourcePorts],
|
|
||||||
properties: {
|
|
||||||
"org.eclipse.elk.portConstraints": "FIXED_ORDER",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const layoutEdges = edges.map((edge) => {
|
|
||||||
return {
|
|
||||||
id: edge.id,
|
|
||||||
sources: [edge.sourceHandle || edge.source],
|
|
||||||
targets: [edge.targetHandle || edge.target],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Connect sub-workflows' root nodes to the rootNode
|
|
||||||
const rootNode: any = { id: "#root", width: 1, height: 1 };
|
|
||||||
layoutNodes.push(rootNode);
|
|
||||||
for (const subWorkflowRootNode of subWorkflowRootNodes) {
|
|
||||||
layoutEdges.push({
|
|
||||||
id: `${rootNode.id}-${subWorkflowRootNode.id}`,
|
|
||||||
sources: [rootNode.id],
|
|
||||||
targets: [subWorkflowRootNode.id],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const layouted = await elk
|
|
||||||
.layout({
|
|
||||||
id: "@root",
|
|
||||||
children: layoutNodes,
|
|
||||||
edges: layoutEdges,
|
|
||||||
layoutOptions: {
|
|
||||||
// - https://www.eclipse.org/elk/reference/algorithms.html
|
|
||||||
"elk.algorithm": algorithms[algorithm],
|
|
||||||
"elk.direction": isHorizontal ? "RIGHT" : "DOWN",
|
|
||||||
// - https://www.eclipse.org/elk/reference/options.html
|
|
||||||
"elk.spacing.nodeNode": isHorizontal
|
|
||||||
? spacing.y.toString()
|
|
||||||
: spacing.x.toString(),
|
|
||||||
"elk.layered.spacing.nodeNodeBetweenLayers": isHorizontal
|
|
||||||
? spacing.x.toString()
|
|
||||||
: spacing.y.toString(),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
console.log("❌ ELK layout failed", e);
|
|
||||||
}) as ElkNode
|
|
||||||
|
|
||||||
if (!layouted?.children) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const layoutedNodePositions = layouted.children.reduce((pre, v) => {
|
|
||||||
pre[v.id] = {
|
|
||||||
x: v.x ?? 0,
|
|
||||||
y: v.y ?? 0,
|
|
||||||
};
|
|
||||||
return pre;
|
|
||||||
}, {} as Record<string, { x: number; y: number }>);
|
|
||||||
|
|
||||||
return {
|
|
||||||
nodes: nodes.map((node) => {
|
|
||||||
const position = layoutedNodePositions[node.id];
|
|
||||||
return getNodeLayouted({ node, position, direction, visibility });
|
|
||||||
}),
|
|
||||||
edges: edges.map((edge) => getEdgeLayouted({ edge, visibility })),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const kElkAlgorithms: Record<string, LayoutAlgorithm> = Object.keys(
|
|
||||||
algorithms
|
|
||||||
).reduce((pre, algorithm) => {
|
|
||||||
pre[algorithm] = (props: any) => {
|
|
||||||
return layoutELK({ ...props, algorithm });
|
|
||||||
};
|
|
||||||
return pre;
|
|
||||||
}, {} as any);
|
|
|
@ -1,20 +0,0 @@
|
||||||
import { LayoutAlgorithm } from "..";
|
|
||||||
import { getEdgeLayouted, getNodeLayouted } from "../../metadata";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Positions all nodes at the origin (0,0) in the layout.
|
|
||||||
*/
|
|
||||||
export const layoutOrigin: LayoutAlgorithm = async (props) => {
|
|
||||||
const { nodes, edges, direction, visibility } = props;
|
|
||||||
return {
|
|
||||||
nodes: nodes.map((node) => {
|
|
||||||
return getNodeLayouted({
|
|
||||||
node,
|
|
||||||
direction,
|
|
||||||
visibility,
|
|
||||||
position: { x: 0, y: 0 },
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
edges: edges.map((edge) => getEdgeLayouted({ edge, visibility })),
|
|
||||||
};
|
|
||||||
};
|
|
|
@ -1,149 +0,0 @@
|
||||||
/**
|
|
||||||
* 图形布局模块
|
|
||||||
*
|
|
||||||
* 该模块提供了一系列用于处理 ReactFlow 图形布局的工具和算法。
|
|
||||||
* 支持多种布局算法,包括原始布局、树形布局、层次布局等。
|
|
||||||
* 主要用于自动计算和调整图形中节点和边的位置。
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { ReactFlowGraph } from "../../types";
|
|
||||||
import { removeEmpty } from "../../utils/base";
|
|
||||||
import { D3DAGLayoutAlgorithms, kD3DAGAlgorithms } from "./algorithms/d3-dag";
|
|
||||||
import { layoutD3Hierarchy } from "./algorithms/d3-hierarchy";
|
|
||||||
import { layoutDagreTree } from "./algorithms/dagre-tree";
|
|
||||||
import { ELKLayoutAlgorithms, kElkAlgorithms } from "./algorithms/elk";
|
|
||||||
import { layoutOrigin } from "./algorithms/origin";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 布局方向类型
|
|
||||||
* vertical: 垂直布局
|
|
||||||
* horizontal: 水平布局
|
|
||||||
*/
|
|
||||||
export type LayoutDirection = "vertical" | "horizontal";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 布局可见性类型
|
|
||||||
* visible: 可见
|
|
||||||
* hidden: 隐藏
|
|
||||||
*/
|
|
||||||
export type LayoutVisibility = "visible" | "hidden";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 布局间距配置接口
|
|
||||||
* x: 水平间距
|
|
||||||
* y: 垂直间距
|
|
||||||
*/
|
|
||||||
export interface LayoutSpacing {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ReactFlow 布局配置接口
|
|
||||||
* 定义了布局所需的各项参数
|
|
||||||
*/
|
|
||||||
export type ReactFlowLayoutConfig = {
|
|
||||||
algorithm: LayoutAlgorithms; // 使用的布局算法
|
|
||||||
direction: LayoutDirection; // 布局方向
|
|
||||||
spacing: LayoutSpacing; // 节点间距
|
|
||||||
/**
|
|
||||||
* 布局可见性配置
|
|
||||||
* 在首次布局时如果节点大小不可用,可能需要隐藏布局
|
|
||||||
*/
|
|
||||||
visibility: LayoutVisibility;
|
|
||||||
/**
|
|
||||||
* 是否反转源节点手柄顺序
|
|
||||||
*/
|
|
||||||
reverseSourceHandles: boolean;
|
|
||||||
autoCenterRoot: boolean
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 布局算法所需的属性类型
|
|
||||||
* 继承自 ReactFlowGraph 并包含布局配置(除算法外)
|
|
||||||
*/
|
|
||||||
export type LayoutAlgorithmProps = ReactFlowGraph &
|
|
||||||
Omit<ReactFlowLayoutConfig, "algorithm">;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 布局算法函数类型定义
|
|
||||||
* 接收布局属性作为参数,返回布局后的图形数据
|
|
||||||
*/
|
|
||||||
export type LayoutAlgorithm = (
|
|
||||||
props: LayoutAlgorithmProps
|
|
||||||
) => Promise<ReactFlowGraph | undefined>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 可用的布局算法映射表
|
|
||||||
* 包含所有支持的布局算法实现
|
|
||||||
*/
|
|
||||||
export const layoutAlgorithms: Record<string, LayoutAlgorithm> = {
|
|
||||||
origin: layoutOrigin,
|
|
||||||
"dagre-tree": layoutDagreTree,
|
|
||||||
"d3-hierarchy": layoutD3Hierarchy,
|
|
||||||
...kElkAlgorithms,
|
|
||||||
...kD3DAGAlgorithms,
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 默认布局配置
|
|
||||||
*/
|
|
||||||
export const defaultLayoutConfig: ReactFlowLayoutConfig = {
|
|
||||||
algorithm: "dagre-tree", // 默认使用 elk-mr-tree 算法
|
|
||||||
direction: "horizontal", // 默认垂直布局
|
|
||||||
visibility: "visible", // 默认可见
|
|
||||||
spacing: { x: 120, y: 120 }, // 默认间距
|
|
||||||
reverseSourceHandles: false, // 默认不反转源节点手柄
|
|
||||||
autoCenterRoot: false
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 支持的布局算法类型联合
|
|
||||||
*/
|
|
||||||
export type LayoutAlgorithms =
|
|
||||||
| "origin"
|
|
||||||
| "dagre-tree"
|
|
||||||
| "d3-hierarchy"
|
|
||||||
| ELKLayoutAlgorithms
|
|
||||||
| D3DAGLayoutAlgorithms;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ReactFlow 布局类型
|
|
||||||
* 包含图形数据和可选的布局配置
|
|
||||||
*/
|
|
||||||
export type ReactFlowLayout = ReactFlowGraph & Partial<ReactFlowLayoutConfig>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 执行 ReactFlow 图形布局的主函数
|
|
||||||
*
|
|
||||||
* @param options - 布局选项,包含图形数据和布局配置
|
|
||||||
* @returns 返回布局后的图形数据
|
|
||||||
*
|
|
||||||
* 函数流程:
|
|
||||||
* 1. 合并默认配置和用户配置
|
|
||||||
* 2. 获取对应的布局算法
|
|
||||||
* 3. 执行布局计算
|
|
||||||
* 4. 如果布局失败,回退到原始布局
|
|
||||||
*/
|
|
||||||
export const layoutReactFlow = async (
|
|
||||||
options: ReactFlowLayout
|
|
||||||
): Promise<ReactFlowGraph> => {
|
|
||||||
// 合并配置,移除空值
|
|
||||||
const config = { ...defaultLayoutConfig, ...removeEmpty(options) };
|
|
||||||
const { nodes = [], edges = [] } = config;
|
|
||||||
|
|
||||||
// 获取并执行布局算法
|
|
||||||
const layout = layoutAlgorithms[config.algorithm];
|
|
||||||
let result = await layout({ ...config, nodes, edges });
|
|
||||||
|
|
||||||
// 布局失败时回退处理
|
|
||||||
if (!result) {
|
|
||||||
result = await layoutReactFlow({
|
|
||||||
...config,
|
|
||||||
nodes,
|
|
||||||
edges,
|
|
||||||
algorithm: "origin",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return result!;
|
|
||||||
};
|
|
|
@ -1,23 +0,0 @@
|
||||||
import { Node, Edge } from "@xyflow/react";
|
|
||||||
// 基础接口和类型定义
|
|
||||||
export interface LayoutOptions {
|
|
||||||
nodes: Node[];
|
|
||||||
edges: Edge[];
|
|
||||||
levelSeparation?: number;
|
|
||||||
nodeSeparation?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NodeWithLayout extends Node {
|
|
||||||
children?: NodeWithLayout[];
|
|
||||||
parent?: NodeWithLayout;
|
|
||||||
subtreeHeight?: number;
|
|
||||||
subtreeWidth?: number;
|
|
||||||
isRight?: boolean;
|
|
||||||
relativeY?: number
|
|
||||||
verticalLevel?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
// 布局策略接口
|
|
||||||
export interface LayoutStrategy {
|
|
||||||
layout(options: LayoutOptions): { nodes: Node[], edges: Edge[] };
|
|
||||||
}
|
|
|
@ -1,179 +0,0 @@
|
||||||
import { memo, useCallback, useEffect, useRef, useState } from 'react';
|
|
||||||
import { Handle, Position, NodeProps, Node, useUpdateNodeInternals } from '@xyflow/react';
|
|
||||||
import useGraphStore from '../store';
|
|
||||||
import { shallow } from 'zustand/shallow';
|
|
||||||
import { GraphState } from '../types';
|
|
||||||
import { cn } from '@web/src/utils/classname';
|
|
||||||
import { LEVEL_STYLES, NODE_BASE_STYLES, TEXTAREA_BASE_STYLES } from './style';
|
|
||||||
|
|
||||||
export type GraphNode = Node<{
|
|
||||||
label: string;
|
|
||||||
color?: string;
|
|
||||||
level?: number;
|
|
||||||
}, 'graph-node'>;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
interface TextMeasurerProps {
|
|
||||||
element: HTMLTextAreaElement;
|
|
||||||
minWidth?: number;
|
|
||||||
maxWidth?: number;
|
|
||||||
padding?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const measureTextWidth = ({
|
|
||||||
element,
|
|
||||||
minWidth = 60,
|
|
||||||
maxWidth = 400,
|
|
||||||
padding = 16,
|
|
||||||
}: TextMeasurerProps): number => {
|
|
||||||
const span = document.createElement('span');
|
|
||||||
const styles = {
|
|
||||||
visibility: 'hidden',
|
|
||||||
position: 'absolute',
|
|
||||||
whiteSpace: 'pre',
|
|
||||||
fontSize: window.getComputedStyle(element).fontSize,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
Object.assign(span.style, styles);
|
|
||||||
span.textContent = element.value || element.placeholder;
|
|
||||||
document.body.appendChild(span);
|
|
||||||
|
|
||||||
const contentWidth = Math.min(Math.max(span.offsetWidth + padding, minWidth), maxWidth);
|
|
||||||
document.body.removeChild(span);
|
|
||||||
|
|
||||||
return contentWidth;
|
|
||||||
};
|
|
||||||
|
|
||||||
const selector = (store: GraphState) => ({
|
|
||||||
updateNode: store.updateNode,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const GraphNode = memo(({ id, selected, width, height, data, isConnectable }: NodeProps<GraphNode>) => {
|
|
||||||
const { updateNode } = useGraphStore(selector, shallow);
|
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
|
||||||
const [inputValue, setInputValue] = useState(data.label);
|
|
||||||
const [isComposing, setIsComposing] = useState(false);
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
||||||
const updateNodeInternals = useUpdateNodeInternals();
|
|
||||||
// const [nodeWidth, setNodeWidth] = useState(width)
|
|
||||||
// const [nodeHeight, setNodeHeight] = useState(height)
|
|
||||||
const updateTextareaSize = useCallback((element: HTMLTextAreaElement) => {
|
|
||||||
const contentWidth = measureTextWidth({ element });
|
|
||||||
element.style.whiteSpace = contentWidth >= 400 ? 'pre-wrap' : 'pre';
|
|
||||||
element.style.width = `${contentWidth}px`;
|
|
||||||
element.style.height = 'auto';
|
|
||||||
element.style.height = `${element.scrollHeight}px`;
|
|
||||||
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleChange = useCallback((evt: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
||||||
const newValue = evt.target.value;
|
|
||||||
setInputValue(newValue);
|
|
||||||
updateNode(id, { label: newValue });
|
|
||||||
updateTextareaSize(evt.target);
|
|
||||||
}, [updateNode, id, updateTextareaSize]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (textareaRef.current) {
|
|
||||||
updateTextareaSize(textareaRef.current);
|
|
||||||
}
|
|
||||||
}, [isEditing, inputValue, updateTextareaSize]);
|
|
||||||
|
|
||||||
const handleKeyDown = useCallback((evt: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
||||||
const isAlphanumeric = /^[a-zA-Z0-9]$/.test(evt.key);
|
|
||||||
const isSpaceKey = evt.key === ' ';
|
|
||||||
|
|
||||||
if (!isEditing && (isAlphanumeric || isSpaceKey)) {
|
|
||||||
evt.preventDefault();
|
|
||||||
evt.stopPropagation();
|
|
||||||
|
|
||||||
const newValue = isAlphanumeric ? evt.key : data.label;
|
|
||||||
setIsEditing(true);
|
|
||||||
setInputValue(newValue);
|
|
||||||
updateNode(id, { label: newValue });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isEditing && evt.key === 'Enter' && !evt.shiftKey && !isComposing) {
|
|
||||||
evt.preventDefault();
|
|
||||||
setIsEditing(false);
|
|
||||||
}
|
|
||||||
}, [isEditing, isComposing, data.label, id, updateNode]);
|
|
||||||
|
|
||||||
const handleDoubleClick = useCallback(() => {
|
|
||||||
setIsEditing(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleBlur = useCallback(() => {
|
|
||||||
setIsEditing(false);
|
|
||||||
}, []);
|
|
||||||
useEffect(() => {
|
|
||||||
const resizeObserver = new ResizeObserver((entries) => {
|
|
||||||
for (const entry of entries) {
|
|
||||||
const { width, height } = entry.contentRect;
|
|
||||||
updateNodeInternals(id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (containerRef.current) {
|
|
||||||
resizeObserver.observe(containerRef.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
resizeObserver.disconnect();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={containerRef}
|
|
||||||
onDoubleClick={handleDoubleClick}
|
|
||||||
className={cn(
|
|
||||||
NODE_BASE_STYLES,
|
|
||||||
LEVEL_STYLES[data.level ?? 2].container,
|
|
||||||
selected && 'ring-2 ring-blue-400',
|
|
||||||
isEditing && 'ring-2 ring-blue-500'
|
|
||||||
)}
|
|
||||||
data-testid="graph-node"
|
|
||||||
>
|
|
||||||
<textarea
|
|
||||||
ref={textareaRef}
|
|
||||||
value={inputValue}
|
|
||||||
onChange={handleChange}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
onCompositionStart={() => setIsComposing(true)}
|
|
||||||
onCompositionEnd={() => setIsComposing(false)}
|
|
||||||
className={cn(
|
|
||||||
TEXTAREA_BASE_STYLES,
|
|
||||||
LEVEL_STYLES[data.level ?? 2].fontSize,
|
|
||||||
isEditing ? 'nodrag' : 'cursor-default'
|
|
||||||
)}
|
|
||||||
placeholder={isEditing ? "输入节点内容..." : "双击编辑"}
|
|
||||||
rows={1}
|
|
||||||
readOnly={!isEditing}
|
|
||||||
aria-label="节点内容"
|
|
||||||
/>
|
|
||||||
<Handle
|
|
||||||
type="source"
|
|
||||||
position={Position.Left}
|
|
||||||
isConnectable={isConnectable}
|
|
||||||
id="source"
|
|
||||||
style={{ left: 0 }}
|
|
||||||
className="w-3 h-3 bg-blue-400 border-2 border-white rounded-full"
|
|
||||||
/>
|
|
||||||
<Handle
|
|
||||||
type="target"
|
|
||||||
position={Position.Right}
|
|
||||||
isConnectable={isConnectable}
|
|
||||||
id="target"
|
|
||||||
style={{ right: 0 }}
|
|
||||||
className="w-3 h-3 bg-blue-400 border-2 border-white rounded-full"
|
|
||||||
/>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
GraphNode.displayName = 'GraphNode';
|
|
|
@ -1,49 +0,0 @@
|
||||||
export const LEVEL_STYLES = {
|
|
||||||
0: {
|
|
||||||
container: `
|
|
||||||
bg-gradient-to-br from-blue-500 to-blue-600
|
|
||||||
text-white px-8 py-4
|
|
||||||
`,
|
|
||||||
fontSize: 'text-xl font-semibold'
|
|
||||||
},
|
|
||||||
1: {
|
|
||||||
container: `
|
|
||||||
bg-white
|
|
||||||
border-2 border-blue-400
|
|
||||||
text-gray-700 px-4 py-2
|
|
||||||
hover:border-blue-500
|
|
||||||
`,
|
|
||||||
fontSize: 'text-lg'
|
|
||||||
},
|
|
||||||
2: {
|
|
||||||
container: `
|
|
||||||
bg-gray-50
|
|
||||||
border border-gray-200
|
|
||||||
text-gray-600 px-2 py-1
|
|
||||||
hover:border-blue-300
|
|
||||||
hover:bg-gray-100
|
|
||||||
`,
|
|
||||||
fontSize: 'text-base'
|
|
||||||
}
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const NODE_BASE_STYLES = `
|
|
||||||
flex items-center justify-center
|
|
||||||
rounded-xl
|
|
||||||
min-w-[60px]
|
|
||||||
w-fit
|
|
||||||
relative
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const TEXTAREA_BASE_STYLES = `
|
|
||||||
bg-transparent
|
|
||||||
text-center
|
|
||||||
break-words
|
|
||||||
whitespace-pre-wrap
|
|
||||||
resize-none
|
|
||||||
overflow-hidden
|
|
||||||
outline-none
|
|
||||||
min-w-0
|
|
||||||
w-auto
|
|
||||||
flex-shrink
|
|
||||||
`;
|
|
|
@ -1,142 +0,0 @@
|
||||||
import { addEdge, applyNodeChanges, applyEdgeChanges, Node, Edge, Connection, NodeChange, EdgeChange } from '@xyflow/react';
|
|
||||||
import { createWithEqualityFn } from 'zustand/traditional';
|
|
||||||
import { nanoid } from 'nanoid';
|
|
||||||
import debounce from 'lodash/debounce';
|
|
||||||
import { GraphState } from './types';
|
|
||||||
import { initialEdges, initialNodes } from './data';
|
|
||||||
|
|
||||||
const MAX_HISTORY_LENGTH = 100;
|
|
||||||
const HISTORY_DEBOUNCE_MS = 100;
|
|
||||||
|
|
||||||
const useGraphStore = createWithEqualityFn<GraphState>((set, get) => {
|
|
||||||
return {
|
|
||||||
past: [],
|
|
||||||
future: [],
|
|
||||||
present: {
|
|
||||||
nodes: initialNodes,
|
|
||||||
edges: initialEdges,
|
|
||||||
},
|
|
||||||
record: (callback: () => void) => {
|
|
||||||
const currentState = get().present;
|
|
||||||
|
|
||||||
console.group('Recording new state');
|
|
||||||
console.log('Current state:', currentState);
|
|
||||||
console.log('Past states count:', get().past.length);
|
|
||||||
console.log('Future states count:', get().future.length);
|
|
||||||
|
|
||||||
set(state => {
|
|
||||||
const newPast = [...state.past.slice(-MAX_HISTORY_LENGTH), currentState];
|
|
||||||
console.log('New past states count:', newPast.length);
|
|
||||||
console.groupEnd();
|
|
||||||
return {
|
|
||||||
past: newPast,
|
|
||||||
future: [],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
callback();
|
|
||||||
},
|
|
||||||
|
|
||||||
undo: () => {
|
|
||||||
const { past, present } = get();
|
|
||||||
console.group('Undo operation');
|
|
||||||
console.log('Current state:', present);
|
|
||||||
console.log('Past states count:', past.length);
|
|
||||||
|
|
||||||
if (past.length === 0) {
|
|
||||||
console.warn('Cannot undo - no past states available');
|
|
||||||
console.groupEnd();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const previous = past[past.length - 1];
|
|
||||||
const newPast = past.slice(0, past.length - 1);
|
|
||||||
|
|
||||||
console.log('Reverting to previous state:', previous);
|
|
||||||
console.log('New past states count:', newPast.length);
|
|
||||||
console.log('New future states count:', get().future.length + 1);
|
|
||||||
console.groupEnd();
|
|
||||||
|
|
||||||
set({
|
|
||||||
past: newPast,
|
|
||||||
present: previous,
|
|
||||||
future: [present, ...get().future],
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
redo: () => {
|
|
||||||
const { future, present } = get();
|
|
||||||
console.group('Redo operation');
|
|
||||||
console.log('Current state:', present);
|
|
||||||
console.log('Future states count:', future.length);
|
|
||||||
|
|
||||||
if (future.length === 0) {
|
|
||||||
console.warn('Cannot redo - no future states available');
|
|
||||||
console.groupEnd();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const next = future[0];
|
|
||||||
const newFuture = future.slice(1);
|
|
||||||
|
|
||||||
console.log('Moving to next state:', next);
|
|
||||||
console.log('New past states count:', get().past.length + 1);
|
|
||||||
console.log('New future states count:', newFuture.length);
|
|
||||||
console.groupEnd();
|
|
||||||
|
|
||||||
set({
|
|
||||||
past: [...get().past, present],
|
|
||||||
present: next,
|
|
||||||
future: newFuture,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
setNodes: (nodes: Node[]) => {
|
|
||||||
set(state => ({
|
|
||||||
present: {
|
|
||||||
nodes: nodes,
|
|
||||||
edges: state.present.edges
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
setEdges: (edges: Edge[]) => {
|
|
||||||
set(state => ({
|
|
||||||
present: {
|
|
||||||
nodes: state.present.nodes,
|
|
||||||
edges: edges
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
onNodesChange: (changes: NodeChange[]) => {
|
|
||||||
set(state => ({
|
|
||||||
present: {
|
|
||||||
nodes: applyNodeChanges(changes, state.present.nodes),
|
|
||||||
edges: state.present.edges
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
onEdgesChange: (changes: EdgeChange[]) => {
|
|
||||||
set(state => ({
|
|
||||||
present: {
|
|
||||||
nodes: state.present.nodes,
|
|
||||||
edges: applyEdgeChanges(changes, state.present.edges)
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
canUndo: () => get().past.length > 0,
|
|
||||||
canRedo: () => get().future.length > 0,
|
|
||||||
updateNode: (nodeId: string, data: any) => {
|
|
||||||
const newNodes = get().present.nodes.map(node =>
|
|
||||||
node.id === nodeId ? { ...node, data: { ...node.data, ...data } } : node
|
|
||||||
);
|
|
||||||
set({
|
|
||||||
present: {
|
|
||||||
nodes: newNodes,
|
|
||||||
edges: get().present.edges
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
export default useGraphStore;
|
|
|
@ -1,94 +0,0 @@
|
||||||
import { Edge, NodeProps, Node, OnConnect, OnEdgesChange, OnNodesChange, Connection, NodeChange, EdgeChange, OnSelectionChangeParams, XYPosition } from "@xyflow/react";
|
|
||||||
import { GraphEdge } from "./edges/GraphEdge";
|
|
||||||
import { GraphNode } from "./nodes/GraphNode";
|
|
||||||
import { ControlPoint } from "./layout/edge/point";
|
|
||||||
import { ReactFlowLayout, ReactFlowLayoutConfig } from "./layout/node";
|
|
||||||
// 添加新的类型定义
|
|
||||||
export type HistoryState = {
|
|
||||||
nodes: Node[];
|
|
||||||
edges: Edge[];
|
|
||||||
type: string; // 记录操作类型
|
|
||||||
timestamp: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type GraphState = {
|
|
||||||
past: Array<{ nodes: Node[], edges: Edge[] }>;
|
|
||||||
present: {
|
|
||||||
nodes: Node[];
|
|
||||||
edges: Edge[];
|
|
||||||
};
|
|
||||||
future: Array<{ nodes: Node[], edges: Edge[] }>;
|
|
||||||
canUndo: () => boolean;
|
|
||||||
canRedo: () => boolean;
|
|
||||||
onNodesChange: (changes: NodeChange[]) => void;
|
|
||||||
onEdgesChange: (changes: EdgeChange[]) => void;
|
|
||||||
updateNode: (id: string, data: any) => void;
|
|
||||||
undo: () => void;
|
|
||||||
redo: () => void;
|
|
||||||
setNodes: (nodes: Node[]) => void;
|
|
||||||
setEdges: (edges: Edge[]) => void;
|
|
||||||
record: (callback: () => void) => void
|
|
||||||
};
|
|
||||||
export const nodeTypes = {
|
|
||||||
'graph-node': GraphNode
|
|
||||||
}
|
|
||||||
|
|
||||||
export const edgeTypes = {
|
|
||||||
'graph-edge': GraphEdge
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ReactFlowGraph {
|
|
||||||
nodes: Node[]
|
|
||||||
edges: Edge[]
|
|
||||||
}
|
|
||||||
export interface ReactFlowEdgePort {
|
|
||||||
/**
|
|
||||||
* Total number of edges in this direction (source or target).
|
|
||||||
*/
|
|
||||||
edges: number;
|
|
||||||
/**
|
|
||||||
* Number of ports
|
|
||||||
*/
|
|
||||||
portCount: number;
|
|
||||||
/**
|
|
||||||
* Port's index.
|
|
||||||
*/
|
|
||||||
portIndex: number;
|
|
||||||
/**
|
|
||||||
* Total number of Edges under the current port.
|
|
||||||
*/
|
|
||||||
edgeCount: number;
|
|
||||||
/**
|
|
||||||
* Index of the Edge under the current port.
|
|
||||||
*/
|
|
||||||
edgeIndex: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EdgeLayout {
|
|
||||||
/**
|
|
||||||
* SVG path for edge rendering
|
|
||||||
*/
|
|
||||||
path: string;
|
|
||||||
/**
|
|
||||||
* Control points on the edge.
|
|
||||||
*/
|
|
||||||
points: ControlPoint[];
|
|
||||||
labelPosition: XYPosition;
|
|
||||||
/**
|
|
||||||
* Current layout dependent variables (re-layout when changed).
|
|
||||||
*/
|
|
||||||
deps?: any;
|
|
||||||
/**
|
|
||||||
* Potential control points on the edge, for debugging purposes only.
|
|
||||||
*/
|
|
||||||
inputPoints: ControlPoint[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ReactFlowEdgeData {
|
|
||||||
/**
|
|
||||||
* Data related to the current edge's layout, such as control points.
|
|
||||||
*/
|
|
||||||
layout?: EdgeLayout;
|
|
||||||
sourcePort: ReactFlowEdgePort;
|
|
||||||
targetPort: ReactFlowEdgePort;
|
|
||||||
}
|
|
|
@ -1,144 +0,0 @@
|
||||||
import { useCallback, useMemo } from "react";
|
|
||||||
import { nanoid } from 'nanoid';
|
|
||||||
import { shallow } from 'zustand/shallow';
|
|
||||||
import { throttle } from 'lodash';
|
|
||||||
import { Edge, Node, useReactFlow } from "@xyflow/react";
|
|
||||||
import { GraphState } from "./types";
|
|
||||||
import useGraphStore from "./store";
|
|
||||||
|
|
||||||
// Store selector
|
|
||||||
const selector = (store: GraphState) => ({
|
|
||||||
nodes: store.present.nodes,
|
|
||||||
edges: store.present.edges,
|
|
||||||
setNodes: store.setNodes,
|
|
||||||
setEdges: store.setEdges,
|
|
||||||
record: store.record
|
|
||||||
});
|
|
||||||
|
|
||||||
// Helper functions
|
|
||||||
const createNode = (label: string): Node => ({
|
|
||||||
id: nanoid(6),
|
|
||||||
type: 'graph-node',
|
|
||||||
data: { label },
|
|
||||||
position: { x: 0, y: 0 },
|
|
||||||
});
|
|
||||||
|
|
||||||
const createEdge = (source: string, target: string): Edge => ({
|
|
||||||
id: nanoid(6),
|
|
||||||
source,
|
|
||||||
target,
|
|
||||||
type: 'graph-edge',
|
|
||||||
});
|
|
||||||
|
|
||||||
export function useGraphOperation() {
|
|
||||||
const store = useGraphStore(selector, shallow);
|
|
||||||
const { addEdges, addNodes } = useReactFlow();
|
|
||||||
|
|
||||||
const selectedNodes = useMemo(() =>
|
|
||||||
store.nodes.filter(node => node.selected),
|
|
||||||
[store.nodes]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Find parent node ID for a given node
|
|
||||||
const findParentId = useCallback((nodeId: string) => {
|
|
||||||
const parentEdge = store.edges.find(edge => edge.target === nodeId);
|
|
||||||
return parentEdge?.source;
|
|
||||||
}, [store.edges]);
|
|
||||||
|
|
||||||
// Update node selection
|
|
||||||
const updateNodeSelection = useCallback((nodeIds: string[]) => {
|
|
||||||
return store.nodes.map(node => ({
|
|
||||||
...node,
|
|
||||||
selected: nodeIds.includes(node.id)
|
|
||||||
}));
|
|
||||||
}, [store.nodes]);
|
|
||||||
|
|
||||||
// Create new node and connect it
|
|
||||||
const createConnectedNode = useCallback((parentId: string, deselectOthers = true) => {
|
|
||||||
const newNode = createNode(`新节点${store.nodes.length}`);
|
|
||||||
const newEdge = createEdge(parentId, newNode.id);
|
|
||||||
|
|
||||||
store.record(() => {
|
|
||||||
addNodes({ ...newNode, selected: true });
|
|
||||||
addEdges(newEdge);
|
|
||||||
|
|
||||||
if (deselectOthers) {
|
|
||||||
store.setNodes(updateNodeSelection([newNode.id]));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, [store, addNodes, addEdges, updateNodeSelection]);
|
|
||||||
|
|
||||||
// Handle node creation operations
|
|
||||||
const handleCreateChildNodes = useCallback(() => {
|
|
||||||
if (selectedNodes.length === 0) return;
|
|
||||||
|
|
||||||
throttle(() => {
|
|
||||||
selectedNodes.forEach(node => {
|
|
||||||
if (node.id) createConnectedNode(node.id);
|
|
||||||
});
|
|
||||||
}, 300)();
|
|
||||||
}, [selectedNodes, createConnectedNode]);
|
|
||||||
|
|
||||||
const handleCreateSiblingNodes = useCallback(() => {
|
|
||||||
if (selectedNodes.length === 0) return;
|
|
||||||
|
|
||||||
throttle(() => {
|
|
||||||
selectedNodes.forEach(node => {
|
|
||||||
const parentId = findParentId(node.id) || node.id;
|
|
||||||
createConnectedNode(parentId);
|
|
||||||
});
|
|
||||||
}, 300)();
|
|
||||||
}, [selectedNodes, findParentId, createConnectedNode]);
|
|
||||||
|
|
||||||
const handleDeleteNodes = useCallback(() => {
|
|
||||||
if (selectedNodes.length === 0) return;
|
|
||||||
|
|
||||||
const nodesToDelete = new Set<string>();
|
|
||||||
|
|
||||||
// Collect all nodes to delete including children
|
|
||||||
const collectNodesToDelete = (nodeId: string) => {
|
|
||||||
nodesToDelete.add(nodeId);
|
|
||||||
store.edges
|
|
||||||
.filter(edge => edge.source === nodeId)
|
|
||||||
.forEach(edge => collectNodesToDelete(edge.target));
|
|
||||||
};
|
|
||||||
|
|
||||||
selectedNodes.forEach(node => collectNodesToDelete(node.id));
|
|
||||||
|
|
||||||
store.record(() => {
|
|
||||||
// Filter out deleted nodes and their edges
|
|
||||||
const remainingNodes = store.nodes.filter(node => !nodesToDelete.has(node.id));
|
|
||||||
const remainingEdges = store.edges.filter(edge =>
|
|
||||||
!nodesToDelete.has(edge.source) && !nodesToDelete.has(edge.target)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Select next node (sibling or parent of first deleted node)
|
|
||||||
const firstDeletedNode = selectedNodes[0];
|
|
||||||
const parentId = findParentId(firstDeletedNode.id);
|
|
||||||
|
|
||||||
let nextSelectedId: string | undefined;
|
|
||||||
if (parentId) {
|
|
||||||
const siblingEdge = store.edges.find(edge =>
|
|
||||||
edge.source === parentId &&
|
|
||||||
!nodesToDelete.has(edge.target) &&
|
|
||||||
edge.target !== firstDeletedNode.id
|
|
||||||
);
|
|
||||||
nextSelectedId = siblingEdge?.target || parentId;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update nodes with new selection and set the remaining nodes
|
|
||||||
const updatedNodes = remainingNodes.map(node => ({
|
|
||||||
...node,
|
|
||||||
selected: node.id === nextSelectedId
|
|
||||||
}));
|
|
||||||
|
|
||||||
store.setNodes(updatedNodes);
|
|
||||||
store.setEdges(remainingEdges);
|
|
||||||
});
|
|
||||||
}, [selectedNodes, store, findParentId]);
|
|
||||||
return {
|
|
||||||
handleCreateChildNodes,
|
|
||||||
handleCreateSiblingNodes,
|
|
||||||
handleDeleteNodes
|
|
||||||
};
|
|
||||||
}
|
|
|
@ -1,44 +0,0 @@
|
||||||
import { useHotkeys } from 'react-hotkeys-hook';
|
|
||||||
import { shallow } from 'zustand/shallow';
|
|
||||||
import { useGraphOperation } from './useGraphOperation';
|
|
||||||
import useGraphStore from './store';
|
|
||||||
import { GraphState } from './types';
|
|
||||||
|
|
||||||
const selector = (store: GraphState) => ({
|
|
||||||
undo: store.undo,
|
|
||||||
redo: store.redo
|
|
||||||
});
|
|
||||||
|
|
||||||
export function useKeyboardCtrl() {
|
|
||||||
const { undo, redo } = useGraphStore(selector, shallow);
|
|
||||||
const {
|
|
||||||
handleCreateChildNodes,
|
|
||||||
handleCreateSiblingNodes,
|
|
||||||
handleDeleteNodes
|
|
||||||
} = useGraphOperation();
|
|
||||||
|
|
||||||
useHotkeys('tab', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
handleCreateChildNodes();
|
|
||||||
}, [handleCreateChildNodes]);
|
|
||||||
|
|
||||||
useHotkeys('enter', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
handleCreateSiblingNodes();
|
|
||||||
}, [handleCreateSiblingNodes]);
|
|
||||||
|
|
||||||
useHotkeys('ctrl+z', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
undo();
|
|
||||||
}, [undo]);
|
|
||||||
|
|
||||||
useHotkeys('ctrl+y', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
redo();
|
|
||||||
}, [redo]);
|
|
||||||
|
|
||||||
useHotkeys('delete', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
handleDeleteNodes();
|
|
||||||
}, [handleDeleteNodes]);
|
|
||||||
}
|
|
|
@ -1,147 +0,0 @@
|
||||||
import { Position, Node, InternalNode } from "@xyflow/react";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 定义交点的接口类型
|
|
||||||
* 用于表示两条线段相交的坐标点
|
|
||||||
*/
|
|
||||||
interface IntersectionPoint {
|
|
||||||
x: number; // 交点的x坐标
|
|
||||||
y: number; // 交点的y坐标
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 定义边缘连接参数的接口类型
|
|
||||||
* 包含源节点和目标节点的连接位置信息
|
|
||||||
*/
|
|
||||||
interface EdgeParams {
|
|
||||||
sx: number; // 源节点连接点x坐标
|
|
||||||
sy: number; // 源节点连接点y坐标
|
|
||||||
tx: number; // 目标节点连接点x坐标
|
|
||||||
ty: number; // 目标节点连接点y坐标
|
|
||||||
sourcePos: Position; // 源节点连接位置(上下左右)
|
|
||||||
targetPos: Position; // 目标节点连接位置(上下左右)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 计算节点之间的交点坐标
|
|
||||||
*
|
|
||||||
* 功能说明:
|
|
||||||
* 该函数用于计算两个节点之间连线与节点边界的精确交点位置。这在绘制流程图等图形时,
|
|
||||||
* 确保连接线能够准确地从节点边界开始和结束,而不是从节点中心点开始。
|
|
||||||
*
|
|
||||||
* 算法原理:
|
|
||||||
* 1. 首先获取两个节点的位置和尺寸信息
|
|
||||||
* 2. 计算节点的中心点坐标
|
|
||||||
* 3. 使用几何算法计算连线与节点矩形边界的交点
|
|
||||||
* 4. 返回交点的精确坐标
|
|
||||||
*
|
|
||||||
* @param intersectionNode - 起始节点,需要计算交点的源节点
|
|
||||||
* @param targetNode - 目标节点,与源节点相连的终点节点
|
|
||||||
* @returns {IntersectionPoint} 返回交点坐标 {x, y}
|
|
||||||
*/
|
|
||||||
function getNodeIntersection(intersectionNode: InternalNode, targetNode: InternalNode): IntersectionPoint {
|
|
||||||
// 获取起始节点的宽度和高度
|
|
||||||
const { width: intersectionNodeWidth, height: intersectionNodeHeight } = intersectionNode.measured;
|
|
||||||
// 获取两个节点的绝对位置信息
|
|
||||||
const intersectionNodePosition = intersectionNode.internals.positionAbsolute;
|
|
||||||
const targetPosition = targetNode.internals.positionAbsolute;
|
|
||||||
|
|
||||||
// 计算起始节点的半宽和半高,用于后续的坐标计算
|
|
||||||
const w = intersectionNodeWidth / 2;
|
|
||||||
const h = intersectionNodeHeight / 2;
|
|
||||||
|
|
||||||
// 计算两个节点的中心点坐标
|
|
||||||
// (x2,y2)为起始节点的中心点
|
|
||||||
const x2 = intersectionNodePosition.x + w;
|
|
||||||
const y2 = intersectionNodePosition.y + h;
|
|
||||||
// (x1,y1)为目标节点的中心点
|
|
||||||
const x1 = targetPosition.x + targetNode.measured.width / 2;
|
|
||||||
const y1 = targetPosition.y + targetNode.measured.height / 2;
|
|
||||||
|
|
||||||
// 使用数学公式计算交点坐标
|
|
||||||
// 这里使用的是参数化方程,将节点边界视为矩形来计算交点
|
|
||||||
const xx1 = (x1 - x2) / (2 * w) - (y1 - y2) / (2 * h);
|
|
||||||
const yy1 = (x1 - x2) / (2 * w) + (y1 - y2) / (2 * h);
|
|
||||||
// 通过标准化确保交点在节点边界上
|
|
||||||
const a = 1 / (Math.abs(xx1) + Math.abs(yy1));
|
|
||||||
const xx3 = a * xx1;
|
|
||||||
const yy3 = a * yy1;
|
|
||||||
// 计算最终的交点坐标
|
|
||||||
const x = w * (xx3 + yy3) + x2;
|
|
||||||
const y = h * (-xx3 + yy3) + y2;
|
|
||||||
|
|
||||||
return { x, y };
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* 确定边缘连接点的位置(上下左右)
|
|
||||||
*
|
|
||||||
* 功能说明:
|
|
||||||
* 根据节点和交点的位置关系,计算边缘线应该连接到节点的哪个位置(上/下/左/右)
|
|
||||||
*
|
|
||||||
* 实现原理:
|
|
||||||
* 1. 获取节点的绝对定位信息和尺寸信息
|
|
||||||
* 2. 将节点四条边界划分为不同区域
|
|
||||||
* 3. 通过比较交点坐标与边界位置,确定最合适的连接点
|
|
||||||
*
|
|
||||||
* @param node - 需要确定连接位置的节点对象
|
|
||||||
* 包含节点的位置信息(x,y)和尺寸信息(width,height)
|
|
||||||
* @param intersectionPoint - 交点坐标对象
|
|
||||||
* 包含交点的x,y坐标值
|
|
||||||
* @returns Position - 返回枚举值,表示连接位置(Top/Right/Bottom/Left)
|
|
||||||
*/
|
|
||||||
function getEdgePosition(node: InternalNode, intersectionPoint: IntersectionPoint): Position {
|
|
||||||
// 合并节点的绝对定位信息,确保获取准确的节点位置
|
|
||||||
const n = { ...node.internals.positionAbsolute, ...node };
|
|
||||||
|
|
||||||
// 对坐标进行取整,避免浮点数计算误差
|
|
||||||
const nx = Math.round(n.x); // 节点左边界x坐标
|
|
||||||
const ny = Math.round(n.y); // 节点上边界y坐标
|
|
||||||
const px = Math.round(intersectionPoint.x); // 交点x坐标
|
|
||||||
const py = Math.round(intersectionPoint.y); // 交点y坐标
|
|
||||||
|
|
||||||
// 判断逻辑:通过比较交点与节点各边界的位置关系确定连接位置
|
|
||||||
// 添加1px的容差值,增强判断的容错性
|
|
||||||
if (px <= nx + 1) {
|
|
||||||
return Position.Left; // 交点在节点左侧
|
|
||||||
}
|
|
||||||
if (px >= nx + n.measured.width - 1) {
|
|
||||||
return Position.Right; // 交点在节点右侧
|
|
||||||
}
|
|
||||||
if (py <= ny + 1) {
|
|
||||||
return Position.Top; // 交点在节点上方
|
|
||||||
}
|
|
||||||
if (py >= n.y + n.measured.height - 1) {
|
|
||||||
return Position.Bottom; // 交点在节点下方
|
|
||||||
}
|
|
||||||
|
|
||||||
// 若都不满足,默认返回顶部位置作为连接点
|
|
||||||
return Position.Top;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 计算两个节点之间边缘连接的所有必要参数
|
|
||||||
* @param source - 源节点
|
|
||||||
* @param target - 目标节点
|
|
||||||
* @returns 返回包含边缘连接所需所有参数的对象
|
|
||||||
*
|
|
||||||
* 这是主要的导出函数,用于获取创建边缘连接线所需的所有参数
|
|
||||||
*/
|
|
||||||
export function getEdgeParams(source: InternalNode, target: InternalNode): EdgeParams {
|
|
||||||
// 计算源节点和目标节点的交点
|
|
||||||
const sourceIntersectionPoint = getNodeIntersection(source, target);
|
|
||||||
const targetIntersectionPoint = getNodeIntersection(target, source);
|
|
||||||
|
|
||||||
// 确定连接点在各自节点上的位置
|
|
||||||
const sourcePos = getEdgePosition(source, sourceIntersectionPoint);
|
|
||||||
const targetPos = getEdgePosition(target, targetIntersectionPoint);
|
|
||||||
|
|
||||||
// 返回所有必要的参数
|
|
||||||
return {
|
|
||||||
sx: sourceIntersectionPoint.x,
|
|
||||||
sy: sourceIntersectionPoint.y,
|
|
||||||
tx: targetIntersectionPoint.x,
|
|
||||||
ty: targetIntersectionPoint.y,
|
|
||||||
sourcePos,
|
|
||||||
targetPos,
|
|
||||||
};
|
|
||||||
}
|
|
|
@ -1,115 +0,0 @@
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
||||||
|
|
||||||
export const nextTick = async (frames = 1) => {
|
|
||||||
const _nextTick = async (idx: number) => {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
requestAnimationFrame(() => resolve(idx));
|
|
||||||
});
|
|
||||||
};
|
|
||||||
for (let i = 0; i < frames; i++) {
|
|
||||||
await _nextTick(i);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const firstOf = <T = any>(datas?: T[]) =>
|
|
||||||
datas ? (datas.length < 1 ? undefined : datas[0]) : undefined;
|
|
||||||
|
|
||||||
export const lastOf = <T = any>(datas?: T[]) =>
|
|
||||||
datas ? (datas.length < 1 ? undefined : datas[datas.length - 1]) : undefined;
|
|
||||||
|
|
||||||
export const randomInt = (min: number, max?: number) => {
|
|
||||||
if (!max) {
|
|
||||||
max = min;
|
|
||||||
min = 0;
|
|
||||||
}
|
|
||||||
return Math.floor(Math.random() * (max - min + 1) + min);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const pickOne = <T = any>(datas: T[]) =>
|
|
||||||
datas.length < 1 ? undefined : datas[randomInt(datas.length - 1)];
|
|
||||||
|
|
||||||
export const range = (start: number, end?: number) => {
|
|
||||||
if (!end) {
|
|
||||||
end = start;
|
|
||||||
start = 0;
|
|
||||||
}
|
|
||||||
return Array.from({ length: end - start }, (_, index) => start + index);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* clamp(-1,0,1)=0
|
|
||||||
*/
|
|
||||||
export function clamp(num: number, min: number, max: number): number {
|
|
||||||
return num < max ? (num > min ? num : min) : max;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const toSet = <T = any>(datas: T[], byKey?: (e: T) => any) => {
|
|
||||||
if (byKey) {
|
|
||||||
const keys: Record<string, boolean> = {};
|
|
||||||
const newDatas: T[] = [];
|
|
||||||
datas.forEach((e) => {
|
|
||||||
const key = jsonEncode({ key: byKey(e) }) as any;
|
|
||||||
if (!keys[key]) {
|
|
||||||
newDatas.push(e);
|
|
||||||
keys[key] = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return newDatas;
|
|
||||||
}
|
|
||||||
return Array.from(new Set(datas));
|
|
||||||
};
|
|
||||||
|
|
||||||
export function jsonEncode(obj: any, prettier = false) {
|
|
||||||
try {
|
|
||||||
return prettier ? JSON.stringify(obj, undefined, 4) : JSON.stringify(obj);
|
|
||||||
} catch (error) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function jsonDecode(json: string | undefined) {
|
|
||||||
if (json == undefined) return undefined;
|
|
||||||
try {
|
|
||||||
return JSON.parse(json!);
|
|
||||||
} catch (error) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function removeEmpty<T = any>(data: T): T {
|
|
||||||
if (Array.isArray(data)) {
|
|
||||||
return data.filter((e) => e != undefined) as any;
|
|
||||||
}
|
|
||||||
const res = {} as any;
|
|
||||||
for (const key in data) {
|
|
||||||
if (data[key] != undefined) {
|
|
||||||
res[key] = data[key];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return res;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const deepClone = <T>(obj: T): T => {
|
|
||||||
if (obj === null || typeof obj !== "object") {
|
|
||||||
return obj;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(obj)) {
|
|
||||||
const copy: any[] = [];
|
|
||||||
obj.forEach((item, index) => {
|
|
||||||
copy[index] = deepClone(item);
|
|
||||||
});
|
|
||||||
|
|
||||||
return copy as unknown as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
const copy = {} as T;
|
|
||||||
|
|
||||||
for (const key in obj) {
|
|
||||||
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
|
||||||
(copy as any)[key] = deepClone((obj as any)[key]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return copy;
|
|
||||||
};
|
|
|
@ -1,105 +0,0 @@
|
||||||
// @ts-nocheck
|
|
||||||
|
|
||||||
// Source: https://github.com/AsyncBanana/microdiff
|
|
||||||
|
|
||||||
interface Difference {
|
|
||||||
type: "CREATE" | "REMOVE" | "CHANGE";
|
|
||||||
path: (string | number)[];
|
|
||||||
value?: any;
|
|
||||||
}
|
|
||||||
interface Options {
|
|
||||||
cyclesFix: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const t = true;
|
|
||||||
const richTypes = { Date: t, RegExp: t, String: t, Number: t };
|
|
||||||
|
|
||||||
export function isEqual(oldObj: any, newObj: any): boolean {
|
|
||||||
return (
|
|
||||||
diff(
|
|
||||||
{
|
|
||||||
obj: oldObj,
|
|
||||||
},
|
|
||||||
{ obj: newObj }
|
|
||||||
).length < 1
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const isNotEqual = (oldObj: any, newObj: any) =>
|
|
||||||
!isEqual(oldObj, newObj);
|
|
||||||
|
|
||||||
function diff(
|
|
||||||
obj: Record<string, any> | any[],
|
|
||||||
newObj: Record<string, any> | any[],
|
|
||||||
options: Partial<Options> = { cyclesFix: true },
|
|
||||||
_stack: Record<string, any>[] = []
|
|
||||||
): Difference[] {
|
|
||||||
const diffs: Difference[] = [];
|
|
||||||
const isObjArray = Array.isArray(obj);
|
|
||||||
|
|
||||||
for (const key in obj) {
|
|
||||||
const objKey = obj[key];
|
|
||||||
const path = isObjArray ? Number(key) : key;
|
|
||||||
if (!(key in newObj)) {
|
|
||||||
diffs.push({
|
|
||||||
type: "REMOVE",
|
|
||||||
path: [path],
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const newObjKey = newObj[key];
|
|
||||||
const areObjects =
|
|
||||||
typeof objKey === "object" && typeof newObjKey === "object";
|
|
||||||
if (
|
|
||||||
objKey &&
|
|
||||||
newObjKey &&
|
|
||||||
areObjects &&
|
|
||||||
!richTypes[Object.getPrototypeOf(objKey).constructor.name] &&
|
|
||||||
(options.cyclesFix ? !_stack.includes(objKey) : true)
|
|
||||||
) {
|
|
||||||
const nestedDiffs = diff(
|
|
||||||
objKey,
|
|
||||||
newObjKey,
|
|
||||||
options,
|
|
||||||
options.cyclesFix ? _stack.concat([objKey]) : []
|
|
||||||
);
|
|
||||||
// eslint-disable-next-line prefer-spread
|
|
||||||
diffs.push.apply(
|
|
||||||
diffs,
|
|
||||||
nestedDiffs.map((difference) => {
|
|
||||||
difference.path.unshift(path);
|
|
||||||
|
|
||||||
return difference;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
} else if (
|
|
||||||
objKey !== newObjKey &&
|
|
||||||
!(
|
|
||||||
areObjects &&
|
|
||||||
(Number.isNaN(objKey)
|
|
||||||
? String(objKey) === String(newObjKey)
|
|
||||||
: Number(objKey) === Number(newObjKey))
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
diffs.push({
|
|
||||||
path: [path],
|
|
||||||
type: "CHANGE",
|
|
||||||
value: newObjKey,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const isNewObjArray = Array.isArray(newObj);
|
|
||||||
|
|
||||||
for (const key in newObj) {
|
|
||||||
if (!(key in obj)) {
|
|
||||||
diffs.push({
|
|
||||||
type: "CREATE",
|
|
||||||
path: [isNewObjArray ? Number(key) : key],
|
|
||||||
value: newObj[key],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return diffs;
|
|
||||||
}
|
|
|
@ -1,11 +0,0 @@
|
||||||
export function uuid(): string {
|
|
||||||
const uuid = new Array(36);
|
|
||||||
for (let i = 0; i < 36; i++) {
|
|
||||||
uuid[i] = Math.floor(Math.random() * 16);
|
|
||||||
}
|
|
||||||
uuid[14] = 4;
|
|
||||||
uuid[19] = uuid[19] &= ~(1 << 2);
|
|
||||||
uuid[19] = uuid[19] |= 1 << 3;
|
|
||||||
uuid[8] = uuid[13] = uuid[18] = uuid[23] = "-";
|
|
||||||
return uuid.map((x) => x.toString(16)).join("");
|
|
||||||
}
|
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { Button, Input } from "antd";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export default function CarouselUrlInput(
|
||||||
|
{ value, onChange }
|
||||||
|
: {
|
||||||
|
value?: string[];
|
||||||
|
onChange?: (value: string[]) => void;
|
||||||
|
}) {
|
||||||
|
const [url, setUrl] = useState<string>("");
|
||||||
|
const [urls, setUrls] = useState<string[]>(value || []);
|
||||||
|
const handleChange = (e) => {
|
||||||
|
if (e.target.value !== "") setUrl(e.target.value);
|
||||||
|
};
|
||||||
|
const handleDelete = (index) => {
|
||||||
|
setUrls((prevList) => {
|
||||||
|
// 创建一个新数组并移除指定索引的元素
|
||||||
|
const newList = [...prevList];
|
||||||
|
newList.splice(index, 1);
|
||||||
|
return newList;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
useEffect(() => {
|
||||||
|
if (value) {
|
||||||
|
setUrls(value)
|
||||||
|
}
|
||||||
|
}, [value])
|
||||||
|
useEffect(() => {
|
||||||
|
onChange?.(urls);
|
||||||
|
}, [urls]);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="w-full flex gap-2">
|
||||||
|
<Input className="w-[500px]" placeholder="请输入跳转链接" onChange={handleChange} />
|
||||||
|
<Button onClick={() => {
|
||||||
|
if (url) setUrls((prevUrls) => [...prevUrls, url]);
|
||||||
|
}} type="primary">添加轮播图链接</Button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<ul>
|
||||||
|
{urls.map((item, index) => (
|
||||||
|
<li className="flex justify-between mt-2 ml-1 p-2 bg-white rounded-lg" key={index}>
|
||||||
|
<Input className="w-4/5" defaultValue={item} disabled />
|
||||||
|
{/* <span className="w-6 block">{item}</span> */}
|
||||||
|
<button className="text-red-500" onClick={() => handleDelete(index)}>删除</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
|
||||||
|
);
|
||||||
|
}
|
|
@ -82,54 +82,57 @@ export default function ResourcesShower({
|
||||||
</Image.PreviewGroup>
|
</Image.PreviewGroup>
|
||||||
</Row>
|
</Row>
|
||||||
)}
|
)}
|
||||||
<div className=" text-sm px-2">附件:</div>
|
|
||||||
{fileResources.length > 0 && (
|
|
||||||
<div className="rounded-xl p-1 border border-gray-100 bg-white">
|
|
||||||
<div className="flex flex-nowrap overflow-x-auto scrollbar-hide gap-1.5">
|
|
||||||
{fileResources.map((resource) => {
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
key={resource.url}
|
|
||||||
className="flex-shrink-0 relative active:scale-95 transition-transform select-none "
|
|
||||||
href={resource.originalUrl}
|
|
||||||
target="_blank"
|
|
||||||
download={true}
|
|
||||||
title="点击下载文件">
|
|
||||||
{/* 超紧凑卡片容器 */}
|
|
||||||
<div className="w-[120px] h-[80px] p-2 flex flex-col items-center justify-between rounded-xl hover:bg-primary-50/40 cursor-pointer">
|
|
||||||
{/* 微型文件图标 */}
|
|
||||||
<div className="text-primary-600 text-base">
|
|
||||||
{getFileIcon(resource.url)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 压缩信息展示 */}
|
{fileResources.length > 0 && (
|
||||||
<div className="w-full text-center space-y-0.5">
|
<>
|
||||||
<p className="text-xs font-medium text-gray-800 truncate">
|
<div className=" text-sm px-2">附件:</div>
|
||||||
{resource.title?.slice(0, 12) ||
|
<div className="rounded-xl p-1 border border-gray-100 bg-white">
|
||||||
"未命名"}
|
<div className="flex flex-nowrap overflow-x-auto scrollbar-hide gap-1.5">
|
||||||
</p>
|
{fileResources.map((resource) => {
|
||||||
<div className="flex items-center justify-between text-xs text-gray-500">
|
return (
|
||||||
<span className="bg-gray-100 px-0.5 rounded-sm">
|
<a
|
||||||
{resource.url
|
key={resource.url}
|
||||||
.split(".")
|
className="flex-shrink-0 relative active:scale-95 transition-transform select-none "
|
||||||
.pop()
|
href={resource.originalUrl}
|
||||||
?.slice(0, 4)
|
target="_blank"
|
||||||
.toUpperCase()}
|
download={true}
|
||||||
</span>
|
title="点击下载文件">
|
||||||
<span>
|
{/* 超紧凑卡片容器 */}
|
||||||
{resource.meta.size &&
|
<div className="w-[120px] h-[80px] p-2 flex flex-col items-center justify-between rounded-xl hover:bg-primary-50/40 cursor-pointer">
|
||||||
formatFileSize(
|
{/* 微型文件图标 */}
|
||||||
resource.meta.size
|
<div className="text-primary-600 text-base">
|
||||||
)}
|
{getFileIcon(resource.url)}
|
||||||
</span>
|
</div>
|
||||||
|
|
||||||
|
{/* 压缩信息展示 */}
|
||||||
|
<div className="w-full text-center space-y-0.5">
|
||||||
|
<p className="text-xs font-medium text-gray-800 truncate">
|
||||||
|
{resource.title?.slice(0, 12) ||
|
||||||
|
"未命名"}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center justify-between text-xs text-gray-500 ">
|
||||||
|
<span className="bg-gray-100 px-0.5 rounded-sm mr-2 whitespace-pre-wrap">
|
||||||
|
{resource.url
|
||||||
|
.split(".")
|
||||||
|
.pop()
|
||||||
|
?.slice(0, 4)
|
||||||
|
.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
<span className="flex bg-gray-100 px-0.5 rounded-sm justify-items-center whitespace-pre-wrap">
|
||||||
|
{resource.meta.size &&
|
||||||
|
formatFileSize(
|
||||||
|
resource.meta.size
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</a>
|
||||||
</a>
|
);
|
||||||
);
|
})}
|
||||||
})}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -122,7 +122,7 @@ export default function StaffForm() {
|
||||||
noStyle
|
noStyle
|
||||||
rules={[{ required: true }]}
|
rules={[{ required: true }]}
|
||||||
name={"username"}
|
name={"username"}
|
||||||
label="帐号">
|
label="账号">
|
||||||
<Input
|
<Input
|
||||||
placeholder="请输入用户名"
|
placeholder="请输入用户名"
|
||||||
allowClear
|
allowClear
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { CourseDetailProvider } from "./CourseDetailContext";
|
import { PostDetailProvider } from "./PostDetailContext";
|
||||||
import CourseDetailLayout from "./CourseDetailLayout";
|
import CourseDetailLayout from "./CourseDetailLayout";
|
||||||
|
|
||||||
export default function CourseDetail({
|
export default function CourseDetail({
|
||||||
|
@ -8,12 +8,11 @@ export default function CourseDetail({
|
||||||
id?: string;
|
id?: string;
|
||||||
lectureId?: string;
|
lectureId?: string;
|
||||||
}) {
|
}) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CourseDetailProvider editId={id}>
|
<PostDetailProvider editId={id}>
|
||||||
<CourseDetailLayout></CourseDetailLayout>
|
<CourseDetailLayout></CourseDetailLayout>
|
||||||
</CourseDetailProvider>
|
</PostDetailProvider>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { Course, TaxonomySlug } from "@nice/common";
|
import { Course, CourseDto, TaxonomySlug } from "@nice/common";
|
||||||
import React, { useContext, useMemo } from "react";
|
import React, { useContext, useEffect, useMemo } from "react";
|
||||||
import { Image, Typography, Skeleton, Tag } from "antd"; // 引入 antd 组件
|
import { Image, Typography, Skeleton, Tag } from "antd"; // 引入 antd 组件
|
||||||
import { CourseDetailContext } from "./CourseDetailContext";
|
import { CourseDetailContext } from "./PostDetailContext";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { useStaff } from "@nice/client";
|
import { useStaff } from "@nice/client";
|
||||||
import { useAuth } from "@web/src/providers/auth-provider";
|
import { useAuth } from "@web/src/providers/auth-provider";
|
||||||
|
@ -10,7 +10,7 @@ import { PictureOutlined } from "@ant-design/icons";
|
||||||
|
|
||||||
export const CourseDetailDescription: React.FC = () => {
|
export const CourseDetailDescription: React.FC = () => {
|
||||||
const {
|
const {
|
||||||
course,
|
post,
|
||||||
canEdit,
|
canEdit,
|
||||||
isLoading,
|
isLoading,
|
||||||
selectedLectureId,
|
selectedLectureId,
|
||||||
|
@ -22,14 +22,14 @@ export const CourseDetailDescription: React.FC = () => {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { update } = useStaff();
|
const { update } = useStaff();
|
||||||
const firstLectureId = useMemo(() => {
|
const firstLectureId = useMemo(() => {
|
||||||
return course?.sections?.[0]?.lectures?.[0]?.id;
|
return (post as CourseDto)?.sections?.[0]?.lectures?.[0]?.id;
|
||||||
}, [course]);
|
}, [post]);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
return (
|
return (
|
||||||
// <div className="w-full bg-white shadow-md rounded-lg border border-gray-200 p-5 my-4">
|
// <div className="w-full bg-white shadow-md rounded-lg border border-gray-200 p-5 my-4">
|
||||||
<div className="w-full px-5 my-2">
|
<div className="w-full px-5 my-2">
|
||||||
{isLoading || !course ? (
|
{isLoading || !post ? (
|
||||||
<Skeleton active paragraph={{ rows: 4 }} />
|
<Skeleton active paragraph={{ rows: 4 }} />
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
@ -39,7 +39,7 @@ export const CourseDetailDescription: React.FC = () => {
|
||||||
<div
|
<div
|
||||||
className="w-full rounded-xl aspect-video bg-cover bg-center z-0"
|
className="w-full rounded-xl aspect-video bg-cover bg-center z-0"
|
||||||
style={{
|
style={{
|
||||||
backgroundImage: `url(${course?.meta?.thumbnail || "/placeholder.webp"})`,
|
backgroundImage: `url(${post?.meta?.thumbnail || "/placeholder.webp"})`,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
@ -52,7 +52,7 @@ export const CourseDetailDescription: React.FC = () => {
|
||||||
data: {
|
data: {
|
||||||
learningPosts: {
|
learningPosts: {
|
||||||
connect: {
|
connect: {
|
||||||
id: course.id,
|
id: post.id,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -69,19 +69,22 @@ export const CourseDetailDescription: React.FC = () => {
|
||||||
<div className="text-lg font-bold">{"课程简介:"}</div>
|
<div className="text-lg font-bold">{"课程简介:"}</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<div className="flex gap-2 flex-wrap items-center float-start">
|
<div className="flex gap-2 flex-wrap items-center float-start">
|
||||||
{course?.subTitle && <div>{course?.subTitle}</div>}
|
<TermInfo terms={post.terms}></TermInfo>
|
||||||
<TermInfo post={course}></TermInfo>
|
</div>
|
||||||
|
<div className="flex gap-2 flex-wrap items-center float-start pl-2">
|
||||||
|
{post?.subTitle && <div>{post?.subTitle}</div>}
|
||||||
|
{/* <TermInfo terms={post.terms}></TermInfo> */}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Paragraph
|
<Paragraph
|
||||||
className="text-gray-600"
|
className="text-gray-600 pl-2"
|
||||||
ellipsis={{
|
ellipsis={{
|
||||||
rows: 3,
|
rows: 3,
|
||||||
expandable: true,
|
expandable: true,
|
||||||
symbol: "展开",
|
symbol: "展开",
|
||||||
onExpand: () => console.log("展开"),
|
onExpand: () => console.log("展开"),
|
||||||
}}>
|
}}>
|
||||||
{course?.content}
|
{post?.content}
|
||||||
</Paragraph>
|
</Paragraph>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -4,18 +4,17 @@ import React, { useContext, useRef, useState } from "react";
|
||||||
import { VideoPlayer } from "@web/src/components/presentation/video-player/VideoPlayer";
|
import { VideoPlayer } from "@web/src/components/presentation/video-player/VideoPlayer";
|
||||||
import { CourseDetailDescription } from "./CourseDetailDescription";
|
import { CourseDetailDescription } from "./CourseDetailDescription";
|
||||||
import { Course, LectureType, PostType } from "@nice/common";
|
import { Course, LectureType, PostType } from "@nice/common";
|
||||||
import { CourseDetailContext } from "./CourseDetailContext";
|
import { CourseDetailContext } from "./PostDetailContext";
|
||||||
import CollapsibleContent from "@web/src/components/common/container/CollapsibleContent";
|
import CollapsibleContent from "@web/src/components/common/container/CollapsibleContent";
|
||||||
import { Skeleton } from "antd";
|
import { Skeleton } from "antd";
|
||||||
import ResourcesShower from "@web/src/components/common/uploader/ResourceShower";
|
import ResourcesShower from "@web/src/components/common/uploader/ResourceShower";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import CourseDetailTitle from "./CourseDetailTitle";
|
import CourseDetailTitle from "./CourseDetailTitle";
|
||||||
|
|
||||||
|
|
||||||
export const CourseDetailDisplayArea: React.FC = () => {
|
export const CourseDetailDisplayArea: React.FC = () => {
|
||||||
// 创建滚动动画效果
|
// 创建滚动动画效果
|
||||||
const {
|
const {
|
||||||
course,
|
|
||||||
isLoading,
|
isLoading,
|
||||||
canEdit,
|
canEdit,
|
||||||
lecture,
|
lecture,
|
||||||
|
|
|
@ -1,97 +0,0 @@
|
||||||
import { useContext, useState } from "react";
|
|
||||||
import { Input, Layout, Avatar, Button, Dropdown } from "antd";
|
|
||||||
import {
|
|
||||||
EditFilled,
|
|
||||||
HomeOutlined,
|
|
||||||
SearchOutlined,
|
|
||||||
UserOutlined,
|
|
||||||
} from "@ant-design/icons";
|
|
||||||
import { useAuth } from "@web/src/providers/auth-provider";
|
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
|
||||||
import { UserMenu } from "@web/src/app/main/layout/UserMenu/UserMenu";
|
|
||||||
import { CourseDetailContext } from "../CourseDetailContext";
|
|
||||||
import { usePost, useStaff } from "@nice/client";
|
|
||||||
import toast from "react-hot-toast";
|
|
||||||
import { NavigationMenu } from "@web/src/app/main/layout/NavigationMenu";
|
|
||||||
|
|
||||||
const { Header } = Layout;
|
|
||||||
|
|
||||||
export function CourseDetailHeader() {
|
|
||||||
const { id } = useParams();
|
|
||||||
const { isAuthenticated, user, hasSomePermissions, hasEveryPermissions } =
|
|
||||||
useAuth();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { course, canEdit, userIsLearning } = useContext(CourseDetailContext);
|
|
||||||
const { update } = useStaff();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Header className="select-none flex items-center justify-center bg-white shadow-md border-b border-gray-100 fixed w-full z-30">
|
|
||||||
<div className="w-full max-w-screen-3xl px-4 md:px-6 flex items-center justify-between h-full">
|
|
||||||
<div className="flex items-center space-x-8">
|
|
||||||
<div
|
|
||||||
onClick={() => navigate("/")}
|
|
||||||
className="text-2xl font-bold bg-gradient-to-r from-primary-600 via-primary-500 to-primary-400 bg-clip-text text-transparent hover:scale-105 transition-transform cursor-pointer">
|
|
||||||
烽火慕课
|
|
||||||
</div>
|
|
||||||
<NavigationMenu />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center space-x-6">
|
|
||||||
{isAuthenticated && (
|
|
||||||
<Button
|
|
||||||
onClick={async () => {
|
|
||||||
if (!userIsLearning) {
|
|
||||||
await update.mutateAsync({
|
|
||||||
where: { id: user?.id },
|
|
||||||
data: {
|
|
||||||
learningPosts: {
|
|
||||||
connect: { id: course.id },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await update.mutateAsync({
|
|
||||||
where: { id: user?.id },
|
|
||||||
data: {
|
|
||||||
learningPosts: {
|
|
||||||
disconnect: {
|
|
||||||
id: course.id,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="flex items-center space-x-1 bg-gradient-to-r from-blue-500 to-blue-600 text-white hover:from-blue-600 hover:to-blue-700 border-none shadow-md hover:shadow-lg transition-all"
|
|
||||||
icon={<EditFilled />}>
|
|
||||||
{userIsLearning ? "退出学习" : "加入学习"}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{canEdit && (
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
const url = id
|
|
||||||
? `/course/${id}/editor`
|
|
||||||
: "/course/editor";
|
|
||||||
navigate(url);
|
|
||||||
}}
|
|
||||||
className="flex items-center space-x-1 bg-gradient-to-r from-blue-500 to-blue-600 text-white hover:from-blue-600 hover:to-blue-700 border-none shadow-md hover:shadow-lg transition-all"
|
|
||||||
icon={<EditFilled />}>
|
|
||||||
{"编辑课程"}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{isAuthenticated ? (
|
|
||||||
<UserMenu />
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
onClick={() => navigate("/login")}
|
|
||||||
className="flex items-center space-x-1 bg-gradient-to-r from-blue-500 to-blue-600 text-white hover:from-blue-600 hover:to-blue-700 border-none shadow-md hover:shadow-lg transition-all"
|
|
||||||
icon={<UserOutlined />}>
|
|
||||||
登录
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Header>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,14 +1,14 @@
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { useContext, useState } from "react";
|
import { useContext, useState } from "react";
|
||||||
import { CourseDetailContext } from "./CourseDetailContext";
|
import { CourseDetailContext } from "./PostDetailContext";
|
||||||
|
|
||||||
import CourseDetailDisplayArea from "./CourseDetailDisplayArea";
|
import CourseDetailDisplayArea from "./CourseDetailDisplayArea";
|
||||||
import { CourseSyllabus } from "./CourseSyllabus/CourseSyllabus";
|
import { CourseSyllabus } from "./CourseSyllabus/CourseSyllabus";
|
||||||
import { CourseDetailHeader } from "./CourseDetailHeader/CourseDetailHeader";
|
import { CourseDto } from "packages/common/dist";
|
||||||
|
|
||||||
export default function CourseDetailLayout() {
|
export default function CourseDetailLayout() {
|
||||||
const {
|
const {
|
||||||
course,
|
post,
|
||||||
|
|
||||||
setSelectedLectureId,
|
setSelectedLectureId,
|
||||||
} = useContext(CourseDetailContext);
|
} = useContext(CourseDetailContext);
|
||||||
|
@ -19,7 +19,6 @@ export default function CourseDetailLayout() {
|
||||||
const [isSyllabusOpen, setIsSyllabusOpen] = useState(true);
|
const [isSyllabusOpen, setIsSyllabusOpen] = useState(true);
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
|
|
||||||
<div className="pt-12 px-32">
|
<div className="pt-12 px-32">
|
||||||
{" "}
|
{" "}
|
||||||
{/* 添加这个包装 div */}
|
{/* 添加这个包装 div */}
|
||||||
|
@ -36,7 +35,7 @@ export default function CourseDetailLayout() {
|
||||||
</motion.div>
|
</motion.div>
|
||||||
{/* 课程大纲侧边栏 */}
|
{/* 课程大纲侧边栏 */}
|
||||||
<CourseSyllabus
|
<CourseSyllabus
|
||||||
sections={course?.sections || []}
|
sections={(post as CourseDto)?.sections || []}
|
||||||
onLectureClick={handleLectureClick}
|
onLectureClick={handleLectureClick}
|
||||||
isOpen={isSyllabusOpen}
|
isOpen={isSyllabusOpen}
|
||||||
onToggle={() => setIsSyllabusOpen(!isSyllabusOpen)}
|
onToggle={() => setIsSyllabusOpen(!isSyllabusOpen)}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { useContext } from "react";
|
import { useContext } from "react";
|
||||||
import { CourseDetailContext } from "./CourseDetailContext";
|
import { CourseDetailContext } from "./PostDetailContext";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
BookOutlined,
|
BookOutlined,
|
||||||
|
@ -9,11 +9,14 @@ import {
|
||||||
ReloadOutlined,
|
ReloadOutlined,
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import CourseOperationBtns from "./JoinLearingButton";
|
import CourseOperationBtns from "./CourseOperationBtns/CourseOperationBtns";
|
||||||
|
|
||||||
export default function CourseDetailTitle() {
|
export default function CourseDetailTitle() {
|
||||||
const { course, lecture, selectedLectureId } =
|
const {
|
||||||
useContext(CourseDetailContext);
|
post: course,
|
||||||
|
lecture,
|
||||||
|
selectedLectureId,
|
||||||
|
} = useContext(CourseDetailContext);
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center flex-col items-center gap-2 w-full my-2 px-6">
|
<div className="flex justify-center flex-col items-center gap-2 w-full my-2 px-6">
|
||||||
<div className="flex justify-start w-full text-2xl font-bold">
|
<div className="flex justify-start w-full text-2xl font-bold">
|
||||||
|
@ -54,7 +57,9 @@ export default function CourseDetailTitle() {
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<EyeOutlined></EyeOutlined>
|
<EyeOutlined></EyeOutlined>
|
||||||
<div>{`观看次数${
|
<div>{`观看次数${
|
||||||
!selectedLectureId ? course?.views : lecture?.views || 0
|
!selectedLectureId
|
||||||
|
? course?.views || 0
|
||||||
|
: lecture?.views || 0
|
||||||
}`}</div>
|
}`}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
|
|
|
@ -0,0 +1,98 @@
|
||||||
|
import { useAuth } from "@web/src/providers/auth-provider";
|
||||||
|
import { useContext, useState } from "react";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import { CourseDetailContext } from "../PostDetailContext";
|
||||||
|
import { useStaff } from "@nice/client";
|
||||||
|
import {
|
||||||
|
CheckCircleOutlined,
|
||||||
|
CloseCircleOutlined,
|
||||||
|
EditTwoTone,
|
||||||
|
LoginOutlined,
|
||||||
|
} from "@ant-design/icons";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import JoinButton from "./JoinButton";
|
||||||
|
|
||||||
|
export default function CourseOperationBtns() {
|
||||||
|
// const { isAuthenticated, user } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { post, canEdit, userIsLearning, setUserIsLearning } =
|
||||||
|
useContext(CourseDetailContext);
|
||||||
|
// const { update } = useStaff();
|
||||||
|
// const [isHovered, setIsHovered] = useState(false);
|
||||||
|
|
||||||
|
// const toggleLearning = async () => {
|
||||||
|
// if (!userIsLearning) {
|
||||||
|
// await update.mutateAsync({
|
||||||
|
// where: { id: user?.id },
|
||||||
|
// data: {
|
||||||
|
// learningPosts: {
|
||||||
|
// connect: { id: course.id },
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
// setUserIsLearning(true);
|
||||||
|
// toast.success("加入学习成功");
|
||||||
|
// } else {
|
||||||
|
// await update.mutateAsync({
|
||||||
|
// where: { id: user?.id },
|
||||||
|
// data: {
|
||||||
|
// learningPosts: {
|
||||||
|
// disconnect: {
|
||||||
|
// id: course.id,
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
// toast.success("退出学习成功");
|
||||||
|
// setUserIsLearning(false);
|
||||||
|
// }
|
||||||
|
// };
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* {isAuthenticated && (
|
||||||
|
<div
|
||||||
|
onClick={toggleLearning}
|
||||||
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
|
className={`flex px-1 py-0.5 gap-1 hover:cursor-pointer transition-all ${
|
||||||
|
userIsLearning
|
||||||
|
? isHovered
|
||||||
|
? "text-red-500 border-red-500 rounded-md "
|
||||||
|
: "text-green-500 "
|
||||||
|
: "text-primary "
|
||||||
|
}`}>
|
||||||
|
{userIsLearning ? (
|
||||||
|
isHovered ? (
|
||||||
|
<CloseCircleOutlined />
|
||||||
|
) : (
|
||||||
|
<CheckCircleOutlined />
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<LoginOutlined />
|
||||||
|
)}
|
||||||
|
<span>
|
||||||
|
{userIsLearning
|
||||||
|
? isHovered
|
||||||
|
? "退出学习"
|
||||||
|
: "正在学习"
|
||||||
|
: "加入学习"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)} */}
|
||||||
|
<JoinButton></JoinButton>
|
||||||
|
{canEdit && (
|
||||||
|
<div
|
||||||
|
className="flex gap-1 px-1 py-0.5 text-primary hover:cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
|
const url = post?.id
|
||||||
|
? `/course/${post?.id}/editor`
|
||||||
|
: "/course/editor";
|
||||||
|
navigate(url);
|
||||||
|
}}>
|
||||||
|
<EditTwoTone></EditTwoTone>
|
||||||
|
{"编辑课程"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,24 +1,20 @@
|
||||||
import { useAuth } from "@web/src/providers/auth-provider";
|
import { useAuth } from "@web/src/providers/auth-provider";
|
||||||
import { useContext, useState } from "react";
|
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
|
||||||
import { CourseDetailContext } from "./CourseDetailContext";
|
|
||||||
import { useStaff } from "@nice/client";
|
import { useStaff } from "@nice/client";
|
||||||
|
import { useContext, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { CourseDetailContext } from "../PostDetailContext";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
import {
|
import {
|
||||||
CheckCircleFilled,
|
|
||||||
CheckCircleOutlined,
|
CheckCircleOutlined,
|
||||||
CloseCircleFilled,
|
|
||||||
CloseCircleOutlined,
|
CloseCircleOutlined,
|
||||||
EditFilled,
|
|
||||||
EditTwoTone,
|
|
||||||
LoginOutlined,
|
LoginOutlined,
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
|
|
||||||
export default function CourseOperationBtns() {
|
export default function JoinButton() {
|
||||||
const { id } = useParams();
|
const { isAuthenticated, user } = useAuth();
|
||||||
const { isAuthenticated, user, hasSomePermissions, hasEveryPermissions } =
|
|
||||||
useAuth();
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { course, canEdit, userIsLearning } = useContext(CourseDetailContext);
|
const { post, canEdit, userIsLearning, setUserIsLearning } =
|
||||||
|
useContext(CourseDetailContext);
|
||||||
const { update } = useStaff();
|
const { update } = useStaff();
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
const toggleLearning = async () => {
|
const toggleLearning = async () => {
|
||||||
|
@ -27,21 +23,25 @@ export default function CourseOperationBtns() {
|
||||||
where: { id: user?.id },
|
where: { id: user?.id },
|
||||||
data: {
|
data: {
|
||||||
learningPosts: {
|
learningPosts: {
|
||||||
connect: { id: course.id },
|
connect: { id: post.id },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
setUserIsLearning(true);
|
||||||
|
toast.success("加入学习成功");
|
||||||
} else {
|
} else {
|
||||||
await update.mutateAsync({
|
await update.mutateAsync({
|
||||||
where: { id: user?.id },
|
where: { id: user?.id },
|
||||||
data: {
|
data: {
|
||||||
learningPosts: {
|
learningPosts: {
|
||||||
disconnect: {
|
disconnect: {
|
||||||
id: course.id,
|
id: post.id,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
toast.success("退出学习成功");
|
||||||
|
setUserIsLearning(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
|
@ -76,19 +76,6 @@ export default function CourseOperationBtns() {
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{canEdit && (
|
|
||||||
<div
|
|
||||||
className="flex gap-1 px-1 py-0.5 text-primary hover:cursor-pointer"
|
|
||||||
onClick={() => {
|
|
||||||
const url = course?.id
|
|
||||||
? `/course/${course?.id}/editor`
|
|
||||||
: "/course/editor";
|
|
||||||
navigate(url);
|
|
||||||
}}>
|
|
||||||
<EditTwoTone></EditTwoTone>
|
|
||||||
{"编辑课程"}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
|
@ -3,16 +3,16 @@ import { CoursePreviewMsg } from "@web/src/app/main/course/preview/type.ts";
|
||||||
import { Button, Tabs, Image, Skeleton } from "antd";
|
import { Button, Tabs, Image, Skeleton } from "antd";
|
||||||
import type { TabsProps } from "antd";
|
import type { TabsProps } from "antd";
|
||||||
import { PlayCircleOutlined } from "@ant-design/icons";
|
import { PlayCircleOutlined } from "@ant-design/icons";
|
||||||
import { CourseDetailContext } from "../CourseDetailContext";
|
import { CourseDetailContext } from "../PostDetailContext";
|
||||||
export function CoursePreview() {
|
export function CoursePreview() {
|
||||||
const { course, isLoading, lecture, lectureIsLoading, selectedLectureId } =
|
const { post, isLoading, lecture, lectureIsLoading, selectedLectureId } =
|
||||||
useContext(CourseDetailContext);
|
useContext(CourseDetailContext);
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen max-w-7xl mx-auto px-6 lg:px-8">
|
<div className="min-h-screen max-w-7xl mx-auto px-6 lg:px-8">
|
||||||
<div className="overflow-auto flex justify-around align-items-center w-full mx-auto my-8">
|
<div className="overflow-auto flex justify-around align-items-center w-full mx-auto my-8">
|
||||||
<div className="relative w-[600px] h-[340px] m-4 overflow-hidden flex justify-center items-center">
|
<div className="relative w-[600px] h-[340px] m-4 overflow-hidden flex justify-center items-center">
|
||||||
<Image
|
<Image
|
||||||
src={isLoading ? "error" : course?.meta?.thumbnail}
|
src={isLoading ? "error" : post?.meta?.thumbnail}
|
||||||
alt="example"
|
alt="example"
|
||||||
preview={false}
|
preview={false}
|
||||||
className="w-full h-full object-cover z-0"
|
className="w-full h-full object-cover z-0"
|
||||||
|
@ -28,13 +28,13 @@ export function CoursePreview() {
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<span className="text-3xl font-bold my-3 ">
|
<span className="text-3xl font-bold my-3 ">
|
||||||
{course.title}
|
{post.title}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xl font-semibold my-3 text-gray-700">
|
<span className="text-xl font-semibold my-3 text-gray-700">
|
||||||
{course.subTitle}
|
{post.subTitle}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-lg font-light my-3 text-gray-500 text-clip">
|
<span className="text-lg font-light my-3 text-gray-500 text-clip">
|
||||||
{course.content}
|
{post.content}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -10,7 +10,7 @@ import { SectionDto, TaxonomySlug } from "@nice/common";
|
||||||
import { SyllabusHeader } from "./SyllabusHeader";
|
import { SyllabusHeader } from "./SyllabusHeader";
|
||||||
import { SectionItem } from "./SectionItem";
|
import { SectionItem } from "./SectionItem";
|
||||||
import { CollapsedButton } from "./CollapsedButton";
|
import { CollapsedButton } from "./CollapsedButton";
|
||||||
import { CourseDetailContext } from "../CourseDetailContext";
|
import { CourseDetailContext } from "../PostDetailContext";
|
||||||
import { api } from "@nice/client";
|
import { api } from "@nice/client";
|
||||||
|
|
||||||
interface CourseSyllabusProps {
|
interface CourseSyllabusProps {
|
||||||
|
|
|
@ -4,6 +4,7 @@ import {
|
||||||
CourseDto,
|
CourseDto,
|
||||||
Lecture,
|
Lecture,
|
||||||
lectureDetailSelect,
|
lectureDetailSelect,
|
||||||
|
PostDto,
|
||||||
RolePerms,
|
RolePerms,
|
||||||
VisitType,
|
VisitType,
|
||||||
} from "@nice/common";
|
} from "@nice/common";
|
||||||
|
@ -19,7 +20,7 @@ import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
|
||||||
interface CourseDetailContextType {
|
interface CourseDetailContextType {
|
||||||
editId?: string; // 添加 editId
|
editId?: string; // 添加 editId
|
||||||
course?: CourseDto;
|
post?: PostDto;
|
||||||
lecture?: Lecture;
|
lecture?: Lecture;
|
||||||
selectedLectureId?: string | undefined;
|
selectedLectureId?: string | undefined;
|
||||||
setSelectedLectureId?: React.Dispatch<React.SetStateAction<string>>;
|
setSelectedLectureId?: React.Dispatch<React.SetStateAction<string>>;
|
||||||
|
@ -29,6 +30,7 @@ interface CourseDetailContextType {
|
||||||
setIsHeaderVisible: (visible: boolean) => void; // 新增
|
setIsHeaderVisible: (visible: boolean) => void; // 新增
|
||||||
canEdit?: boolean;
|
canEdit?: boolean;
|
||||||
userIsLearning?: boolean;
|
userIsLearning?: boolean;
|
||||||
|
setUserIsLearning: (learning: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CourseFormProviderProps {
|
interface CourseFormProviderProps {
|
||||||
|
@ -38,7 +40,7 @@ interface CourseFormProviderProps {
|
||||||
|
|
||||||
export const CourseDetailContext =
|
export const CourseDetailContext =
|
||||||
createContext<CourseDetailContextType | null>(null);
|
createContext<CourseDetailContextType | null>(null);
|
||||||
export function CourseDetailProvider({
|
export function PostDetailProvider({
|
||||||
children,
|
children,
|
||||||
editId,
|
editId,
|
||||||
}: CourseFormProviderProps) {
|
}: CourseFormProviderProps) {
|
||||||
|
@ -47,23 +49,24 @@ export function CourseDetailProvider({
|
||||||
const { user, hasSomePermissions, isAuthenticated } = useAuth();
|
const { user, hasSomePermissions, isAuthenticated } = useAuth();
|
||||||
const { lectureId } = useParams();
|
const { lectureId } = useParams();
|
||||||
|
|
||||||
const { data: course, isLoading }: { data: CourseDto; isLoading: boolean } =
|
const { data: post, isLoading }: { data: PostDto; isLoading: boolean } = (
|
||||||
(api.post as any).findFirst.useQuery(
|
api.post as any
|
||||||
{
|
).findFirst.useQuery(
|
||||||
where: { id: editId },
|
{
|
||||||
select: courseDetailSelect,
|
where: { id: editId },
|
||||||
},
|
select: courseDetailSelect,
|
||||||
{ enabled: Boolean(editId) }
|
},
|
||||||
);
|
{ enabled: Boolean(editId) }
|
||||||
|
);
|
||||||
const userIsLearning = useMemo(() => {
|
const [userIsLearning, setUserIsLearning] = useState(false);
|
||||||
return (course?.studentIds || []).includes(user?.id);
|
useEffect(() => {
|
||||||
}, [user, course, isLoading]);
|
setUserIsLearning((post?.studentIds || []).includes(user?.id));
|
||||||
|
}, [user, post, isLoading]);
|
||||||
const canEdit = useMemo(() => {
|
const canEdit = useMemo(() => {
|
||||||
const isAuthor = isAuthenticated && user?.id === course?.authorId;
|
const isAuthor = isAuthenticated && user?.id === post?.authorId;
|
||||||
const isRoot = hasSomePermissions(RolePerms?.MANAGE_ANY_POST);
|
const isRoot = hasSomePermissions(RolePerms?.MANAGE_ANY_POST);
|
||||||
return isAuthor || isRoot;
|
return isAuthor || isRoot;
|
||||||
}, [user, course]);
|
}, [user, post]);
|
||||||
|
|
||||||
const [selectedLectureId, setSelectedLectureId] = useState<
|
const [selectedLectureId, setSelectedLectureId] = useState<
|
||||||
string | undefined
|
string | undefined
|
||||||
|
@ -79,17 +82,24 @@ export function CourseDetailProvider({
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (lecture?.id) {
|
if (lectureId) {
|
||||||
console.log(123);
|
|
||||||
read.mutateAsync({
|
read.mutateAsync({
|
||||||
data: {
|
data: {
|
||||||
visitorId: user?.id || null,
|
visitorId: user?.id || null,
|
||||||
postId: lecture?.id,
|
postId: lectureId,
|
||||||
|
type: VisitType.READED,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
read.mutateAsync({
|
||||||
|
data: {
|
||||||
|
visitorId: user?.id || null,
|
||||||
|
postId: editId,
|
||||||
type: VisitType.READED,
|
type: VisitType.READED,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [course]);
|
}, [editId, lectureId]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (lectureId !== selectedLectureId) {
|
if (lectureId !== selectedLectureId) {
|
||||||
navigate(`/course/${editId}/detail/${selectedLectureId}`);
|
navigate(`/course/${editId}/detail/${selectedLectureId}`);
|
||||||
|
@ -100,7 +110,7 @@ export function CourseDetailProvider({
|
||||||
<CourseDetailContext.Provider
|
<CourseDetailContext.Provider
|
||||||
value={{
|
value={{
|
||||||
editId,
|
editId,
|
||||||
course,
|
post,
|
||||||
lecture,
|
lecture,
|
||||||
selectedLectureId,
|
selectedLectureId,
|
||||||
setSelectedLectureId,
|
setSelectedLectureId,
|
||||||
|
@ -110,6 +120,7 @@ export function CourseDetailProvider({
|
||||||
setIsHeaderVisible,
|
setIsHeaderVisible,
|
||||||
canEdit,
|
canEdit,
|
||||||
userIsLearning,
|
userIsLearning,
|
||||||
|
setUserIsLearning,
|
||||||
}}>
|
}}>
|
||||||
{children}
|
{children}
|
||||||
</CourseDetailContext.Provider>
|
</CourseDetailContext.Provider>
|
|
@ -202,7 +202,11 @@ export const SortableLecture: React.FC<SortableLectureProps> = ({
|
||||||
message: "请输入内容",
|
message: "请输入内容",
|
||||||
},
|
},
|
||||||
]}>
|
]}>
|
||||||
<QuillEditor />
|
<QuillEditor
|
||||||
|
style={{
|
||||||
|
width:"700px",
|
||||||
|
}}
|
||||||
|
></QuillEditor>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name={["meta", "fileIds"]}
|
name={["meta", "fileIds"]}
|
||||||
|
|
|
@ -76,7 +76,7 @@ export default function DepartmentList() {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
headerName: "操作",
|
headerName: "操作",
|
||||||
sortable: true,
|
// sortable: true,
|
||||||
cellRenderer: (props: CustomCellRendererProps) => (
|
cellRenderer: (props: CustomCellRendererProps) => (
|
||||||
<OpreationRenderer props={props} />
|
<OpreationRenderer props={props} />
|
||||||
),
|
),
|
||||||
|
|
|
@ -13,6 +13,11 @@ const { Title } = Typography;
|
||||||
export default function PostCard({ post, onClick }: PostCardProps) {
|
export default function PostCard({ post, onClick }: PostCardProps) {
|
||||||
const handleClick = (post: PostDto) => {
|
const handleClick = (post: PostDto) => {
|
||||||
onClick?.(post);
|
onClick?.(post);
|
||||||
|
// 添加平滑滚动到顶部
|
||||||
|
window.scrollTo({
|
||||||
|
top: 0,
|
||||||
|
behavior: "smooth", // 关键参数,启用平滑滚动
|
||||||
|
});
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
|
@ -39,7 +44,7 @@ export default function PostCard({ post, onClick }: PostCardProps) {
|
||||||
<div className="px-4 ">
|
<div className="px-4 ">
|
||||||
<div className="overflow-hidden hover:overflow-auto">
|
<div className="overflow-hidden hover:overflow-auto">
|
||||||
<div className="flex gap-2 h-7 whiteSpace-nowrap">
|
<div className="flex gap-2 h-7 whiteSpace-nowrap">
|
||||||
<TermInfo post={post}></TermInfo>
|
<TermInfo terms={post.terms}></TermInfo>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Title
|
<Title
|
||||||
|
@ -65,4 +70,3 @@ export default function PostCard({ post, onClick }: PostCardProps) {
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,105 @@
|
||||||
|
import { api } from "@nice/client";
|
||||||
|
import { Input, Select } from "antd";
|
||||||
|
import { Lecture, postDetailSelect, Prisma } from "@nice/common";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import PostSelectOption from "./PostSelectOption";
|
||||||
|
import { DefaultArgs } from "@prisma/client/runtime/library";
|
||||||
|
import { safeOR } from "@nice/utils";
|
||||||
|
import { LinkOutlined } from "@ant-design/icons";
|
||||||
|
|
||||||
|
export default function PostSelect({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder = "请选择课时",
|
||||||
|
params = { where: {}, select: {} },
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
value?: string | string[];
|
||||||
|
onChange?: (value: string | string[]) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
params?: {
|
||||||
|
where?: Prisma.PostWhereInput;
|
||||||
|
select?: Prisma.PostSelect<DefaultArgs>;
|
||||||
|
};
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const [searchValue, setSearch] = useState("");
|
||||||
|
const searchCondition: Prisma.PostWhereInput = useMemo(() => {
|
||||||
|
const containTextCondition: Prisma.StringNullableFilter = {
|
||||||
|
contains: searchValue,
|
||||||
|
mode: "insensitive" as Prisma.QueryMode, // 使用类型断言
|
||||||
|
};
|
||||||
|
return searchValue
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{ title: containTextCondition },
|
||||||
|
|
||||||
|
{ content: containTextCondition },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {};
|
||||||
|
}, [searchValue]);
|
||||||
|
// 核心条件生成逻辑
|
||||||
|
const idCondition: Prisma.PostWhereInput = useMemo(() => {
|
||||||
|
if (value === undefined) return {}; // 无值时返回空对象
|
||||||
|
// 字符串类型增强判断
|
||||||
|
if (typeof value === "string") {
|
||||||
|
// 如果明确需要支持逗号分隔字符串
|
||||||
|
|
||||||
|
return { id: value };
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.length > 0 ? { id: { in: value } } : {}; // 空数组不注入条件
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}, [value]);
|
||||||
|
const {
|
||||||
|
data: lectures,
|
||||||
|
isLoading,
|
||||||
|
}: { data: Lecture[]; isLoading: boolean } = api.post.findMany.useQuery({
|
||||||
|
where: safeOR([
|
||||||
|
{ ...idCondition },
|
||||||
|
|
||||||
|
{ ...searchCondition, ...(params?.where || {}) },
|
||||||
|
]),
|
||||||
|
select: { ...postDetailSelect, ...(params?.select || {}) },
|
||||||
|
take: 15,
|
||||||
|
});
|
||||||
|
const options = useMemo(() => {
|
||||||
|
return (lectures || []).map((lecture, index) => {
|
||||||
|
return {
|
||||||
|
value: lecture.id,
|
||||||
|
label: <PostSelectOption post={lecture}></PostSelectOption>,
|
||||||
|
tag: lecture?.title,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [lectures, isLoading]);
|
||||||
|
const tagRender = (props) => {
|
||||||
|
// 根据 value 找到对应的 option
|
||||||
|
const option = options.find((opt) => opt.value === props.value);
|
||||||
|
// 使用自定义的展示内容(这里假设你的 option 中有 customDisplay 字段)
|
||||||
|
return <span style={{ marginRight: 3 }}>{option?.tag}</span>;
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
}}>
|
||||||
|
<Select
|
||||||
|
showSearch
|
||||||
|
value={value}
|
||||||
|
dropdownStyle={{
|
||||||
|
minWidth: 200, // 设置合适的最小宽度
|
||||||
|
}}
|
||||||
|
placeholder={placeholder}
|
||||||
|
onChange={onChange}
|
||||||
|
filterOption={false}
|
||||||
|
loading={isLoading}
|
||||||
|
className={`flex-1 w-full ${className}`}
|
||||||
|
options={options}
|
||||||
|
tagRender={tagRender}
|
||||||
|
optionLabelProp="tag" // 新增这个属性 ✅
|
||||||
|
onSearch={(inputValue) => setSearch(inputValue)}></Select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { Lecture, LessonTypeLabel } from "@nice/common";
|
||||||
|
|
||||||
|
// 修改 PostSelectOption 组件
|
||||||
|
export default function PostSelectOption({ post }: { post: Lecture }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
{" "}
|
||||||
|
{/* 添加 min-w-0 */}
|
||||||
|
<img
|
||||||
|
src={post?.meta?.thumbnail || "/placeholder.webp"}
|
||||||
|
className="w-8 h-8 object-cover rounded flex-shrink-0" // 添加 flex-shrink-0
|
||||||
|
alt="课程封面"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col min-w-0 flex-1">
|
||||||
|
{" "}
|
||||||
|
{/* 修改这里 */}
|
||||||
|
{post?.meta?.type && (
|
||||||
|
<span className="text-sm text-gray-500 truncate">
|
||||||
|
{LessonTypeLabel[post?.meta?.type]}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="font-medium truncate">{post?.title}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,46 @@
|
||||||
|
type PrismaCondition = Record<string, any>;
|
||||||
|
type SafeOROptions = {
|
||||||
|
/**
|
||||||
|
* 当所有条件为空时的处理方式
|
||||||
|
* @default 'return-undefined' 返回 undefined (等效查询所有)
|
||||||
|
* 'throw-error' 抛出错误
|
||||||
|
* 'return-empty' 返回空对象
|
||||||
|
*/
|
||||||
|
emptyBehavior?: "return-undefined" | "throw-error" | "return-empty";
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安全合并多个查询条件为 OR 关系
|
||||||
|
* @param conditions 多个查询条件
|
||||||
|
* @param options 配置选项
|
||||||
|
* @returns 安全的 Prisma WHERE 条件
|
||||||
|
*/
|
||||||
|
const safeOR = (
|
||||||
|
conditions: PrismaCondition[],
|
||||||
|
options?: SafeOROptions
|
||||||
|
): PrismaCondition | undefined => {
|
||||||
|
const { emptyBehavior = "return-undefined" } = options || {};
|
||||||
|
|
||||||
|
// 过滤空条件和无效值
|
||||||
|
const validConditions = conditions.filter(
|
||||||
|
(cond) => cond && Object.keys(cond).length > 0
|
||||||
|
);
|
||||||
|
|
||||||
|
// 处理全空情况
|
||||||
|
if (validConditions.length === 0) {
|
||||||
|
switch (emptyBehavior) {
|
||||||
|
case "throw-error":
|
||||||
|
throw new Error("No valid conditions provided to OR query");
|
||||||
|
case "return-empty":
|
||||||
|
return {};
|
||||||
|
case "return-undefined":
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 优化单条件查询
|
||||||
|
return validConditions.length === 1
|
||||||
|
? validConditions[0]
|
||||||
|
: { OR: validConditions };
|
||||||
|
};
|
|
@ -83,7 +83,7 @@ export default function AssignList() {
|
||||||
|
|
||||||
{
|
{
|
||||||
headerName: "操作",
|
headerName: "操作",
|
||||||
sortable: true,
|
// sortable: true,
|
||||||
|
|
||||||
cellRenderer: (props) => <OpreationRenderer props={props}></OpreationRenderer>, // 指定 cellRenderer
|
cellRenderer: (props) => <OpreationRenderer props={props}></OpreationRenderer>, // 指定 cellRenderer
|
||||||
maxWidth: 100,
|
maxWidth: 100,
|
||||||
|
|
|
@ -171,7 +171,7 @@ const StaffList = ({
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
headerName: "操作",
|
headerName: "操作",
|
||||||
sortable: true,
|
// sortable: true,
|
||||||
|
|
||||||
cellRenderer: (props) => (
|
cellRenderer: (props) => (
|
||||||
<OpreationRenderer props={props}></OpreationRenderer>
|
<OpreationRenderer props={props}></OpreationRenderer>
|
||||||
|
|
|
@ -13,7 +13,7 @@ import { ObjectType, Term, TreeDataNode } from "@nice/common";
|
||||||
import DepartmentSelect from "../department/department-select";
|
import DepartmentSelect from "../department/department-select";
|
||||||
import { TermEditorContext } from "./term-editor";
|
import { TermEditorContext } from "./term-editor";
|
||||||
import { useAuth } from "@web/src/providers/auth-provider";
|
import { useAuth } from "@web/src/providers/auth-provider";
|
||||||
import { api } from "@nice/client"
|
import { api } from "@nice/client";
|
||||||
import { Menu, MenuItem } from "../../presentation/dropdown-menu";
|
import { Menu, MenuItem } from "../../presentation/dropdown-menu";
|
||||||
import AgServerTable from "../../presentation/ag-server-table";
|
import AgServerTable from "../../presentation/ag-server-table";
|
||||||
|
|
||||||
|
@ -42,7 +42,7 @@ const OpreationRenderer = ({ props }: { props: CustomCellRendererProps }) => {
|
||||||
label="添加子节点"
|
label="添加子节点"
|
||||||
icon={<PlusOutlined></PlusOutlined>}
|
icon={<PlusOutlined></PlusOutlined>}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setParentId(props?.data?.id)
|
setParentId(props?.data?.id);
|
||||||
// setEditId(data?.id)
|
// setEditId(data?.id)
|
||||||
setTermModalOpen(true);
|
setTermModalOpen(true);
|
||||||
}}></MenuItem>
|
}}></MenuItem>
|
||||||
|
@ -57,14 +57,21 @@ const OpreationRenderer = ({ props }: { props: CustomCellRendererProps }) => {
|
||||||
<MenuItem
|
<MenuItem
|
||||||
label="移除"
|
label="移除"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
softDeleteByIds.mutateAsync({
|
softDeleteByIds.mutateAsync(
|
||||||
ids: [props?.data?.id],
|
{
|
||||||
}, {
|
ids: [props?.data?.id],
|
||||||
onSettled: () => {
|
|
||||||
message.success("删除成功");
|
|
||||||
emitDataChange(ObjectType.TERM, props.data as any, CrudOperation.DELETED)
|
|
||||||
},
|
},
|
||||||
});
|
{
|
||||||
|
onSettled: () => {
|
||||||
|
message.success("删除成功");
|
||||||
|
emitDataChange(
|
||||||
|
ObjectType.TERM,
|
||||||
|
props.data as any,
|
||||||
|
CrudOperation.DELETED
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
icon={<DeleteOutlined></DeleteOutlined>}></MenuItem>
|
icon={<DeleteOutlined></DeleteOutlined>}></MenuItem>
|
||||||
</Menu>
|
</Menu>
|
||||||
|
@ -86,27 +93,31 @@ export default function TermList() {
|
||||||
setDomainId(user.domainId);
|
setDomainId(user.domainId);
|
||||||
}
|
}
|
||||||
}, [user]);
|
}, [user]);
|
||||||
const [params, setParams] = useState({ parentId: null, domainId: null, taxonomyId: null });
|
const [params, setParams] = useState({
|
||||||
|
parentId: null,
|
||||||
|
domainId: null,
|
||||||
|
taxonomyId: null,
|
||||||
|
});
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (taxonomyId) {
|
if (taxonomyId) {
|
||||||
setParams((prev) => ({ ...prev, taxonomyId }))
|
setParams((prev) => ({ ...prev, taxonomyId }));
|
||||||
}
|
}
|
||||||
if (domainId) {
|
if (domainId) {
|
||||||
setParams((prev) => ({ ...prev, domainId }))
|
setParams((prev) => ({ ...prev, domainId }));
|
||||||
} else {
|
} else {
|
||||||
setParams((prev) => ({ ...prev, domainId: null }))
|
setParams((prev) => ({ ...prev, domainId: null }));
|
||||||
}
|
}
|
||||||
}, [taxonomyId, domainId])
|
}, [taxonomyId, domainId]);
|
||||||
const columnDefs = useMemo<ColDef[]>(() => {
|
const columnDefs = useMemo<ColDef[]>(() => {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
field: "order",
|
field: "order",
|
||||||
hide: true,
|
hide: true,
|
||||||
sort: "asc" as SortDirection
|
sort: "asc" as SortDirection,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
headerName: "操作",
|
headerName: "操作",
|
||||||
sortable: true,
|
// sortable: true,
|
||||||
cellRenderer: (props: CustomCellRendererProps) => (
|
cellRenderer: (props: CustomCellRendererProps) => (
|
||||||
<OpreationRenderer props={props} />
|
<OpreationRenderer props={props} />
|
||||||
),
|
),
|
||||||
|
@ -115,12 +126,15 @@ export default function TermList() {
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const autoGroupColumnDef = useMemo(() => ({
|
const autoGroupColumnDef = useMemo(
|
||||||
rowDrag: true,
|
() => ({
|
||||||
headerName: "术语名",
|
rowDrag: true,
|
||||||
field: "name",
|
headerName: "术语名",
|
||||||
filter: "agTextColumnFilter",
|
field: "name",
|
||||||
}), []);
|
filter: "agTextColumnFilter",
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
const getServerSideGroupKey = useCallback((item) => item.id, []);
|
const getServerSideGroupKey = useCallback((item) => item.id, []);
|
||||||
const isServerSideGroup = useCallback((item) => item.has_children, []);
|
const isServerSideGroup = useCallback((item) => item.has_children, []);
|
||||||
|
@ -163,11 +177,10 @@ export default function TermList() {
|
||||||
columnDefs={columnDefs}
|
columnDefs={columnDefs}
|
||||||
objectType={ObjectType.TERM}
|
objectType={ObjectType.TERM}
|
||||||
treeData={true}
|
treeData={true}
|
||||||
params={params} // 使用 state 中的 params
|
params={params} // 使用 state 中的 params
|
||||||
getServerSideGroupKey={getServerSideGroupKey}
|
getServerSideGroupKey={getServerSideGroupKey}
|
||||||
isServerSideGroup={isServerSideGroup}
|
isServerSideGroup={isServerSideGroup}
|
||||||
autoGroupColumnDef={autoGroupColumnDef}
|
autoGroupColumnDef={autoGroupColumnDef}></AgServerTable>
|
||||||
></AgServerTable>
|
|
||||||
{/* <div
|
{/* <div
|
||||||
className="p-2"
|
className="p-2"
|
||||||
style={{ height: "calc(100vh - 49px - 48px - 49px)" }}>
|
style={{ height: "calc(100vh - 49px - 48px - 49px)" }}>
|
||||||
|
|
|
@ -1,50 +1,55 @@
|
||||||
import { api } from "@nice/client/";
|
import { api } from "@nice/client/";
|
||||||
import { Checkbox, Form } from "antd";
|
import { Checkbox, Skeleton } from "antd";
|
||||||
import { TermDto } from "@nice/common";
|
import { TermDto } from "@nice/common";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
|
||||||
export default function TermParentSelector({
|
export default function TermParentSelector({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
className,
|
className,
|
||||||
placeholder = "选择分类",
|
taxonomyId,
|
||||||
multiple = true,
|
domainId = undefined,
|
||||||
taxonomyId,
|
style,
|
||||||
domainId,
|
}: {
|
||||||
style,
|
value?: string[];
|
||||||
}: any) {
|
onChange?: (value: string[]) => void;
|
||||||
const [selectedValues, setSelectedValues] = useState<string[]>([]); // 用于存储选中的值
|
className?: string;
|
||||||
const {
|
taxonomyId: string;
|
||||||
data,
|
domainId?: string;
|
||||||
isLoading,
|
style?: React.CSSProperties;
|
||||||
}: { data: TermDto[]; isLoading: boolean } = api.term.findMany.useQuery({
|
}) {
|
||||||
where: {
|
const { data, isLoading }: { data: TermDto[]; isLoading: boolean } =
|
||||||
taxonomy: {
|
api.term.findMany.useQuery({
|
||||||
id: taxonomyId,
|
where: {
|
||||||
},
|
taxonomyId: taxonomyId,
|
||||||
parentId: null
|
parentId: null,
|
||||||
},
|
domainId,
|
||||||
});
|
},
|
||||||
const handleCheckboxChange = (checkedValues: string[]) => {
|
});
|
||||||
setSelectedValues(checkedValues); // 更新选中的值
|
const handleCheckboxChange = (checkedValues: string[]) => {
|
||||||
if (onChange) {
|
// setSelectedValues(checkedValues); // 更新选中的值
|
||||||
onChange(checkedValues); // 调用外部传入的 onChange 回调
|
if (onChange) {
|
||||||
}
|
onChange(checkedValues); // 调用外部传入的 onChange 回调
|
||||||
};
|
}
|
||||||
return (
|
};
|
||||||
<div className={className} style={style}>
|
return (
|
||||||
<Form onFinish={null}>
|
<div className={className} style={style}>
|
||||||
<Form.Item name="categories">
|
{isLoading ? (
|
||||||
<Checkbox.Group onChange={handleCheckboxChange}>
|
<Skeleton
|
||||||
{data?.map((category) => (
|
paragraph={{
|
||||||
<div className="w-full h-9 p-2 my-1">
|
rows: 4,
|
||||||
<Checkbox className="text-base text-slate-700" key={category.id} value={category.id}>
|
}}></Skeleton>
|
||||||
{category.name}
|
) : (
|
||||||
</Checkbox>
|
<Checkbox.Group value={value} onChange={handleCheckboxChange}>
|
||||||
</div>
|
{data?.filter(category=>!category.deletedAt)?.map((category) => (
|
||||||
))}
|
<div className="w-full h-9 p-2 my-1" key={category.id}>
|
||||||
</Checkbox.Group>
|
<Checkbox
|
||||||
</Form.Item>
|
className="text-base text-slate-700"
|
||||||
</Form>
|
value={category.id}>
|
||||||
</div>
|
{category.name}
|
||||||
)
|
</Checkbox>
|
||||||
}
|
</div>
|
||||||
|
))}
|
||||||
|
</Checkbox.Group>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
@ -1,9 +0,0 @@
|
||||||
import { useEffect, useRef } from 'react';
|
|
||||||
|
|
||||||
export default function MindMapEditor(): JSX.Element {
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue