783 lines
19 KiB
Vue
783 lines
19 KiB
Vue
<template>
|
||
<div class="admin-page">
|
||
<section class="card">
|
||
<div class="filter-row">
|
||
<input
|
||
v-model="filters.search"
|
||
class="input"
|
||
type="text"
|
||
placeholder="搜索标题 / 描述"
|
||
/>
|
||
<input v-model="filters.author" class="input" type="text" placeholder="作者用户名" />
|
||
<button class="btn" type="button" @click="loadArticles">查询</button>
|
||
<button class="btn ghost" type="button" @click="loadArticles" :disabled="loading">
|
||
{{ loading ? '刷新中...' : '刷新列表' }}
|
||
</button>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="card table-card">
|
||
<div v-if="loading" class="empty">文章列表加载中...</div>
|
||
<div v-else-if="articles.length === 0" class="empty">暂无文章</div>
|
||
<div v-else class="table-wrapper">
|
||
<table class="table">
|
||
<thead>
|
||
<tr>
|
||
<th>标题 / 描述</th>
|
||
<th style="width: 160px">标签</th>
|
||
<th>置顶</th>
|
||
<th>推荐</th>
|
||
<th style="width: 90px">权重</th>
|
||
<th>作者</th>
|
||
<th>浏览</th>
|
||
<th style="width: 220px">操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="article in articles" :key="article.slug">
|
||
<td>
|
||
<div class="article-title">{{ article.title }}</div>
|
||
<div class="user-meta">Slug:{{ article.slug }}</div>
|
||
<div v-if="article.description" class="article-desc">
|
||
{{ article.description }}
|
||
</div>
|
||
</td>
|
||
<td>
|
||
<div v-if="article.tagList?.length" class="tag-list">
|
||
<span
|
||
v-for="tag in article.tagList"
|
||
:key="`${article.slug}-${tag}`"
|
||
class="tag-chip"
|
||
>
|
||
{{ tag }}
|
||
</span>
|
||
</div>
|
||
<span v-else class="muted">--</span>
|
||
</td>
|
||
<td class="flag-cell">
|
||
<button
|
||
class="btn small"
|
||
:class="{ primary: article.isTop }"
|
||
type="button"
|
||
:disabled="savingFlags[article.slug]"
|
||
@click="toggleTop(article)"
|
||
>
|
||
{{ article.isTop ? '已置顶' : '置顶' }}
|
||
</button>
|
||
</td>
|
||
<td class="flag-cell">
|
||
<button
|
||
class="btn small"
|
||
:class="{ primary: article.isFeatured }"
|
||
type="button"
|
||
:disabled="savingFlags[article.slug]"
|
||
@click="toggleFeatured(article)"
|
||
>
|
||
{{ article.isFeatured ? '已推荐' : '推荐' }}
|
||
</button>
|
||
</td>
|
||
<td>
|
||
<div class="weight-cell">
|
||
<span class="weight-value">{{ article.sortWeight ?? 0 }}</span>
|
||
<button
|
||
class="btn small ghost"
|
||
type="button"
|
||
:disabled="savingFlags[article.slug]"
|
||
@click="setSortWeight(article)"
|
||
>
|
||
调整
|
||
</button>
|
||
</div>
|
||
</td>
|
||
<td>{{ article.author?.username || '未知' }}</td>
|
||
<td>{{ article.views ?? 0 }}</td>
|
||
<td class="actions">
|
||
<button class="btn small primary" type="button" @click="openEdit(article)">
|
||
编辑
|
||
</button>
|
||
<button class="btn small danger" type="button" @click="deleteArticle(article)">
|
||
删除
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- <section class="card">
|
||
<div class="menu-header">
|
||
<div>
|
||
<div class="menu-title">菜单标签配置</div>
|
||
<div class="menu-sub">为「资讯广场」「使用教程」分配标签(多标签 AND 匹配)</div>
|
||
</div>
|
||
<button class="btn ghost" type="button" @click="loadMenuSlots" :disabled="menuLoading">
|
||
{{ menuLoading ? '刷新中...' : '刷新配置' }}
|
||
</button>
|
||
</div>
|
||
|
||
<div v-if="tagsLoading" class="empty">加载标签...</div>
|
||
<div v-else class="menu-slots">
|
||
<div v-for="slot in menuSlots" :key="slot.slotKey" class="menu-slot">
|
||
<div class="menu-slot-title">{{ slot.label }}</div>
|
||
<div class="menu-slot-sub">
|
||
选择文章需要同时命中的标签(空为全部)
|
||
</div>
|
||
<select v-model="slot.tags" class="input" multiple>
|
||
<option v-for="tag in allTags" :key="tag" :value="tag">
|
||
{{ tag }}
|
||
</option>
|
||
</select>
|
||
<div class="menu-slot-actions">
|
||
<button
|
||
class="btn primary small"
|
||
type="button"
|
||
:disabled="menuSaving[slot.slotKey]"
|
||
@click="saveMenuSlot(slot)"
|
||
>
|
||
{{ menuSaving[slot.slotKey] ? '保存中...' : '保存标签' }}
|
||
</button>
|
||
<button class="btn small ghost" type="button" @click="clearSlotTags(slot)">
|
||
清空
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section> -->
|
||
|
||
<!-- 编辑文章 -->
|
||
<div v-if="showDialog && editing" class="modal-mask">
|
||
<div class="modal">
|
||
<div class="modal-header">
|
||
<h3>编辑文章</h3>
|
||
<button class="modal-close" type="button" @click="closeDialog">×</button>
|
||
</div>
|
||
<form class="form" @submit.prevent="submitEdit">
|
||
<label>标题</label>
|
||
<input v-model="editing.title" class="input" type="text" required />
|
||
|
||
<label>描述</label>
|
||
<textarea v-model="editing.description" class="input" rows="3" />
|
||
|
||
<div class="inline-flags">
|
||
<label class="checkbox-row">
|
||
<input v-model="editing.isTop" type="checkbox" />
|
||
<span>置顶</span>
|
||
</label>
|
||
<label class="checkbox-row">
|
||
<input v-model="editing.isFeatured" type="checkbox" />
|
||
<span>推荐</span>
|
||
</label>
|
||
</div>
|
||
|
||
<label>权重(数字越大越靠前)</label>
|
||
<input v-model.number="editing.sortWeight" class="input" type="number" />
|
||
|
||
<div class="modal-footer">
|
||
<button class="btn" type="button" @click="closeDialog" :disabled="saving">
|
||
取消
|
||
</button>
|
||
<button class="btn primary" type="submit" :disabled="saving">
|
||
{{ saving ? '保存中...' : '保存' }}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, onMounted, reactive, ref, watch, type Ref } from 'vue'
|
||
import { navigateTo } from '#app'
|
||
import { useAuth } from '@/composables/useAuth'
|
||
import { useAuthToken, useApi } from '@/composables/useApi'
|
||
|
||
definePageMeta({
|
||
layout: 'admin',
|
||
adminTitle: '文章管理',
|
||
adminSub: '按标题、描述或作者过滤,支持置顶、推荐与菜单标签配置',
|
||
})
|
||
|
||
interface ArticleItem {
|
||
slug: string
|
||
title: string
|
||
description: string | null
|
||
body?: string
|
||
tagList?: string[]
|
||
isTop?: boolean
|
||
isFeatured?: boolean
|
||
sortWeight?: number
|
||
createdAt?: string
|
||
updatedAt?: string
|
||
author: {
|
||
username: string
|
||
image?: string | null
|
||
}
|
||
views?: number
|
||
}
|
||
|
||
interface AdminArticlesResponse {
|
||
articles?: ArticleItem[]
|
||
}
|
||
|
||
interface TagsResponse {
|
||
tags?: string[]
|
||
}
|
||
|
||
interface MenuSlot {
|
||
slotKey: string
|
||
label: string
|
||
tags: string[]
|
||
}
|
||
|
||
interface MenuSlotsResponse {
|
||
slots?: MenuSlot[]
|
||
}
|
||
|
||
const menuDefaults: MenuSlot[] = [
|
||
{ slotKey: 'news', label: '资讯广场', tags: [] },
|
||
{ slotKey: 'tutorial', label: '使用教程', tags: [] },
|
||
]
|
||
|
||
const { token } = useAuthToken()
|
||
const { user: authUser, fetchMe } = useAuth() as {
|
||
user: Ref<{ roles?: string[] } | null>
|
||
fetchMe: () => Promise<unknown>
|
||
}
|
||
const api = useApi()
|
||
|
||
const articles = ref<ArticleItem[]>([])
|
||
const loading = ref(false)
|
||
const saving = ref(false)
|
||
const showDialog = ref(false)
|
||
const hasAccess = ref(false)
|
||
const savingFlags = reactive<Record<string, boolean>>({})
|
||
|
||
const filters = reactive({
|
||
search: '',
|
||
author: '',
|
||
})
|
||
|
||
const editing = ref<{
|
||
slug: string
|
||
title: string
|
||
description: string | null
|
||
isTop: boolean
|
||
isFeatured: boolean
|
||
sortWeight: number
|
||
} | null>(null)
|
||
|
||
const menuSlots = ref<MenuSlot[]>([...menuDefaults])
|
||
const menuSaving = reactive<Record<string, boolean>>({})
|
||
const menuLoading = ref(false)
|
||
const tagsLoading = ref(false)
|
||
const allTags = ref<string[]>([])
|
||
|
||
const isAdmin = computed(() => {
|
||
const rs = authUser.value?.roles
|
||
return Array.isArray(rs) && rs.includes('admin')
|
||
})
|
||
|
||
async function ensureAccess(redirect = true): Promise<boolean> {
|
||
if (!token.value) {
|
||
hasAccess.value = false
|
||
if (redirect) await navigateTo('/login')
|
||
return false
|
||
}
|
||
if (!authUser.value) {
|
||
try {
|
||
await fetchMe()
|
||
} catch (err) {
|
||
console.error('[Admin] fetch user failed', err)
|
||
}
|
||
}
|
||
const ok = isAdmin.value
|
||
hasAccess.value = ok
|
||
if (!ok && redirect) await navigateTo('/')
|
||
return ok
|
||
}
|
||
|
||
function normalizeArticle(item: ArticleItem): ArticleItem {
|
||
return {
|
||
...item,
|
||
isTop: Boolean(item.isTop),
|
||
isFeatured: Boolean(item.isFeatured),
|
||
sortWeight: Number(item.sortWeight ?? 0),
|
||
}
|
||
}
|
||
|
||
async function loadArticles(): Promise<void> {
|
||
if (!hasAccess.value) return
|
||
loading.value = true
|
||
try {
|
||
const query: Record<string, any> = { limit: 50, offset: 0 }
|
||
const kw = filters.search.trim()
|
||
if (kw) query.search = kw
|
||
const author = filters.author.trim()
|
||
if (author) query.author = author
|
||
const res = (await api.get('/admin/articles', query)) as AdminArticlesResponse
|
||
const list = Array.isArray(res.articles) ? res.articles : []
|
||
articles.value = list.map((a) => normalizeArticle(a))
|
||
} catch (err) {
|
||
console.error('[Admin] load articles failed', err)
|
||
alert('加载文章列表失败')
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
function openEdit(article: ArticleItem): void {
|
||
editing.value = {
|
||
slug: article.slug,
|
||
title: article.title,
|
||
description: article.description || '',
|
||
isTop: Boolean(article.isTop),
|
||
isFeatured: Boolean(article.isFeatured),
|
||
sortWeight: Number(article.sortWeight ?? 0),
|
||
}
|
||
showDialog.value = true
|
||
}
|
||
|
||
function closeDialog(): void {
|
||
editing.value = null
|
||
showDialog.value = false
|
||
}
|
||
|
||
async function submitEdit(): Promise<void> {
|
||
if (!hasAccess.value || !editing.value) return
|
||
saving.value = true
|
||
try {
|
||
await api.put(`/admin/articles/${editing.value.slug}`, {
|
||
article: {
|
||
title: editing.value.title,
|
||
description: editing.value.description,
|
||
isTop: editing.value.isTop,
|
||
isFeatured: editing.value.isFeatured,
|
||
sortWeight: editing.value.sortWeight,
|
||
},
|
||
})
|
||
closeDialog()
|
||
await loadArticles()
|
||
} catch (err: any) {
|
||
console.error('[Admin] save article failed', err)
|
||
alert(err?.statusMessage || '保存文章失败')
|
||
} finally {
|
||
saving.value = false
|
||
}
|
||
}
|
||
|
||
async function deleteArticle(article: ArticleItem): Promise<void> {
|
||
if (!hasAccess.value || !article?.slug) return
|
||
if (!confirm(`确定删除文章「${article.title}」吗?`)) return
|
||
try {
|
||
await api.del(`/admin/articles/${article.slug}`)
|
||
await loadArticles()
|
||
} catch (err: any) {
|
||
console.error('[Admin] delete article failed', err)
|
||
alert(err?.statusMessage || '删除文章失败')
|
||
}
|
||
}
|
||
|
||
async function updateArticleFlags(article: ArticleItem, patch: Partial<ArticleItem>): Promise<void> {
|
||
if (!hasAccess.value || !article?.slug) return
|
||
if (savingFlags[article.slug]) return
|
||
savingFlags[article.slug] = true
|
||
try {
|
||
await api.put(`/admin/articles/${article.slug}`, { article: patch })
|
||
Object.assign(article, normalizeArticle({ ...article, ...patch }))
|
||
} catch (err: any) {
|
||
console.error('[Admin] update flags failed', err)
|
||
alert(err?.statusMessage || '更新失败')
|
||
} finally {
|
||
delete savingFlags[article.slug]
|
||
}
|
||
}
|
||
|
||
async function toggleTop(article: ArticleItem): Promise<void> {
|
||
await updateArticleFlags(article, { isTop: !article.isTop })
|
||
}
|
||
|
||
async function toggleFeatured(article: ArticleItem): Promise<void> {
|
||
await updateArticleFlags(article, { isFeatured: !article.isFeatured })
|
||
}
|
||
|
||
async function setSortWeight(article: ArticleItem): Promise<void> {
|
||
const current = Number(article.sortWeight ?? 0)
|
||
const input = prompt('设置权重(整数,越大越靠前)', String(current))
|
||
if (input === null) return
|
||
const num = Number(input)
|
||
if (Number.isNaN(num)) {
|
||
alert('请输入数字')
|
||
return
|
||
}
|
||
await updateArticleFlags(article, { sortWeight: num })
|
||
}
|
||
|
||
async function loadAllTags(): Promise<void> {
|
||
tagsLoading.value = true
|
||
try {
|
||
const res = (await api.get('/tags')) as TagsResponse
|
||
allTags.value = Array.isArray(res.tags) ? res.tags : []
|
||
} catch (err) {
|
||
console.error('[Admin] load tags failed', err)
|
||
allTags.value = []
|
||
} finally {
|
||
tagsLoading.value = false
|
||
}
|
||
}
|
||
|
||
function mergeMenuSlots(serverSlots: MenuSlot[] | undefined): MenuSlot[] {
|
||
const serverMap = new Map<string, MenuSlot>()
|
||
for (const s of serverSlots || []) {
|
||
serverMap.set(s.slotKey, {
|
||
slotKey: s.slotKey,
|
||
label: s.label || s.slotKey,
|
||
tags: Array.isArray(s.tags) ? [...s.tags] : [],
|
||
})
|
||
}
|
||
return menuDefaults.map((d) => {
|
||
const matched = serverMap.get(d.slotKey)
|
||
return {
|
||
slotKey: d.slotKey,
|
||
label: matched?.label || d.label,
|
||
tags: matched?.tags || [],
|
||
}
|
||
})
|
||
}
|
||
|
||
async function loadMenuSlots(): Promise<void> {
|
||
if (!hasAccess.value) return
|
||
menuLoading.value = true
|
||
try {
|
||
const res = (await api.get('/admin/menu-slots')) as MenuSlotsResponse
|
||
menuSlots.value = mergeMenuSlots(res.slots)
|
||
} catch (err) {
|
||
console.error('[Admin] load menu slots failed', err)
|
||
alert('加载菜单标签配置失败')
|
||
} finally {
|
||
menuLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function saveMenuSlot(slot: MenuSlot): Promise<void> {
|
||
if (!hasAccess.value) return
|
||
if (menuSaving[slot.slotKey]) return
|
||
menuSaving[slot.slotKey] = true
|
||
try {
|
||
const res = (await api.put(`/admin/menu-slots/${slot.slotKey}`, {
|
||
slot: { tags: slot.tags, label: slot.label },
|
||
})) as { slot?: MenuSlot }
|
||
if (res?.slot) {
|
||
Object.assign(slot, {
|
||
tags: Array.isArray(res.slot.tags) ? res.slot.tags : [],
|
||
label: res.slot.label || slot.label,
|
||
})
|
||
}
|
||
} catch (err: any) {
|
||
console.error('[Admin] save menu slot failed', err)
|
||
alert(err?.statusMessage || '保存菜单标签失败')
|
||
} finally {
|
||
delete menuSaving[slot.slotKey]
|
||
}
|
||
}
|
||
|
||
function clearSlotTags(slot: MenuSlot): void {
|
||
slot.tags = []
|
||
}
|
||
|
||
onMounted(async () => {
|
||
if (await ensureAccess(true)) {
|
||
await Promise.all([loadArticles(), loadAllTags(), loadMenuSlots()])
|
||
}
|
||
})
|
||
|
||
watch(
|
||
() => authUser.value?.roles,
|
||
async () => {
|
||
if (await ensureAccess(false)) {
|
||
await Promise.all([loadArticles(), loadAllTags(), loadMenuSlots()])
|
||
}
|
||
},
|
||
)
|
||
|
||
watch(
|
||
() => token.value,
|
||
async () => {
|
||
if (await ensureAccess(false)) {
|
||
await Promise.all([loadArticles(), loadAllTags(), loadMenuSlots()])
|
||
}
|
||
},
|
||
)
|
||
</script>
|
||
|
||
<style scoped>
|
||
.admin-page {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
}
|
||
|
||
.card {
|
||
background: #ffffff;
|
||
border-radius: 16px;
|
||
border: 1px solid #e5e7eb;
|
||
padding: 14px 16px;
|
||
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.08);
|
||
}
|
||
|
||
.filter-row {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 10px;
|
||
align-items: center;
|
||
}
|
||
|
||
.input {
|
||
padding: 8px 10px;
|
||
border-radius: 10px;
|
||
border: 1px solid #d1d5db;
|
||
min-width: 180px;
|
||
font-size: 14px;
|
||
background: #fff;
|
||
}
|
||
|
||
.btn {
|
||
padding: 8px 14px;
|
||
border-radius: 999px;
|
||
border: 1px solid #d1d5db;
|
||
background: #fff;
|
||
cursor: pointer;
|
||
transition: all 0.18s ease;
|
||
}
|
||
|
||
.btn.primary {
|
||
background: #111827;
|
||
color: #fff;
|
||
border-color: #111827;
|
||
}
|
||
|
||
.btn.ghost {
|
||
border-color: #e5e7eb;
|
||
background: #fff;
|
||
}
|
||
|
||
.btn.danger {
|
||
color: #b91c1c;
|
||
border-color: #fecdd3;
|
||
background: #fff5f5;
|
||
}
|
||
|
||
.btn.small {
|
||
padding: 6px 10px;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.table-card {
|
||
padding: 10px 12px;
|
||
}
|
||
|
||
.table-wrapper {
|
||
overflow-x: auto;
|
||
}
|
||
|
||
.table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.table th,
|
||
.table td {
|
||
padding: 9px 10px;
|
||
border-bottom: 1px solid #f3f4f6;
|
||
text-align: left;
|
||
}
|
||
|
||
.table th {
|
||
color: #6b7280;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.article-title {
|
||
font-weight: 600;
|
||
color: #0f172a;
|
||
}
|
||
|
||
.article-desc {
|
||
margin-top: 4px;
|
||
color: #4b5563;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.user-meta {
|
||
margin-top: 2px;
|
||
color: #9ca3af;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.tag-list {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
}
|
||
|
||
.tag-chip {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
padding: 4px 8px;
|
||
border-radius: 12px;
|
||
background: #f3f4f6;
|
||
color: #4b5563;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.flag-cell {
|
||
min-width: 80px;
|
||
}
|
||
|
||
.weight-cell {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
|
||
.weight-value {
|
||
font-weight: 600;
|
||
color: #111827;
|
||
}
|
||
|
||
.actions {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
}
|
||
|
||
.empty {
|
||
padding: 18px 6px;
|
||
text-align: center;
|
||
color: #9ca3af;
|
||
}
|
||
|
||
.muted {
|
||
color: #9ca3af;
|
||
}
|
||
|
||
.modal-mask {
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(15, 23, 42, 0.55);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 30;
|
||
}
|
||
|
||
.modal {
|
||
width: min(520px, 94vw);
|
||
background: #fff;
|
||
border-radius: 16px;
|
||
padding: 16px;
|
||
box-shadow: 0 24px 60px rgba(15, 23, 42, 0.45);
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.modal-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.modal-close {
|
||
background: none;
|
||
border: none;
|
||
font-size: 20px;
|
||
cursor: pointer;
|
||
color: #6b7280;
|
||
}
|
||
|
||
.form {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
|
||
.form label {
|
||
font-size: 13px;
|
||
color: #6b7280;
|
||
}
|
||
|
||
.inline-flags {
|
||
display: flex;
|
||
gap: 12px;
|
||
margin-top: 4px;
|
||
}
|
||
|
||
.checkbox-row {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
font-size: 13px;
|
||
color: #374151;
|
||
}
|
||
|
||
.modal-footer {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: 8px;
|
||
margin-top: 10px;
|
||
}
|
||
|
||
.menu-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 10px;
|
||
}
|
||
|
||
.menu-title {
|
||
font-weight: 600;
|
||
font-size: 16px;
|
||
}
|
||
|
||
.menu-sub {
|
||
color: #6b7280;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.menu-slots {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||
gap: 12px;
|
||
margin-top: 12px;
|
||
}
|
||
|
||
.menu-slot {
|
||
border: 1px solid #e5e7eb;
|
||
border-radius: 12px;
|
||
padding: 12px;
|
||
background: #fafafa;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
|
||
.menu-slot-title {
|
||
font-weight: 600;
|
||
}
|
||
|
||
.menu-slot-sub {
|
||
font-size: 13px;
|
||
color: #6b7280;
|
||
}
|
||
|
||
.menu-slot-actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
}
|
||
</style>
|