301 lines
7.8 KiB
Vue
301 lines
7.8 KiB
Vue
<!--
|
||||
|
|
Copyright 2024-2026 ChatterMate
|
|||
|
|
|
|||
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|||
|
|
you may not use this file except in compliance with the License.
|
|||
|
|
You may obtain a copy of the License at
|
|||
|
|
|
|||
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|||
|
|
|
|||
|
|
Unless required by applicable law or agreed to in writing, software
|
|||
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|||
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|||
|
|
See the License for the specific language governing permissions and
|
|||
|
|
limitations under the License.
|
|||
|
|
|
|||
|
|
Per-article SEO overrides inside the FAQ editor: URL slug, the preserved URL
|
|||
|
|
path for a migrated article, meta title and meta description. Collapsed by
|
|||
|
|
default — every field is optional, and leaving one empty keeps the value the
|
|||
|
|
public page derives from the question and answer.
|
|||
|
|
-->
|
|||
|
|
|
|||
|
|
<script setup lang="ts">
|
|||
|
|
import { computed, ref } from 'vue'
|
|||
|
|
|
|||
|
|
const props = withDefaults(
|
|||
|
|
defineProps<{
|
|||
|
|
slug?: string
|
|||
|
|
urlPath?: string
|
|||
|
|
metaTitle?: string
|
|||
|
|
metaDescription?: string
|
|||
|
|
/** Shown as the placeholder for the title, so the derived default is visible. */
|
|||
|
|
question?: string
|
|||
|
|
}>(),
|
|||
|
|
{ slug: '', urlPath: '', metaTitle: '', metaDescription: '', question: '' },
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
const emit = defineEmits<{
|
|||
|
|
'update:slug': [value: string]
|
|||
|
|
'update:urlPath': [value: string]
|
|||
|
|
'update:metaTitle': [value: string]
|
|||
|
|
'update:metaDescription': [value: string]
|
|||
|
|
}>()
|
|||
|
|
|
|||
|
|
// Hard caps mirror the backend column widths in app/models/faq.py, which is the
|
|||
|
|
// source of truth — the API rejects anything longer with a 422, so these only
|
|||
|
|
// stop the user typing past the limit. The "recommended" numbers are softer:
|
|||
|
|
// where search engines start truncating the tag in results.
|
|||
|
|
const MAX_SLUG = 80
|
|||
|
|
const MAX_URL_PATH = 400
|
|||
|
|
const MAX_TITLE = 120
|
|||
|
|
const MAX_DESCRIPTION = 300
|
|||
|
|
const RECOMMENDED_TITLE = 60
|
|||
|
|
const RECOMMENDED_DESCRIPTION = 160
|
|||
|
|
|
|||
|
|
// A customised article opens the section on mount, so an override is never
|
|||
|
|
// hidden behind a collapsed panel — but it stays a plain toggle afterwards, so
|
|||
|
|
// the user can still fold it away. The editor mounts fresh per article, which
|
|||
|
|
// is what makes the initial value correct.
|
|||
|
|
const hasValues = computed(
|
|||
|
|
() => Boolean(props.urlPath.trim() || props.metaTitle.trim() || props.metaDescription.trim()),
|
|||
|
|
)
|
|||
|
|
const expanded = ref(hasValues.value)
|
|||
|
|
|
|||
|
|
const titleCount = computed(() => props.metaTitle.trim().length)
|
|||
|
|
const descriptionCount = computed(() => props.metaDescription.trim().length)
|
|||
|
|
|
|||
|
|
function onInput(event: Event): string {
|
|||
|
|
return (event.target as HTMLInputElement | HTMLTextAreaElement).value
|
|||
|
|
}
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<template>
|
|||
|
|
<div class="seo">
|
|||
|
|
<button class="seo__toggle" type="button" :aria-expanded="expanded" @click="expanded = !expanded">
|
|||
|
|
<svg
|
|||
|
|
class="seo__chevron"
|
|||
|
|
:class="{ 'seo__chevron--open': expanded }"
|
|||
|
|
viewBox="0 0 24 24"
|
|||
|
|
width="13"
|
|||
|
|
height="13"
|
|||
|
|
fill="none"
|
|||
|
|
stroke="currentColor"
|
|||
|
|
stroke-width="2.2"
|
|||
|
|
stroke-linecap="round"
|
|||
|
|
stroke-linejoin="round"
|
|||
|
|
>
|
|||
|
|
<path d="M9 6l6 6-6 6" />
|
|||
|
|
</svg>
|
|||
|
|
SEO 与 URL
|
|||
|
|
<span v-if="hasValues" class="seo__badge">已自定义</span>
|
|||
|
|
</button>
|
|||
|
|
|
|||
|
|
<div v-if="expanded" class="seo__fields">
|
|||
|
|
<label class="seo__field">
|
|||
|
|
<span class="seo__label">URL 别名</span>
|
|||
|
|
<span class="seo__slug">
|
|||
|
|
<span class="seo__prefix">/a/</span>
|
|||
|
|
<input
|
|||
|
|
type="text"
|
|||
|
|
class="seo__input seo__input--slug"
|
|||
|
|
:maxlength="MAX_SLUG"
|
|||
|
|
placeholder="根据问题自动生成"
|
|||
|
|
:value="slug"
|
|||
|
|
@input="emit('update:slug', onInput($event))"
|
|||
|
|
/>
|
|||
|
|
</span>
|
|||
|
|
<span class="seo__hint">
|
|||
|
|
修改后,指向该文章的现有链接将失效。空格和符号会转换为连字符。
|
|||
|
|
</span>
|
|||
|
|
</label>
|
|||
|
|
|
|||
|
|
<label class="seo__field">
|
|||
|
|
<span class="seo__label">自定义 URL 路径</span>
|
|||
|
|
<input
|
|||
|
|
type="text"
|
|||
|
|
class="seo__input seo__input--path"
|
|||
|
|
:maxlength="MAX_URL_PATH"
|
|||
|
|
placeholder="/hc/en-us/articles/360012-reset-password"
|
|||
|
|
:value="urlPath"
|
|||
|
|
@input="emit('update:urlPath', onInput($event))"
|
|||
|
|
/>
|
|||
|
|
<span class="seo__hint">
|
|||
|
|
将这篇文章发布到它在其他帮助中心已有的 URL 上,以保留搜索排名和外部链接。粘贴旧 URL 或路径即可,留空则使用上方的别名。
|
|||
|
|
</span>
|
|||
|
|
</label>
|
|||
|
|
|
|||
|
|
<label class="seo__field">
|
|||
|
|
<span class="seo__label">
|
|||
|
|
Meta 标题
|
|||
|
|
<span class="seo__count" :class="{ 'seo__count--over': titleCount > RECOMMENDED_TITLE }">
|
|||
|
|
{{ titleCount }}/{{ RECOMMENDED_TITLE }}
|
|||
|
|
</span>
|
|||
|
|
</span>
|
|||
|
|
<input
|
|||
|
|
type="text"
|
|||
|
|
class="seo__input"
|
|||
|
|
:maxlength="MAX_TITLE"
|
|||
|
|
:placeholder="question || '默认为问题内容'"
|
|||
|
|
:value="metaTitle"
|
|||
|
|
@input="emit('update:metaTitle', onInput($event))"
|
|||
|
|
/>
|
|||
|
|
</label>
|
|||
|
|
|
|||
|
|
<label class="seo__field">
|
|||
|
|
<span class="seo__label">
|
|||
|
|
Meta 描述
|
|||
|
|
<span
|
|||
|
|
class="seo__count"
|
|||
|
|
:class="{ 'seo__count--over': descriptionCount > RECOMMENDED_DESCRIPTION }"
|
|||
|
|
>
|
|||
|
|
{{ descriptionCount }}/{{ RECOMMENDED_DESCRIPTION }}
|
|||
|
|
</span>
|
|||
|
|
</span>
|
|||
|
|
<textarea
|
|||
|
|
class="seo__input seo__textarea"
|
|||
|
|
rows="2"
|
|||
|
|
:maxlength="MAX_DESCRIPTION"
|
|||
|
|
placeholder="默认为回答的开头部分"
|
|||
|
|
:value="metaDescription"
|
|||
|
|
@input="emit('update:metaDescription', onInput($event))"
|
|||
|
|
></textarea>
|
|||
|
|
</label>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<style scoped>
|
|||
|
|
.seo {
|
|||
|
|
margin-top: 10px;
|
|||
|
|
border-top: 1px solid var(--o08);
|
|||
|
|
padding-top: 10px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.seo__toggle {
|
|||
|
|
display: inline-flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 7px;
|
|||
|
|
padding: 0;
|
|||
|
|
background: none;
|
|||
|
|
border: none;
|
|||
|
|
cursor: pointer;
|
|||
|
|
font-family: var(--font-mono);
|
|||
|
|
font-size: 10.5px;
|
|||
|
|
letter-spacing: 0.08em;
|
|||
|
|
color: var(--muted);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.seo__toggle:hover {
|
|||
|
|
color: var(--text);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.seo__chevron {
|
|||
|
|
transition: transform 0.15s ease;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.seo__chevron--open {
|
|||
|
|
transform: rotate(90deg);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.seo__badge {
|
|||
|
|
padding: 2px 7px;
|
|||
|
|
border-radius: 999px;
|
|||
|
|
background: var(--o08);
|
|||
|
|
font-size: 9.5px;
|
|||
|
|
letter-spacing: 0.06em;
|
|||
|
|
color: var(--muted2);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.seo__fields {
|
|||
|
|
display: grid;
|
|||
|
|
gap: 12px;
|
|||
|
|
margin-top: 12px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.seo__field {
|
|||
|
|
display: block;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.seo__label {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: space-between;
|
|||
|
|
gap: 8px;
|
|||
|
|
margin-bottom: 5px;
|
|||
|
|
font-size: 12px;
|
|||
|
|
font-weight: 600;
|
|||
|
|
color: var(--muted);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.seo__count {
|
|||
|
|
font-family: var(--font-mono);
|
|||
|
|
font-size: 10.5px;
|
|||
|
|
font-weight: 400;
|
|||
|
|
color: var(--muted2);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.seo__count--over {
|
|||
|
|
color: var(--c-coral);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.seo__slug {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
background: var(--bg);
|
|||
|
|
border: 1px solid var(--o12);
|
|||
|
|
border-radius: 9px;
|
|||
|
|
overflow: hidden;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.seo__prefix {
|
|||
|
|
padding: 0 2px 0 11px;
|
|||
|
|
font-family: var(--font-mono);
|
|||
|
|
font-size: 12.5px;
|
|||
|
|
color: var(--muted2);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.seo__input {
|
|||
|
|
width: 100%;
|
|||
|
|
box-sizing: border-box;
|
|||
|
|
background: var(--bg);
|
|||
|
|
border: 1px solid var(--o12);
|
|||
|
|
border-radius: 9px;
|
|||
|
|
padding: 9px 11px;
|
|||
|
|
color: var(--text);
|
|||
|
|
font-family: var(--font-sans);
|
|||
|
|
font-size: 13px;
|
|||
|
|
outline: none;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.seo__input:focus {
|
|||
|
|
border-color: var(--o20);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* The slug input sits inside the prefixed shell, which draws the border. */
|
|||
|
|
.seo__input--slug {
|
|||
|
|
border: none;
|
|||
|
|
border-radius: 0;
|
|||
|
|
padding-left: 0;
|
|||
|
|
font-family: var(--font-mono);
|
|||
|
|
font-size: 12.5px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* A URL, so it reads in the same monospace as the slug — but it keeps the
|
|||
|
|
standard input shell, since it has no "/a/" prefix to sit behind. */
|
|||
|
|
.seo__input--path {
|
|||
|
|
font-family: var(--font-mono);
|
|||
|
|
font-size: 12.5px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.seo__textarea {
|
|||
|
|
resize: vertical;
|
|||
|
|
line-height: 1.5;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.seo__hint {
|
|||
|
|
display: block;
|
|||
|
|
margin-top: 5px;
|
|||
|
|
font-size: 11px;
|
|||
|
|
color: var(--muted2);
|
|||
|
|
}
|
|||
|
|
</style>
|