Files
replypier/frontend/src/components/faq/FaqSeoFields.vue
T

301 lines
7.8 KiB
Vue
Raw Normal View History

<!--
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>