feat: add slug existence check when creating posts (#7617)

#### What type of PR is this?

/area ui
/kind improvement
/milestone 2.21.x

#### What this PR does / why we need it:

This PR adds frontend support for checking whether an slug already exists when creating posts.

Note:

1. The current implementation isn’t perfect, some actions (like clicking the “Publish” button) don’t check for duplicate slug yet.
2. Slug checking in the user center might not be accurate, since it may not have permission to query all posts.

<img width="695" alt="image" src="https://github.com/user-attachments/assets/baa37a82-49c2-43be-a4d8-0e0f22a9d73b" />

#### Which issue(s) this PR fixes:

Fixes https://github.com/halo-dev/halo/issues/7615
Fixes https://github.com/halo-dev/halo/issues/3332

#### Special notes for your reviewer:

#### Does this PR introduce a user-facing change?

```release-note
创建文章时支持检查别名是否重复
```
pull/7642/head
Ryan Wang 2025-07-27 13:19:17 +08:00 committed by GitHub
parent cc3b3323c7
commit 2cf0d6853a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 176 additions and 56 deletions

View File

@ -6,7 +6,7 @@ import { toDatetimeLocal, toISOString } from "@/utils/date";
import { randomUUID } from "@/utils/id";
import useSlugify from "@console/composables/use-slugify";
import { useThemeCustomTemplates } from "@console/modules/interface/themes/composables/use-theme";
import { submitForm } from "@formkit/core";
import { submitForm, type FormKitNode } from "@formkit/core";
import type { SinglePage } from "@halo-dev/api-client";
import { coreApiClient } from "@halo-dev/api-client";
import {
@ -266,6 +266,28 @@ const { handleGenerateSlug } = useSlugify(
computed(() => !isUpdateMode),
FormType.SINGLE_PAGE
);
// fixme: check if slug is unique
// Finally, we need to check if the slug is unique in the database
async function slugUniqueValidation(node: FormKitNode) {
const value = node.value;
if (!value) {
return true;
}
const fieldSelector = [`spec.slug=${value}`];
if (isUpdateMode) {
fieldSelector.push(`metadata.name!=${formState.value.metadata.name}`);
}
const { data: pagesWithSameSlug } =
await coreApiClient.content.singlePage.listSinglePage({
fieldSelector,
});
return !pagesWithSameSlug.total;
}
</script>
<template>
@ -309,7 +331,13 @@ const { handleGenerateSlug } = useSlugify(
:label="$t('core.page.settings.fields.slug.label')"
name="slug"
type="text"
validation="required|length:0,100"
validation="required|length:0,100|slugUniqueValidation"
:validation-rules="{ slugUniqueValidation }"
:validation-messages="{
slugUniqueValidation: $t(
'core.common.form.validation.slug_unique'
),
}"
:help="$t('core.page.settings.fields.slug.help')"
>
<template #suffix>

View File

@ -34,6 +34,7 @@ import type { EditorProvider } from "@halo-dev/console-shared";
import { useLocalStorage } from "@vueuse/core";
import { useRouteQuery } from "@vueuse/router";
import type { AxiosRequestConfig } from "axios";
import ShortUniqueId from "short-unique-id";
import {
computed,
nextTick,
@ -49,6 +50,8 @@ import { useRouter } from "vue-router";
import PostSettingModal from "./components/PostSettingModal.vue";
import { usePostUpdateMutate } from "./composables/use-post-update-mutate";
const uid = new ShortUniqueId();
const router = useRouter();
const { t } = useI18n();
const { mutateAsync: postUpdateMutate } = usePostUpdateMutate();
@ -151,6 +154,21 @@ provide<ComputedRef<string | undefined>>(
computed(() => formState.value.post.status?.permalink)
);
// Slug generation
const { handleGenerateSlug } = useSlugify(
computed(() => formState.value.post.spec.title),
computed({
get() {
return formState.value.post.spec.slug;
},
set(value) {
formState.value.post.spec.slug = value;
},
}),
computed(() => !isUpdateMode.value),
FormType.POST
);
const handleSave = async (options?: { mute?: boolean }) => {
try {
if (!options?.mute) {
@ -162,10 +180,6 @@ const handleSave = async (options?: { mute?: boolean }) => {
formState.value.post.spec.title = t("core.post_editor.untitled");
}
if (!formState.value.post.spec.slug) {
formState.value.post.spec.slug = new Date().getTime().toString();
}
if (isUpdateMode.value) {
// Save post title
if (isTitleChanged.value) {
@ -186,6 +200,21 @@ const handleSave = async (options?: { mute?: boolean }) => {
// Clear new post content cache
handleClearCache();
if (!formState.value.post.spec.slug) {
handleGenerateSlug(true);
}
// fixme: check if slug is unique
// Finally, we need to check if the slug is unique in the database
const { data: postsWithSameSlug } =
await coreApiClient.content.post.listPost({
fieldSelector: [`spec.slug=${formState.value.post.spec.slug}`],
});
if (postsWithSameSlug.total) {
formState.value.post.spec.slug = `${formState.value.post.spec.slug}-${uid.randomUUID(8)}`;
}
const { data } = await consoleApiClient.content.post.draftPost({
postRequest: formState.value,
});
@ -465,21 +494,6 @@ async function handleUploadImage(file: File, options?: AxiosRequestConfig) {
);
return data;
}
// Slug generation
useSlugify(
computed(() => formState.value.post.spec.title),
computed({
get() {
return formState.value.post.spec.slug;
},
set(value) {
formState.value.post.spec.slug = value;
},
}),
computed(() => !isUpdateMode.value),
FormType.POST
);
</script>
<template>

View File

@ -6,7 +6,7 @@ import { formatDatetime, toDatetimeLocal, toISOString } from "@/utils/date";
import { randomUUID } from "@/utils/id";
import useSlugify from "@console/composables/use-slugify";
import { useThemeCustomTemplates } from "@console/modules/interface/themes/composables/use-theme";
import { submitForm } from "@formkit/core";
import { submitForm, type FormKitNode } from "@formkit/core";
import type { Post } from "@halo-dev/api-client";
import { consoleApiClient, coreApiClient } from "@halo-dev/api-client";
import {
@ -258,6 +258,29 @@ const { handleGenerateSlug } = useSlugify(
FormType.POST
);
// fixme: check if slug is unique
// Finally, we need to check if the slug is unique in the database
async function slugUniqueValidation(node: FormKitNode) {
const value = node.value;
if (!value) {
return true;
}
const fieldSelector = [`spec.slug=${value}`];
if (isUpdateMode.value) {
fieldSelector.push(`metadata.name!=${formState.value.metadata.name}`);
}
const { data: postsWithSameSlug } = await coreApiClient.content.post.listPost(
{
fieldSelector,
}
);
return !postsWithSameSlug.total;
}
// Buttons condition
const showPublishButton = computed(() => {
if (!props.publishSupport) {
@ -322,7 +345,13 @@ const showCancelPublishButton = computed(() => {
:label="$t('core.post.settings.fields.slug.label')"
name="slug"
type="text"
validation="required|length:0,100"
validation="required|length:0,100|slugUniqueValidation"
:validation-rules="{ slugUniqueValidation }"
:validation-messages="{
slugUniqueValidation: $t(
'core.common.form.validation.slug_unique'
),
}"
:help="$t('core.post.settings.fields.slug.help')"
>
<template #suffix>

View File

@ -12,7 +12,7 @@ import { usePermission } from "@/utils/permission";
import { useSaveKeybinding } from "@console/composables/use-save-keybinding";
import useSlugify from "@console/composables/use-slugify";
import type { Content, Post, Snapshot } from "@halo-dev/api-client";
import { ucApiClient } from "@halo-dev/api-client";
import { publicApiClient, ucApiClient } from "@halo-dev/api-client";
import {
Dialog,
IconBookRead,
@ -29,6 +29,7 @@ import { usePostUpdateMutate } from "@uc/modules/contents/posts/composables/use-
import { useLocalStorage } from "@vueuse/core";
import { useRouteQuery } from "@vueuse/router";
import { AxiosError, type AxiosRequestConfig } from "axios";
import ShortUniqueId from "short-unique-id";
import type { ComputedRef } from "vue";
import { computed, nextTick, onMounted, provide, ref, toRef, watch } from "vue";
import { useI18n } from "vue-i18n";
@ -36,6 +37,8 @@ import { useRouter } from "vue-router";
import PostCreationModal from "./components/PostCreationModal.vue";
import PostSettingEditModal from "./components/PostSettingEditModal.vue";
const uid = new ShortUniqueId();
const router = useRouter();
const { t } = useI18n();
const { currentUserHasPermission } = usePermission();
@ -88,6 +91,10 @@ watch(
}
);
const isUpdateMode = computed(
() => !!formState.value.metadata.creationTimestamp
);
// provide some data to editor
provide<ComputedRef<string | undefined>>(
"owner",
@ -178,6 +185,21 @@ useAutoSaveContent(currentCache, toRef(content.value, "raw"), async () => {
}
});
// Slug generation
const { handleGenerateSlug } = useSlugify(
computed(() => formState.value.spec.title),
computed({
get() {
return formState.value.spec.slug;
},
set(value) {
formState.value.spec.slug = value;
},
}),
computed(() => !isUpdateMode.value),
FormType.POST
);
async function getLatestPost() {
if (!name.value) {
return;
@ -283,8 +305,20 @@ async function handleCreate() {
if (!formState.value.spec.title) {
formState.value.spec.title = t("core.post_editor.untitled");
}
if (!formState.value.spec.slug) {
formState.value.spec.slug = new Date().getTime().toString();
handleGenerateSlug(true);
}
// fixme: check if slug is unique
// Finally, we need to check if the slug is unique in the database
const { data: postsWithSameSlug } =
await publicApiClient.content.post.queryPosts({
fieldSelector: [`spec.slug=${formState.value.spec.slug}`],
});
if (postsWithSameSlug.total) {
formState.value.spec.slug = `${formState.value.spec.slug}-${uid.randomUUID(8)}`;
}
const { data: createdPost } = await ucApiClient.content.post.createMyPost({
@ -303,9 +337,6 @@ async function onCreatePostSuccess(data: Post) {
}
// Save post
const isUpdateMode = computed(
() => !!formState.value.metadata.creationTimestamp
);
const { mutateAsync: postUpdateMutate } = usePostUpdateMutate();
@ -446,21 +477,6 @@ async function handleUploadImage(file: File, options?: AxiosRequestConfig) {
// Keep session alive
useSessionKeepAlive();
// Slug generation
useSlugify(
computed(() => formState.value.spec.title),
computed({
get() {
return formState.value.spec.slug;
},
set(value) {
formState.value.spec.slug = value;
},
}),
computed(() => !isUpdateMode.value),
FormType.POST
);
</script>
<template>

View File

@ -65,20 +65,21 @@ async function onSubmit(data: PostFormState) {
>
<PostSettingForm
:form-state="{
title: props.post.spec.title,
slug: props.post.spec.slug,
cover: props.post.spec.cover,
categories: props.post.spec.categories,
tags: props.post.spec.tags,
allowComment: props.post.spec.allowComment,
visible: props.post.spec.visible,
pinned: props.post.spec.pinned,
publishTime: props.post.spec.publishTime
? toDatetimeLocal(props.post.spec.publishTime)
title: post.spec.title,
slug: post.spec.slug,
cover: post.spec.cover,
categories: post.spec.categories,
tags: post.spec.tags,
allowComment: post.spec.allowComment,
visible: post.spec.visible,
pinned: post.spec.pinned,
publishTime: post.spec.publishTime
? toDatetimeLocal(post.spec.publishTime)
: undefined,
excerptAutoGenerate: props.post.spec.excerpt.autoGenerate,
excerptRaw: props.post.spec.excerpt.raw,
excerptAutoGenerate: post.spec.excerpt.autoGenerate,
excerptRaw: post.spec.excerpt.raw,
}"
:name="post.metadata.name"
update-mode
@submit="onSubmit"
/>

View File

@ -3,6 +3,8 @@ import HasPermission from "@/components/permission/HasPermission.vue";
import { FormType } from "@/types/slug";
import { formatDatetime, toISOString } from "@/utils/date";
import useSlugify from "@console/composables/use-slugify";
import type { FormKitNode } from "@formkit/core";
import { publicApiClient } from "@halo-dev/api-client";
import { IconRefreshLine } from "@halo-dev/components";
import { computed, ref } from "vue";
import { useI18n } from "vue-i18n";
@ -12,10 +14,12 @@ const { t } = useI18n();
const props = withDefaults(
defineProps<{
name?: string;
formState?: PostFormState;
updateMode?: boolean;
}>(),
{
name: undefined,
formState: undefined,
updateMode: false,
}
@ -63,6 +67,28 @@ const { handleGenerateSlug } = useSlugify(
FormType.POST
);
// fixme: check if slug is unique
// Finally, we need to check if the slug is unique in the database
async function slugUniqueValidation(node: FormKitNode) {
const value = node.value;
if (!value) {
return true;
}
const fieldSelector = [`spec.slug=${value}`];
if (props.name) {
fieldSelector.push(`metadata.name!=${props.name}`);
}
const { data: postsWithSameSlug } =
await publicApiClient.content.post.queryPosts({
fieldSelector,
});
return !postsWithSameSlug.total;
}
const isScheduledPublish = computed(() => {
const { publishTime } = internalFormState.value;
return publishTime && new Date(publishTime) > new Date();
@ -107,7 +133,13 @@ const publishTimeHelp = computed(() => {
:label="$t('core.post.settings.fields.slug.label')"
name="slug"
type="text"
validation="required|length:0,100"
validation="required|length:0,100|slugUniqueValidation"
:validation-rules="{ slugUniqueValidation }"
:validation-messages="{
slugUniqueValidation: $t(
'core.common.form.validation.slug_unique'
),
}"
:help="$t('core.post.settings.fields.slug.help')"
>
<template #suffix>