feat: 功能优化
This commit is contained in:
@@ -77,6 +77,7 @@ globs: **/*.vue, **/*.ts, components/**/*
|
||||
- 实现完整的错误处理
|
||||
- 规范事件处理机制
|
||||
- 为复杂逻辑添加文档注释
|
||||
- 代码简洁易于人类阅读
|
||||
|
||||
## 构建与工具链
|
||||
- 使用 Vite 进行开发
|
||||
|
||||
@@ -39,7 +39,7 @@ export const InterfaceType = {
|
||||
/** 抖音 - 网页端通用搜索结果 */
|
||||
DOUYIN_WEB_GENERAL_SEARCH: '12',
|
||||
/** 抖音 - APP通用搜索结果 */
|
||||
DOUYIN_SEARCH_GENERAL_SEARCH: '14',
|
||||
DOUYIN_SEARCH_GENERAL_SEARCH: '13',
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
203
frontend/app/web-gold/src/components/GradientButton.vue
Normal file
203
frontend/app/web-gold/src/components/GradientButton.vue
Normal file
@@ -0,0 +1,203 @@
|
||||
<template>
|
||||
<button
|
||||
:class="['gradient-button', { 'gradient-button--disabled': disabled, 'gradient-button--loading': loading }]"
|
||||
:disabled="disabled || loading"
|
||||
@click="handleClick"
|
||||
>
|
||||
<span v-if="loading" class="gradient-button__loading">
|
||||
<a-spin size="small" />
|
||||
<span v-if="loadingText" class="gradient-button__loading-text">{{ loadingText }}</span>
|
||||
</span>
|
||||
<span v-else class="gradient-button__content">
|
||||
<slot name="icon">
|
||||
<span v-if="icon" class="gradient-button__icon">
|
||||
<GmIcon :name="icon" :size="iconSize" />
|
||||
</span>
|
||||
</slot>
|
||||
<span class="gradient-button__text">
|
||||
<slot>{{ text }}</slot>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import GmIcon from '@/components/icons/Icon.vue'
|
||||
|
||||
const props = defineProps({
|
||||
// 按钮文本
|
||||
text: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
// 是否禁用
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
// 是否加载中
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
// 加载中的文本
|
||||
loadingText: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
// 图标名称
|
||||
icon: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
// 图标大小
|
||||
iconSize: {
|
||||
type: Number,
|
||||
default: 16
|
||||
},
|
||||
// 按钮大小
|
||||
size: {
|
||||
type: String,
|
||||
default: 'large', // large, middle, small
|
||||
validator: (value) => ['large', 'middle', 'small'].includes(value)
|
||||
},
|
||||
// 是否块级按钮
|
||||
block: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['click'])
|
||||
|
||||
const handleClick = (event) => {
|
||||
if (!props.disabled && !props.loading) {
|
||||
emit('click', event)
|
||||
}
|
||||
}
|
||||
|
||||
const buttonClass = computed(() => {
|
||||
return [
|
||||
`gradient-button--${props.size}`,
|
||||
{
|
||||
'gradient-button--block': props.block
|
||||
}
|
||||
]
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.gradient-button {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
border: 1px solid rgba(24, 144, 255, 0.3);
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #ffffff;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
background: linear-gradient(135deg, #1890FF 0%, #40A9FF 100%);
|
||||
box-shadow: 0 0 0 0 rgba(24, 144, 255, 0);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.gradient-button:hover {
|
||||
background: linear-gradient(135deg, #1890FF 0%, #40A9FF 100%);
|
||||
border-color: rgba(24, 144, 255, 0.5);
|
||||
box-shadow: 0 0 6px rgba(24, 144, 255, 0.25);
|
||||
}
|
||||
|
||||
.gradient-button:active {
|
||||
background: linear-gradient(135deg, #096DD9 0%, #1890FF 100%);
|
||||
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.gradient-button__content,
|
||||
.gradient-button__loading {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.gradient-button__icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.gradient-button__text {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.gradient-button__loading-text {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
/* 尺寸变体 */
|
||||
.gradient-button--large {
|
||||
padding: 10px 20px;
|
||||
font-size: 15px;
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.gradient-button--middle {
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.gradient-button--small {
|
||||
padding: 4px 12px;
|
||||
font-size: 13px;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
/* 块级按钮 */
|
||||
.gradient-button--block {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* 禁用状态 */
|
||||
.gradient-button--disabled,
|
||||
.gradient-button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
background: linear-gradient(135deg, #096DD9 0%, #1890FF 100%);
|
||||
border-color: rgba(24, 144, 255, 0.2);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.gradient-button--disabled:hover,
|
||||
.gradient-button:disabled:hover {
|
||||
background: linear-gradient(135deg, #096DD9 0%, #1890FF 100%);
|
||||
border-color: rgba(24, 144, 255, 0.2);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* 加载状态 */
|
||||
.gradient-button--loading {
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.gradient-button--loading:hover {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* 响应式调整 */
|
||||
@media (max-width: 768px) {
|
||||
.gradient-button--large {
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
min-height: 32px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, computed, watch } from 'vue'
|
||||
import { ref, onMounted, onActivated, computed, watch } from 'vue'
|
||||
import { usePromptStore } from '@/stores/prompt'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import { message } from 'ant-design-vue'
|
||||
@@ -8,6 +8,8 @@ import useVoiceText from '@gold/hooks/web/useVoiceText'
|
||||
import GmIcon from '@/components/icons/Icon.vue'
|
||||
import { UserPromptApi } from '@/api/userPrompt'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import GradientButton from '@/components/GradientButton.vue'
|
||||
import { setJSON, getJSON } from '@/utils/storage'
|
||||
|
||||
const promptStore = usePromptStore()
|
||||
const userStore = useUserStore()
|
||||
@@ -86,8 +88,8 @@ async function loadUserPrompts() {
|
||||
return timeB - timeA
|
||||
})
|
||||
|
||||
// 如果用户没有选择提示词,默认选中第一个
|
||||
autoSelectFirstPromptIfNeeded()
|
||||
// 如果用户没有选择提示词,尝试恢复本地存储的选中项或默认选中第一个
|
||||
await restoreOrSelectPrompt()
|
||||
} else {
|
||||
throw new Error(response?.msg || response?.message || '加载失败')
|
||||
}
|
||||
@@ -100,24 +102,86 @@ async function loadUserPrompts() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动选中第一个提示词(如果用户还没有选择)
|
||||
* 保存选中的提示词ID到本地存储
|
||||
*/
|
||||
function autoSelectFirstPromptIfNeeded() {
|
||||
const hasPrompts = allPrompts.value.length > 0
|
||||
const hasNoSelection = !selectedPromptId.value && !form.value.prompt
|
||||
|
||||
if (hasPrompts && hasNoSelection) {
|
||||
const firstPrompt = allPrompts.value[0]
|
||||
if (firstPrompt?.content) {
|
||||
selectedPromptId.value = firstPrompt.id
|
||||
form.value.prompt = firstPrompt.content
|
||||
promptStore.setPrompt(firstPrompt.content, firstPrompt)
|
||||
}
|
||||
async function saveSelectedPromptId(promptId) {
|
||||
if (promptId) {
|
||||
await setJSON('copywriting_selected_prompt_id', promptId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从本地存储恢复选中的提示词ID
|
||||
*/
|
||||
async function loadSelectedPromptId() {
|
||||
try {
|
||||
const savedId = await getJSON('copywriting_selected_prompt_id', null)
|
||||
return savedId
|
||||
} catch (error) {
|
||||
console.error('加载保存的提示词ID失败:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据ID选中提示词
|
||||
*/
|
||||
async function selectPromptById(promptId) {
|
||||
if (!promptId) return false
|
||||
|
||||
const prompt = allPrompts.value.find(p => p.id === promptId)
|
||||
if (prompt && prompt.content) {
|
||||
selectedPromptId.value = prompt.id
|
||||
form.value.prompt = prompt.content
|
||||
promptStore.setPrompt(prompt.content, prompt)
|
||||
await saveSelectedPromptId(promptId)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 恢复或选中提示词
|
||||
* 优先级:本地存储的选中项 > 第一个提示词
|
||||
*/
|
||||
async function restoreOrSelectPrompt() {
|
||||
if (allPrompts.value.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 如果已经有选中项且内容存在,不需要重新选择
|
||||
if (selectedPromptId.value && form.value.prompt) {
|
||||
// 验证选中的提示词是否还在列表中
|
||||
const currentPrompt = allPrompts.value.find(p => p.id === selectedPromptId.value)
|
||||
if (currentPrompt && currentPrompt.content === form.value.prompt) {
|
||||
return true // 已经正确选中,无需操作
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试恢复本地存储的选中项
|
||||
const savedPromptId = await loadSelectedPromptId()
|
||||
if (savedPromptId) {
|
||||
const restored = await selectPromptById(savedPromptId)
|
||||
if (restored) {
|
||||
return true // 成功恢复保存的选中项
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有保存的选中项或恢复失败,则选中第一个
|
||||
const firstPrompt = allPrompts.value[0]
|
||||
if (firstPrompt?.content) {
|
||||
selectedPromptId.value = firstPrompt.id
|
||||
form.value.prompt = firstPrompt.content
|
||||
promptStore.setPrompt(firstPrompt.content, firstPrompt)
|
||||
await saveSelectedPromptId(firstPrompt.id)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// 选择提示词
|
||||
function selectPrompt(prompt) {
|
||||
async function selectPrompt(prompt) {
|
||||
if (!prompt || !prompt.content) {
|
||||
message.warning('提示词内容为空')
|
||||
return
|
||||
@@ -126,6 +190,7 @@ function selectPrompt(prompt) {
|
||||
selectedPromptId.value = prompt.id
|
||||
form.value.prompt = prompt.content
|
||||
promptStore.setPrompt(prompt.content, prompt)
|
||||
await saveSelectedPromptId(prompt.id)
|
||||
showAllPromptsModal.value = false
|
||||
}
|
||||
|
||||
@@ -189,6 +254,29 @@ onMounted(() => {
|
||||
initializePage()
|
||||
})
|
||||
|
||||
/**
|
||||
* keep-alive 激活时的处理
|
||||
* 1. 如果有提示词列表,尝试恢复或选中提示词
|
||||
* 2. 如果没有提示词列表但用户已登录,加载提示词列表
|
||||
*/
|
||||
onActivated(async () => {
|
||||
// 如果已经有提示词列表,尝试恢复或选中提示词
|
||||
if (allPrompts.value.length > 0) {
|
||||
await restoreOrSelectPrompt()
|
||||
}
|
||||
// 如果提示词列表为空,但用户已登录,则尝试加载
|
||||
else if (userStore.userId) {
|
||||
await loadUserPrompts()
|
||||
}
|
||||
// 如果用户未登录,等待用户信息加载
|
||||
else if (!userStore.userId && userStore.isLoggedIn) {
|
||||
await ensureUserInfoLoaded()
|
||||
if (userStore.userId) {
|
||||
await loadUserPrompts()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 监听 userId 变化:如果之前没有 userId,现在有了,则自动加载提示词
|
||||
watch(() => userStore.userId, async (newUserId, oldUserId) => {
|
||||
const userIdChanged = newUserId && !oldUserId
|
||||
@@ -500,23 +588,16 @@ defineOptions({ name: 'ContentStyleCopywriting' })
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item class="form-item">
|
||||
<a-button
|
||||
type="primary"
|
||||
@click="generateCopywriting"
|
||||
<GradientButton
|
||||
text="生成文案"
|
||||
icon="icon-sparkle"
|
||||
:disabled="!getCurrentInputValue() || !selectedPromptId || isLoading"
|
||||
:loading="isLoading"
|
||||
block
|
||||
loading-text="生成中..."
|
||||
size="large"
|
||||
class="generate-btn"
|
||||
>
|
||||
<template v-if="!isLoading">
|
||||
<span class="btn-icon"><GmIcon name="icon-sparkle" :size="16" /></span>
|
||||
生成文案
|
||||
</template>
|
||||
<template v-else>
|
||||
生成中...
|
||||
</template>
|
||||
</a-button>
|
||||
:block="true"
|
||||
@click="generateCopywriting"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-card>
|
||||
@@ -814,47 +895,7 @@ defineOptions({ name: 'ContentStyleCopywriting' })
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 生成按钮样式 */
|
||||
.generate-btn {
|
||||
margin-top: 16px;
|
||||
height: 40px;
|
||||
border-radius: 6px;
|
||||
background: var(--color-primary);
|
||||
border: none;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.generate-btn:hover {
|
||||
background: var(--color-primary);
|
||||
box-shadow: var(--glow-primary);
|
||||
}
|
||||
|
||||
.generate-btn:active {
|
||||
background: var(--color-primary);
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* 按钮禁用态保持主色,不换颜色(仅本页) */
|
||||
:deep(.ant-btn-primary[disabled]),
|
||||
:deep(.ant-btn-primary[disabled]:hover),
|
||||
:deep(.ant-btn-primary[disabled]:active),
|
||||
:deep(.ant-btn-primary.ant-btn-disabled),
|
||||
:deep(.ant-btn-primary.ant-btn-disabled:hover),
|
||||
:deep(.ant-btn-primary.ant-btn-disabled:active) {
|
||||
background: var(--color-primary) !important;
|
||||
border-color: var(--color-primary) !important;
|
||||
color: #fff !important;
|
||||
opacity: 0.6; /* 仅降低不透明度,不改变颜色 */
|
||||
cursor: not-allowed;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
/* 生成按钮样式 - 已替换为 GradientButton 组件 */
|
||||
|
||||
/* 操作按钮样式 */
|
||||
.action-btn {
|
||||
@@ -1248,11 +1289,6 @@ defineOptions({ name: 'ContentStyleCopywriting' })
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.generate-btn {
|
||||
height: 36px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.generated-content {
|
||||
padding: 14px;
|
||||
font-size: 15px;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import GradientButton from '@/components/GradientButton.vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
@@ -52,9 +53,13 @@ function handleReset() {
|
||||
<div class="form-hint">数量越大越全面,但分析时间更长;建议 20–30。</div>
|
||||
</a-form-item>
|
||||
<a-space>
|
||||
<a-button type="primary" :loading="loading" @click="handleAnalyze">
|
||||
{{ loading ? '分析中…' : '开始分析' }}
|
||||
</a-button>
|
||||
<GradientButton
|
||||
text="开始分析"
|
||||
:loading="loading"
|
||||
loading-text="分析中…"
|
||||
size="middle"
|
||||
@click="handleAnalyze"
|
||||
/>
|
||||
<a-button @click="handleReset">清空</a-button>
|
||||
</a-space>
|
||||
</a-form>
|
||||
|
||||
@@ -3,8 +3,9 @@ import { reactive, h } from 'vue'
|
||||
import { DownloadOutlined } from '@ant-design/icons-vue'
|
||||
import { formatTime } from '../utils/benchmarkUtils'
|
||||
import ExpandedRowContent from './ExpandedRowContent.vue'
|
||||
import GradientButton from '@/components/GradientButton.vue'
|
||||
|
||||
const props = defineProps({
|
||||
defineProps({
|
||||
data: {
|
||||
type: Array,
|
||||
required: true,
|
||||
@@ -81,9 +82,12 @@ function onExpandedRowKeysChange(keys) {
|
||||
</template>
|
||||
导出Excel ({{ selectedRowKeys.length }}/10)
|
||||
</a-button>
|
||||
<a-button size="small" type="primary" class="batch-btn" @click="$emit('batchAnalyze')">
|
||||
批量分析 ({{ selectedRowKeys.length }})
|
||||
</a-button>
|
||||
<GradientButton
|
||||
:text="`批量分析 (${selectedRowKeys.length})`"
|
||||
size="small"
|
||||
@click="$emit('batchAnalyze')"
|
||||
:disabled="selectedRowKeys.length === 0"
|
||||
/>
|
||||
</a-space>
|
||||
</div>
|
||||
<a-table
|
||||
@@ -145,9 +149,14 @@ function onExpandedRowKeysChange(keys) {
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a-button size="small" type="primary" :loading="record._analyzing" :disabled="record._analyzing" @click="$emit('analyze', record)">
|
||||
{{ record._analyzing ? '分析中…' : '分析' }}
|
||||
</a-button>
|
||||
<GradientButton
|
||||
text="分析"
|
||||
size="small"
|
||||
:loading="record._analyzing"
|
||||
loading-text="分析中…"
|
||||
:disabled="record._analyzing"
|
||||
@click="$emit('analyze', record)"
|
||||
/>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -3,10 +3,8 @@ import { ref, onMounted, reactive, h } from 'vue'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import { EditOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons-vue'
|
||||
import { UserPromptApi } from '@/api/userPrompt'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 表格数据
|
||||
const dataSource = ref([])
|
||||
@@ -105,10 +103,11 @@ const columns = [
|
||||
width: 150,
|
||||
fixed: 'right',
|
||||
customRender: ({ record }) => {
|
||||
return h('div', { style: { display: 'flex', gap: '8px' } }, [
|
||||
return h('div', { class: 'action-buttons' }, [
|
||||
h('a-button', {
|
||||
type: 'link',
|
||||
size: 'small',
|
||||
class: 'action-btn action-btn-edit',
|
||||
onClick: () => handleEdit(record),
|
||||
}, [
|
||||
h(EditOutlined),
|
||||
@@ -118,6 +117,7 @@ const columns = [
|
||||
type: 'link',
|
||||
size: 'small',
|
||||
danger: true,
|
||||
class: 'action-btn action-btn-delete',
|
||||
onClick: () => handleDelete(record),
|
||||
}, [
|
||||
h(DeleteOutlined),
|
||||
@@ -199,7 +199,7 @@ async function handleSave() {
|
||||
try {
|
||||
await editFormRef.value.validate()
|
||||
} catch (error) {
|
||||
return
|
||||
console.error('保存提示词失败:', error)
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
@@ -485,5 +485,36 @@ onMounted(() => {
|
||||
:deep(.ant-pagination) {
|
||||
margin: 16px;
|
||||
}
|
||||
|
||||
/* 操作按钮样式 */
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 使用 :deep() 穿透 Ant Design 的样式 */
|
||||
:deep(.action-btn) {
|
||||
transition: all 0.2s ease;
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
:deep(.action-btn-edit:hover),
|
||||
:deep(.action-btn-edit:hover .anticon) {
|
||||
background: rgba(24, 144, 255, 0.1) !important;
|
||||
color: #1890FF !important;
|
||||
}
|
||||
|
||||
:deep(.action-btn-delete:hover),
|
||||
:deep(.action-btn-delete:hover .anticon) {
|
||||
background: rgba(24, 144, 255, 0.1) !important;
|
||||
color: #1890FF !important;
|
||||
}
|
||||
|
||||
:deep(.action-btn:hover) {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
<script setup>
|
||||
import { ref, reactive } from 'vue'
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import TikhubService, { InterfaceType, MethodType, ParamType } from '@/api/tikhub'
|
||||
import { CommonService } from '@/api/common'
|
||||
import { UserPromptApi } from '@/api/userPrompt'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import GradientButton from '@/components/GradientButton.vue'
|
||||
|
||||
// 定义组件名称
|
||||
defineOptions({
|
||||
@@ -18,36 +22,240 @@ defineOptions({
|
||||
// 搜索关键词
|
||||
const searchKeyword = ref('')
|
||||
|
||||
// 搜索参数
|
||||
const searchParams = reactive({
|
||||
keyword: '',
|
||||
offset: 0, // 偏移量,第一次请求时为0,后续从返回数据中的 cursor 获取
|
||||
sort_type: 1, // 0:综合排序 1:最多点赞 2:最新发布
|
||||
publish_time: 7, // 0:不限 1:最近一天 7:最近一周 180:最近半年
|
||||
filter_duration: '0', // '0':不限 '0-1':1分钟以内 '1-5':1-5分钟 '5-10000':5分钟以上
|
||||
content_type: 0 // 0:不限 1:视频 2:图集
|
||||
})
|
||||
|
||||
// 加载状态
|
||||
const isLoading = ref(false)
|
||||
|
||||
// 热点列表数据(模拟数据)
|
||||
// 热点列表数据
|
||||
const hotTopics = ref([])
|
||||
|
||||
// 选中的热点
|
||||
const selectedTopic = ref(null)
|
||||
|
||||
// 当前 cursor(用于翻页)
|
||||
const currentCursor = ref(null)
|
||||
|
||||
// 用户store
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 右侧详细信息
|
||||
const topicDetails = reactive({
|
||||
title: '',
|
||||
copywriting: '',
|
||||
stylePrompt: ''
|
||||
stylePrompt: '',
|
||||
stylePromptId: null
|
||||
})
|
||||
|
||||
// 生成的文案内容
|
||||
const generatedContent = ref('')
|
||||
|
||||
// 生成文案的loading状态
|
||||
const isGenerating = ref(false)
|
||||
|
||||
// 提示词相关状态
|
||||
const allPrompts = ref([])
|
||||
const loadingPrompts = ref(false)
|
||||
const showAllPromptsModal = ref(false)
|
||||
const promptSearchKeyword = ref('')
|
||||
const DISPLAY_COUNT = 6 // 展示的提示词数量
|
||||
|
||||
// 计算属性:展示的部分提示词
|
||||
const displayPrompts = computed(() => {
|
||||
return allPrompts.value.slice(0, DISPLAY_COUNT)
|
||||
})
|
||||
|
||||
// 计算属性:过滤后的全部提示词(用于"更多"弹窗)
|
||||
const filteredPrompts = computed(() => {
|
||||
if (!promptSearchKeyword.value.trim()) {
|
||||
return allPrompts.value
|
||||
}
|
||||
const keyword = promptSearchKeyword.value.trim().toLowerCase()
|
||||
return allPrompts.value.filter(p =>
|
||||
p.name.toLowerCase().includes(keyword) ||
|
||||
(p.content && p.content.toLowerCase().includes(keyword))
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* 加载用户提示词列表
|
||||
*/
|
||||
async function loadUserPrompts() {
|
||||
if (!userStore.userId) {
|
||||
console.warn('用户未登录,无法加载提示词')
|
||||
return
|
||||
}
|
||||
|
||||
loadingPrompts.value = true
|
||||
try {
|
||||
const response = await UserPromptApi.getUserPromptPage({
|
||||
pageNo: 1,
|
||||
pageSize: 100,
|
||||
status: 1 // 只获取启用状态的提示词
|
||||
})
|
||||
|
||||
if (response?.data?.list) {
|
||||
allPrompts.value = response.data.list
|
||||
// 如果没有选中的提示词,自动选中第一个
|
||||
if (!topicDetails.stylePromptId && allPrompts.value.length > 0) {
|
||||
const firstPrompt = allPrompts.value[0]
|
||||
topicDetails.stylePromptId = firstPrompt.id
|
||||
topicDetails.stylePrompt = firstPrompt.content || ''
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载提示词失败:', error)
|
||||
message.error('加载提示词失败')
|
||||
} finally {
|
||||
loadingPrompts.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 选择提示词
|
||||
*/
|
||||
function selectPrompt(prompt) {
|
||||
if (!prompt || !prompt.content) {
|
||||
message.warning('提示词内容为空')
|
||||
return
|
||||
}
|
||||
|
||||
topicDetails.stylePromptId = prompt.id
|
||||
topicDetails.stylePrompt = prompt.content
|
||||
showAllPromptsModal.value = false
|
||||
}
|
||||
|
||||
// 点击创作按钮
|
||||
const handleCreate = (topic) => {
|
||||
selectedTopic.value = topic.id
|
||||
topicDetails.title = topic.title
|
||||
topicDetails.copywriting = ''
|
||||
topicDetails.stylePrompt = ''
|
||||
// 提取文案:使用标题作为初始文案
|
||||
topicDetails.copywriting = topic.title || ''
|
||||
// 保持已选择的风格提示词,如果没有则使用第一个
|
||||
if (!topicDetails.stylePromptId && allPrompts.value.length > 0) {
|
||||
const firstPrompt = allPrompts.value[0]
|
||||
topicDetails.stylePromptId = firstPrompt.id
|
||||
topicDetails.stylePrompt = firstPrompt.content || ''
|
||||
}
|
||||
}
|
||||
|
||||
// 立即生成
|
||||
const handleGenerate = () => {
|
||||
console.log('生成内容', topicDetails)
|
||||
// TODO: 调用生成API
|
||||
// 生成文案(流式)
|
||||
async function handleGenerate() {
|
||||
if (!topicDetails.copywriting || !topicDetails.copywriting.trim()) {
|
||||
message.warning('请输入文案内容')
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否选择了提示词风格
|
||||
if (!topicDetails.stylePrompt || !topicDetails.stylePrompt.trim()) {
|
||||
message.warning('请先选择提示词风格')
|
||||
return
|
||||
}
|
||||
|
||||
isGenerating.value = true
|
||||
generatedContent.value = '' // 清空之前的内容
|
||||
|
||||
try {
|
||||
// 调用 callWorkflow 流式 API
|
||||
const requestData = {
|
||||
audio_prompt: topicDetails.stylePrompt || '', // 音频提示词
|
||||
user_text: topicDetails.copywriting.trim(), // 用户输入内容
|
||||
amplitude: 50 // 幅度,默认50%
|
||||
}
|
||||
|
||||
const ctrl = new AbortController()
|
||||
let fullText = ''
|
||||
let errorOccurred = false
|
||||
let isResolved = false
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
// 设置超时
|
||||
const timeout = setTimeout(() => {
|
||||
if (!isResolved) {
|
||||
ctrl.abort()
|
||||
reject(new Error('请求超时,请稍后重试'))
|
||||
}
|
||||
}, 180000) // 3分钟超时
|
||||
|
||||
CommonService.callWorkflowStream({
|
||||
data: requestData,
|
||||
ctrl,
|
||||
onMessage: (event) => {
|
||||
try {
|
||||
if (errorOccurred) return
|
||||
|
||||
const dataStr = event?.data || ''
|
||||
if (!dataStr) return
|
||||
|
||||
try {
|
||||
const obj = JSON.parse(dataStr)
|
||||
// 根据实际返回格式解析
|
||||
const piece = obj?.text || obj?.content || obj?.data || ''
|
||||
if (piece) {
|
||||
fullText += piece
|
||||
generatedContent.value = fullText
|
||||
}
|
||||
} catch (parseErr) {
|
||||
console.warn('解析流数据异常:', parseErr)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('解析流数据异常:', e)
|
||||
}
|
||||
},
|
||||
onError: (err) => {
|
||||
clearTimeout(timeout)
|
||||
if (!isResolved) {
|
||||
errorOccurred = true
|
||||
ctrl.abort()
|
||||
const errorMsg = err?.message || '网络请求失败'
|
||||
console.error('SSE请求错误:', err)
|
||||
message.error(errorMsg)
|
||||
reject(new Error(errorMsg))
|
||||
}
|
||||
},
|
||||
onClose: () => {
|
||||
clearTimeout(timeout)
|
||||
if (!isResolved) {
|
||||
isResolved = true
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
generatedContent.value = fullText.trim()
|
||||
message.success('文案生成成功')
|
||||
} catch (error) {
|
||||
console.error('生成文案失败:', error)
|
||||
message.error('生成文案失败,请重试')
|
||||
} finally {
|
||||
isGenerating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化:加载提示词
|
||||
onMounted(async () => {
|
||||
// 等待用户信息加载
|
||||
if (userStore.userId) {
|
||||
await loadUserPrompts()
|
||||
} else if (userStore.isLoggedIn) {
|
||||
// 如果已登录但userId未加载,等待一下
|
||||
setTimeout(async () => {
|
||||
if (userStore.userId) {
|
||||
await loadUserPrompts()
|
||||
}
|
||||
}, 500)
|
||||
}
|
||||
})
|
||||
|
||||
// 切换平台
|
||||
// const switchPlatform = (platformId) => {
|
||||
// activePlatform.value = platformId
|
||||
@@ -57,51 +265,103 @@ const handleGenerate = () => {
|
||||
// topicDetails.stylePrompt = ''
|
||||
// }
|
||||
|
||||
// 搜索热点
|
||||
/**
|
||||
* 处理搜索结果响应
|
||||
*/
|
||||
function processSearchResults(response, startId = 1) {
|
||||
try {
|
||||
// 提取 cursor(用于翻页)
|
||||
const cursor = response?.data?.cursor || null
|
||||
currentCursor.value = cursor
|
||||
|
||||
// 处理搜索结果
|
||||
const dataList = response?.data?.data || []
|
||||
const searchResults = dataList
|
||||
.map(el => el.aweme_info)
|
||||
.filter(el => el)
|
||||
.map((item, index) => ({
|
||||
id: startId + index,
|
||||
title: item.desc || '无标题',
|
||||
videoId: item.aweme_id,
|
||||
videoUrl: `https://www.douyin.com/video/${item.aweme_id}`,
|
||||
// 封面图片:优先使用 origin_cover,其次 cover,再次 dynamic_cover,最后 animated_cover
|
||||
cover: item?.video?.origin_cover?.url_list?.[0]
|
||||
|| item?.video?.cover?.url_list?.[0]
|
||||
|| item?.video?.dynamic_cover?.url_list?.[0]
|
||||
|| item?.video?.animated_cover?.url_list?.[0]
|
||||
|| item?.cover?.url_list?.[0]
|
||||
|| '',
|
||||
// 作者信息
|
||||
author: item.author?.nickname || item.author?.unique_id || '未知',
|
||||
authorAvatar: item.author?.avatar_thumb?.url_list?.[0] || item.author?.avatar_larger?.url_list?.[0] || '',
|
||||
// 统计数据
|
||||
playCount: item.statistics?.play_count || 0,
|
||||
diggCount: item.statistics?.digg_count || 0,
|
||||
commentCount: item.statistics?.comment_count || 0,
|
||||
shareCount: item.statistics?.share_count || 0,
|
||||
collectCount: item.statistics?.collect_count || 0,
|
||||
}))
|
||||
|
||||
return searchResults
|
||||
} catch (error) {
|
||||
console.error('处理搜索结果失败:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索热点
|
||||
*/
|
||||
const handleSearch = async () => {
|
||||
if (!searchKeyword.value.trim()) {
|
||||
message.warning('请输入搜索关键词')
|
||||
return
|
||||
}
|
||||
|
||||
// 重置参数
|
||||
searchParams.offset = 0
|
||||
searchParams.keyword = searchKeyword.value.trim()
|
||||
currentCursor.value = null
|
||||
hotTopics.value = []
|
||||
|
||||
isLoading.value = true
|
||||
try {
|
||||
const response = await TikhubService.postTikHup(
|
||||
{
|
||||
type: InterfaceType.DOUYIN_SEARCH_GENERAL_SEARCH, // 使用网页端通用搜索结果接口
|
||||
methodType: MethodType.POST,
|
||||
urlParams: {
|
||||
keyword: searchKeyword.value.trim(),
|
||||
sort_type: '1',
|
||||
offset: 0,
|
||||
count: 20,
|
||||
publish_time: 7
|
||||
},
|
||||
paramType: ParamType.JSON
|
||||
}
|
||||
)
|
||||
// 处理搜索结果
|
||||
const searchResults = response.data.data.map(el => el.aweme_info).filter(el => el).map((item, index) => ({
|
||||
id: hotTopics.value.length + index + 1,
|
||||
title: item.desc || '无标题',
|
||||
videoId: item.aweme_id,
|
||||
videoUrl: `https://www.douyin.com/video/${item.aweme_id}`, // 视频链接
|
||||
author: item.author.nickname,
|
||||
// 统计数据
|
||||
playCount: item.statistics?.play_count || 0, // 播放量
|
||||
diggCount: item.statistics?.digg_count || 0, // 点赞量(喜欢)
|
||||
commentCount: item.statistics?.comment_count || 0, // 评论数
|
||||
shareCount: item.statistics?.share_count || 0, // 分享数
|
||||
collectCount: item.statistics?.collect_count || 0, // 收藏数
|
||||
}))
|
||||
// 构建请求参数(POST JSON 格式)
|
||||
// 注意:API 要求 sort_type、publish_time、content_type 为字符串类型
|
||||
const urlParams = {
|
||||
keyword: searchParams.keyword,
|
||||
offset: String(searchParams.offset),
|
||||
count: '20',
|
||||
sort_type: String(searchParams.sort_type),
|
||||
publish_time: String(searchParams.publish_time),
|
||||
filter_duration: searchParams.filter_duration, // 已经是字符串
|
||||
content_type: String(searchParams.content_type),
|
||||
}
|
||||
|
||||
// 将搜索结果添加到列表顶部
|
||||
hotTopics.value = [...searchResults, ...hotTopics.value]
|
||||
const response = await TikhubService.postTikHup({
|
||||
type: InterfaceType.DOUYIN_SEARCH_GENERAL_SEARCH,
|
||||
methodType: MethodType.POST,
|
||||
urlParams: urlParams,
|
||||
paramType: ParamType.JSON
|
||||
})
|
||||
|
||||
// 处理搜索结果
|
||||
const searchResults = processSearchResults(response)
|
||||
|
||||
if (searchResults.length === 0) {
|
||||
message.warning('未找到相关结果')
|
||||
hotTopics.value = []
|
||||
return
|
||||
}
|
||||
|
||||
// 替换列表
|
||||
hotTopics.value = searchResults
|
||||
message.success(`找到 ${searchResults.length} 个结果`)
|
||||
|
||||
} catch (error) {
|
||||
console.error('搜索失败:', error)
|
||||
message.error(error?.message || '搜索失败,请稍后重试')
|
||||
hotTopics.value = []
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
@@ -137,6 +397,11 @@ const truncateTitle = (title, maxLength = 30) => {
|
||||
if (title.length <= maxLength) return title
|
||||
return title.substring(0, maxLength) + '...'
|
||||
}
|
||||
|
||||
// 处理图片加载错误
|
||||
const handleImageError = (event) => {
|
||||
event.target.style.display = 'none'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -166,20 +431,58 @@ const truncateTitle = (title, maxLength = 30) => {
|
||||
<div class="search-input-wrapper">
|
||||
<input v-model="searchKeyword" type="text" placeholder="输入关键词搜索抖音热点..." class="search-input"
|
||||
:disabled="isLoading" @keypress="handleSearchKeypress" />
|
||||
<button @click="handleSearch" :disabled="isLoading || !searchKeyword.trim()" class="search-btn"
|
||||
:class="{ 'search-btn--loading': isLoading }">
|
||||
<svg v-if="!isLoading" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<button @click="handleSearch" :disabled="isLoading || !searchKeyword.trim()" class="search-btn">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<svg v-else class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
|
||||
</path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 搜索参数配置 -->
|
||||
<div class="search-params">
|
||||
<div class="param-row">
|
||||
<div class="param-item">
|
||||
<label class="param-label">排序方式</label>
|
||||
<select v-model="searchParams.sort_type" class="param-select">
|
||||
<option :value="0">综合排序</option>
|
||||
<option :value="1">最多点赞</option>
|
||||
<option :value="2">最新发布</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="param-item">
|
||||
<label class="param-label">发布时间</label>
|
||||
<select v-model="searchParams.publish_time" class="param-select">
|
||||
<option value="0">不限</option>
|
||||
<option value="1">最近一天</option>
|
||||
<option value="7">最近一周</option>
|
||||
<option value="180">最近半年</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="param-item">
|
||||
<label class="param-label">视频时长</label>
|
||||
<select v-model="searchParams.filter_duration" class="param-select">
|
||||
<option value="0">不限</option>
|
||||
<option value="0-1">1分钟以内</option>
|
||||
<option value="1-5">1-5分钟</option>
|
||||
<option value="5-10000">5分钟以上</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="param-row">
|
||||
<div class="param-item">
|
||||
<label class="param-label">内容类型</label>
|
||||
<select v-model="searchParams.content_type" class="param-select">
|
||||
<option value="0">不限</option>
|
||||
<option value="1">视频</option>
|
||||
<option value="2">图集</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 热点列表 -->
|
||||
@@ -199,19 +502,52 @@ const truncateTitle = (title, maxLength = 30) => {
|
||||
<div v-for="topic in hotTopics" :key="topic.id" @click="handleCreate(topic)" class="topic-item"
|
||||
:class="{ 'topic-item--selected': selectedTopic === topic.id }">
|
||||
<div class="flex flex-col flex-1 min-w-0">
|
||||
<div class="flex items-center mb-2">
|
||||
<span class="topic-number">{{ topic.id }}</span>
|
||||
<span
|
||||
v-if="topic.videoUrl"
|
||||
@click="openVideo(topic, $event)"
|
||||
class="flex-1 topic-title topic-title--clickable"
|
||||
:title="topic.title"
|
||||
>
|
||||
{{ truncateTitle(topic.title) }}
|
||||
</span>
|
||||
<span v-else class="flex-1 topic-title" :title="topic.title">
|
||||
{{ truncateTitle(topic.title) }}
|
||||
</span>
|
||||
<!-- 封面和标题 -->
|
||||
<div class="flex items-start gap-3 mb-2">
|
||||
<!-- 封面图片 -->
|
||||
<div class="topic-cover-wrapper">
|
||||
<img
|
||||
v-if="topic.cover"
|
||||
:src="topic.cover"
|
||||
alt="封面"
|
||||
class="topic-cover"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<div v-else class="topic-cover-placeholder">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 标题和作者 -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center mb-1">
|
||||
<span class="topic-number">{{ topic.id }}</span>
|
||||
<span
|
||||
v-if="topic.videoUrl"
|
||||
@click="openVideo(topic, $event)"
|
||||
class="flex-1 topic-title topic-title--clickable"
|
||||
:title="topic.title"
|
||||
>
|
||||
{{ truncateTitle(topic.title) }}
|
||||
</span>
|
||||
<span v-else class="flex-1 topic-title" :title="topic.title">
|
||||
{{ truncateTitle(topic.title) }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- 作者信息 -->
|
||||
<div class="flex items-center gap-2 text-xs text-gray-500 topic-author">
|
||||
<img
|
||||
v-if="topic.authorAvatar"
|
||||
:src="topic.authorAvatar"
|
||||
alt="作者头像"
|
||||
class="author-avatar"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<span>{{ topic.author }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 统计信息 -->
|
||||
<div v-if="topic.diggCount || topic.playCount || topic.commentCount || topic.collectCount || topic.shareCount" class="flex flex-wrap items-center gap-4 text-xs text-gray-500 topic-stats">
|
||||
@@ -247,11 +583,19 @@ const truncateTitle = (title, maxLength = 30) => {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button @click.stop="handleCreate(topic)" class="create-btn">
|
||||
创作
|
||||
</button>
|
||||
<GradientButton
|
||||
text="创作"
|
||||
size="small"
|
||||
@click.stop="handleCreate(topic)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading 指示器 -->
|
||||
<div v-if="isLoading" class="loading-indicator">
|
||||
<a-spin size="small" />
|
||||
<span class="loading-text">搜索中...</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -275,23 +619,107 @@ const truncateTitle = (title, maxLength = 30) => {
|
||||
|
||||
<!-- 风格提示词 -->
|
||||
<div>
|
||||
<label class="form-label">风格提示词</label>
|
||||
<input v-model="topicDetails.stylePrompt" type="text" placeholder="例如:专业、权威、温暖、幽默等" class="form-input" />
|
||||
<div class="form-label-wrapper">
|
||||
<label class="form-label">风格提示词</label>
|
||||
<a-button
|
||||
v-if="allPrompts.length > DISPLAY_COUNT"
|
||||
size="small"
|
||||
type="link"
|
||||
@click="showAllPromptsModal = true"
|
||||
style="padding: 0; height: auto; font-size: 12px;">
|
||||
更多 ({{ allPrompts.length }})
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<!-- 提示词标签展示区域 -->
|
||||
<div v-if="displayPrompts.length > 0" class="prompt-tags-container">
|
||||
<div class="prompt-tags-grid">
|
||||
<div
|
||||
v-for="prompt in displayPrompts"
|
||||
:key="prompt.id"
|
||||
class="prompt-tag"
|
||||
:class="{ 'prompt-tag-selected': topicDetails.stylePromptId === prompt.id }"
|
||||
@click="selectPrompt(prompt)">
|
||||
<span class="prompt-tag-name">{{ prompt.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-else-if="!loadingPrompts" class="prompt-empty">
|
||||
<div style="color: var(--color-text-secondary); font-size: 12px; text-align: center; padding: 20px;">
|
||||
您可以在视频分析页面保存风格
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-else class="prompt-loading">
|
||||
<a-spin size="small" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 立即生成按钮 -->
|
||||
<!-- 生成文案按钮 -->
|
||||
<div class="pt-2">
|
||||
<button @click="handleGenerate" class="generate-btn">
|
||||
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd"
|
||||
d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
立即生成
|
||||
</button>
|
||||
<GradientButton
|
||||
text="生成文案"
|
||||
icon="icon-sparkle"
|
||||
:disabled="!topicDetails.copywriting?.trim() || !topicDetails.stylePromptId || isGenerating"
|
||||
:loading="isGenerating"
|
||||
loading-text="生成中..."
|
||||
size="middle"
|
||||
:block="true"
|
||||
@click="handleGenerate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 生成的文案显示区域 -->
|
||||
<div v-if="generatedContent" class="generated-content-section">
|
||||
<div class="form-label">生成结果</div>
|
||||
<div class="generated-content-wrapper">
|
||||
<div class="generated-content-text">{{ generatedContent }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 更多提示词弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="showAllPromptsModal"
|
||||
title="选择提示词风格"
|
||||
:width="600"
|
||||
:footer="null">
|
||||
<div class="prompt-modal-content">
|
||||
<!-- 搜索框 -->
|
||||
<a-input
|
||||
v-model:value="promptSearchKeyword"
|
||||
placeholder="搜索提示词..."
|
||||
style="margin-bottom: 16px;"
|
||||
allow-clear>
|
||||
<template #prefix>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||
</svg>
|
||||
</template>
|
||||
</a-input>
|
||||
|
||||
<!-- 提示词列表 -->
|
||||
<div v-if="filteredPrompts.length > 0" class="all-prompts-grid">
|
||||
<div
|
||||
v-for="prompt in filteredPrompts"
|
||||
:key="prompt.id"
|
||||
class="all-prompt-tag"
|
||||
:class="{ 'all-prompt-tag-selected': topicDetails.stylePromptId === prompt.id }"
|
||||
@click="selectPrompt(prompt)">
|
||||
<span class="all-prompt-tag-name">{{ prompt.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-else style="text-align: center; padding: 40px; color: var(--color-text-secondary);">
|
||||
没有找到匹配的提示词
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -368,6 +796,52 @@ const truncateTitle = (title, maxLength = 30) => {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* 搜索参数配置 */
|
||||
.search-params {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.param-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.param-item {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.param-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.param-select {
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
font-size: 13px;
|
||||
color: var(--color-text);
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.param-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.1);
|
||||
}
|
||||
|
||||
.param-select:hover {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.search-input-wrapper {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
@@ -426,8 +900,18 @@ const truncateTitle = (title, maxLength = 30) => {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.search-btn--loading {
|
||||
opacity: 0.8;
|
||||
/* Loading 指示器 */
|
||||
.loading-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 热点列表 */
|
||||
@@ -536,6 +1020,47 @@ const truncateTitle = (title, maxLength = 30) => {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* 封面图片 */
|
||||
.topic-cover-wrapper {
|
||||
flex-shrink: 0;
|
||||
width: 80px;
|
||||
height: 45px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.topic-cover {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.topic-cover-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-text-secondary);
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
/* 作者信息 */
|
||||
.topic-author {
|
||||
padding-left: 32px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.author-avatar {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 统计信息 */
|
||||
.topic-stats {
|
||||
padding-left: 32px;
|
||||
@@ -554,28 +1079,6 @@ const truncateTitle = (title, maxLength = 30) => {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* 创作按钮 */
|
||||
.create-btn {
|
||||
margin-left: auto;
|
||||
padding: 6px 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: white;
|
||||
background: var(--color-primary);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 8px rgba(0, 176, 48, 0.2);
|
||||
transition: all 0.2s;
|
||||
flex-shrink: 0;
|
||||
align-self: flex-start;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.create-btn:hover {
|
||||
box-shadow: 0 0 12px rgba(0, 176, 48, 0.5);
|
||||
filter: brightness(1.1);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
/* 详情内容 */
|
||||
.detail-content {
|
||||
@@ -620,23 +1123,142 @@ const truncateTitle = (title, maxLength = 30) => {
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.generate-btn {
|
||||
width: 100%;
|
||||
padding: 8px 24px;
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 8px rgba(0, 176, 48, 0.3);
|
||||
transition: all 0.2s;
|
||||
/* 表单标签包装器 */
|
||||
.form-label-wrapper {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.generate-btn:hover {
|
||||
box-shadow: 0 0 12px rgba(0, 176, 48, 0.4);
|
||||
filter: brightness(1.05);
|
||||
transform: scale(1.01);
|
||||
/* 提示词标签样式 */
|
||||
.prompt-tags-container {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.prompt-tags-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.prompt-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 6px 14px;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.prompt-tag:hover {
|
||||
border-color: var(--color-primary);
|
||||
background: rgba(24, 144, 255, 0.08);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.prompt-tag-selected {
|
||||
border-color: var(--color-primary);
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.prompt-tag-selected:hover {
|
||||
background: var(--color-primary);
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.prompt-tag-name {
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.prompt-empty {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.prompt-loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* 生成结果区域 */
|
||||
.generated-content-section {
|
||||
margin-top: 24px;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.generated-content-wrapper {
|
||||
margin-top: 12px;
|
||||
padding: 16px;
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.generated-content-text {
|
||||
color: var(--color-text);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
/* 更多提示词弹窗样式 */
|
||||
.prompt-modal-content {
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.all-prompts-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.all-prompt-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 8px 16px;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.all-prompt-tag:hover {
|
||||
border-color: var(--color-primary);
|
||||
background: rgba(24, 144, 255, 0.08);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.all-prompt-tag-selected {
|
||||
border-color: var(--color-primary);
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.all-prompt-tag-selected:hover {
|
||||
background: var(--color-primary);
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.all-prompt-tag-name {
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -59,36 +59,103 @@ public class TikHupServiceImpl implements TikHupService {
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object postTikHup(String type,String methodType,String urlParams,String paramType){
|
||||
public Object postTikHup(String type, String methodType, String urlParams, String paramType) {
|
||||
// 1. 参数校验
|
||||
if (StringUtils.isBlank(type)) {
|
||||
log.error("postTikHup: type 参数为空");
|
||||
return CommonResult.error(400, "接口类型不能为空");
|
||||
}
|
||||
if (StringUtils.isBlank(methodType)) {
|
||||
log.error("postTikHup: methodType 参数为空");
|
||||
return CommonResult.error(400, "请求方法类型不能为空");
|
||||
}
|
||||
|
||||
// 2. 获取接口配置信息
|
||||
TikTokenVO tikTokenVO = tikTokenMapper.getInterfaceUrl(type);
|
||||
String Authorization = tikTokenVO.getPlatformToken();
|
||||
if (tikTokenVO == null) {
|
||||
log.error("postTikHup: 未找到接口类型 {} 的配置信息", type);
|
||||
return CommonResult.error(404, "未找到接口类型 " + type + " 的配置信息");
|
||||
}
|
||||
|
||||
String authorization = tikTokenVO.getPlatformToken();
|
||||
String url = tikTokenVO.getPlatformUrl();
|
||||
try{
|
||||
|
||||
if (StringUtils.isBlank(authorization)) {
|
||||
log.error("postTikHup: 接口类型 {} 的 token 为空", type);
|
||||
return CommonResult.error(500, "接口配置错误:token 为空");
|
||||
}
|
||||
if (StringUtils.isBlank(url)) {
|
||||
log.error("postTikHup: 接口类型 {} 的 URL 为空", type);
|
||||
return CommonResult.error(500, "接口配置错误:URL 为空");
|
||||
}
|
||||
|
||||
// 3. 统一转换为小写进行比较(兼容大小写)
|
||||
String methodTypeLower = methodType.toLowerCase();
|
||||
String paramTypeLower = paramType != null ? paramType.toLowerCase() : "";
|
||||
|
||||
try {
|
||||
Unirest.setTimeouts(0, 0);
|
||||
HttpResponse<String> response;
|
||||
if("post".equals(methodType) && "json".equals(paramType)){
|
||||
|
||||
// 4. 根据请求方法和参数类型构建请求
|
||||
if ("post".equals(methodTypeLower) && "json".equals(paramTypeLower)) {
|
||||
// POST + JSON: 将 urlParams 作为 JSON body
|
||||
log.debug("postTikHup: POST JSON 请求, URL: {}, Body: {}", url, urlParams);
|
||||
response = Unirest.post(url)
|
||||
.header("Authorization", "Bearer "+Authorization)
|
||||
.header("Authorization", "Bearer " + authorization)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(urlParams)
|
||||
.asString();
|
||||
} else if("post".equals(methodType)){
|
||||
} else if ("post".equals(methodTypeLower)) {
|
||||
// POST + 表单参数: 将 urlParams 作为 URL 查询参数
|
||||
log.debug("postTikHup: POST 表单请求, URL: {}?{}", url, urlParams);
|
||||
response = Unirest.post(url + "?" + urlParams)
|
||||
.header("Authorization", "Bearer "+Authorization)
|
||||
.header("Authorization", "Bearer " + authorization)
|
||||
.asString();
|
||||
} else {
|
||||
response = Unirest.get(url + "?" + urlParams)
|
||||
.header("Authorization", "Bearer "+Authorization)
|
||||
// GET 或其他方法: 将 urlParams 作为 URL 查询参数
|
||||
// 处理 URL 拼接:如果 URL 已包含查询参数,使用 & 连接,否则使用 ? 连接
|
||||
String finalUrl = url;
|
||||
if (urlParams != null && !urlParams.trim().isEmpty()) {
|
||||
if (url.contains("?")) {
|
||||
// URL 已包含查询参数,使用 & 连接
|
||||
finalUrl = url + "&" + urlParams;
|
||||
} else {
|
||||
// URL 不包含查询参数,使用 ? 连接
|
||||
finalUrl = url + "?" + urlParams;
|
||||
}
|
||||
}
|
||||
log.info("postTikHup: GET 请求, 原始URL: {}, 参数: {}, 最终URL: {}", url, urlParams, finalUrl);
|
||||
response = Unirest.get(finalUrl)
|
||||
.header("Authorization", "Bearer " + authorization)
|
||||
.asString();
|
||||
}
|
||||
Long userId = SecurityFrameworkUtils.getLoginUser().getId();
|
||||
if(response.getBody() != null){
|
||||
return response.getBody();
|
||||
|
||||
// 5. 检查响应状态码
|
||||
int statusCode = response.getStatus();
|
||||
String responseBody = response.getBody();
|
||||
|
||||
if (statusCode == 200) {
|
||||
if (StringUtils.isNotBlank(responseBody)) {
|
||||
// 尝试解析为 JSON,如果失败则直接返回字符串
|
||||
try {
|
||||
return JSON.parseObject(responseBody);
|
||||
} catch (Exception e) {
|
||||
// 如果不是 JSON,直接返回字符串
|
||||
return responseBody;
|
||||
}
|
||||
} else {
|
||||
log.warn("postTikHup: 接口返回空响应, URL: {}", url);
|
||||
return CommonResult.error(500, "接口返回空响应");
|
||||
}
|
||||
} else {
|
||||
log.error("postTikHup: 接口调用失败, URL: {}, 状态码: {}, 响应: {}", url, statusCode, responseBody);
|
||||
return CommonResult.error(statusCode, "接口调用失败: " + (StringUtils.isNotBlank(responseBody) ? responseBody : "HTTP " + statusCode));
|
||||
}
|
||||
}catch (Exception e){
|
||||
log.error("{}接口调用异常",url);
|
||||
} catch (Exception e) {
|
||||
log.error("postTikHup: 接口调用异常, URL: {}, 错误信息: {}", url, e.getMessage(), e);
|
||||
return CommonResult.error(500, "接口调用异常: " + e.getMessage());
|
||||
}
|
||||
return new HashMap<>();
|
||||
}
|
||||
|
||||
private final String appKey = "sldJ4XSpYp3rKALZ ";
|
||||
|
||||
@@ -32,9 +32,36 @@ public class AppUserPromptController {
|
||||
|
||||
@PostMapping("/create")
|
||||
@Operation(summary = "创建用户提示词")
|
||||
public CommonResult<Long> createUserPrompt(@Valid @RequestBody UserPromptSaveReqVO createReqVO) {
|
||||
// 设置当前登录用户ID
|
||||
createReqVO.setUserId(getLoginUserId());
|
||||
public CommonResult<Long> createUserPrompt(@RequestBody UserPromptSaveReqVO createReqVO) {
|
||||
// 先设置当前登录用户ID(在验证之前设置,避免 @NotNull 验证失败)
|
||||
Long userId = getLoginUserId();
|
||||
if (userId == null) {
|
||||
return CommonResult.error(401, "用户未登录");
|
||||
}
|
||||
createReqVO.setUserId(userId);
|
||||
|
||||
// 手动验证必要字段
|
||||
if (createReqVO.getName() == null || createReqVO.getName().trim().isEmpty()) {
|
||||
return CommonResult.error(400, "提示词名称不能为空");
|
||||
}
|
||||
if (createReqVO.getContent() == null || createReqVO.getContent().trim().isEmpty()) {
|
||||
return CommonResult.error(400, "提示词内容不能为空");
|
||||
}
|
||||
if (createReqVO.getStatus() == null) {
|
||||
return CommonResult.error(400, "状态不能为空");
|
||||
}
|
||||
|
||||
// 设置默认值(如果前端没有传递)
|
||||
if (createReqVO.getIsPublic() == null) {
|
||||
createReqVO.setIsPublic(false); // 默认私有
|
||||
}
|
||||
if (createReqVO.getSort() == null) {
|
||||
createReqVO.setSort(0); // 默认排序为 0
|
||||
}
|
||||
if (createReqVO.getUseCount() == null) {
|
||||
createReqVO.setUseCount(0); // 默认使用次数为 0
|
||||
}
|
||||
|
||||
return success(userPromptService.createUserPrompt(createReqVO));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user