feat: add supports for automatic slug generation (halo-dev/console#831)

#### What type of PR is this?

/kind feature
/milestone 2.2.x

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

文章、独立页面、分类、标签支持自动生成别名。

策略:

1. 仅在创建时会自动根据标题或者名称自动生成别名,编辑时如果需要重新生成,可以点击输入框右侧的按钮。
2. 中文会被转为拼音并用 - 隔开,需要注意多音字的情况,目前无法保证多音字是否符合预期。
3. 使用了 https://www.npmjs.com/package/transliteration 库。

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

Fixes https://github.com/halo-dev/halo/issues/3171

#### Screenshots:

<img width="782" alt="image" src="https://user-images.githubusercontent.com/21301288/213849446-18d70974-7b2c-420c-bd50-93d2c3193895.png">

#### Special notes for your reviewer:

测试方式:

1. 需要 `pnpm install`
4. 测试文章、独立页面、分类、标签创建时,是否可以自动设置别名。
5. 测试别名输入框右侧的生成别名按钮是否正常工作。
6. 测试分类和标签选择器创建新分类或标签时,是否正确设置了别名。

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

```release-note
Console 端的文章、独立页面、分类、标签支持自动生成别名
```
pull/3445/head
Ryan Wang 2023-01-30 14:52:11 +08:00 committed by GitHub
parent 82db7d0afd
commit 241ad3cc2f
9 changed files with 179 additions and 26 deletions

View File

@ -74,6 +74,7 @@
"pinia": "^2.0.26",
"pretty-bytes": "^6.0.0",
"qs": "^6.11.0",
"transliteration": "^2.3.5",
"vue": "^3.2.45",
"vue-grid-layout": "3.0.0-beta1",
"vue-router": "^4.1.6",

View File

@ -85,6 +85,7 @@ importers:
tailwindcss: ^3.2.4
tailwindcss-safe-area: ^0.2.2
tailwindcss-themer: ^2.0.2
transliteration: ^2.3.5
typescript: ~4.7.4
unplugin-icons: ^0.14.14
vite: ^4.0.4
@ -141,6 +142,7 @@ importers:
pinia: 2.0.26_e7lp6ggkpgyi5vqd44m2kxvk6i
pretty-bytes: 6.0.0
qs: 6.11.0
transliteration: 2.3.5
vue: 3.2.45
vue-grid-layout: 3.0.0-beta1
vue-router: 4.1.6_vue@3.2.45
@ -4267,7 +4269,6 @@ packages:
/ansi-regex/5.0.1:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'}
dev: true
/ansi-regex/6.0.1:
resolution: {integrity: sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==}
@ -4286,7 +4287,6 @@ packages:
engines: {node: '>=8'}
dependencies:
color-convert: 2.0.1
dev: true
/ansi-styles/6.2.1:
resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==}
@ -4783,7 +4783,6 @@ packages:
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi: 7.0.0
dev: true
/clone/1.0.4:
resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==}
@ -4817,7 +4816,6 @@ packages:
engines: {node: '>=7.0.0'}
dependencies:
color-name: 1.1.4
dev: true
/color-name/1.1.3:
resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==}
@ -5358,7 +5356,6 @@ packages:
/emoji-regex/8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
dev: true
/emoji-regex/9.2.2:
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
@ -5693,7 +5690,6 @@ packages:
/escalade/3.1.1:
resolution: {integrity: sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==}
engines: {node: '>=6'}
dev: true
/escape-html/1.0.3:
resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
@ -6296,7 +6292,6 @@ packages:
/get-caller-file/2.0.5:
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
engines: {node: 6.* || 8.* || >= 10.*}
dev: true
/get-func-name/2.0.0:
resolution: {integrity: sha512-Hm0ixYtaSZ/V7C8FJrtZIuBBI+iSgL+1Aq82zSu8VQNB4S3Gk8e7Qs3VwBDJAhmRZcFqkl3tQu36g/Foh5I5ig==}
@ -6802,7 +6797,6 @@ packages:
/is-fullwidth-code-point/3.0.0:
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
engines: {node: '>=8'}
dev: true
/is-fullwidth-code-point/4.0.0:
resolution: {integrity: sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==}
@ -8447,7 +8441,6 @@ packages:
/require-directory/2.1.1:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
engines: {node: '>=0.10.0'}
dev: true
/require-from-string/2.0.2:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
@ -8894,7 +8887,6 @@ packages:
emoji-regex: 8.0.0
is-fullwidth-code-point: 3.0.0
strip-ansi: 6.0.1
dev: true
/string-width/5.1.2:
resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==}
@ -8954,7 +8946,6 @@ packages:
engines: {node: '>=8'}
dependencies:
ansi-regex: 5.0.1
dev: true
/strip-ansi/7.0.1:
resolution: {integrity: sha512-cXNxvT8dFNRVfhVME3JAe98mkXDYN2O1l7jmcwMnOslDeESg1rF/OZMtK0nRAhiari1unG5cD4jG3rapUAkLbw==}
@ -9264,6 +9255,14 @@ packages:
punycode: 2.1.1
dev: true
/transliteration/2.3.5:
resolution: {integrity: sha512-HAGI4Lq4Q9dZ3Utu2phaWgtm3vB6PkLUFqWAScg/UW+1eZ/Tg6Exo4oC0/3VUol/w4BlefLhUUSVBr/9/ZGQOw==}
engines: {node: '>=6.0.0'}
hasBin: true
dependencies:
yargs: 17.5.1
dev: false
/trim-newlines/3.0.1:
resolution: {integrity: sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw==}
engines: {node: '>=8'}
@ -10267,7 +10266,6 @@ packages:
ansi-styles: 4.3.0
string-width: 4.2.3
strip-ansi: 6.0.1
dev: true
/wrappy/1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
@ -10306,7 +10304,6 @@ packages:
/y18n/5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
dev: true
/yallist/2.1.2:
resolution: {integrity: sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A==}
@ -10345,7 +10342,6 @@ packages:
/yargs-parser/21.1.1:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
engines: {node: '>=12'}
dev: true
/yargs/15.4.1:
resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
@ -10388,7 +10384,6 @@ packages:
string-width: 4.2.3
y18n: 5.0.8
yargs-parser: 21.1.1
dev: true
/yauzl/2.10.0:
resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==}

View File

@ -0,0 +1,26 @@
import { slugify } from "transliteration";
import { watch, type Ref } from "vue";
export default function useSlugify(
source: Ref<string>,
target: Ref<string>,
auto: Ref<boolean>
) {
watch(
() => source.value,
() => {
if (auto.value) {
handleGenerateSlug();
}
}
);
const handleGenerateSlug = () => {
target.value = slugify(source.value, {
trim: true,
});
};
return {
handleGenerateSlug,
};
}

View File

@ -13,6 +13,7 @@ import CategoryTag from "./components/CategoryTag.vue";
import SearchResultListItem from "./components/SearchResultListItem.vue";
import { apiClient } from "@/utils/api-client";
import { usePermission } from "@/utils/permission";
import { slugify } from "transliteration";
const { currentUserHasPermission } = usePermission();
@ -212,7 +213,7 @@ const handleCreateCategory = async () => {
category: {
spec: {
displayName: text.value,
slug: text.value,
slug: slugify(text.value, { trim: true }),
description: "",
cover: "",
template: "",

View File

@ -12,6 +12,7 @@ import {
import { onClickOutside } from "@vueuse/core";
import Fuse from "fuse.js";
import { usePermission } from "@/utils/permission";
import { slugify } from "transliteration";
const { currentUserHasPermission } = usePermission();
@ -196,7 +197,7 @@ const handleCreateTag = async () => {
tag: {
spec: {
displayName: text.value,
slug: text.value,
slug: slugify(text.value, { trim: true }),
color: "#ffffff",
cover: "",
},

View File

@ -1,5 +1,11 @@
<script lang="ts" setup>
import { Toast, VButton, VModal, VSpace } from "@halo-dev/components";
import {
IconRefreshLine,
Toast,
VButton,
VModal,
VSpace,
} from "@halo-dev/components";
import { computed, nextTick, ref, watchEffect } from "vue";
import type { SinglePage } from "@halo-dev/api-client";
import cloneDeep from "lodash.clonedeep";
@ -10,6 +16,7 @@ import { randomUUID } from "@/utils/id";
import { toDatetimeLocal, toISOString } from "@/utils/date";
import { submitForm } from "@formkit/core";
import AnnotationsForm from "@/components/form/AnnotationsForm.vue";
import useSlugify from "@/composables/use-slugify";
const initialFormState: SinglePage = {
spec: {
@ -258,6 +265,20 @@ const publishTime = computed(() => {
const onPublishTimeChange = (value: string) => {
formState.value.spec.publishTime = value ? toISOString(value) : undefined;
};
// slug
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)
);
</script>
<template>
@ -302,7 +323,20 @@ const onPublishTimeChange = (value: string) => {
name="slug"
type="text"
validation="required|length:0,100"
></FormKit>
help="通常用于生成页面的固定链接"
>
<template #suffix>
<div
v-tooltip="'根据标题重新生成别名'"
class="group flex h-full cursor-pointer items-center border-l px-3 transition-all hover:bg-gray-100"
@click="handleGenerateSlug"
>
<IconRefreshLine
class="h-4 w-4 text-gray-500 group-hover:text-gray-700"
/>
</div>
</template>
</FormKit>
<FormKit
v-model="formState.spec.excerpt.autoGenerate"
:options="[

View File

@ -4,7 +4,13 @@ import { computed, nextTick, ref, watch } from "vue";
import { apiClient } from "@/utils/api-client";
// components
import { Toast, VButton, VModal, VSpace } from "@halo-dev/components";
import {
IconRefreshLine,
Toast,
VButton,
VModal,
VSpace,
} from "@halo-dev/components";
import SubmitButton from "@/components/button/SubmitButton.vue";
// types
@ -16,6 +22,7 @@ import { reset } from "@formkit/core";
import { setFocus } from "@/formkit/utils/focus";
import { useThemeCustomTemplates } from "@/modules/interface/themes/composables/use-theme";
import AnnotationsForm from "@/components/form/AnnotationsForm.vue";
import useSlugify from "@/composables/use-slugify";
const props = withDefaults(
defineProps<{
@ -138,6 +145,20 @@ watch(
// custom templates
const { templates } = useThemeCustomTemplates("category");
// slug
const { handleGenerateSlug } = useSlugify(
computed(() => formState.value.spec.displayName),
computed({
get() {
return formState.value.spec.slug;
},
set(value) {
formState.value.spec.slug = value;
},
}),
computed(() => !isUpdateMode.value)
);
</script>
<template>
<VModal
@ -171,12 +192,24 @@ const { templates } = useThemeCustomTemplates("category");
></FormKit>
<FormKit
v-model="formState.spec.slug"
help="通常作为分类访问地址标识"
help="通常用于生成分类的固定链接"
name="slug"
label="别名"
type="text"
validation="required|length:0,50"
></FormKit>
>
<template #suffix>
<div
v-tooltip="'根据名称重新生成别名'"
class="group flex h-full cursor-pointer items-center border-l px-3 transition-all hover:bg-gray-100"
@click="handleGenerateSlug"
>
<IconRefreshLine
class="h-4 w-4 text-gray-500 group-hover:text-gray-700"
/>
</div>
</template>
</FormKit>
<FormKit
v-model="formState.spec.template"
:options="templates"

View File

@ -1,5 +1,11 @@
<script lang="ts" setup>
import { Toast, VButton, VModal, VSpace } from "@halo-dev/components";
import {
IconRefreshLine,
Toast,
VButton,
VModal,
VSpace,
} from "@halo-dev/components";
import { computed, nextTick, ref, watchEffect } from "vue";
import type { Post } from "@halo-dev/api-client";
import cloneDeep from "lodash.clonedeep";
@ -10,6 +16,7 @@ import { randomUUID } from "@/utils/id";
import { toDatetimeLocal, toISOString } from "@/utils/date";
import AnnotationsForm from "@/components/form/AnnotationsForm.vue";
import { submitForm } from "@formkit/core";
import useSlugify from "@/composables/use-slugify";
const initialFormState: Post = {
spec: {
@ -222,6 +229,20 @@ const onPublishTimeChange = (value: string) => {
};
const annotationsFormRef = ref<InstanceType<typeof AnnotationsForm>>();
// slug
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)
);
</script>
<template>
<VModal
@ -265,7 +286,20 @@ const annotationsFormRef = ref<InstanceType<typeof AnnotationsForm>>();
name="slug"
type="text"
validation="required|length:0,100"
></FormKit>
help="通常用于生成文章的固定链接"
>
<template #suffix>
<div
v-tooltip="'根据标题重新生成别名'"
class="group flex h-full cursor-pointer items-center border-l px-3 transition-all hover:bg-gray-100"
@click="handleGenerateSlug"
>
<IconRefreshLine
class="h-4 w-4 text-gray-500 group-hover:text-gray-700"
/>
</div>
</template>
</FormKit>
<FormKit
v-model="formState.spec.categories"
label="分类目录"

View File

@ -7,6 +7,7 @@ import { apiClient } from "@/utils/api-client";
import {
IconArrowLeft,
IconArrowRight,
IconRefreshLine,
Toast,
VButton,
VModal,
@ -22,6 +23,7 @@ import cloneDeep from "lodash.clonedeep";
import { reset } from "@formkit/core";
import { setFocus } from "@/formkit/utils/focus";
import AnnotationsForm from "@/components/form/AnnotationsForm.vue";
import useSlugify from "@/composables/use-slugify";
const props = withDefaults(
defineProps<{
@ -139,6 +141,20 @@ watch(
}
}
);
// slug
const { handleGenerateSlug } = useSlugify(
computed(() => formState.value.spec.displayName),
computed({
get() {
return formState.value.spec.slug;
},
set(value) {
formState.value.spec.slug = value;
},
}),
computed(() => !isUpdateMode.value)
);
</script>
<template>
<VModal
@ -181,12 +197,24 @@ watch(
></FormKit>
<FormKit
v-model="formState.spec.slug"
help="通常作为标签访问地址标识"
help="通常用于生成标签的固定链接"
label="别名"
name="slug"
type="text"
validation="required|length:0,50"
></FormKit>
>
<template #suffix>
<div
v-tooltip="'根据名称重新生成别名'"
class="group flex h-full cursor-pointer items-center border-l px-3 transition-all hover:bg-gray-100"
@click="handleGenerateSlug"
>
<IconRefreshLine
class="h-4 w-4 text-gray-500 group-hover:text-gray-700"
/>
</div>
</template>
</FormKit>
<FormKit
v-model="formState.spec.color"
name="color"