Files
template-nuxt4/app/pages/admin/articles.vue
2026-04-29 01:33:33 +08:00

860 lines
26 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="articles-page">
<div class="page-header">
<div>
<h2 class="page-title">📝 文章管理</h2>
<p class="page-desc">管理平台文章内容支持分类封面推荐与状态流转</p>
</div>
<a-space>
<a-button @click="navigateTo('/admin/article-categories')">
分类管理
</a-button>
<a-button type="primary" @click="handleCreate">
<template #icon><PlusOutlined /></template>
新增文章
</a-button>
<a-button :loading="loading" @click="loadArticles">
<template #icon><ReloadOutlined /></template>
刷新
</a-button>
</a-space>
</div>
<a-row :gutter="[16, 16]" class="mb-6">
<a-col v-for="stat in statCards" :key="stat.key" :md="6" :xs="12">
<div
:class="[
stat.color,
{
active:
(filterStatus === undefined && stat.key === -1) ||
filterStatus === stat.key,
},
]"
class="stat-card"
@click="handleStatFilter(stat.key)"
>
<div class="stat-icon">{{ stat.icon }}</div>
<div class="stat-info">
<div class="stat-value">{{ stat.value }}</div>
<div class="stat-label">{{ stat.label }}</div>
</div>
</div>
</a-col>
</a-row>
<div class="panel">
<div class="panel-header">
<span class="panel-title">📋 文章列表</span>
<a-space wrap>
<a-select v-model:value="filterStatus" style="width: 120px" @change="handleSearch">
<a-select-option :value="undefined">全部状态</a-select-option>
<a-select-option :value="0">已发布</a-select-option>
<a-select-option :value="1">待审核</a-select-option>
<a-select-option :value="2">已驳回</a-select-option>
<a-select-option :value="3">违规</a-select-option>
</a-select>
<a-select
v-model:value="filterCategoryId"
allow-clear
placeholder="全部分类"
style="width: 180px"
@change="handleSearch"
>
<a-select-option
v-for="item in categoryOptions"
:key="item.categoryId"
:value="item.categoryId"
>
{{ item.title }}
</a-select-option>
</a-select>
<a-input-search
v-model:value="searchKeyword"
placeholder="搜索标题 / 摘要 / 作者"
style="width: 240px"
@search="handleSearch"
/>
</a-space>
</div>
<a-table
:columns="columns"
:data-source="pagedArticles"
:loading="loading"
:pagination="tablePagination"
row-key="articleId"
size="middle"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'info'">
<div class="article-info-cell">
<img v-if="record.image" :src="record.image" class="article-thumb" />
<div v-else class="article-thumb-empty">📄</div>
<div class="article-info-text">
<div class="article-title">{{ record.title }}</div>
<div class="article-meta">
<span v-if="record.author"> {{ record.author }}</span>
<span v-if="resolveCategoryName(record)" class="meta-item">📁 {{ resolveCategoryName(record) }}</span>
</div>
<div class="article-overview">{{ record.overview || '暂无摘要' }}</div>
</div>
</div>
</template>
<template v-if="column.key === 'status'">
<a-tag :color="statusColor(record.status)">{{ statusText(record.status) }}</a-tag>
</template>
<template v-if="column.key === 'metrics'">
<div class="metrics-cell">
<div>👁 {{ record.actualViews || 0 }} 次阅读</div>
<div> {{ record.likes || 0 }} 点赞</div>
</div>
</template>
<template v-if="column.key === 'recommend'">
<a-switch
:checked="!!record.recommend"
size="small"
@change="(val: boolean) => handleToggleRecommend(record, val)"
/>
</template>
<template v-if="column.key === 'createTime'">
<span class="text-sm text-gray">{{ record.createTime?.substring(0, 10) || '-' }}</span>
</template>
<template v-if="column.key === 'action'">
<a-space>
<a-button size="small" type="link" @click="handleView(record)">预览</a-button>
<a-button size="small" type="link" @click="handleEdit(record)">编辑</a-button>
<a-popconfirm title="确认删除此文章?" @confirm="handleDelete(record)">
<a-button danger size="small" type="link">删除</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</div>
<a-modal
v-model:open="showFormModal"
:confirm-loading="saving"
:title="editingArticle?.articleId ? '编辑文章' : '新增文章'"
width="760px"
@cancel="showFormModal = false"
@ok="handleSave"
>
<a-form :model="formData" layout="vertical">
<a-form-item label="文章标题" required>
<a-input
v-model:value="formData.title"
:maxlength="200"
placeholder="请输入文章标题"
show-count
/>
</a-form-item>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="作者">
<a-input v-model:value="formData.author" placeholder="文章作者" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="来源">
<a-input v-model:value="formData.source" placeholder="文章来源" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="文章分类">
<a-select
v-model:value="formData.categoryId"
:options="categorySelectOptions"
allow-clear
option-filter-prop="label"
placeholder="请选择文章分类"
show-search
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="状态">
<a-select v-model:value="formData.status">
<a-select-option :value="0">已发布</a-select-option>
<a-select-option :value="1">待审核</a-select-option>
<a-select-option :value="2">已驳回</a-select-option>
<a-select-option :value="3">违规</a-select-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-form-item label="封面图">
<div class="cover-upload-wrap">
<div v-if="formData.image" class="cover-preview-card">
<img :src="formData.image" class="cover-preview-image" />
<div class="cover-preview-actions">
<a-button size="small" @click="handlePreviewImage(formData.image)">预览</a-button>
<a-button danger size="small" @click="handleRemoveCover">移除</a-button>
</div>
</div>
<a-upload
:before-upload="beforeImageUpload"
:custom-request="handleCoverUpload"
:show-upload-list="false"
accept="image/*"
>
<a-button :loading="imageUploading">上传封面</a-button>
</a-upload>
<div class="field-hint">支持 jpg/png/webp建议横版封面单张不超过 5MB</div>
</div>
</a-form-item>
<a-form-item label="文章摘要">
<a-textarea
v-model:value="formData.overview"
:maxlength="500"
:rows="3"
placeholder="文章简短描述"
show-count
/>
</a-form-item>
<a-form-item label="文章内容" required>
<div class="content-editor-wrap">
<div class="editor-tabs">
<a-radio-group v-model:value="editorMode" button-style="solid" size="small">
<a-radio-button value="edit">编辑</a-radio-button>
<a-radio-button value="preview">预览</a-radio-button>
</a-radio-group>
</div>
<div v-show="editorMode === 'edit'">
<MarkdownEditor
v-model="formData.content"
:show-preview="false"
min-height="320px"
placeholder="请输入 Markdown 内容,支持 # 标题、**加粗**、*斜体*、[链接](url)、![图片](url)、代码块等语法"
/>
</div>
<div v-show="editorMode === 'preview'" class="preview-only-mode">
<MarkdownRenderer v-if="formData.content" :content="formData.content" />
<div v-else class="empty-preview">暂无内容</div>
</div>
</div>
</a-form-item>
<a-form-item label="内容格式">
<a-tag :color="isMarkdown ? 'blue' : 'default'">
{{ isMarkdown ? 'Markdown' : '纯文本/HTML' }}
</a-tag>
<span class="format-hint">
当前编辑器支持 Markdown 语法编写
</span>
</a-form-item>
<a-form-item label="是否推荐">
<a-switch v-model:checked="formRecommend" />
<span class="switch-tip">推荐文章将优先出现在列表与前台推荐位</span>
</a-form-item>
</a-form>
</a-modal>
<a-modal
v-model:open="showPreviewModal"
:footer="null"
:title="previewData?.title || '文章预览'"
width="760px"
>
<template v-if="previewData">
<div class="preview-meta">
<a-tag :color="statusColor(previewData.status)">{{ statusText(previewData.status) }}</a-tag>
<a-tag v-if="previewData.recommend" color="gold">推荐</a-tag>
<a-tag v-if="previewData.categoryName" color="blue">{{ previewData.categoryName }}</a-tag>
<span class="preview-meta-text">{{ previewData.createTime?.substring(0, 16) || '-' }}</span>
</div>
<div v-if="previewData.image" class="preview-cover-wrap">
<img :src="previewData.image" class="preview-cover" />
</div>
<div v-if="previewData.overview" class="preview-summary">{{ previewData.overview }}</div>
<a-divider />
<div class="preview-content">
<MarkdownRenderer v-if="isPreviewMarkdown" :content="previewData.content" />
<div v-else v-html="previewData.content || previewData.overview || '暂无内容'"></div>
</div>
</template>
</a-modal>
<a-modal v-model:open="showImagePreview" :footer="null" title="封面预览" width="640px">
<img v-if="previewImageUrl" :src="previewImageUrl" class="image-preview-modal" />
</a-modal>
</div>
</template>
<script lang="ts" setup>
import { PlusOutlined, ReloadOutlined } from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
import {
listAppArticle as listCmsArticle,
addAppArticle as addCmsArticle,
updateAppArticle as updateCmsArticle,
removeAppArticle as removeCmsArticle,
} from '@/api/app/article'
import { listAppArticleCategory as listCmsArticleCategory } from '@/api/app/articleCategory'
import { uploadFile } from '@/api/system/file'
import type { AppArticle as CmsArticle } from '@/api/app/article/model'
import type { AppArticleCategory as CmsArticleCategory } from '@/api/app/articleCategory/model'
import MarkdownEditor from '@/components/admin/MarkdownEditor.vue'
import MarkdownRenderer from '@/components/admin/MarkdownRenderer.vue'
definePageMeta({ layout: 'admin' })
useHead({ title: '文章管理 - 平台管理' })
type UploadRequestOption = {
file?: File
onSuccess?: (body: unknown, file: File) => void
onError?: (err: unknown) => void
}
const ANNOUNCE_MODEL = 'announcement'
const loading = ref(false)
const imageUploading = ref(false)
const allArticles = ref<CmsArticle[]>([])
const categoryOptions = ref<CmsArticleCategory[]>([])
const filterStatus = ref<number | undefined>(undefined)
const filterCategoryId = ref<number | undefined>(undefined)
const searchKeyword = ref('')
const pagination = reactive({
current: 1,
pageSize: 20,
showSizeChanger: true,
showQuickJumper: true,
})
const statCards = reactive([
{ key: 0, icon: '✅', label: '已发布', value: 0, color: 'green' },
{ key: 1, icon: '⏳', label: '待审核', value: 0, color: 'orange' },
{ key: 2, icon: '❌', label: '已驳回', value: 0, color: 'red' },
{ key: -1, icon: '📝', label: '全部文章', value: 0, color: 'blue' },
])
const columns = [
{ title: '文章信息', key: 'info', width: 360 },
{ title: '状态', key: 'status', width: 110 },
{ title: '数据', key: 'metrics', width: 120 },
{ title: '推荐', key: 'recommend', width: 80 },
{ title: '创建时间', key: 'createTime', width: 120 },
{ title: '操作', key: 'action', width: 170 },
]
const showFormModal = ref(false)
const saving = ref(false)
const editingArticle = ref<CmsArticle | null>(null)
const formData = reactive<CmsArticle>({
title: '',
author: '',
source: '',
overview: '',
content: '',
status: 0,
categoryId: undefined,
image: '',
})
const formRecommend = ref(false)
const editorMode = ref<'edit' | 'preview'>('edit')
const isMarkdown = computed(() => {
return formData.content && (
/[#*`_\[\]()!>-]/.test(formData.content) ||
/^(#{1,6}\s|[-*]\s|\d+\.\s|>)/m.test(formData.content)
)
})
const isPreviewMarkdown = computed(() => {
if (!previewData.value?.content) return false
return (
/[#*`_\[\]()!>-]/.test(previewData.value.content) ||
/^(#{1,6}\s|[-*]\s|\d+\.\s|>)/m.test(previewData.value.content)
)
})
const showPreviewModal = ref(false)
const previewData = ref<CmsArticle | null>(null)
const showImagePreview = ref(false)
const previewImageUrl = ref('')
const categorySelectOptions = computed(() =>
categoryOptions.value.map(item => ({
value: item.categoryId,
label: item.title || `分类 ${item.categoryId}`,
}))
)
const standardArticles = computed(() => allArticles.value.filter(isStandardArticle))
const filteredArticles = computed(() => {
const keyword = searchKeyword.value.trim().toLowerCase()
return [...standardArticles.value]
.filter(item => filterStatus.value === undefined || item.status === filterStatus.value)
.filter(item => filterCategoryId.value === undefined || item.categoryId === filterCategoryId.value)
.filter(item => {
if (!keyword) return true
return [item.title, item.overview, item.author, item.source]
.some(value => String(value || '').toLowerCase().includes(keyword))
})
.sort((a, b) => {
const timeA = a.createTime || ''
const timeB = b.createTime || ''
if (timeA && timeB && timeA !== timeB) {
return timeB.localeCompare(timeA)
}
return (b.articleId || 0) - (a.articleId || 0)
})
})
const pagedArticles = computed(() => {
const start = (pagination.current - 1) * pagination.pageSize
return filteredArticles.value.slice(start, start + pagination.pageSize)
})
const tablePagination = computed(() => ({
current: pagination.current,
pageSize: pagination.pageSize,
total: filteredArticles.value.length,
showSizeChanger: pagination.showSizeChanger,
showQuickJumper: pagination.showQuickJumper,
}))
async function loadCategories(silent = false) {
try {
const list = await listCmsArticleCategory({ status: 0 })
categoryOptions.value = (list || [])
.filter(item => item.categoryId)
.sort((a, b) => (a.sortNumber || 0) - (b.sortNumber || 0))
} catch (e: any) {
if (!silent) {
message.error(e?.message || '加载文章分类失败')
}
}
}
async function loadArticles() {
loading.value = true
try {
const list = await listCmsArticle()
allArticles.value = list || []
ensurePaginationInRange()
updateStats()
} catch (e: any) {
message.error(e?.message || '加载文章列表失败')
} finally {
loading.value = false
}
}
function updateStats() {
const list = standardArticles.value
statCards[0].value = list.filter(item => item.status === 0).length
statCards[1].value = list.filter(item => item.status === 1).length
statCards[2].value = list.filter(item => item.status === 2).length
statCards[3].value = list.length
}
function ensurePaginationInRange() {
const total = filteredArticles.value.length
const maxPage = Math.max(1, Math.ceil(total / pagination.pageSize))
if (pagination.current > maxPage) {
pagination.current = maxPage
}
}
function isStandardArticle(item: CmsArticle) {
return (item.model || '').trim() !== ANNOUNCE_MODEL
}
function handleStatFilter(key: number) {
filterStatus.value = key === -1 ? undefined : key
pagination.current = 1
}
function handleSearch() {
pagination.current = 1
}
function handleTableChange(pag: { current: number; pageSize: number }) {
pagination.current = pag.current
pagination.pageSize = pag.pageSize
ensurePaginationInRange()
}
function resetForm() {
Object.assign(formData, {
articleId: undefined,
title: '',
author: '',
source: '',
overview: '',
content: '',
status: 0,
categoryId: undefined,
image: '',
})
formRecommend.value = false
}
function handleCreate() {
editingArticle.value = null
resetForm()
showFormModal.value = true
}
function handleEdit(record: CmsArticle) {
editingArticle.value = record
Object.assign(formData, {
articleId: record.articleId,
title: record.title || '',
author: record.author || '',
source: record.source || '',
overview: record.overview || '',
content: record.content || '',
status: record.status ?? 0,
categoryId: record.categoryId,
image: record.image || '',
})
formRecommend.value = !!record.recommend
showFormModal.value = true
}
function handleView(record: CmsArticle) {
previewData.value = {
...record,
categoryName: resolveCategoryName(record),
}
showPreviewModal.value = true
}
async function handleSave() {
if (!formData.title?.trim()) {
message.warning('请输入文章标题')
return
}
if (!formData.content?.trim()) {
message.warning('请输入文章内容')
return
}
saving.value = true
try {
const data: CmsArticle = {
...formData,
model: undefined,
categoryName: resolveCategoryNameById(formData.categoryId),
recommend: formRecommend.value ? 1 : 0,
}
if (editingArticle.value?.articleId) {
await updateCmsArticle(data)
message.success('文章已更新')
} else {
await addCmsArticle(data)
message.success('文章已创建')
}
showFormModal.value = false
await loadArticles()
} catch (e: any) {
message.error(e?.message || '保存失败')
} finally {
saving.value = false
}
}
async function handleDelete(record: CmsArticle) {
try {
await removeCmsArticle(record.articleId)
message.success('文章已删除')
await loadArticles()
} catch (e: any) {
message.error(e?.message || '删除失败')
}
}
async function handleToggleRecommend(record: CmsArticle, val: boolean) {
try {
await updateCmsArticle({ articleId: record.articleId, recommend: val ? 1 : 0 })
message.success(val ? '已加入推荐' : '已取消推荐')
await loadArticles()
} catch (e: any) {
message.error(e?.message || '操作失败')
}
}
function beforeImageUpload(file: File) {
if (!file.type.startsWith('image/')) {
message.error('只能上传图片文件')
return false
}
if (file.size > 5 * 1024 * 1024) {
message.error('图片大小不能超过 5MB')
return false
}
return true
}
async function handleCoverUpload(option: UploadRequestOption) {
const rawFile = option.file
if (!rawFile) return
imageUploading.value = true
try {
const record = await uploadFile(rawFile)
const url = (record?.url || record?.downloadUrl || '').trim()
if (!url) throw new Error('上传成功但未返回图片地址')
formData.image = url
option.onSuccess?.(record, rawFile)
message.success('封面上传成功')
} catch (e) {
option.onError?.(e)
message.error(e instanceof Error ? e.message : '封面上传失败')
} finally {
imageUploading.value = false
}
}
function handleRemoveCover() {
formData.image = ''
}
function handlePreviewImage(url?: string) {
if (!url) return
previewImageUrl.value = url
showImagePreview.value = true
}
function resolveCategoryName(record: CmsArticle) {
return record.categoryName || resolveCategoryNameById(record.categoryId)
}
function resolveCategoryNameById(categoryId?: number) {
if (!categoryId) return ''
return categoryOptions.value.find(item => item.categoryId === categoryId)?.title || ''
}
function statusText(status?: number) {
const map: Record<number, string> = {
0: '已发布',
1: '待审核',
2: '已驳回',
3: '违规',
}
return map[status ?? -1] || '-'
}
function statusColor(status?: number) {
const map: Record<number, string> = {
0: 'success',
1: 'orange',
2: 'error',
3: 'volcano',
}
return map[status ?? -1] || 'default'
}
watch([filteredArticles, () => pagination.pageSize], () => {
ensurePaginationInRange()
})
onMounted(async () => {
await loadCategories(true)
await loadArticles()
})
</script>
<style scoped>
.articles-page { min-height: 100%; }
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.page-title { font-size: 18px; font-weight: 700; color: #1f2937; margin: 0; }
.page-desc { font-size: 13px; color: #9ca3af; margin: 2px 0 0; }
.stat-card {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
border-radius: 12px;
border: 2px solid transparent;
cursor: pointer;
transition: all 0.2s;
}
.stat-card:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); }
.stat-card.active { box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); }
.stat-card.blue { background: #eff6ff; border-color: #dbeafe; }
.stat-card.green { background: #f0fdf4; border-color: #bbf7d0; }
.stat-card.orange { background: #fff7ed; border-color: #fed7aa; }
.stat-card.red { background: #fff1f2; border-color: #fecdd3; }
.stat-card.active.blue { border-color: #3b82f6; }
.stat-card.active.green { border-color: #22c55e; }
.stat-card.active.orange { border-color: #f97316; }
.stat-card.active.red { border-color: #ef4444; }
.stat-icon { font-size: 28px; flex-shrink: 0; }
.stat-value { font-size: 22px; font-weight: 700; color: rgba(0, 0, 0, 0.85); line-height: 1.2; }
.stat-label { font-size: 12px; color: rgba(0, 0, 0, 0.45); margin-top: 2px; }
.panel { background: #fff; border: 1px solid #f0f0f0; border-radius: 12px; overflow: hidden; }
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
border-bottom: 1px solid #f5f5f5;
flex-wrap: wrap;
gap: 10px;
}
.panel-title { font-size: 14px; font-weight: 600; color: rgba(0, 0, 0, 0.85); }
.article-info-cell { display: flex; align-items: flex-start; gap: 12px; }
.article-thumb {
width: 72px;
height: 48px;
border-radius: 8px;
object-fit: cover;
flex-shrink: 0;
border: 1px solid #f0f0f0;
}
.article-thumb-empty {
width: 72px;
height: 48px;
border-radius: 8px;
background: #f3f4f6;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
flex-shrink: 0;
}
.article-info-text { flex: 1; min-width: 0; }
.article-title {
font-size: 14px;
font-weight: 500;
color: rgba(0, 0, 0, 0.85);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.article-meta {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
margin-top: 4px;
}
.meta-item { margin-left: 8px; }
.article-overview {
margin-top: 4px;
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.metrics-cell { font-size: 12px; color: rgba(0, 0, 0, 0.45); line-height: 1.7; }
.cover-upload-wrap { display: flex; flex-direction: column; gap: 10px; }
.cover-preview-card {
width: 220px;
padding: 8px;
border: 1px dashed #d9d9d9;
border-radius: 10px;
background: #fafafa;
}
.cover-preview-image {
width: 100%;
height: 124px;
object-fit: cover;
border-radius: 8px;
display: block;
}
.cover-preview-actions { display: flex; gap: 8px; margin-top: 8px; }
.field-hint { font-size: 12px; color: rgba(0, 0, 0, 0.45); }
.switch-tip { margin-left: 8px; font-size: 12px; color: rgba(0, 0, 0, 0.45); }
.content-editor-wrap {
border: 1px solid #d9d9d9;
border-radius: 8px;
overflow: hidden;
}
.editor-tabs {
padding: 8px 12px;
background: #fafafa;
border-bottom: 1px solid #f0f0f0;
}
.preview-only-mode {
padding: 16px;
min-height: 320px;
background: #fff;
}
.empty-preview {
color: rgba(0, 0, 0, 0.25);
font-style: italic;
text-align: center;
padding: 60px 0;
}
.format-hint {
margin-left: 8px;
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
.preview-meta { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.preview-meta-text { font-size: 12px; color: rgba(0, 0, 0, 0.45); }
.preview-cover-wrap { margin: 16px 0 12px; }
.preview-cover {
width: 100%;
max-height: 320px;
object-fit: cover;
border-radius: 12px;
border: 1px solid #f0f0f0;
}
.preview-summary {
margin-top: 12px;
padding: 12px 14px;
background: #fafafa;
border-radius: 10px;
color: rgba(0, 0, 0, 0.65);
line-height: 1.7;
}
.preview-content {
font-size: 15px;
line-height: 1.8;
color: rgba(0, 0, 0, 0.85);
}
.image-preview-modal {
width: 100%;
max-height: 70vh;
object-fit: contain;
border-radius: 8px;
}
.text-sm { font-size: 12px; }
.text-gray { color: rgba(0, 0, 0, 0.45); }
.mb-6 { margin-bottom: 24px; }
</style>