gemercheung 7 месяцев назад
Родитель
Сommit
a876081689

+ 18 - 0
packages/backend/src/modules/article/article.entity.tranlation.ts

@@ -0,0 +1,18 @@
+import { Column, Entity, JoinColumn, ManyToOne } from 'typeorm';
+import { TranslationEntity } from 'typeorm-translatable';
+import { Article } from './article.entity';
+
+@Entity()
+export class ArticleTranslation extends TranslationEntity<Article> {
+  @Column({ unique: false, default: '', length: 200 })
+  title?: string;
+
+  @Column({ type: 'longtext', nullable: true })
+  content?: string;
+
+  @ManyToOne(() => Article, (article) => article.translations, {
+    onDelete: 'CASCADE',
+    createForeignKeyConstraints: false,
+  })
+  source?: Article | undefined;
+}

+ 16 - 33
packages/backend/src/modules/article/article.entity.ts

@@ -13,19 +13,15 @@ import {
 } from 'typeorm';
 import { Category } from '../category/category.entity';
 import { User } from '../user/user.entity';
-import { I18nColumn } from 'typeorm-i18n';
-
+import { ArticleTranslation } from './article.entity.tranlation';
+import { TranslatableEntity, Translation } from 'typeorm-translatable';
 @Entity()
-export class Article {
-  @PrimaryGeneratedColumn()
-  id: number;
+export class Article extends TranslatableEntity<ArticleTranslation> {
+  @PrimaryGeneratedColumn({ type: 'bigint', unsigned: true })
+  id?: number;
 
-  @I18nColumn({
-    default_language: 'cn',
-    languages: ['cn', 'en'],
-  })
   @Column({ unique: false, default: '', length: 200 })
-  title: string;
+  title?: string;
 
   @Column({ default: true })
   enable: boolean;
@@ -34,7 +30,7 @@ export class Article {
   isShow: boolean;
 
   @Column({ type: 'longtext', nullable: true })
-  content: string;
+  content?: string;
 
   @Column({ default: '' })
   remark: string;
@@ -52,6 +48,8 @@ export class Article {
 
   @OneToOne(() => User, {
     cascade: true,
+    onDelete: 'CASCADE',
+    createForeignKeyConstraints: false,
   })
   @JoinColumn()
   user: User;
@@ -65,26 +63,11 @@ export class Article {
   @UpdateDateColumn()
   updateTime: Date;
 
-  // @OneToMany(() => ArticleContent, (articleContent) => articleContent.content)
-  // public contents: ArticleContent[];
-}
-
-// @Entity()
-// export class ArticleContent {
-//   @PrimaryGeneratedColumn()
-//   id: number;
-
-//   @ManyToOne(() => Article, (article) => article.contents, {
-//     createForeignKeyConstraints: false,
-//   })
-//   article: Article;
-
-//   @Column({ default: '', length: 200 })
-//   title: string;
-
-//   @Column({ unique: false, default: '', length: 200 })
-//   lang: string;
+  @OneToMany(() => ArticleTranslation, (articleTranslation) => articleTranslation.source, {
+    cascade: true,
+    onDelete: 'CASCADE',
+  })
+  translations?: Translation<ArticleTranslation>[] | undefined;
 
-//   @Column({ type: 'longtext', nullable: true })
-//   content: string;
-// }
+  static translatableFields = new Set(['title', 'content']);
+}

+ 3 - 1
packages/backend/src/modules/article/article.module.ts

@@ -2,9 +2,11 @@ import { Module } from '@nestjs/common';
 import { ArticleService } from './article.service';
 import { ArticleController } from './article.controller';
 import { Article } from './article.entity';
+
 import { TypeOrmModule } from '@nestjs/typeorm';
+import { ArticleTranslation } from './article.entity.tranlation';
 @Module({
-  imports: [TypeOrmModule.forFeature([Article])],
+  imports: [TypeOrmModule.forFeature([Article, ArticleTranslation])],
   providers: [ArticleService],
   controllers: [ArticleController],
 })

+ 40 - 5
packages/backend/src/modules/article/article.service.ts

@@ -2,31 +2,52 @@ import { BadRequestException, Injectable } from '@nestjs/common';
 import { Article } from './article.entity';
 import { InjectRepository } from '@nestjs/typeorm';
 import { Like, Repository } from 'typeorm';
-import { CreateArticleDto, GetArticleDto, QueryArticleDto, UpdateArticleDto } from './dto';
+import {
+  CreateArticleDto,
+  CreateArticleTranslations,
+  GetArticleDto,
+  QueryArticleDto,
+  UpdateArticleDto,
+} from './dto';
+import { ArticleTranslation } from './article.entity.tranlation';
 @Injectable()
 export class ArticleService {
   constructor(
     @InjectRepository(Article)
     private articleRepo: Repository<Article>,
-  ) {}
+
+    @InjectRepository(ArticleTranslation)
+    private articleTranslationRepo: Repository<ArticleTranslation>,
+  ) { }
   async create(createArticleDto: CreateArticleDto) {
     const article = this.articleRepo.create(createArticleDto);
+    // console.log('article', article);
     return this.articleRepo.save(article);
   }
 
   async findAll(query: GetArticleDto) {
     return this.articleRepo.find({ where: query });
   }
-
   async findPagination(query: QueryArticleDto) {
     const pageSize = query.pageSize || 10;
     const pageNo = query.pageNo || 1;
+
     const [data, total] = await this.articleRepo.findAndCount({
       where: {
         title: Like(`%${query.title || ''}%`),
         enable: query.enable || undefined,
       },
-      relations: { user: true, category: true },
+      relations: { user: true, category: true, translations: true },
+      select: {
+        user: {
+          id: true,
+          username: true,
+        },
+        category: {
+          id: true,
+          title: true,
+        },
+      },
       order: {
         // title: 'ASC',
         createTime: 'DESC',
@@ -46,12 +67,26 @@ export class ArticleService {
   }
 
   async find(id: number) {
-    return await this.articleRepo.findOne({ where: { id } });
+    return await this.articleRepo.findOne({
+      where: { id },
+      relations: { user: true, translations: true },
+      select: {
+        user: {
+          id: true,
+          username: true,
+        },
+        category: {
+          id: true,
+          title: true,
+        },
+      },
+    });
   }
 
   async update(id: number, updateArticleDto: UpdateArticleDto) {
     const article = await this.articleRepo.findOne({ where: { id } });
     if (!article) throw new BadRequestException('权限不存在或者已删除');
+
     const updateArticle = this.articleRepo.merge(article, updateArticleDto);
     await this.articleRepo.save(updateArticle);
     return true;

+ 23 - 29
packages/backend/src/modules/article/dto.ts

@@ -41,8 +41,30 @@ export class CreateArticleDto {
   @IsBoolean()
   @IsOptional()
   enable?: boolean;
+
+  @ApiProperty({ required: false })
+  @IsArray()
+  @IsOptional()
+  translations?: CreateArticleTranslations[];
 }
+export class CreateArticleTranslations {
+  @ApiProperty({ required: false })
+  @IsString()
+  locale: string;
 
+  @ApiProperty({ required: false })
+  @IsString()
+  title: string;
+
+  @ApiProperty({ required: false })
+  @IsString()
+  content: string;
+
+  @ApiProperty({ required: false })
+  @IsString()
+  @IsOptional()
+  sourceId?: number;
+}
 export class ArticleContentDto {
   @ApiProperty()
   @IsString()
@@ -82,32 +104,4 @@ export class GetArticleDto {
 
 export class QueryArticleDto extends GetArticleDto {}
 
-export class UpdateArticleDto {
-  @ApiProperty()
-  @IsString()
-  @IsOptional()
-  @Length(1, 200, {
-    message: `用户名长度必须大于$constraint1到$constraint2之间,当前传递的值是$value`,
-  })
-  title?: string;
-
-  @ApiProperty({ required: false })
-  @IsOptional()
-  @IsString()
-  content?: string;
-
-  @ApiProperty({ required: false })
-  @IsBoolean()
-  @IsOptional()
-  enable?: boolean;
-
-  @ApiProperty({ required: false })
-  @IsOptional()
-  @IsNumber()
-  userId?: number;
-
-  @ApiProperty({ required: false })
-  @IsOptional()
-  @IsNumber()
-  categoryId?: number;
-}
+export class UpdateArticleDto extends CreateArticleDto {}

+ 1 - 1
packages/backend/src/modules/operation-log/operation-log.service.ts

@@ -15,7 +15,7 @@ export class OperationLogService {
   constructor(
     @InjectRepository(OperationLog)
     private operationLogRepo: Repository<OperationLog>,
-  ) { }
+  ) {}
 
   create(createOperationLogDto: CreateOperationLogDto) {
     return this.operationLogRepo.save(createOperationLogDto);

+ 1 - 0
packages/frontend/.env

@@ -1 +1,2 @@
 VITE_TITLE = 'TESTING'
+VITE_LANGS=zh,en

+ 22 - 0
packages/frontend/src/utils/translations.js

@@ -0,0 +1,22 @@
+import { computed } from 'vue'
+
+export const langs = computed(() =>
+  String(import.meta.env.VITE_LANGS).split(','),
+)
+
+export const langLabel = {
+  zh: '中文',
+  en: 'English',
+}
+export function initTranslations(object, keys) {
+  object.translations = langs.value.map((lang) => {
+    const mapper = {}
+    if (keys && Array.isArray(keys)) {
+      mapper.locale = lang
+      keys.forEach((key) => {
+        mapper[key] = ''
+      })
+    }
+    return mapper
+  })
+}

+ 32 - 13
packages/frontend/src/views/article/add.vue

@@ -7,38 +7,55 @@
     </template>
 
     <div class="editor-wrap">
-      <n-form
+      <n-form
         ref="modalFormRef" class="form wh-full" label-placement="left" label-align="left" :label-width="80"
-        :model="modalForm"
+        :model="modalForm"
       >
-        <n-form-item
+        <n-form-item
           label="文章名称" path="title" :rule="{
             required: true,
             message: '请输入文章名称',
             trigger: ['input', 'blur'],
-          }"
+          }"
         >
           <n-input v-model:value="modalForm.title" />
         </n-form-item>
 
-        <n-form-item
+        <n-form-item
           label="文章分类" path="categoryId" :rule="{
             required: true,
             type: 'number',
             trigger: ['change', 'blur'],
             message: '请输入文章分类',
-          }"
+          }"
         >
-          <n-select
+          <n-select
             v-model:value="modalForm.categoryId" :options="allCategory" clearable filterable tag
-            style="max-width: 300px;"
+            style="max-width: 300px;"
           />
         </n-form-item>
 
-        <VividEditor v-model="modalForm.content" :dark="isDark">
-          <SlashCommand />
-          <DragHandle />
-        </VividEditor>
+        <n-tabs type="line" animated>
+          <template v-for="(lang, index) in langs" :key="lang">
+            <n-tab-pane :name="lang" :tab="langLabel[lang]" :index="index">
+              <n-form-item
+                label="文章名称" path="title" :rule="{
+                  required: true,
+                  message: '请输入文章名称',
+                  trigger: ['input', 'blur'],
+                }"
+              >
+                <n-input v-model:value="modalForm.translations[index].title" />
+              </n-form-item>
+              <div class="h-450">
+                <VividEditor v-model="modalForm.translations[index].content" :dark="isDark">
+                  <SlashCommand />
+                  <DragHandle />
+                </VividEditor>
+              </div>
+            </n-tab-pane>
+          </template>
+        </n-tabs>
       </n-form>
     </div>
   </CommonPage>
@@ -47,6 +64,7 @@
 <script setup>
 import { DragHandle, SlashCommand, VividEditor } from '@4dkankan/vivid'
 import { useUserStore } from '@/store/index.js'
+import { initTranslations, langLabel, langs } from '@/utils/translations'
 import { useDark } from '@vueuse/core'
 import { NButton, useThemeVars } from 'naive-ui'
 import { ref } from 'vue'
@@ -55,6 +73,7 @@ import categoryApi from '../category/api'
 import articleApi from './api'
 import '@4dkankan/vivid/dist/style.css'
 
+// const langs = computed(() => String(import.meta.env.VITE_LANGS).split(','))
 const isDark = useDark()
 const vars = useThemeVars()
 const modalFormRef = ref('')
@@ -66,7 +85,7 @@ const modalForm = ref({
   content: '',
   userId,
 })
-
+initTranslations(modalForm.value, ['title', 'content'])
 onMounted(() => {
   console.log('VividEditor', VividEditor)
 })

+ 31 - 13
packages/frontend/src/views/article/edit.vue

@@ -7,38 +7,55 @@
     </template>
 
     <div class="editor-wrap">
-      <n-form
+      <n-form
         ref="modalFormRef" class="form wh-full" label-placement="left" label-align="left" :label-width="80"
-        :model="modalForm"
+        :model="modalForm"
       >
-        <n-form-item
+        <n-form-item
           label="文章名称" path="title" :rule="{
             required: true,
             message: '请输入文章名称',
             trigger: ['input', 'blur'],
-          }"
+          }"
         >
           <n-input v-model:value="modalForm.title" />
         </n-form-item>
 
-        <n-form-item
+        <n-form-item
           label="文章分类" path="categoryId" :rule="{
             required: true,
             type: 'number',
             trigger: ['change', 'blur'],
             message: '请输入文章分类',
-          }"
+          }"
         >
-          <n-select
+          <n-select
             v-model:value="modalForm.categoryId" :options="allCategory" clearable filterable tag
-            style="max-width: 300px;"
+            style="max-width: 300px;"
           />
         </n-form-item>
 
-        <VividEditor v-model="modalForm.content" :dark="isDark">
-          <SlashCommand />
-          <DragHandle />
-        </VividEditor>
+        <n-tabs type="line" animated>
+          <template v-for="(lang, index) in langs" :key="lang">
+            <n-tab-pane :name="lang" :tab="langLabel[lang]" :index="index">
+              <n-form-item
+                label="文章名称" path="title" :rule="{
+                  required: true,
+                  message: '请输入文章名称',
+                  trigger: ['input', 'blur'],
+                }"
+              >
+                <n-input v-model:value="modalForm.translations[index].title" />
+              </n-form-item>
+              <div class="h-450">
+                <VividEditor v-model="modalForm.translations[index].content" :dark="isDark">
+                  <SlashCommand />
+                  <DragHandle />
+                </VividEditor>
+              </div>
+            </n-tab-pane>
+          </template>
+        </n-tabs>
       </n-form>
     </div>
   </CommonPage>
@@ -47,6 +64,7 @@
 <script setup>
 import { DragHandle, SlashCommand, VividEditor } from '@4dkankan/vivid'
 import { useUserStore } from '@/store/index.js'
+import { initTranslations, langLabel, langs } from '@/utils/translations'
 import { useDark } from '@vueuse/core'
 import { NButton, useThemeVars } from 'naive-ui'
 import { ref } from 'vue'
@@ -67,7 +85,7 @@ const modalForm = ref({
   content: '',
   userId,
 })
-
+initTranslations(modalForm.value, ['title', 'content'])
 onMounted(async () => {
   console.log('edit', route.params.id)
   const { data } = await articleApi.getOne(route.params.id)

+ 1 - 1
packages/frontend/src/views/article/index.vue

@@ -61,7 +61,7 @@ const columns = [
     title: '内容',
     key: 'content',
     width: '400',
-    render: row => h('div', htmlspecialchars(row.content)),
+    render: row => h('div', htmlspecialchars(row.translations?.length ? row.translations[0].content : row.content)),
   },
   {
     title: '创建时间',

+ 1 - 1
packages/frontend/src/views/menu/index.vue

@@ -109,7 +109,7 @@ const { modalRef, modalFormRef, modalAction, modalForm, handleAdd, handleDelete,
     doCreate: MenuApi.create,
     doDelete: MenuApi.delete,
     doUpdate: MenuApi.update,
-    initForm: { enable: true },
+    initForm: { enable: true, isPublish: true },
     refresh: (_, keepCurrentPage) => $table.value?.handleSearch(keepCurrentPage),
   })
 onMounted(() => {