library-picturebook-activity/frontend/src/views/public/works/Detail.vue
aid 37bd82714d feat: 作品封面引入 PIP 画中画展示原图,详情页加全屏放大查看
- 大图为 AI 生成的绘本封面(coverUrl),右下角小图为用户上传的原图(originalImageUrl),形成"创作素材→AI 成果"的视觉对比
- 草稿等无 AI 封面的作品:大图为占位图,PIP 仍展示原图

涉及文件:
- works/Index.vue 作品库列表卡片加 PIP(右下角 34% 正方形 + 白边阴影)
- Gallery.vue 发现页卡片加同款 PIP
- mine/Favorites.vue 收藏列表加 PIP,type 加 originalImageUrl 字段
- components/WorkSelector.vue 作品选择器加更小尺寸 PIP(32%)
- works/Detail.vue 详情页新增「画作原图」独立卡片(左 84px 缩略图 + 右文字描述)
  · 点击缩略图全屏 overlay 放大查看,背景毛玻璃 + 紫黑半透明
  · hover 缩略图时显示放大镜图标
- _dev-mock.ts 5 条 mock 作品都加 originalImageUrl(不同 hue 区分),id=102 (draft) 的 coverUrl 设为 null 测试占位边界

兼容性:
- v-if 检查 originalImageUrl 不为空且与 coverUrl 不同,防止字段未拆分时显示重复
- 后端 originalImageUrl 字段为 null 时 PIP 不显示,老数据自动兼容

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 19:29:31 +08:00

967 lines
26 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

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="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>