library-picturebook-activity/frontend/src/views/public/works/Detail.vue

967 lines
26 KiB
Vue
Raw Normal View History

<template>
<div class="work-detail-page">
<a-spin :spinning="loading">
<template v-if="work">
<!-- 顶部 -->
<div class="detail-header">
<button class="back-btn" @click="$router.back()">
<left-outlined />
</button>
<h1>{{ work.title }}</h1>
<span :class="['status-tag', work.status]">{{ statusTextMap[work.status] }}</span>
</div>
<!-- 拒绝原因仅作者 + rejected-->
<div v-if="isOwner && work.status === 'rejected' && work.reviewNote" class="reject-card">
<warning-filled class="reject-icon" />
<div class="reject-body">
<div class="reject-title">未通过审核</div>
<div class="reject-content">{{ work.reviewNote }}</div>
</div>
</div>
<!-- 草稿提示仅作者 + draft-->
<div v-else-if="isOwner && work.status === 'draft'" class="info-card draft-card">
<info-circle-outlined class="info-icon" />
<div class="info-body">
<div class="info-title">这是一个未完成的草稿</div>
<div class="info-desc">继续完成创作后才能公开发布</div>
</div>
</div>
<!-- 未发布提示仅作者 + unpublished-->
<div v-else-if="isOwner && work.status === 'unpublished'" class="info-card unpublished-card">
<info-circle-outlined class="info-icon" />
<div class="info-body">
<div class="info-title">作品仅你自己可见</div>
<div class="info-desc">点击下方公开发布提交审核通过后将在发现页展示</div>
</div>
</div>
<!-- 绘本阅读器 -->
<div class="book-reader" v-if="work.pages && work.pages.length > 0">
<div class="page-display">
<img v-if="currentPageData?.imageUrl" :src="currentPageData.imageUrl" :alt="`第${currentPageIndex + 1}页`" class="page-image" />
<div v-else class="page-placeholder">
<picture-outlined />
</div>
</div>
<div class="page-text" v-if="currentPageData?.text">
{{ currentPageData.text }}
</div>
<div class="page-audio" v-if="currentPageData?.audioUrl">
<audio :src="currentPageData.audioUrl" controls class="audio-player"></audio>
</div>
<div class="page-nav">
<button class="nav-btn" :disabled="currentPageIndex === 0" @click="prevPage">
<left-outlined />
</button>
<span class="page-indicator">{{ currentPageIndex + 1 }} / {{ work.pages.length }}</span>
<button class="nav-btn" :disabled="currentPageIndex === work.pages.length - 1" @click="nextPage">
<right-outlined />
</button>
</div>
</div>
<!-- 画作原图 -->
<div v-if="work.originalImageUrl" class="original-card">
<div class="original-thumb" @click="previewOriginal = work.originalImageUrl || ''">
<img :src="work.originalImageUrl" alt="画作原图" />
<div class="zoom-hint"><zoom-in-outlined /></div>
</div>
<div class="original-text">
<div class="original-title">画作原图</div>
<div class="original-desc">AI 根据这张画作生成的绘本</div>
</div>
</div>
<!-- 作品信息 -->
<div class="info-section">
<div class="author-row">
<a-avatar :size="36" :src="work.creator?.avatar">
{{ work.creator?.nickname?.charAt(0) }}
</a-avatar>
<div class="author-info">
<span class="author-name">{{ work.creator?.nickname }}</span>
<span class="create-time">{{ formatDate(work.createTime) }}</span>
</div>
</div>
<div v-if="work.description" class="description">{{ work.description }}</div>
<div v-if="work.tags?.length" class="tags-row">
<span v-for="t in work.tags" :key="t.tag.id" class="info-tag">{{ t.tag.name }}</span>
</div>
</div>
<!-- 互动栏仅在已发布作品上显示 -->
<div v-if="work.status === 'published'" class="interaction-bar">
<div :class="['action-btn', { active: interaction.liked }]" @click="handleLike">
<heart-filled v-if="interaction.liked" />
<heart-outlined v-else />
<span>{{ displayLikeCount }}</span>
</div>
<div :class="['action-btn', { active: interaction.favorited }]" @click="handleFavorite">
<star-filled v-if="interaction.favorited" />
<star-outlined v-else />
<span>{{ displayFavoriteCount }}</span>
</div>
<div class="action-btn">
<eye-outlined />
<span>{{ work.viewCount || 0 }}</span>
</div>
</div>
<!-- 作者私有操作 -->
<div v-if="isOwner" class="owner-actions">
<!-- 主操作根据 status 切换 -->
<button
v-if="work.status === 'unpublished'"
class="op-btn primary"
:disabled="actionLoading"
@click="handlePublish"
>
<send-outlined />
<span>公开发布</span>
</button>
<button
v-else-if="work.status === 'rejected'"
class="op-btn primary"
:disabled="actionLoading"
@click="handleResubmit"
>
<send-outlined />
<span>修改后重交</span>
</button>
<button
v-else-if="work.status === 'draft'"
class="op-btn primary"
@click="handleContinue"
>
<edit-outlined />
<span>继续创作</span>
</button>
<button
v-else-if="work.status === 'pending_review'"
class="op-btn outline"
:disabled="actionLoading"
@click="handleWithdraw"
>
<undo-outlined />
<span>撤回审核</span>
</button>
<button
v-else-if="work.status === 'published'"
class="op-btn outline"
:disabled="actionLoading"
@click="handleUnpublish"
>
<inbox-outlined />
<span>下架</span>
</button>
<!-- 编辑信息unpublished 状态-->
<button
v-if="work.status === 'unpublished'"
class="op-btn outline-soft"
@click="handleEditInfo"
>
<edit-outlined />
<span>编辑信息</span>
</button>
<!-- 删除所有状态-->
<button
class="op-btn ghost-danger"
:disabled="actionLoading"
@click="handleDelete"
>
<delete-outlined />
<span>删除</span>
</button>
</div>
</template>
<a-empty v-else-if="!loading" description="作品不存在" style="padding: 80px 0" />
</a-spin>
<!-- 二次确认弹窗 -->
<a-modal
v-model:open="confirmVisible"
:title="confirmTitle"
:ok-text="confirmOkText"
cancel-text="取消"
:confirm-loading="actionLoading"
@ok="handleConfirmOk"
@cancel="handleConfirmCancel"
>
<p>{{ confirmContent }}</p>
</a-modal>
<!-- 原图全屏预览 -->
<Transition name="fade">
<div v-if="previewOriginal" class="preview-overlay" @click="previewOriginal = ''">
<img :src="previewOriginal" class="preview-full-img" alt="画作原图" />
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import {
LeftOutlined, RightOutlined,
HeartOutlined, HeartFilled,
StarOutlined, StarFilled,
EyeOutlined,
PictureOutlined,
WarningFilled,
InfoCircleOutlined,
SendOutlined,
EditOutlined,
UndoOutlined,
InboxOutlined,
DeleteOutlined,
ZoomInOutlined,
} from '@ant-design/icons-vue'
import {
publicUserWorksApi,
publicGalleryApi,
publicInteractionApi,
type UserWork,
} from '@/api/public'
import { getMockWorkDetail, isMockWorkId } from './_dev-mock'
import dayjs from 'dayjs'
const route = useRoute()
const router = useRouter()
const workId = Number(route.params.id)
const isDev = import.meta.env.DEV
const work = ref<UserWork | null>(null)
const loading = ref(true)
const currentPageIndex = ref(0)
const interaction = ref({ liked: false, favorited: false })
const actionLoading = ref(false)
const previewOriginal = ref('')
const currentPageData = computed(() => work.value?.pages?.[currentPageIndex.value] || null)
const isLoggedIn = computed(() => !!localStorage.getItem('public_token'))
const isOwner = computed(() => {
// dev mock 模式mock 作品默认是当前用户作品
if (isDev && work.value && isMockWorkId(work.value.id)) return true
const u = localStorage.getItem('public_user')
if (!u || !work.value) return false
try { return JSON.parse(u).id === work.value.userId } catch { return false }
})
const displayLikeCount = computed(() => work.value?.likeCount || 0)
const displayFavoriteCount = computed(() => work.value?.favoriteCount || 0)
const statusTextMap: Record<string, string> = {
draft: '草稿',
unpublished: '未发布',
pending_review: '审核中',
published: '已发布',
rejected: '被拒绝',
taken_down: '已下架',
}
const formatDate = (d: string) => dayjs(d).format('YYYY-MM-DD')
const prevPage = () => { if (currentPageIndex.value > 0) currentPageIndex.value-- }
const nextPage = () => { if (work.value?.pages && currentPageIndex.value < work.value.pages.length - 1) currentPageIndex.value++ }
// ─── 互动 ───
const handleLike = async () => {
if (!isLoggedIn.value) { router.push('/p/login'); return }
if (actionLoading.value) return
actionLoading.value = true
const wasLiked = interaction.value.liked
interaction.value.liked = !wasLiked
if (work.value) work.value.likeCount = (work.value.likeCount || 0) + (wasLiked ? -1 : 1)
try {
const res = await publicInteractionApi.like(workId)
interaction.value.liked = res.liked
if (work.value) work.value.likeCount = res.likeCount
} catch {
interaction.value.liked = wasLiked
if (work.value) work.value.likeCount = (work.value.likeCount || 0) + (wasLiked ? 1 : -1)
message.error('操作失败')
} finally {
actionLoading.value = false
}
}
const handleFavorite = async () => {
if (!isLoggedIn.value) { router.push('/p/login'); return }
if (actionLoading.value) return
actionLoading.value = true
const wasFavorited = interaction.value.favorited
interaction.value.favorited = !wasFavorited
if (work.value) work.value.favoriteCount = (work.value.favoriteCount || 0) + (wasFavorited ? -1 : 1)
try {
const res = await publicInteractionApi.favorite(workId)
interaction.value.favorited = res.favorited
if (work.value) work.value.favoriteCount = res.favoriteCount
} catch {
interaction.value.favorited = wasFavorited
if (work.value) work.value.favoriteCount = (work.value.favoriteCount || 0) + (wasFavorited ? 1 : -1)
message.error('操作失败')
} finally {
actionLoading.value = false
}
}
// ─── 作者操作 ───
const isMock = computed(() => isDev && work.value && isMockWorkId(work.value.id))
/** 公开发布unpublished → pending_review */
async function handlePublish() {
if (!work.value) return
actionLoading.value = true
try {
if (isMock.value) {
await new Promise(r => setTimeout(r, 300))
} else {
await publicUserWorksApi.publish(workId)
}
work.value.status = 'pending_review'
message.success('已提交审核,等待超管确认')
} catch (e: any) {
message.error(e.message || '发布失败')
} finally {
actionLoading.value = false
}
}
/** 修改后重交rejected → 跳到编辑信息页 */
function handleResubmit() {
// TODO: 真实场景需要 leai workId 跳到 EditInfoView等后端 work.leaiWorkId 字段确认后接入
message.info('编辑功能待后端联调dev 模式暂无法跳转')
}
/** 继续创作draft → 跳回创作流程 */
function handleContinue() {
router.push('/p/create')
}
/** 撤回审核pending_review → unpublished */
function handleWithdraw() {
showConfirm(
'撤回审核',
'撤回后作品将回到「未发布」状态,可继续编辑或重新提交审核',
'确认撤回',
async () => {
if (!work.value) return
actionLoading.value = true
try {
if (isMock.value) {
await new Promise(r => setTimeout(r, 300))
} else {
// TODO: 后端需要新增 POST /public/works/{id}/withdraw 接口
message.warning('撤回接口待后端联调')
return
}
work.value.status = 'unpublished'
message.success('已撤回审核')
} catch (e: any) {
message.error(e.message || '撤回失败')
} finally {
actionLoading.value = false
}
},
)
}
/** 下架作品published → unpublished */
function handleUnpublish() {
showConfirm(
'下架作品',
'下架后作品将从「发现」页移除,回到「未发布」状态。下架后仍可重新提交审核',
'确认下架',
async () => {
if (!work.value) return
actionLoading.value = true
try {
if (isMock.value) {
await new Promise(r => setTimeout(r, 300))
} else {
// TODO: 后端需要新增 POST /public/works/{id}/unpublish 接口
message.warning('下架接口待后端联调')
return
}
work.value.status = 'unpublished'
message.success('已下架到「未发布」')
} catch (e: any) {
message.error(e.message || '下架失败')
} finally {
actionLoading.value = false
}
},
)
}
/** 编辑信息:跳到 EditInfoView */
function handleEditInfo() {
// TODO: 真实场景需要 work.leaiWorkId 字段,等后端确认后接入
message.info('编辑信息功能待后端联调')
}
/** 删除作品 */
function handleDelete() {
showConfirm(
'删除作品',
'删除后无法恢复,确认要删除这个作品吗?',
'确认删除',
async () => {
actionLoading.value = true
try {
if (isMock.value) {
await new Promise(r => setTimeout(r, 300))
} else {
await publicUserWorksApi.delete(workId)
}
message.success('已删除')
router.push('/p/works')
} catch (e: any) {
message.error(e.message || '删除失败')
} finally {
actionLoading.value = false
}
},
)
}
// ─── 二次确认弹窗 ───
const confirmVisible = ref(false)
const confirmTitle = ref('')
const confirmContent = ref('')
const confirmOkText = ref('确认')
let confirmHandler: (() => Promise<void>) | null = null
function showConfirm(title: string, content: string, okText: string, handler: () => Promise<void>) {
confirmTitle.value = title
confirmContent.value = content
confirmOkText.value = okText
confirmHandler = handler
confirmVisible.value = true
}
async function handleConfirmOk() {
if (confirmHandler) {
await confirmHandler()
confirmHandler = null
}
confirmVisible.value = false
}
function handleConfirmCancel() {
confirmVisible.value = false
confirmHandler = null
}
// ─── 加载作品 ───
const fetchWork = async () => {
loading.value = true
// dev 兜底mock id 直接用 mock 数据
if (isDev && isMockWorkId(workId)) {
const mock = getMockWorkDetail(workId)
if (mock) {
work.value = mock
loading.value = false
return
}
}
try {
// 优先尝试广场接口(公开作品),失败再尝试自己作品库
try {
work.value = await publicGalleryApi.detail(workId)
} catch {
work.value = await publicUserWorksApi.detail(workId)
}
if (isLoggedIn.value) {
try {
interaction.value = await publicInteractionApi.getInteraction(workId)
} catch { /* 忽略 */ }
}
} catch {
// dev 兜底:真实接口失败时尝试 mock 数据
if (isDev) {
const mock = getMockWorkDetail(workId) || getMockWorkDetail(101)
if (mock) {
work.value = mock
} else {
message.error('获取作品详情失败')
}
} else {
message.error('获取作品详情失败')
}
} finally {
loading.value = false
}
}
onMounted(fetchWork)
</script>
<style scoped lang="scss">
$primary: #6366f1;
$accent: #ec4899;
.work-detail-page {
max-width: 600px;
margin: 0 auto;
padding-bottom: 24px;
}
/* ---------- 顶部 ---------- */
.detail-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
h1 {
font-size: 17px;
font-weight: 700;
color: #1e1b4b;
margin: 0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.back-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 50%;
background: rgba($primary, 0.08);
color: $primary;
border: none;
cursor: pointer;
transition: all 0.2s;
flex-shrink: 0;
:deep(.anticon) { font-size: 15px; }
&:hover { background: rgba($primary, 0.14); }
}
.status-tag {
display: inline-block;
padding: 3px 10px;
border-radius: 10px;
font-size: 11px;
font-weight: 700;
color: #fff;
letter-spacing: 0.3px;
&.draft { background: rgba(107, 114, 128, 0.85); }
&.unpublished { background: rgba(99, 102, 241, 0.9); }
&.pending_review { background: rgba(245, 158, 11, 0.92); }
&.published { background: rgba(16, 185, 129, 0.92); }
&.rejected { background: rgba(239, 68, 68, 0.92); }
&.taken_down { background: rgba(107, 114, 128, 0.85); }
}
/* ---------- 拒绝原因 / 信息提示卡片 ---------- */
.reject-card,
.info-card {
display: flex;
gap: 12px;
padding: 14px 16px;
border-radius: 14px;
margin-bottom: 14px;
}
.reject-card {
background: rgba(239, 68, 68, 0.06);
border: 1px solid rgba(239, 68, 68, 0.2);
}
.reject-icon {
font-size: 18px;
color: #ef4444;
flex-shrink: 0;
margin-top: 2px;
}
.reject-body { flex: 1; }
.reject-title { font-size: 13px; font-weight: 700; color: #b91c1c; margin-bottom: 4px; }
.reject-content { font-size: 13px; color: #4b5563; line-height: 1.6; }
.info-card {
background: linear-gradient(135deg, rgba($primary, 0.08), rgba($accent, 0.05));
border: 1px solid rgba($primary, 0.15);
}
.info-icon {
font-size: 18px;
color: $primary;
flex-shrink: 0;
margin-top: 2px;
}
.info-body { flex: 1; }
.info-title { font-size: 13px; font-weight: 700; color: #1e1b4b; margin-bottom: 3px; }
.info-desc { font-size: 12px; color: #6b7280; line-height: 1.6; }
/* ---------- 画作原图卡片 ---------- */
.original-card {
display: flex;
align-items: center;
gap: 14px;
background: #fff;
border: 1px solid rgba($primary, 0.06);
border-radius: 16px;
padding: 14px;
margin-bottom: 14px;
box-shadow: 0 2px 12px rgba($primary, 0.05);
}
.original-thumb {
position: relative;
width: 84px;
aspect-ratio: 1 / 1;
border-radius: 12px;
overflow: hidden;
border: 2px solid rgba($primary, 0.18);
cursor: zoom-in;
flex-shrink: 0;
background: #f5f3ff;
transition: all 0.2s;
&:hover {
border-color: $primary;
transform: scale(1.03);
.zoom-hint { opacity: 1; }
}
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.zoom-hint {
position: absolute;
inset: 0;
background: rgba(15, 12, 41, 0.4);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
opacity: 0;
transition: opacity 0.2s;
}
}
.original-text {
flex: 1;
min-width: 0;
}
.original-title {
font-size: 14px;
font-weight: 700;
color: #1e1b4b;
margin-bottom: 4px;
}
.original-desc {
font-size: 12px;
color: #6b7280;
line-height: 1.5;
}
/* ---------- 全屏原图预览 ---------- */
.preview-overlay {
position: fixed;
inset: 0;
z-index: 999;
background: rgba(15, 12, 41, 0.88);
backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
cursor: zoom-out;
}
.preview-full-img {
max-width: 90%;
max-height: 80vh;
object-fit: contain;
border-radius: 16px;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.4);
}
.fade-enter-active,
.fade-leave-active { transition: opacity 0.2s; }
.fade-enter-from,
.fade-leave-to { opacity: 0; }
/* ---------- 绘本阅读器 ---------- */
.book-reader {
background: #fff;
border-radius: 16px;
overflow: hidden;
margin-bottom: 14px;
border: 1px solid rgba($primary, 0.06);
box-shadow: 0 2px 12px rgba($primary, 0.05);
.page-display {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
min-height: 200px;
background: #1e1b4b;
.page-image {
display: block;
max-width: 100%;
width: auto;
height: auto;
max-height: min(72vh, 85vw);
object-fit: contain;
}
.page-placeholder {
display: flex;
align-items: center;
justify-content: center;
min-height: 200px;
width: 100%;
color: rgba(255, 255, 255, 0.3);
font-size: 36px;
}
}
.page-text {
padding: 16px 20px;
font-size: 14px;
line-height: 1.8;
color: #374151;
border-top: 1px solid rgba($primary, 0.06);
}
.page-audio {
padding: 0 20px 12px;
.audio-player { width: 100%; height: 36px; }
}
.page-nav {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 20px;
border-top: 1px solid rgba($primary, 0.06);
.nav-btn {
width: 34px;
height: 34px;
border-radius: 50%;
border: 1px solid rgba($primary, 0.2);
background: #fff;
color: $primary;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
:deep(.anticon) { font-size: 14px; }
&:hover:not(:disabled) {
border-color: $primary;
background: rgba($primary, 0.04);
}
&:disabled {
opacity: 0.35;
cursor: not-allowed;
}
}
.page-indicator {
font-size: 13px;
color: #6b7280;
font-weight: 600;
letter-spacing: 0.5px;
}
}
}
/* ---------- 作品信息 ---------- */
.info-section {
background: #fff;
border-radius: 16px;
padding: 16px 18px;
border: 1px solid rgba($primary, 0.06);
box-shadow: 0 2px 12px rgba($primary, 0.05);
margin-bottom: 14px;
.author-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
.author-info { display: flex; flex-direction: column; }
.author-name { font-size: 14px; font-weight: 600; color: #1e1b4b; }
.create-time { font-size: 11px; color: #9ca3af; }
}
.description {
font-size: 13px;
color: #4b5563;
line-height: 1.7;
margin-bottom: 10px;
}
.tags-row {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.info-tag {
display: inline-block;
padding: 3px 10px;
border-radius: 10px;
background: rgba($primary, 0.08);
color: $primary;
font-size: 11px;
font-weight: 600;
border: 1px solid rgba($primary, 0.15);
}
}
/* ---------- 互动栏 ---------- */
.interaction-bar {
display: flex;
justify-content: space-around;
margin-bottom: 14px;
background: #fff;
border-radius: 16px;
padding: 12px 0;
border: 1px solid rgba($primary, 0.06);
box-shadow: 0 2px 12px rgba($primary, 0.05);
.action-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 18px;
border-radius: 22px;
font-size: 18px;
color: #9ca3af;
cursor: pointer;
transition: all 0.2s;
user-select: none;
span { font-size: 13px; font-weight: 500; }
&:hover {
background: rgba($primary, 0.04);
color: #6b7280;
}
&.active {
color: $accent;
&:hover { background: rgba($accent, 0.06); }
}
&.active :deep(.anticon) {
animation: pop 0.3s ease;
}
}
}
@keyframes pop {
0% { transform: scale(1); }
50% { transform: scale(1.3); }
100% { transform: scale(1); }
}
/* ---------- 作者私有操作区 ---------- */
.owner-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
background: #fff;
border-radius: 16px;
padding: 14px 16px;
border: 1px solid rgba($primary, 0.06);
box-shadow: 0 2px 12px rgba($primary, 0.05);
}
.op-btn {
flex: 1;
min-width: 100px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 11px 12px;
border-radius: 20px;
font-size: 13px;
font-weight: 700;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
:deep(.anticon) { font-size: 13px; }
&:active { transform: scale(0.97); }
&:disabled { opacity: 0.4; pointer-events: none; }
}
.op-btn.primary {
background: linear-gradient(135deg, $primary 0%, $accent 100%);
color: #fff;
border: none;
box-shadow: 0 4px 14px rgba($primary, 0.32);
&:hover { transform: translateY(-1px); box-shadow: 0 6px 18px rgba($primary, 0.4); }
}
.op-btn.outline {
background: #fff;
color: $primary;
border: 1.5px solid rgba($primary, 0.4);
&:hover {
border-color: $primary;
background: rgba($primary, 0.04);
}
}
.op-btn.outline-soft {
background: rgba($primary, 0.04);
color: $primary;
border: 1px solid rgba($primary, 0.2);
&:hover {
background: rgba($primary, 0.08);
border-color: rgba($primary, 0.4);
}
}
.op-btn.ghost-danger {
flex: 0 0 auto;
min-width: 0;
padding: 11px 16px;
background: transparent;
color: #ef4444;
border: 1px solid rgba(239, 68, 68, 0.2);
&:hover {
background: rgba(239, 68, 68, 0.04);
border-color: rgba(239, 68, 68, 0.4);
}
}
</style>