refactor(settings): 提取 TagInput 组件以复用标签输入逻辑
将设置页面的允许域名、IP 黑名单和邮箱黑名单的标签输入逻辑提取为独立的 TagInput 组件,消除重复代码并提高可维护性。同时将页面容器类名从 `.page` 重命名为 `.settings-content` 以更语义化。
This commit is contained in:
157
cwd-admin/src/components/TagInput.vue
Normal file
157
cwd-admin/src/components/TagInput.vue
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
<template>
|
||||||
|
<div class="tag-input">
|
||||||
|
<div class="tag-input-inner">
|
||||||
|
<span v-for="item in props.modelValue" :key="item" class="tag-input-tag">
|
||||||
|
<span class="tag-input-tag-text">{{ item }}</span>
|
||||||
|
<button type="button" class="tag-input-tag-remove" @click="removeItem(item)">
|
||||||
|
<PhTrash :size="14" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
v-model="inputValue"
|
||||||
|
class="tag-input-input"
|
||||||
|
@keyup="handleKeyup"
|
||||||
|
@blur="handleBlur"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: string[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "update:modelValue", value: string[]): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const inputValue = ref("");
|
||||||
|
|
||||||
|
function addFromInput() {
|
||||||
|
const raw = inputValue.value;
|
||||||
|
if (!raw) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parts = raw
|
||||||
|
.split(/[,,\s]+/)
|
||||||
|
.map((d) => d.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (parts.length === 0) {
|
||||||
|
inputValue.value = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const existing = new Set(props.modelValue);
|
||||||
|
const next: string[] = [...props.modelValue];
|
||||||
|
for (const part of parts) {
|
||||||
|
if (!existing.has(part)) {
|
||||||
|
next.push(part);
|
||||||
|
existing.add(part);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
emit("update:modelValue", next);
|
||||||
|
inputValue.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyup(event: KeyboardEvent) {
|
||||||
|
if (
|
||||||
|
event.key === " " ||
|
||||||
|
event.key === "Spacebar" ||
|
||||||
|
event.key === "," ||
|
||||||
|
event.key === "," ||
|
||||||
|
event.key === "Enter"
|
||||||
|
) {
|
||||||
|
addFromInput();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBlur() {
|
||||||
|
addFromInput();
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeItem(value: string) {
|
||||||
|
const next = props.modelValue.filter((item) => item !== value);
|
||||||
|
emit("update:modelValue", next);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.tag-input {
|
||||||
|
padding: 4px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background-color: var(--bg-input);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-input-inner {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-input-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background-color: var(--bg-sider);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-input-tag-text {
|
||||||
|
max-width: 180px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-input-tag-remove {
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-input-tag-remove:hover {
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-input-tag:hover .tag-input-tag-text {
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-input-tag:hover .tag-input-tag-remove {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-input-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 120px;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
background: transparent;
|
||||||
|
padding: 4px 2px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
.page {
|
.settings-content {
|
||||||
max-width: 620px;
|
max-width: 620px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,83 +69,6 @@
|
|||||||
box-shadow: 0 0 0 1px rgba(9, 105, 218, 0.2);
|
box-shadow: 0 0 0 1px rgba(9, 105, 218, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tag-input {
|
|
||||||
padding: 4px 6px;
|
|
||||||
border-radius: 4px;
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
background-color: var(--bg-input);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-input-inner {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-input-tag {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
max-width: 100%;
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background-color: var(--bg-sider);
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-size: 13px;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-input-tag-text {
|
|
||||||
max-width: 180px;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-input-tag-remove {
|
|
||||||
border: none;
|
|
||||||
padding: 0;
|
|
||||||
background: none;
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 12px;
|
|
||||||
opacity: 0;
|
|
||||||
visibility: hidden;
|
|
||||||
position: absolute;
|
|
||||||
left: 50%;
|
|
||||||
top: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-input-tag-remove:hover {
|
|
||||||
color: var(--color-danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-input-tag:hover .tag-input-tag-text {
|
|
||||||
opacity: 0;
|
|
||||||
visibility: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-input-tag:hover .tag-input-tag-remove {
|
|
||||||
opacity: 1;
|
|
||||||
visibility: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-input-input {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 120px;
|
|
||||||
border: none;
|
|
||||||
outline: none;
|
|
||||||
background: transparent;
|
|
||||||
padding: 4px 2px;
|
|
||||||
font-size: 14px;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.switch {
|
.switch {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|||||||
@@ -61,7 +61,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<transition name="tab-fade" mode="out-in">
|
<transition name="tab-fade" mode="out-in">
|
||||||
<div :key="activeTab">
|
<div :key="activeTab" class="settings-content">
|
||||||
<template v-if="activeTab === 'comment'">
|
<template v-if="activeTab === 'comment'">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
@@ -113,84 +113,19 @@
|
|||||||
<label class="form-label">
|
<label class="form-label">
|
||||||
允许调用的域名(设置后仅匹配域名可调用前台评论组件,留空则不限制。使用空格或者逗号进行分割。)
|
允许调用的域名(设置后仅匹配域名可调用前台评论组件,留空则不限制。使用空格或者逗号进行分割。)
|
||||||
</label>
|
</label>
|
||||||
<div class="tag-input">
|
<TagInput v-model="allowedDomainTags" />
|
||||||
<div class="tag-input-inner">
|
|
||||||
<span
|
|
||||||
v-for="domain in allowedDomainTags"
|
|
||||||
:key="domain"
|
|
||||||
class="tag-input-tag"
|
|
||||||
>
|
|
||||||
<span class="tag-input-tag-text">{{ domain }}</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="tag-input-tag-remove"
|
|
||||||
@click="removeAllowedDomain(domain)"
|
|
||||||
>
|
|
||||||
<PhTrash :size="14" />
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
v-model="allowedDomainInput"
|
|
||||||
class="tag-input-input"
|
|
||||||
@keyup="handleAllowedDomainKeyup"
|
|
||||||
@blur="handleAllowedDomainBlur"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="form-item">
|
<div class="form-item">
|
||||||
<label class="form-label">
|
<label class="form-label">
|
||||||
IP 黑名单(多个 IP 用逗号或换行分隔,留空则不限制)
|
IP 黑名单(多个 IP 用逗号或换行分隔,留空则不限制)
|
||||||
</label>
|
</label>
|
||||||
<div class="tag-input">
|
<TagInput v-model="blockedIpTags" />
|
||||||
<div class="tag-input-inner">
|
|
||||||
<span v-for="ip in blockedIpTags" :key="ip" class="tag-input-tag">
|
|
||||||
<span class="tag-input-tag-text">{{ ip }}</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="tag-input-tag-remove"
|
|
||||||
@click="removeBlockedIp(ip)"
|
|
||||||
>
|
|
||||||
<PhTrash :size="14" />
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
v-model="blockedIpInput"
|
|
||||||
class="tag-input-input"
|
|
||||||
@keyup="handleBlockedIpKeyup"
|
|
||||||
@blur="handleBlockedIpBlur"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="form-item">
|
<div class="form-item">
|
||||||
<label class="form-label"
|
<label class="form-label"
|
||||||
>邮箱黑名单(多个邮箱用逗号或换行分隔,留空则不限制)</label
|
>邮箱黑名单(多个邮箱用逗号或换行分隔,留空则不限制)</label
|
||||||
>
|
>
|
||||||
<div class="tag-input">
|
<TagInput v-model="blockedEmailTags" />
|
||||||
<div class="tag-input-inner">
|
|
||||||
<span
|
|
||||||
v-for="email in blockedEmailTags"
|
|
||||||
:key="email"
|
|
||||||
class="tag-input-tag"
|
|
||||||
>
|
|
||||||
<span class="tag-input-tag-text">{{ email }}</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="tag-input-tag-remove"
|
|
||||||
@click="removeBlockedEmail(email)"
|
|
||||||
>
|
|
||||||
<PhTrash :size="14" />
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
v-model="blockedEmailInput"
|
|
||||||
class="tag-input-input"
|
|
||||||
@keyup="handleBlockedEmailKeyup"
|
|
||||||
@blur="handleBlockedEmailBlur"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-actions">
|
<div class="card-actions">
|
||||||
@@ -563,6 +498,7 @@ import {
|
|||||||
} from "../../api/admin";
|
} from "../../api/admin";
|
||||||
|
|
||||||
import DomainSettings from "./components/DomainSettings.vue";
|
import DomainSettings from "./components/DomainSettings.vue";
|
||||||
|
import TagInput from "../../components/TagInput.vue";
|
||||||
|
|
||||||
const DEFAULT_REPLY_TEMPLATE = `<div style="background-color:#f4f4f5;padding:24px 0;">
|
const DEFAULT_REPLY_TEMPLATE = `<div style="background-color:#f4f4f5;padding:24px 0;">
|
||||||
<div style="max-width:640px;margin:0 auto;background:#ffffff;border-radius:12px;border:1px solid #e5e7eb;overflow:hidden;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;color:#111827;">
|
<div style="max-width:640px;margin:0 auto;background:#ffffff;border-radius:12px;border:1px solid #e5e7eb;overflow:hidden;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;color:#111827;">
|
||||||
@@ -646,11 +582,8 @@ const avatarPrefix = ref("");
|
|||||||
const commentAdminEnabled = ref(false);
|
const commentAdminEnabled = ref(false);
|
||||||
const adminLayoutTitle = ref("CWD 评论系统");
|
const adminLayoutTitle = ref("CWD 评论系统");
|
||||||
const allowedDomainTags = ref<string[]>([]);
|
const allowedDomainTags = ref<string[]>([]);
|
||||||
const allowedDomainInput = ref("");
|
|
||||||
const blockedIpTags = ref<string[]>([]);
|
const blockedIpTags = ref<string[]>([]);
|
||||||
const blockedIpInput = ref("");
|
|
||||||
const blockedEmailTags = ref<string[]>([]);
|
const blockedEmailTags = ref<string[]>([]);
|
||||||
const blockedEmailInput = ref("");
|
|
||||||
const commentAdminKey = ref("");
|
const commentAdminKey = ref("");
|
||||||
const adminKeySet = ref(false);
|
const adminKeySet = ref(false);
|
||||||
const requireReview = ref(false);
|
const requireReview = ref(false);
|
||||||
@@ -703,135 +636,6 @@ watch(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
function addAllowedDomainsFromInput() {
|
|
||||||
const raw = allowedDomainInput.value;
|
|
||||||
if (!raw) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const parts = raw
|
|
||||||
.split(/[,,\s]+/)
|
|
||||||
.map((d) => d.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
if (parts.length === 0) {
|
|
||||||
allowedDomainInput.value = "";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const existing = new Set(allowedDomainTags.value);
|
|
||||||
for (const part of parts) {
|
|
||||||
if (!existing.has(part)) {
|
|
||||||
allowedDomainTags.value.push(part);
|
|
||||||
existing.add(part);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
allowedDomainInput.value = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleAllowedDomainKeyup(event: KeyboardEvent) {
|
|
||||||
if (
|
|
||||||
event.key === " " ||
|
|
||||||
event.key === "Spacebar" ||
|
|
||||||
event.key === "," ||
|
|
||||||
event.key === "," ||
|
|
||||||
event.key === "Enter"
|
|
||||||
) {
|
|
||||||
addAllowedDomainsFromInput();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleAllowedDomainBlur() {
|
|
||||||
addAllowedDomainsFromInput();
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeAllowedDomain(domain: string) {
|
|
||||||
allowedDomainTags.value = allowedDomainTags.value.filter((d) => d !== domain);
|
|
||||||
}
|
|
||||||
|
|
||||||
function addBlockedIpFromInput() {
|
|
||||||
const raw = blockedIpInput.value;
|
|
||||||
if (!raw) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const parts = raw
|
|
||||||
.split(/[,,\s]+/)
|
|
||||||
.map((d) => d.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
if (parts.length === 0) {
|
|
||||||
blockedIpInput.value = "";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const existing = new Set(blockedIpTags.value);
|
|
||||||
for (const part of parts) {
|
|
||||||
if (!existing.has(part)) {
|
|
||||||
blockedIpTags.value.push(part);
|
|
||||||
existing.add(part);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
blockedIpInput.value = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleBlockedIpKeyup(event: KeyboardEvent) {
|
|
||||||
if (
|
|
||||||
event.key === " " ||
|
|
||||||
event.key === "Spacebar" ||
|
|
||||||
event.key === "," ||
|
|
||||||
event.key === "," ||
|
|
||||||
event.key === "Enter"
|
|
||||||
) {
|
|
||||||
addBlockedIpFromInput();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleBlockedIpBlur() {
|
|
||||||
addBlockedIpFromInput();
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeBlockedIp(ip: string) {
|
|
||||||
blockedIpTags.value = blockedIpTags.value.filter((t) => t !== ip);
|
|
||||||
}
|
|
||||||
|
|
||||||
function addBlockedEmailFromInput() {
|
|
||||||
const raw = blockedEmailInput.value;
|
|
||||||
if (!raw) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const parts = raw
|
|
||||||
.split(/[,,\s]+/)
|
|
||||||
.map((d) => d.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
if (parts.length === 0) {
|
|
||||||
blockedEmailInput.value = "";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const existing = new Set(blockedEmailTags.value);
|
|
||||||
for (const part of parts) {
|
|
||||||
if (!existing.has(part)) {
|
|
||||||
blockedEmailTags.value.push(part);
|
|
||||||
existing.add(part);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
blockedEmailInput.value = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleBlockedEmailKeyup(event: KeyboardEvent) {
|
|
||||||
if (
|
|
||||||
event.key === " " ||
|
|
||||||
event.key === "Spacebar" ||
|
|
||||||
event.key === "," ||
|
|
||||||
event.key === "," ||
|
|
||||||
event.key === "Enter"
|
|
||||||
) {
|
|
||||||
addBlockedEmailFromInput();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleBlockedEmailBlur() {
|
|
||||||
addBlockedEmailFromInput();
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeBlockedEmail(email: string) {
|
|
||||||
blockedEmailTags.value = blockedEmailTags.value.filter((t) => t !== email);
|
|
||||||
}
|
|
||||||
|
|
||||||
const savingEmail = ref(false);
|
const savingEmail = ref(false);
|
||||||
const testingEmail = ref(false);
|
const testingEmail = ref(false);
|
||||||
const savingComment = ref(false);
|
const savingComment = ref(false);
|
||||||
@@ -934,15 +738,12 @@ async function load() {
|
|||||||
allowedDomainTags.value = domains
|
allowedDomainTags.value = domains
|
||||||
.map((d: string) => String(d).trim())
|
.map((d: string) => String(d).trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
allowedDomainInput.value = "";
|
|
||||||
blockedIpTags.value = Array.isArray(commentRes.blockedIps)
|
blockedIpTags.value = Array.isArray(commentRes.blockedIps)
|
||||||
? commentRes.blockedIps
|
? commentRes.blockedIps
|
||||||
: [];
|
: [];
|
||||||
blockedIpInput.value = "";
|
|
||||||
blockedEmailTags.value = Array.isArray(commentRes.blockedEmails)
|
blockedEmailTags.value = Array.isArray(commentRes.blockedEmails)
|
||||||
? commentRes.blockedEmails
|
? commentRes.blockedEmails
|
||||||
: [];
|
: [];
|
||||||
blockedEmailInput.value = "";
|
|
||||||
commentAdminKey.value = commentRes.adminKey || "";
|
commentAdminKey.value = commentRes.adminKey || "";
|
||||||
adminKeySet.value = !!commentRes.adminKeySet;
|
adminKeySet.value = !!commentRes.adminKeySet;
|
||||||
requireReview.value = !!commentRes.requireReview;
|
requireReview.value = !!commentRes.requireReview;
|
||||||
|
|||||||
Reference in New Issue
Block a user