feat(web): add AI joke generator page with favorites
This commit is contained in:
@@ -0,0 +1,355 @@
|
|||||||
|
<template>
|
||||||
|
<div class="generate-page">
|
||||||
|
<h1 class="page-title">
|
||||||
|
<span class="emoji">✨</span>
|
||||||
|
智能笑话生成器
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<!-- 场景选择 -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-label">选择场景(可多选)</div>
|
||||||
|
<div class="scenario-chips">
|
||||||
|
<el-check-tag
|
||||||
|
v-for="s in predefinedScenarios"
|
||||||
|
:key="s"
|
||||||
|
:checked="selectedScenarios.includes(s)"
|
||||||
|
@change="toggleScenario(s)"
|
||||||
|
class="scenario-chip"
|
||||||
|
>
|
||||||
|
{{ s }}
|
||||||
|
</el-check-tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 关键词输入 -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-label">添加关键词(可选)</div>
|
||||||
|
<el-input
|
||||||
|
v-model="keywordInput"
|
||||||
|
placeholder="输入关键词,如:加班、相亲、熊孩子..."
|
||||||
|
@keyup.enter="addKeyword"
|
||||||
|
clearable
|
||||||
|
>
|
||||||
|
<template #append>
|
||||||
|
<el-button @click="addKeyword">添加</el-button>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
<div v-if="keywords.length" class="keyword-tags">
|
||||||
|
<el-tag
|
||||||
|
v-for="(kw, i) in keywords"
|
||||||
|
:key="i"
|
||||||
|
closable
|
||||||
|
@close="removeKeyword(i)"
|
||||||
|
class="keyword-tag"
|
||||||
|
>
|
||||||
|
{{ kw }}
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 生成按钮 -->
|
||||||
|
<div class="generate-actions">
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
size="large"
|
||||||
|
:loading="loading"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="handleGenerate"
|
||||||
|
class="generate-btn"
|
||||||
|
>
|
||||||
|
<span v-if="!loading">🎲 开始生成</span>
|
||||||
|
<span v-else>生成中...</span>
|
||||||
|
</el-button>
|
||||||
|
<el-button v-if="generatedJoke" size="large" @click="resetForm">
|
||||||
|
清空
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 生成结果 -->
|
||||||
|
<div v-if="generatedJoke" class="result-card">
|
||||||
|
<h2 class="result-title">{{ generatedJoke.title }}</h2>
|
||||||
|
<pre class="result-content">{{ generatedJoke.content }}</pre>
|
||||||
|
<div class="result-actions">
|
||||||
|
<el-button type="primary" @click="handleGenerate">
|
||||||
|
🔄 重新生成
|
||||||
|
</el-button>
|
||||||
|
<el-button @click="handleFavorite" :type="isFavorited ? 'danger' : 'default'">
|
||||||
|
<span v-if="!isFavorited">❤ 收藏</span>
|
||||||
|
<span v-else>✔ 已收藏</span>
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 收藏列表 -->
|
||||||
|
<div v-if="favorites.length" class="favorites-section">
|
||||||
|
<h3>📬 我的收藏 ({{ favorites.length }})</h3>
|
||||||
|
<div class="favorite-list">
|
||||||
|
<div
|
||||||
|
v-for="(fav, i) in favorites"
|
||||||
|
:key="fav.id"
|
||||||
|
class="favorite-item"
|
||||||
|
>
|
||||||
|
<div class="fav-title">{{ fav.title }}</div>
|
||||||
|
<div class="fav-content">{{ fav.content.substring(0, 100) }}...</div>
|
||||||
|
<div class="fav-meta">
|
||||||
|
<span class="fav-date">{{ formatDate(fav.created_at) }}</span>
|
||||||
|
<el-button type="danger" size="small" text @click="removeFavorite(i)">
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { generateJoke } from '@/api/generate'
|
||||||
|
|
||||||
|
const predefinedScenarios = ['职场', '校园', '社交', '家庭', '情感', '搞笑日常']
|
||||||
|
|
||||||
|
const selectedScenarios = ref([])
|
||||||
|
const keywordInput = ref('')
|
||||||
|
const keywords = ref([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const generatedJoke = ref(null)
|
||||||
|
const isFavorited = ref(false)
|
||||||
|
|
||||||
|
// 收藏
|
||||||
|
const favorites = ref(JSON.parse(localStorage.getItem('joke_favorites') || '[]'))
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'joke_favorites'
|
||||||
|
|
||||||
|
function toggleScenario(s) {
|
||||||
|
const idx = selectedScenarios.value.indexOf(s)
|
||||||
|
if (idx >= 0) {
|
||||||
|
selectedScenarios.value.splice(idx, 1)
|
||||||
|
} else {
|
||||||
|
selectedScenarios.value.push(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addKeyword() {
|
||||||
|
const kw = keywordInput.value.trim()
|
||||||
|
if (kw && !keywords.value.includes(kw)) {
|
||||||
|
keywords.value.push(kw)
|
||||||
|
keywordInput.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeKeyword(i) {
|
||||||
|
keywords.value.splice(i, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleGenerate() {
|
||||||
|
loading.value = true
|
||||||
|
isFavorited.value = false
|
||||||
|
try {
|
||||||
|
const res = await generateJoke(keywords.value, selectedScenarios.value)
|
||||||
|
generatedJoke.value = res
|
||||||
|
// 检查是否已收藏
|
||||||
|
const exists = favorites.value.find(f => f.content === res.content)
|
||||||
|
isFavorited.value = !!exists
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(e.detail || '生成失败,请重试')
|
||||||
|
generatedJoke.value = null
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFavorite() {
|
||||||
|
if (!generatedJoke.value) return
|
||||||
|
// 检查是否已存在
|
||||||
|
const exists = favorites.value.findIndex(f => f.content === generatedJoke.value.content)
|
||||||
|
if (exists >= 0) {
|
||||||
|
ElMessage.info('已经收藏过了')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
favorites.value.unshift({
|
||||||
|
id: Date.now().toString(),
|
||||||
|
title: generatedJoke.value.title,
|
||||||
|
content: generatedJoke.value.content,
|
||||||
|
created_at: new Date().toISOString()
|
||||||
|
})
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(favorites.value))
|
||||||
|
isFavorited.value = true
|
||||||
|
ElMessage.success('收藏成功')
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeFavorite(i) {
|
||||||
|
favorites.value.splice(i, 1)
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(favorites.value))
|
||||||
|
// 检查当前生成的是否也从收藏中被删除了
|
||||||
|
if (generatedJoke.value) {
|
||||||
|
const stillExists = favorites.value.find(f => f.content === generatedJoke.value.content)
|
||||||
|
isFavorited.value = !!stillExists
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
generatedJoke.value = null
|
||||||
|
isFavorited.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr) {
|
||||||
|
if (!dateStr) return ''
|
||||||
|
return new Date(dateStr).toLocaleDateString('zh-CN')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.generate-page {
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 40px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
.emoji {
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
.section-label {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scenario-chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
:deep(.scenario-chip) {
|
||||||
|
padding: 8px 18px;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1.5px solid var(--border);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
:deep(.scenario-chip:hover) {
|
||||||
|
border-color: #9333ea;
|
||||||
|
}
|
||||||
|
:deep(.scenario-chip.is-checked) {
|
||||||
|
background: #9333ea;
|
||||||
|
border-color: #9333ea;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.keyword-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.keyword-tag {
|
||||||
|
background: #f3e8ff;
|
||||||
|
border-color: #9333ea;
|
||||||
|
color: #9333ea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.generate-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
:deep(.generate-btn) {
|
||||||
|
padding: 12px 40px;
|
||||||
|
font-size: 16px;
|
||||||
|
background: linear-gradient(135deg, #9333ea, #7c3aed);
|
||||||
|
border: none;
|
||||||
|
border-radius: 24px;
|
||||||
|
}
|
||||||
|
:deep(.generate-btn:hover) {
|
||||||
|
box-shadow: 0 4px 20px rgba(147, 51, 234, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 32px;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.result-title {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.result-content {
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.9;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.result-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorites-section {
|
||||||
|
margin-top: 40px;
|
||||||
|
}
|
||||||
|
.favorites-section h3 {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorite-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.favorite-item {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 16px 20px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.fav-title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.fav-content {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.fav-meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.fav-date {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user