feat(ui): 为导航菜单项添加图标并优化设置页的标签输入组件
- 在导航菜单项中引入图标组件,提升视觉识别性 - 将设置页中的文本域输入替换为交互式标签输入组件,支持添加/删除标签 - 新增标签输入组件的样式和交互逻辑,改善用户体验
This commit is contained in:
@@ -90,6 +90,83 @@
|
|||||||
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);
|
||||||
|
}
|
||||||
|
|
||||||
.toast {
|
.toast {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 20px;
|
top: 20px;
|
||||||
|
|||||||
@@ -152,6 +152,13 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item-icon {
|
||||||
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-item:hover {
|
.menu-item:hover {
|
||||||
|
|||||||
@@ -83,35 +83,40 @@
|
|||||||
:class="{ active: isRouteActive('comments') }"
|
:class="{ active: isRouteActive('comments') }"
|
||||||
@click="goComments"
|
@click="goComments"
|
||||||
>
|
>
|
||||||
评论管理
|
<PhChatCircleDots class="menu-item-icon" :size="18" />
|
||||||
|
<span>评论管理</span>
|
||||||
</li>
|
</li>
|
||||||
<li
|
<li
|
||||||
class="menu-item"
|
class="menu-item"
|
||||||
:class="{ active: isRouteActive('stats') }"
|
:class="{ active: isRouteActive('stats') }"
|
||||||
@click="goStats"
|
@click="goStats"
|
||||||
>
|
>
|
||||||
数据看板
|
<PhSquaresFour class="menu-item-icon" :size="18" />
|
||||||
|
<span>数据看板</span>
|
||||||
</li>
|
</li>
|
||||||
<li
|
<li
|
||||||
class="menu-item"
|
class="menu-item"
|
||||||
:class="{ active: isRouteActive('analytics') }"
|
:class="{ active: isRouteActive('analytics') }"
|
||||||
@click="goAnalytics"
|
@click="goAnalytics"
|
||||||
>
|
>
|
||||||
访问统计
|
<PhChartBar class="menu-item-icon" :size="18" />
|
||||||
|
<span>访问统计</span>
|
||||||
</li>
|
</li>
|
||||||
<li
|
<li
|
||||||
class="menu-item"
|
class="menu-item"
|
||||||
:class="{ active: isRouteActive('settings') }"
|
:class="{ active: isRouteActive('settings') }"
|
||||||
@click="goSettings"
|
@click="goSettings"
|
||||||
>
|
>
|
||||||
网站设置
|
<PhGear class="menu-item-icon" :size="18" />
|
||||||
|
<span>网站设置</span>
|
||||||
</li>
|
</li>
|
||||||
<li
|
<li
|
||||||
class="menu-item"
|
class="menu-item"
|
||||||
:class="{ active: isRouteActive('data') }"
|
:class="{ active: isRouteActive('data') }"
|
||||||
@click="goData"
|
@click="goData"
|
||||||
>
|
>
|
||||||
数据管理
|
<PhDatabase class="menu-item-icon" :size="18" />
|
||||||
|
<span>数据管理</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div class="layout-sider-footer" @click="openVersionModal">
|
<div class="layout-sider-footer" @click="openVersionModal">
|
||||||
|
|||||||
@@ -60,36 +60,90 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-item">
|
<div class="form-item">
|
||||||
<label class="form-label">
|
<label class="form-label">
|
||||||
允许调用的域名(多个域名用逗号分隔,留空则不限制。设置后仅匹配域名可调用前台评论组件。)
|
允许调用的域名(设置后仅匹配域名可调用前台评论组件,留空则不限制。使用空格或者逗号进行分割。)
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<div class="tag-input">
|
||||||
v-model="allowedDomains"
|
<div class="tag-input-inner">
|
||||||
class="form-input"
|
<span
|
||||||
rows="3"
|
v-for="domain in allowedDomainTags"
|
||||||
placeholder="例如: example.com, test.com"
|
:key="domain"
|
||||||
></textarea>
|
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>
|
||||||
<textarea
|
<div class="tag-input">
|
||||||
v-model="blockedIps"
|
<div class="tag-input-inner">
|
||||||
class="form-input"
|
<span
|
||||||
rows="3"
|
v-for="ip in blockedIpTags"
|
||||||
placeholder="例如: 1.1.1.1, 2.2.2.2"
|
:key="ip"
|
||||||
></textarea>
|
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
|
||||||
>
|
>
|
||||||
<textarea
|
<div class="tag-input">
|
||||||
v-model="blockedEmails"
|
<div class="tag-input-inner">
|
||||||
class="form-input"
|
<span
|
||||||
rows="3"
|
v-for="email in blockedEmailTags"
|
||||||
placeholder="例如: spam@example.com, bot@test.com"
|
:key="email"
|
||||||
></textarea>
|
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">
|
||||||
@@ -361,7 +415,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from "vue";
|
import { onMounted, ref, type Ref } from "vue";
|
||||||
import {
|
import {
|
||||||
fetchCommentSettings,
|
fetchCommentSettings,
|
||||||
saveCommentSettings,
|
saveCommentSettings,
|
||||||
@@ -456,9 +510,12 @@ const commentAdminEmail = ref("");
|
|||||||
const commentAdminBadge = ref("");
|
const commentAdminBadge = ref("");
|
||||||
const avatarPrefix = ref("");
|
const avatarPrefix = ref("");
|
||||||
const commentAdminEnabled = ref(false);
|
const commentAdminEnabled = ref(false);
|
||||||
const allowedDomains = ref("");
|
const allowedDomainTags = ref<string[]>([]);
|
||||||
const blockedIps = ref("");
|
const allowedDomainInput = ref("");
|
||||||
const blockedEmails = ref("");
|
const blockedIpTags = ref<string[]>([]);
|
||||||
|
const blockedIpInput = ref("");
|
||||||
|
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);
|
||||||
@@ -471,6 +528,135 @@ const savingTelegram = ref(false);
|
|||||||
const settingUpWebhook = ref(false);
|
const settingUpWebhook = ref(false);
|
||||||
const testingTelegram = ref(false);
|
const testingTelegram = ref(false);
|
||||||
|
|
||||||
|
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);
|
||||||
@@ -555,13 +741,21 @@ async function load() {
|
|||||||
commentAdminBadge.value = commentRes.adminBadge ?? "";
|
commentAdminBadge.value = commentRes.adminBadge ?? "";
|
||||||
avatarPrefix.value = commentRes.avatarPrefix || "";
|
avatarPrefix.value = commentRes.avatarPrefix || "";
|
||||||
commentAdminEnabled.value = !!commentRes.adminEnabled;
|
commentAdminEnabled.value = !!commentRes.adminEnabled;
|
||||||
allowedDomains.value = commentRes.allowedDomains
|
const domains = Array.isArray(commentRes.allowedDomains)
|
||||||
? commentRes.allowedDomains.join(", ")
|
? commentRes.allowedDomains
|
||||||
: "";
|
: [];
|
||||||
blockedIps.value = commentRes.blockedIps ? commentRes.blockedIps.join(", ") : "";
|
allowedDomainTags.value = domains
|
||||||
blockedEmails.value = commentRes.blockedEmails
|
.map((d: string) => String(d).trim())
|
||||||
? commentRes.blockedEmails.join(", ")
|
.filter(Boolean);
|
||||||
: "";
|
allowedDomainInput.value = "";
|
||||||
|
blockedIpTags.value = Array.isArray(commentRes.blockedIps)
|
||||||
|
? commentRes.blockedIps
|
||||||
|
: [];
|
||||||
|
blockedIpInput.value = "";
|
||||||
|
blockedEmailTags.value = Array.isArray(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;
|
||||||
@@ -686,20 +880,11 @@ async function saveComment() {
|
|||||||
adminBadge: commentAdminBadge.value,
|
adminBadge: commentAdminBadge.value,
|
||||||
avatarPrefix: avatarPrefix.value,
|
avatarPrefix: avatarPrefix.value,
|
||||||
adminEnabled: commentAdminEnabled.value,
|
adminEnabled: commentAdminEnabled.value,
|
||||||
allowedDomains: allowedDomains.value
|
allowedDomains: allowedDomainTags.value,
|
||||||
.split(/[,,\n]/)
|
|
||||||
.map((d) => d.trim())
|
|
||||||
.filter(Boolean),
|
|
||||||
adminKey: commentAdminKey.value || undefined,
|
adminKey: commentAdminKey.value || undefined,
|
||||||
requireReview: requireReview.value,
|
requireReview: requireReview.value,
|
||||||
blockedIps: blockedIps.value
|
blockedIps: blockedIpTags.value,
|
||||||
.split(/[,,\n]/)
|
blockedEmails: blockedEmailTags.value,
|
||||||
.map((d) => d.trim())
|
|
||||||
.filter(Boolean),
|
|
||||||
blockedEmails: blockedEmails.value
|
|
||||||
.split(/[,,\n]/)
|
|
||||||
.map((d) => d.trim())
|
|
||||||
.filter(Boolean),
|
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user