feat: 小程序页面优化和新增剧本库功能

This commit is contained in:
2026-05-30 20:53:18 +08:00
parent acca1d84f3
commit 094953f1c3
7 changed files with 1672 additions and 996 deletions
@@ -0,0 +1,829 @@
<template>
<view class="script-library">
<view class="page-head">
<view class="back-title" @click="backToScript">
<text class="back-arrow"></text>
<text class="back-text">返回</text>
</view>
<view class="head-actions">
<view class="circle-btn" @click="openSearch">
<view class="search-icon"></view>
</view>
<view class="circle-btn" @click="openMoreMenu">
<view class="more-icon">
<view></view>
<view></view>
<view></view>
</view>
</view>
</view>
</view>
<view class="type-tabs">
<text
v-for="tab in typeTabs"
:key="tab.value"
class="type-tab"
:class="{ active: activeType === tab.value }"
@click="activeType = tab.value"
>{{ tab.label }}</text>
<view class="new-script" @click="createScript">
<text class="plus"></text>
<text>新建剧本</text>
</view>
</view>
<view class="filter-bar">
<scroll-view class="status-scroll" scroll-x :show-scrollbar="false">
<view class="status-row">
<text
v-for="filter in statusFilters"
:key="filter.value"
class="status-chip"
:class="{ active: activeStatus === filter.value }"
@click="activeStatus = filter.value"
>{{ filter.label }}</text>
</view>
</scroll-view>
<view class="sort-tools">
<text class="sort-text" @click="toggleSort">{{ sortLabel }}</text>
<view class="grid-icon" :class="{ active: viewMode === 'grid' }" @click="toggleViewMode">
<view v-for="i in 4" :key="i"></view>
</view>
</view>
</view>
<view v-if="visibleScripts.length" class="script-list" :class="{ grid: viewMode === 'grid' }">
<view
v-for="(script, index) in visibleScripts"
:key="script.id || index"
class="script-card"
@click="viewScript(script)"
>
<view class="cover" :class="'cover-' + (index % 6)">
<text>{{ getInitial(script) }}</text>
</view>
<view class="card-main">
<view class="card-top">
<view class="title-wrap">
<text class="script-title">{{ script.title }}</text>
<text class="length-badge">{{ getLengthLabel(script.length) }}</text>
</view>
<view class="right-state">
<text class="state-pill" :class="'state-' + getStatus(script)">{{ getStatusLabel(script) }}</text>
<text class="ellipsis" @click.stop="openScriptMenu(script)"></text>
</view>
</view>
<view class="tag-row">
<text v-for="tag in getTags(script)" :key="tag" class="tag">{{ tag }}</text>
</view>
<text class="summary">{{ script.summary || script.content || '一段正在生成中的平行人生剧本。' }}</text>
<view class="meta-row">
<text>{{ getChapterCount(script) }}</text>
<text>|</text>
<text>{{ getWordCount(script) }}</text>
<text>|</text>
<text>{{ getDateText(script) }}</text>
</view>
<view v-if="getStatus(script) === 'progress'" class="progress-row">
<view class="progress-track">
<view class="progress-fill" :style="{ width: getProgress(script) + '%' }"></view>
</view>
<text>{{ getProgress(script) }}%</text>
</view>
<view v-else-if="isFavorite(script)" class="favorite-row">
<text class="favorite-star"></text>
<text>已收藏</text>
</view>
</view>
</view>
</view>
<view v-else class="empty-card">
<view class="empty-book">
<view></view>
<view></view>
</view>
<text class="empty-title">还没有人生剧本</text>
<text class="empty-text">去爽文生成页写下一句灵感生成你的第一段平行人生</text>
<view class="empty-action" @click="createScript">新建剧本</view>
</view>
</view>
</template>
<script setup>
import { computed, ref } from 'vue'
import { useAppStore } from '../../stores/app.js'
const store = useAppStore()
const activeType = ref('long')
const activeStatus = ref('all')
const keyword = ref('')
const sortMode = ref('updated')
const viewMode = ref('list')
const localFavorites = ref(uni.getStorageSync('script_favorites') || {})
const typeTabs = [
{ label: '长篇', value: 'long' },
{ label: '短篇', value: 'short' },
{ label: '风格', value: 'style' }
]
const statusFilters = [
{ label: '全部', value: 'all' },
{ label: '进行中', value: 'progress' },
{ label: '已完成', value: 'done' },
{ label: '草稿箱', value: 'draft' },
{ label: '收藏夹', value: 'favorite' }
]
const fallbackScripts = [
{
id: 'demo-1',
title: '逆袭人生:从低谷到巅峰',
length: 'long',
status: 'progress',
tags: ['逆袭成长', '都市', '事业', '热血'],
summary: '从被分手、被否定的低谷开始,凭借天赋、努力与智慧,一步步逆袭成为行业巅峰,收获事业、财富...',
chapterCount: 28,
wordCount: 128000,
updatedAt: '今天 21:30',
progress: 28
},
{
id: 'demo-2',
title: '如果那年我没有放弃',
length: 'long',
status: 'done',
tags: ['成长治愈', '校园', '爱情', '温暖'],
summary: '重回十八岁,弥补遗憾,勇敢追梦,守护那些曾经错过的人和事。',
chapterCount: 36,
wordCount: 156000,
completedAt: '2025.05.10',
isFavorite: true
},
{
id: 'demo-3',
title: '重生之我在未来等你',
length: 'long',
status: 'progress',
tags: ['重生', '科幻', '爱情', '未来'],
summary: '一觉醒来,回到十年前的那一天。这一次,我不仅要改变自己的人生,还要找到你。',
chapterCount: 18,
wordCount: 83000,
updatedAt: '昨天 18:47',
progress: 46
},
{
id: 'demo-4',
title: '天才作曲家的璀璨之路',
length: 'long',
status: 'draft',
tags: ['音乐', '励志', '天赋', '梦想'],
summary: '从默默无闻到享誉全球,用音符征服世界,写下属于自己的传奇乐章。',
chapterCount: 9,
wordCount: 31000,
createdAt: '2025.05.08'
},
{
id: 'demo-5',
title: '咖啡馆里的奇遇',
length: 'short',
status: 'done',
tags: ['生活', '治愈', '奇幻', '温暖'],
summary: '一杯咖啡,一次奇遇,改变了我平凡的生活,也让我遇见了最特别的你。',
chapterCount: 1,
wordCount: 23000,
completedAt: '2025.05.01',
isFavorite: true
},
{
id: 'demo-6',
title: '赛博时代的追光者',
length: 'long',
status: 'draft',
tags: ['科幻', '未来', '冒险', '热血'],
summary: '在数据与代码构建的世界里,我追寻光明,也在黑暗中寻找真正的自由。',
chapterCount: 3,
wordCount: 12000,
createdAt: '2025.05.12'
}
]
const scripts = computed(() => {
const list = store.scripts || []
return list.length ? list : fallbackScripts
})
const visibleScripts = computed(() => {
const filtered = scripts.value.filter(script => {
const status = getStatus(script)
if (keyword.value) {
const haystack = [script.title, script.summary, script.content, script.style, ...(script.tags || [])].join(' ')
if (!haystack.includes(keyword.value)) return false
}
if (activeStatus.value === 'favorite') return isFavorite(script)
if (activeStatus.value !== 'all' && status !== activeStatus.value) return false
if (activeType.value === 'short') return script.length === 'short'
if (activeType.value === 'long') return script.length !== 'short'
return true
})
return [...filtered].sort((a, b) => {
if (sortMode.value === 'words') return Number(b.wordCount || 0) - Number(a.wordCount || 0)
if (sortMode.value === 'progress') return getProgress(b) - getProgress(a)
return String(b.updateTime || b.updatedAt || b.createTime || b.date || '').localeCompare(String(a.updateTime || a.updatedAt || a.createTime || a.date || ''))
})
})
const sortLabel = computed(() => {
const map = { updated: '最近更新⌄', words: '字数最多⌄', progress: '进度最高⌄' }
return map[sortMode.value] || '最近更新⌄'
})
const getStatus = (script) => {
if (script.status) return script.status
if (script.isDraft) return 'draft'
if (script.isCompleted || script.completedAt) return 'done'
return script.progress ? 'progress' : 'done'
}
const getStatusLabel = (script) => {
const map = { progress: '进行中', done: '已完成', draft: '草稿' }
return map[getStatus(script)] || '已完成'
}
const getLengthLabel = (length) => {
return length === 'short' ? '短篇' : '长篇'
}
const getTags = (script) => {
if (Array.isArray(script.tags) && script.tags.length) return script.tags.slice(0, 4)
return [script.style || '逆袭成长', '都市', '事业', '热血']
}
const getChapterCount = (script) => script.chapterCount || script.chapters || Math.max(1, Math.round((script.wordCount || 30000) / 4500))
const getWordCount = (script) => {
const count = Number(script.wordCount || 0)
if (!count) return '3.1万字'
if (count >= 10000) return `${(count / 10000).toFixed(1)}万字`
return `${count}`
}
const getDateText = (script) => {
if (getStatus(script) === 'done') return `完成于:${script.completedAt || script.date || '2025.05.10'}`
if (getStatus(script) === 'draft') return `创建于:${script.createdAt || script.date || '2025.05.08'}`
return `最近更新:${script.updatedAt || script.date || '今天 21:30'}`
}
const getProgress = (script) => Math.max(1, Math.min(99, Number(script.progress || 28)))
const getInitial = (script) => (script.title || '剧').slice(0, 1)
const isFavorite = (script) => {
return Boolean(script.isFavorite || script.favorite || localFavorites.value[String(script.id)])
}
const openScriptChat = (script) => {
if (!script?.id || String(script.id).startsWith('demo-')) return
uni.setStorageSync('pending_open_script_chat', {
id: script.id
})
uni.$emit('switchTab', 'script')
setTimeout(() => {
uni.$emit('openScriptChat', { id: script.id, script })
}, 80)
}
const viewScript = (script) => {
openScriptChat(script)
}
const openScriptDetail = (script) => {
if (!script?.id || String(script.id).startsWith('demo-')) return
uni.navigateTo({ url: `/pages/main/ScriptDetailView?id=${script.id}` })
}
const createScript = () => {
uni.$emit('switchTab', 'script')
}
const backToScript = () => {
uni.$emit('switchTab', 'script')
}
const openSearch = () => {
uni.showModal({
title: '搜索剧本',
editable: true,
placeholderText: '输入标题、标签或关键词',
success: (res) => {
if (res.confirm) keyword.value = String(res.content || '').trim()
}
})
}
const openMoreMenu = () => {
uni.showActionSheet({
itemList: ['清空搜索', '只看收藏', '查看全部'],
success: ({ tapIndex }) => {
if (tapIndex === 0) keyword.value = ''
if (tapIndex === 1) activeStatus.value = 'favorite'
if (tapIndex === 2) {
keyword.value = ''
activeStatus.value = 'all'
}
}
})
}
const toggleSort = () => {
const order = ['updated', 'words', 'progress']
sortMode.value = order[(order.indexOf(sortMode.value) + 1) % order.length]
}
const toggleViewMode = () => {
viewMode.value = viewMode.value === 'list' ? 'grid' : 'list'
}
const openScriptMenu = (script) => {
const favorite = isFavorite(script)
uni.showActionSheet({
itemList: [favorite ? '取消收藏' : '收藏剧本', '继续生成', '查看详情'],
success: ({ tapIndex }) => {
if (tapIndex === 0) {
const next = { ...localFavorites.value }
if (favorite) delete next[String(script.id)]
else next[String(script.id)] = true
localFavorites.value = next
uni.setStorageSync('script_favorites', next)
uni.showToast({ title: favorite ? '已取消收藏' : '已收藏', icon: 'success' })
}
if (tapIndex === 1) viewScript(script)
if (tapIndex === 2) openScriptDetail(script)
}
})
}
</script>
<style scoped>
.script-library {
display: flex;
flex-direction: column;
gap: 24rpx;
padding-bottom: 26rpx;
}
.page-head,
.back-title,
.head-actions,
.type-tabs,
.filter-bar,
.sort-tools,
.card-top,
.title-wrap,
.right-state,
.meta-row,
.progress-row,
.favorite-row {
display: flex;
align-items: center;
}
.page-head {
justify-content: space-between;
}
.back-title {
height: 60rpx;
gap: 10rpx;
color: rgba(255, 255, 255, 0.94);
font-weight: 900;
}
.back-arrow {
font-size: 58rpx;
line-height: 1;
transform: translateY(-2rpx);
}
.back-text {
font-size: 32rpx;
}
.head-actions {
gap: 20rpx;
}
.circle-btn {
width: 58rpx;
height: 58rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
border: 1rpx solid rgba(142, 105, 255, 0.36);
background: rgba(10, 11, 38, 0.72);
}
.search-icon {
width: 25rpx;
height: 25rpx;
border: 4rpx solid #fff;
border-radius: 50%;
position: relative;
}
.search-icon::after {
content: '';
position: absolute;
right: -9rpx;
bottom: -8rpx;
width: 14rpx;
height: 4rpx;
border-radius: 999rpx;
background: #fff;
transform: rotate(45deg);
}
.more-icon {
display: flex;
gap: 5rpx;
}
.more-icon view {
width: 6rpx;
height: 6rpx;
border-radius: 50%;
background: #fff;
}
.type-tabs {
justify-content: space-between;
border-bottom: 1rpx solid rgba(126, 87, 255, 0.18);
padding-bottom: 16rpx;
}
.type-tab {
position: relative;
color: rgba(224, 214, 243, 0.7);
font-size: 31rpx;
font-weight: 900;
padding: 0 20rpx 14rpx;
}
.type-tab.active {
color: #fff;
}
.type-tab.active::after {
content: '';
position: absolute;
left: 20rpx;
right: 20rpx;
bottom: -17rpx;
height: 5rpx;
border-radius: 999rpx;
background: #b246ff;
box-shadow: 0 0 18rpx rgba(178, 70, 255, 0.8);
}
.new-script {
margin-left: auto;
height: 64rpx;
padding: 0 24rpx;
border-radius: 999rpx;
display: flex;
align-items: center;
gap: 8rpx;
color: #fff;
font-size: 24rpx;
font-weight: 800;
background: linear-gradient(135deg, #b346ff, #7330ff);
box-shadow: 0 0 26rpx rgba(168, 85, 247, 0.54);
}
.plus {
font-size: 32rpx;
line-height: 1;
}
.filter-bar {
gap: 14rpx;
}
.status-scroll {
flex: 1;
min-width: 0;
white-space: nowrap;
}
.status-row {
display: inline-flex;
gap: 16rpx;
}
.status-chip {
height: 52rpx;
min-width: 88rpx;
padding: 0 24rpx;
border-radius: 999rpx;
display: inline-flex;
align-items: center;
justify-content: center;
color: rgba(224, 214, 243, 0.78);
font-size: 23rpx;
border: 1rpx solid rgba(151, 111, 255, 0.42);
background: rgba(255, 255, 255, 0.02);
}
.status-chip.active {
color: #fff;
border-color: rgba(206, 82, 255, 0.92);
background: rgba(130, 48, 220, 0.42);
box-shadow: 0 0 18rpx rgba(168, 67, 255, 0.46);
}
.sort-tools {
gap: 14rpx;
flex-shrink: 0;
}
.sort-text {
color: #c99fff;
font-size: 23rpx;
}
.grid-icon {
width: 48rpx;
height: 48rpx;
border-radius: 18rpx;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 6rpx;
padding: 11rpx;
box-sizing: border-box;
border: 1rpx solid rgba(151, 111, 255, 0.32);
}
.grid-icon view {
border: 2rpx solid rgba(230, 222, 250, 0.78);
border-radius: 3rpx;
}
.script-list {
display: flex;
flex-direction: column;
gap: 18rpx;
}
.script-list.grid {
display: grid;
grid-template-columns: 1fr 1fr;
}
.script-list.grid .script-card {
grid-template-columns: 1fr;
}
.script-list.grid .cover {
width: 100%;
}
.grid-icon.active {
border-color: rgba(206, 82, 255, 0.9);
background: rgba(130, 48, 220, 0.28);
}
.script-card {
display: grid;
grid-template-columns: 150rpx 1fr;
gap: 22rpx;
min-height: 196rpx;
padding: 20rpx;
border-radius: 24rpx;
border: 1rpx solid rgba(105, 79, 210, 0.34);
background:
radial-gradient(circle at 100% 0%, rgba(112, 72, 255, 0.14), transparent 38%),
rgba(9, 12, 42, 0.72);
box-shadow: inset 0 0 28rpx rgba(92, 57, 197, 0.08);
}
.cover {
width: 150rpx;
height: 150rpx;
border-radius: 20rpx;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 54rpx;
font-weight: 900;
overflow: hidden;
background: linear-gradient(135deg, #3b1a90, #d65cff);
}
.cover-0 { background: linear-gradient(135deg, #29135f, #9037ff 48%, #191b5e); }
.cover-1 { background: linear-gradient(135deg, #3c1c4a, #f2b3cc 48%, #16143b); }
.cover-2 { background: linear-gradient(135deg, #1a225f, #7d4cff 48%, #0a0f2c); }
.cover-3 { background: linear-gradient(135deg, #2f240b, #f7b44a 48%, #0d0a16); }
.cover-4 { background: linear-gradient(135deg, #3f2417, #d8b58a 48%, #17101d); }
.cover-5 { background: linear-gradient(135deg, #141451, #cc46ff 48%, #0c0b28); }
.card-main {
min-width: 0;
}
.card-top {
justify-content: space-between;
gap: 14rpx;
}
.title-wrap {
min-width: 0;
gap: 10rpx;
}
.script-title {
color: #fff;
font-size: 27rpx;
line-height: 1.25;
font-weight: 900;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.length-badge {
flex-shrink: 0;
padding: 4rpx 9rpx;
border-radius: 8rpx;
color: #c985ff;
font-size: 18rpx;
border: 1rpx solid rgba(182, 92, 255, 0.5);
background: rgba(128, 55, 204, 0.22);
}
.right-state {
flex-shrink: 0;
gap: 14rpx;
}
.state-pill {
height: 34rpx;
padding: 0 14rpx;
border-radius: 999rpx;
display: flex;
align-items: center;
font-size: 19rpx;
}
.state-progress {
color: #ffbf4c;
background: rgba(170, 103, 20, 0.22);
}
.state-done {
color: #79e6a9;
background: rgba(44, 146, 88, 0.2);
}
.state-draft {
color: rgba(224, 214, 243, 0.76);
background: rgba(255, 255, 255, 0.06);
}
.ellipsis {
color: rgba(224, 214, 243, 0.66);
font-size: 24rpx;
letter-spacing: 3rpx;
}
.tag-row {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
margin-top: 16rpx;
}
.tag {
height: 34rpx;
padding: 0 14rpx;
border-radius: 999rpx;
display: flex;
align-items: center;
color: #d49cff;
font-size: 19rpx;
background: rgba(149, 55, 255, 0.2);
}
.summary {
display: -webkit-box;
margin-top: 14rpx;
color: rgba(226, 215, 246, 0.72);
font-size: 22rpx;
line-height: 1.55;
overflow: hidden;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.meta-row {
gap: 14rpx;
margin-top: 14rpx;
color: rgba(218, 204, 243, 0.66);
font-size: 21rpx;
}
.progress-row {
justify-content: flex-end;
gap: 14rpx;
margin-top: 14rpx;
color: #bd72ff;
font-size: 22rpx;
font-weight: 800;
}
.progress-track {
width: 118rpx;
height: 6rpx;
border-radius: 999rpx;
background: rgba(173, 160, 210, 0.18);
overflow: hidden;
}
.progress-fill {
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #b246ff, #d878ff);
}
.favorite-row {
justify-content: flex-end;
gap: 8rpx;
margin-top: 14rpx;
color: #b768ff;
font-size: 23rpx;
font-weight: 800;
}
.favorite-star {
font-size: 28rpx;
}
.empty-card {
margin-top: 30rpx;
border-radius: 26rpx;
padding: 44rpx 30rpx;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
border: 1rpx solid rgba(105, 79, 210, 0.34);
background: rgba(9, 12, 42, 0.72);
}
.empty-book {
display: flex;
gap: 6rpx;
margin-bottom: 18rpx;
}
.empty-book view {
width: 32rpx;
height: 46rpx;
border: 4rpx solid #b768ff;
border-radius: 8rpx 4rpx 4rpx 8rpx;
}
.empty-title {
color: #fff;
font-size: 28rpx;
font-weight: 900;
}
.empty-text {
margin-top: 12rpx;
color: rgba(226, 215, 246, 0.68);
font-size: 22rpx;
line-height: 1.5;
}
.empty-action {
margin-top: 22rpx;
height: 56rpx;
padding: 0 30rpx;
border-radius: 999rpx;
display: flex;
align-items: center;
color: #fff;
font-size: 23rpx;
font-weight: 800;
background: linear-gradient(135deg, #b346ff, #7330ff);
}
</style>