diff --git a/api/src/main/java/run/halo/app/theme/dialect/TemplateFooterProcessor.java b/api/src/main/java/run/halo/app/theme/dialect/TemplateFooterProcessor.java new file mode 100644 index 0000000000..78f32158bb --- /dev/null +++ b/api/src/main/java/run/halo/app/theme/dialect/TemplateFooterProcessor.java @@ -0,0 +1,20 @@ +package run.halo.app.theme.dialect; + +import org.pf4j.ExtensionPoint; +import org.thymeleaf.context.ITemplateContext; +import org.thymeleaf.model.IModel; +import org.thymeleaf.model.IProcessableElementTag; +import org.thymeleaf.processor.element.IElementTagStructureHandler; +import reactor.core.publisher.Mono; + +/** + * Theme template footer tag snippet injection processor. + * + * @author guqing + * @since 2.17.0 + */ +public interface TemplateFooterProcessor extends ExtensionPoint { + + Mono process(ITemplateContext context, IProcessableElementTag tag, + IElementTagStructureHandler structureHandler, IModel model); +} diff --git a/application/src/main/java/run/halo/app/config/WebServerSecurityConfig.java b/application/src/main/java/run/halo/app/config/WebServerSecurityConfig.java index 17954539a0..06c191707d 100644 --- a/application/src/main/java/run/halo/app/config/WebServerSecurityConfig.java +++ b/application/src/main/java/run/halo/app/config/WebServerSecurityConfig.java @@ -127,8 +127,12 @@ public ReactiveIndexedSessionRepository reactiveSessionRepository( @Bean DefaultUserDetailService userDetailsService(UserService userService, - RoleService roleService) { - return new DefaultUserDetailService(userService, roleService); + RoleService roleService, + HaloProperties haloProperties) { + var userDetailService = new DefaultUserDetailService(userService, roleService); + var twoFactorAuthDisabled = haloProperties.getSecurity().getTwoFactorAuth().isDisabled(); + userDetailService.setTwoFactorAuthDisabled(twoFactorAuthDisabled); + return userDetailService; } @Bean diff --git a/application/src/main/java/run/halo/app/core/extension/service/impl/PluginServiceImpl.java b/application/src/main/java/run/halo/app/core/extension/service/impl/PluginServiceImpl.java index e891887f59..be5d5bd06e 100644 --- a/application/src/main/java/run/halo/app/core/extension/service/impl/PluginServiceImpl.java +++ b/application/src/main/java/run/halo/app/core/extension/service/impl/PluginServiceImpl.java @@ -549,15 +549,22 @@ Mono computeIfAbsent(String version, Publisher content) { // double check of the resource .filter(res -> isResourceMatch(res, newFilename)) .switchIfEmpty(Mono.using( - () -> tempDir.resolve(newFilename), + () -> { + if (!Files.exists(tempDir)) { + Files.createDirectories(tempDir); + } + return tempDir.resolve(newFilename); + }, path -> DataBufferUtils.write(content, path, CREATE, TRUNCATE_EXISTING) .then(Mono.fromSupplier( () -> new FileSystemResource(path) )), path -> { - // clean up old resource - cleanUp(this.resource); + if (shouldCleanUp(path)) { + // clean up old resource + cleanUp(this.resource); + } }) .subscribeOn(scheduler) .doOnNext(newResource -> this.resource = newResource) @@ -579,6 +586,18 @@ Mono computeIfAbsent(String version, Publisher content) { }); } + private boolean shouldCleanUp(Path newPath) { + if (this.resource == null || !this.resource.exists()) { + return false; + } + try { + var oldPath = this.resource.getFile().toPath(); + return !oldPath.equals(newPath); + } catch (IOException e) { + return false; + } + } + private static void cleanUp(Resource resource) { if (resource instanceof WritableResource wr && wr.isWritable() diff --git a/application/src/main/java/run/halo/app/infra/properties/SecurityProperties.java b/application/src/main/java/run/halo/app/infra/properties/SecurityProperties.java index 19bfb8bb90..d67b49549a 100644 --- a/application/src/main/java/run/halo/app/infra/properties/SecurityProperties.java +++ b/application/src/main/java/run/halo/app/infra/properties/SecurityProperties.java @@ -16,6 +16,18 @@ public class SecurityProperties { private final RememberMeOptions rememberMe = new RememberMeOptions(); + private final TwoFactorAuthOptions twoFactorAuth = new TwoFactorAuthOptions(); + + @Data + public static class TwoFactorAuthOptions { + + /** + * Whether two-factor authentication is disabled. + */ + private boolean disabled; + + } + @Data public static class FrameOptions { diff --git a/application/src/main/java/run/halo/app/search/lucene/LuceneSearchEngine.java b/application/src/main/java/run/halo/app/search/lucene/LuceneSearchEngine.java index 294e7a1aa5..73559980f4 100644 --- a/application/src/main/java/run/halo/app/search/lucene/LuceneSearchEngine.java +++ b/application/src/main/java/run/halo/app/search/lucene/LuceneSearchEngine.java @@ -348,6 +348,7 @@ private static class DocumentConverter implements Converter findByUsername(String username) { return setAuthorities.then(Mono.fromSupplier(() -> { var twoFactorAuthSettings = TwoFactorUtils.getTwoFactorAuthSettings(user); return new HaloUser.Builder(userBuilder.build()) - .twoFactorAuthEnabled(twoFactorAuthSettings.isAvailable()) + .twoFactorAuthEnabled( + (!twoFactorAuthDisabled) && twoFactorAuthSettings.isAvailable() + ) .totpEncryptedSecret(user.getSpec().getTotpEncryptedSecret()) .build(); })); diff --git a/application/src/main/java/run/halo/app/theme/dialect/TemplateFooterElementTagProcessor.java b/application/src/main/java/run/halo/app/theme/dialect/TemplateFooterElementTagProcessor.java index a126f03b20..74239b1f2e 100644 --- a/application/src/main/java/run/halo/app/theme/dialect/TemplateFooterElementTagProcessor.java +++ b/application/src/main/java/run/halo/app/theme/dialect/TemplateFooterElementTagProcessor.java @@ -1,14 +1,19 @@ package run.halo.app.theme.dialect; +import static org.thymeleaf.spring6.context.SpringContextUtils.getApplicationContext; + import org.springframework.context.ApplicationContext; import org.thymeleaf.context.ITemplateContext; +import org.thymeleaf.model.IModel; import org.thymeleaf.model.IProcessableElementTag; import org.thymeleaf.processor.element.AbstractElementTagProcessor; import org.thymeleaf.processor.element.IElementTagStructureHandler; import org.thymeleaf.spring6.context.SpringContextUtils; import org.thymeleaf.templatemode.TemplateMode; +import reactor.core.publisher.Flux; import run.halo.app.infra.SystemConfigurableEnvironmentFetcher; import run.halo.app.infra.SystemSetting; +import run.halo.app.plugin.extensionpoint.ExtensionGetter; /** *

Footer element tag processor.

@@ -42,12 +47,23 @@ public TemplateFooterElementTagProcessor(final String dialectPrefix) { @Override protected void doProcess(ITemplateContext context, IProcessableElementTag tag, IElementTagStructureHandler structureHandler) { + + IModel modelToInsert = context.getModelFactory().createModel(); /* * Obtain the Spring application context. */ final ApplicationContext appCtx = SpringContextUtils.getApplicationContext(context); + String globalFooterText = getGlobalFooterText(appCtx); - structureHandler.replaceWith(globalFooterText, false); + modelToInsert.add(context.getModelFactory().createText(globalFooterText)); + + getTemplateFooterProcessors(context) + .concatMap(processor -> processor.process(context, tag, + structureHandler, modelToInsert) + ) + .then() + .block(); + structureHandler.replaceWith(modelToInsert, false); } private String getGlobalFooterText(ApplicationContext appCtx) { @@ -57,4 +73,13 @@ private String getGlobalFooterText(ApplicationContext appCtx) { .map(SystemSetting.CodeInjection::getFooter) .block(); } + + private Flux getTemplateFooterProcessors(ITemplateContext context) { + var extensionGetter = getApplicationContext(context).getBeanProvider(ExtensionGetter.class) + .getIfUnique(); + if (extensionGetter == null) { + return Flux.empty(); + } + return extensionGetter.getExtensions(TemplateFooterProcessor.class); + } } diff --git a/application/src/main/java/run/halo/app/theme/finders/PluginFinder.java b/application/src/main/java/run/halo/app/theme/finders/PluginFinder.java index 67339bb644..c113d39a4c 100644 --- a/application/src/main/java/run/halo/app/theme/finders/PluginFinder.java +++ b/application/src/main/java/run/halo/app/theme/finders/PluginFinder.java @@ -9,4 +9,6 @@ public interface PluginFinder { boolean available(String pluginName); + + boolean available(String pluginName, String requiresVersion); } diff --git a/application/src/main/java/run/halo/app/theme/finders/impl/PluginFinderImpl.java b/application/src/main/java/run/halo/app/theme/finders/impl/PluginFinderImpl.java index f275683a57..0426b40536 100644 --- a/application/src/main/java/run/halo/app/theme/finders/impl/PluginFinderImpl.java +++ b/application/src/main/java/run/halo/app/theme/finders/impl/PluginFinderImpl.java @@ -2,9 +2,10 @@ import lombok.AllArgsConstructor; import org.apache.commons.lang3.StringUtils; +import org.pf4j.PluginManager; import org.pf4j.PluginState; import org.pf4j.PluginWrapper; -import run.halo.app.plugin.HaloPluginManager; +import org.springframework.util.Assert; import run.halo.app.theme.finders.Finder; import run.halo.app.theme.finders.PluginFinder; @@ -17,17 +18,29 @@ @Finder("pluginFinder") @AllArgsConstructor public class PluginFinderImpl implements PluginFinder { - private final HaloPluginManager haloPluginManager; + private final PluginManager pluginManager; @Override public boolean available(String pluginName) { if (StringUtils.isBlank(pluginName)) { return false; } - PluginWrapper pluginWrapper = haloPluginManager.getPlugin(pluginName); + PluginWrapper pluginWrapper = pluginManager.getPlugin(pluginName); if (pluginWrapper == null) { return false; } return PluginState.STARTED.equals(pluginWrapper.getPluginState()); } + + @Override + public boolean available(String pluginName, String requiresVersion) { + Assert.notNull(requiresVersion, "Requires version must not be null."); + if (!this.available(pluginName)) { + return false; + } + var pluginWrapper = pluginManager.getPlugin(pluginName); + var pluginVersion = pluginWrapper.getDescriptor().getVersion(); + return pluginManager.getVersionManager() + .checkVersionConstraint(pluginVersion, requiresVersion); + } } diff --git a/application/src/main/resources/extensions/extensionpoint-definitions.yaml b/application/src/main/resources/extensions/extensionpoint-definitions.yaml index 98583bac8c..1a042a3a13 100644 --- a/application/src/main/resources/extensions/extensionpoint-definitions.yaml +++ b/application/src/main/resources/extensions/extensionpoint-definitions.yaml @@ -76,3 +76,15 @@ spec: displayName: "搜索引擎" type: SINGLETON description: "扩展内容搜索引擎" + +--- +apiVersion: plugin.halo.run/v1alpha1 +kind: ExtensionPointDefinition +metadata: + name: template-footer-processor +spec: + className: run.halo.app.theme.dialect.TemplateFooterProcessor + displayName: 页脚标签内容处理器 + type: MULTI_INSTANCE + description: "提供用于扩展 标签内容的扩展方式。" + \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/core/extension/service/impl/PluginServiceImplTest.java b/application/src/test/java/run/halo/app/core/extension/service/impl/PluginServiceImplTest.java index 8d34e55d65..6918aba6e9 100644 --- a/application/src/test/java/run/halo/app/core/extension/service/impl/PluginServiceImplTest.java +++ b/application/src/test/java/run/halo/app/core/extension/service/impl/PluginServiceImplTest.java @@ -46,6 +46,7 @@ import org.pf4j.PluginDescriptor; import org.pf4j.PluginWrapper; import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.util.FileSystemUtils; import org.springframework.web.server.ServerWebInputException; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; @@ -391,6 +392,24 @@ void shouldComputeBundleFileIfAbsent() { } }) .verifyComplete(); + + try { + FileSystemUtils.deleteRecursively(tempDir); + } catch (IOException e) { + throw new RuntimeException(e); + } + cache.computeIfAbsent("fake-version", fakeContent) + .as(StepVerifier::create) + .assertNext(resource -> { + try { + assertThat(Files.exists(tempDir)).isTrue(); + assertEquals(tempDir.resolve("different-version.js"), + resource.getFile().toPath()); + } catch (IOException e) { + throw new RuntimeException(e); + } + }) + .verifyComplete(); } @Test diff --git a/application/src/test/java/run/halo/app/search/lucene/LuceneSearchEngineIntegrationTest.java b/application/src/test/java/run/halo/app/search/lucene/LuceneSearchEngineIntegrationTest.java index c32945fda1..91e4152358 100644 --- a/application/src/test/java/run/halo/app/search/lucene/LuceneSearchEngineIntegrationTest.java +++ b/application/src/test/java/run/halo/app/search/lucene/LuceneSearchEngineIntegrationTest.java @@ -102,6 +102,7 @@ void assertHasResult(int maxAttempts) { assertEquals(1, hits.size()); var doc = hits.get(0); assertEquals("post.content.halo.run-first-post", doc.getId()); + assertEquals("post.content.halo.run", doc.getType()); assertEquals("first halo post", doc.getTitle()); assertNull(doc.getDescription()); assertEquals("halo", doc.getContent()); diff --git a/application/src/test/java/run/halo/app/security/DefaultUserDetailServiceTest.java b/application/src/test/java/run/halo/app/security/DefaultUserDetailServiceTest.java index a6251fdfba..58aa4fea11 100644 --- a/application/src/test/java/run/halo/app/security/DefaultUserDetailServiceTest.java +++ b/application/src/test/java/run/halo/app/security/DefaultUserDetailServiceTest.java @@ -156,11 +156,27 @@ void shouldFindHaloUserDetailsWith2faEnabledWhen2faEnabledAndTotpConfigured() { .verifyComplete(); } + @Test + void shouldFindHaloUserDetailsWith2faDisabledWhen2faDisabledGlobally() { + userDetailService.setTwoFactorAuthDisabled(true); + var fakeUser = createFakeUser(); + fakeUser.getSpec().setTwoFactorAuthEnabled(true); + fakeUser.getSpec().setTotpEncryptedSecret("fake-totp-encrypted-secret"); + when(userService.getUser("faker")).thenReturn(Mono.just(fakeUser)); + when(roleService.listRoleRefs(any())).thenReturn(Flux.empty()); + userDetailService.findByUsername("faker") + .as(StepVerifier::create) + .assertNext(userDetails -> { + assertInstanceOf(HaloUserDetails.class, userDetails); + assertFalse(((HaloUserDetails) userDetails).isTwoFactorAuthEnabled()); + }) + .verifyComplete(); + } + @Test void shouldFindUserDetailsByExistingUsernameButKindOfRoleRefIsNotRole() { var foundUser = createFakeUser(); - var roleGvk = new Role().groupVersionKind(); var roleRef = new RoleRef(); roleRef.setKind("FakeRole"); roleRef.setApiGroup("fake.halo.run"); diff --git a/application/src/test/java/run/halo/app/theme/dialect/HaloProcessorDialectTest.java b/application/src/test/java/run/halo/app/theme/dialect/HaloProcessorDialectTest.java index d0a36706bd..c4c18afe50 100644 --- a/application/src/test/java/run/halo/app/theme/dialect/HaloProcessorDialectTest.java +++ b/application/src/test/java/run/halo/app/theme/dialect/HaloProcessorDialectTest.java @@ -125,6 +125,9 @@ void globalHeadAndFooterProcessors() { when(fetcher.fetch(eq(SystemSetting.Basic.GROUP), eq(SystemSetting.Basic.class))).thenReturn(Mono.just(basic)); + when(extensionGetter.getExtensions(TemplateFooterProcessor.class)) + .thenReturn(Flux.empty()); + Context context = getContext(); String result = templateEngine.process("index", context); @@ -172,6 +175,9 @@ void contentHeadAndFooterAndPostProcessors() { when(fetcher.fetch(eq(SystemSetting.Basic.GROUP), eq(SystemSetting.Basic.class))).thenReturn(Mono.just(basic)); + when(extensionGetter.getExtensions(TemplateFooterProcessor.class)) + .thenReturn(Flux.empty()); + String result = templateEngine.process("post", context); assertThat(result).isEqualTo(""" diff --git a/application/src/test/java/run/halo/app/theme/dialect/TemplateFooterElementTagProcessorTest.java b/application/src/test/java/run/halo/app/theme/dialect/TemplateFooterElementTagProcessorTest.java new file mode 100644 index 0000000000..9fec46b91e --- /dev/null +++ b/application/src/test/java/run/halo/app/theme/dialect/TemplateFooterElementTagProcessorTest.java @@ -0,0 +1,136 @@ +package run.halo.app.theme.dialect; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.context.ApplicationContext; +import org.thymeleaf.IEngineConfiguration; +import org.thymeleaf.TemplateEngine; +import org.thymeleaf.context.Context; +import org.thymeleaf.context.ITemplateContext; +import org.thymeleaf.model.IModel; +import org.thymeleaf.model.IProcessableElementTag; +import org.thymeleaf.processor.IProcessor; +import org.thymeleaf.processor.element.IElementTagStructureHandler; +import org.thymeleaf.spring6.dialect.SpringStandardDialect; +import org.thymeleaf.spring6.expression.ThymeleafEvaluationContext; +import org.thymeleaf.templateresolver.StringTemplateResolver; +import org.thymeleaf.templateresource.ITemplateResource; +import org.thymeleaf.templateresource.StringTemplateResource; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import run.halo.app.infra.SystemConfigurableEnvironmentFetcher; +import run.halo.app.infra.SystemSetting; +import run.halo.app.plugin.extensionpoint.ExtensionGetter; + +/** + * Tests for {@link TemplateFooterElementTagProcessor}. + * + * @author guqing + * @since 2.17.0 + */ +@ExtendWith(MockitoExtension.class) +class TemplateFooterElementTagProcessorTest { + @Mock + private ApplicationContext applicationContext; + + @Mock + ExtensionGetter extensionGetter; + + @Mock + private SystemConfigurableEnvironmentFetcher fetcher; + + private TemplateEngine templateEngine; + + @BeforeEach + void setUp() { + HaloProcessorDialect haloProcessorDialect = new MockHaloProcessorDialect(); + templateEngine = new TemplateEngine(); + templateEngine.setDialects(Set.of(haloProcessorDialect, new SpringStandardDialect())); + templateEngine.addTemplateResolver(new MockTemplateResolver()); + + SystemSetting.CodeInjection codeInjection = new SystemSetting.CodeInjection(); + codeInjection.setFooter( + "

Powered by Halo

"); + lenient().when(fetcher.fetch(eq(SystemSetting.CodeInjection.GROUP), + eq(SystemSetting.CodeInjection.class))).thenReturn(Mono.just(codeInjection)); + + lenient().when(applicationContext.getBeanProvider(ExtensionGetter.class)) + .thenAnswer(invocation -> { + var objectProvider = mock(ObjectProvider.class); + when(objectProvider.getIfUnique()).thenReturn(extensionGetter); + return objectProvider; + }); + lenient().when(applicationContext.getBean(eq(SystemConfigurableEnvironmentFetcher.class))) + .thenReturn(fetcher); + } + + @Test + void footerProcessorTest() { + when(extensionGetter.getExtensions(TemplateFooterProcessor.class)) + .thenReturn(Flux.just(new FakeFooterCodeInjection())); + + String result = templateEngine.process("fake-template", getContext()); + // footer injected code is not processable + assertThat(result).isEqualToIgnoringWhitespace(""" +

Powered by Halo

+
© 2024 guqing's blog
+
+ """); + } + + private Context getContext() { + Context context = new Context(); + context.setVariable( + ThymeleafEvaluationContext.THYMELEAF_EVALUATION_CONTEXT_CONTEXT_VARIABLE_NAME, + new ThymeleafEvaluationContext(applicationContext, null)); + return context; + } + + static class MockTemplateResolver extends StringTemplateResolver { + @Override + protected ITemplateResource computeTemplateResource(IEngineConfiguration configuration, + String ownerTemplate, String template, + Map templateResolutionAttributes) { + return new StringTemplateResource(""" + + """); + } + } + + static class MockHaloProcessorDialect extends HaloProcessorDialect { + @Override + public Set getProcessors(String dialectPrefix) { + var processors = new HashSet(); + processors.add(new TemplateFooterElementTagProcessor(dialectPrefix)); + return processors; + } + } + + static class FakeFooterCodeInjection implements TemplateFooterProcessor { + + @Override + public Mono process(ITemplateContext context, IProcessableElementTag tag, + IElementTagStructureHandler structureHandler, IModel model) { + var factory = context.getModelFactory(); + // regular footer text + var copyRight = factory.createText("
© 2024 guqing's blog
"); + model.add(copyRight); + // variable footer text + model.add(factory.createText("
")); + return Mono.empty(); + } + } +} \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/theme/finders/impl/PluginFinderImplTest.java b/application/src/test/java/run/halo/app/theme/finders/impl/PluginFinderImplTest.java index aba947ef26..4ae4cec7a4 100644 --- a/application/src/test/java/run/halo/app/theme/finders/impl/PluginFinderImplTest.java +++ b/application/src/test/java/run/halo/app/theme/finders/impl/PluginFinderImplTest.java @@ -1,7 +1,9 @@ package run.halo.app.theme.finders.impl; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import org.junit.jupiter.api.Test; @@ -10,6 +12,8 @@ import org.mockito.Mock; import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; +import org.pf4j.DefaultVersionManager; +import org.pf4j.PluginDescriptor; import org.pf4j.PluginState; import org.pf4j.PluginWrapper; import run.halo.app.plugin.HaloPluginManager; @@ -47,4 +51,39 @@ void available() { available = pluginFinder.available("fake-plugin"); assertThat(available).isTrue(); } + + @Test + void availableWithVersionTest() { + when(haloPluginManager.getVersionManager()).thenReturn(new DefaultVersionManager()); + + assertThatThrownBy(() -> pluginFinder.available("fake-plugin", null)) + .isInstanceOf(IllegalArgumentException.class); + + boolean available = pluginFinder.available("fake-plugin", "1.0.0"); + assertThat(available).isFalse(); + + PluginWrapper mockPluginWrapper = Mockito.mock(PluginWrapper.class); + when(haloPluginManager.getPlugin(eq("fake-plugin"))) + .thenReturn(mockPluginWrapper); + + when(mockPluginWrapper.getPluginState()).thenReturn(PluginState.STARTED); + var descriptor = mock(PluginDescriptor.class); + when(mockPluginWrapper.getDescriptor()).thenReturn(descriptor); + when(descriptor.getVersion()).thenReturn("1.0.0"); + + available = pluginFinder.available("fake-plugin", "1.0.0"); + assertThat(available).isTrue(); + + available = pluginFinder.available("fake-plugin", ">=1.0.0"); + assertThat(available).isTrue(); + + available = pluginFinder.available("fake-plugin", "<2.0.0"); + assertThat(available).isTrue(); + + available = pluginFinder.available("fake-plugin", "2.0.0"); + assertThat(available).isFalse(); + + available = pluginFinder.available("fake-plugin", "<1.0.0"); + assertThat(available).isFalse(); + } } \ No newline at end of file diff --git a/ui/console-src/modules/contents/posts/categories/components/CategoryEditingModal.vue b/ui/console-src/modules/contents/posts/categories/components/CategoryEditingModal.vue index 84394a1fdb..7ccf8b3b4d 100644 --- a/ui/console-src/modules/contents/posts/categories/components/CategoryEditingModal.vue +++ b/ui/console-src/modules/contents/posts/categories/components/CategoryEditingModal.vue @@ -21,6 +21,7 @@ import { import { useQueryClient } from "@tanstack/vue-query"; import { cloneDeep } from "lodash-es"; import { useI18n } from "vue-i18n"; +import { submitForm, reset } from "@formkit/core"; const props = withDefaults( defineProps<{ @@ -65,6 +66,7 @@ const formState = ref({ const selectedParentCategory = ref(); const saving = ref(false); const modal = ref | null>(null); +const keepAddingSubmit = ref(false); const isUpdateMode = !!props.category; @@ -133,7 +135,11 @@ const handleSaveCategory = async () => { } } - modal.value?.close(); + if (keepAddingSubmit.value) { + reset("category-form"); + } else { + modal.value?.close(); + } queryClient.invalidateQueries({ queryKey: ["post-categories"] }); @@ -145,6 +151,11 @@ const handleSaveCategory = async () => { } }; +const handleSubmit = (keepAdding = false) => { + keepAddingSubmit.value = keepAdding; + submitForm("category-form"); +}; + onMounted(() => { if (props.category) { formState.value = cloneDeep(props.category); @@ -340,18 +351,29 @@ const { handleGenerateSlug } = useSlugify( diff --git a/ui/console-src/modules/contents/posts/tags/components/TagEditingModal.vue b/ui/console-src/modules/contents/posts/tags/components/TagEditingModal.vue index ec60168e54..a7b6378a31 100644 --- a/ui/console-src/modules/contents/posts/tags/components/TagEditingModal.vue +++ b/ui/console-src/modules/contents/posts/tags/components/TagEditingModal.vue @@ -26,6 +26,7 @@ import useSlugify from "@console/composables/use-slugify"; import { cloneDeep } from "lodash-es"; import { onMounted } from "vue"; import { useI18n } from "vue-i18n"; +import { submitForm, reset } from "@formkit/core"; const props = withDefaults( defineProps<{ @@ -63,6 +64,8 @@ const modal = ref | null>(null); const saving = ref(false); +const keepAddingSubmit = ref(false); + const isUpdateMode = computed(() => !!props.tag); const modalTitle = computed(() => { @@ -101,7 +104,11 @@ const handleSaveTag = async () => { }); } - modal.value?.close(); + if (keepAddingSubmit.value) { + reset("tag-form"); + } else { + modal.value?.close(); + } Toast.success(t("core.common.toast.save_success")); } catch (e) { @@ -111,6 +118,11 @@ const handleSaveTag = async () => { } }; +const handleSubmit = (keepAdding = false) => { + keepAddingSubmit.value = keepAdding; + submitForm("tag-form"); +}; + onMounted(() => { setFocus("displayNameInput"); }); @@ -250,18 +262,29 @@ const { handleGenerateSlug } = useSlugify( diff --git a/ui/src/formkit/theme.ts b/ui/src/formkit/theme.ts index 52dfba5de5..499ae0231e 100644 --- a/ui/src/formkit/theme.ts +++ b/ui/src/formkit/theme.ts @@ -29,7 +29,7 @@ const theme: Record> = { global: { form: "divide-y divide-gray-100", outer: - "formkit-disabled:opacity-50 py-4 first:pt-0 last:pb-0 transition-all", + "formkit-disabled:opacity-70 formkit-disabled:cursor-not-allowed formkit-disabled:pointer-events-none py-4 first:pt-0 last:pb-0 transition-all", help: "text-xs mt-2 text-gray-500", messages: "list-none p-0 mt-1.5 mb-0 transition-all", message: "text-red-500 mt-2 text-xs", diff --git a/ui/src/locales/en.yaml b/ui/src/locales/en.yaml index 1757a21fea..158216d6b6 100644 --- a/ui/src/locales/en.yaml +++ b/ui/src/locales/en.yaml @@ -1729,6 +1729,7 @@ core: buttons: save: Save close: Close + save_and_continue: Save and keep adding close_and_shortcut: Close (Esc) delete: Delete setting: Setting diff --git a/ui/src/locales/es.yaml b/ui/src/locales/es.yaml index 9b59ed9651..8107439eba 100644 --- a/ui/src/locales/es.yaml +++ b/ui/src/locales/es.yaml @@ -1308,6 +1308,7 @@ core: buttons: save: Guardar close: Cerrar + save_and_continue: Guardar y seguir añadiendo close_and_shortcut: Cerrar (Esc) delete: Borrar setting: Configuración diff --git a/ui/src/locales/zh-CN.yaml b/ui/src/locales/zh-CN.yaml index 222bde8731..bf342f3dc2 100644 --- a/ui/src/locales/zh-CN.yaml +++ b/ui/src/locales/zh-CN.yaml @@ -1644,6 +1644,7 @@ core: buttons: save: 保存 close: 关闭 + save_and_continue: 保存并继续添加 close_and_shortcut: 关闭(Esc) delete: 删除 setting: 设置 diff --git a/ui/src/locales/zh-TW.yaml b/ui/src/locales/zh-TW.yaml index 2fac839b56..420e277178 100644 --- a/ui/src/locales/zh-TW.yaml +++ b/ui/src/locales/zh-TW.yaml @@ -1601,6 +1601,7 @@ core: buttons: save: 保存 close: 關閉 + save_and_continue: 保存並繼續添加 close_and_shortcut: 關閉(Esc) delete: 刪除 setting: 設置 diff --git a/ui/src/styles/tailwind.css b/ui/src/styles/tailwind.css index 360a0cec8e..46cdac3bff 100644 --- a/ui/src/styles/tailwind.css +++ b/ui/src/styles/tailwind.css @@ -22,5 +22,5 @@ textarea { } input[type="checkbox"] { - @apply rounded-sm border-gray-500 disabled:bg-gray-200 disabled:opacity-50; + @apply rounded-sm border-gray-500 disabled:cursor-not-allowed disabled:border-gray-400 disabled:opacity-50 disabled:checked:border-none; }