Files
sionrui/frontend/app/web-gold/src/components/PromptSelector.vue

576 lines
15 KiB
Vue
Raw Normal View History

2026-01-18 02:15:08 +08:00
<template>
<div class="prompt-selector">
<!-- 下拉菜单式选择器推荐方案 -->
<div v-if="displayMode === 'select'" class="prompt-select-container">
<a-select
v-model:value="selectedPromptId"
placeholder="选择提示词风格"
style="width: 100%"
@change="handleSelectChange"
:loading="loading"
>
<a-select-option
v-for="prompt in allPrompts"
:key="prompt.id"
:value="prompt.id"
>
<div class="prompt-option">
<span class="prompt-option-name">{{ prompt.name }}</span>
</div>
</a-select-option>
</a-select>
</div>
<!-- 标签式选择器 -->
<div v-else-if="displayMode === 'tags'" 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': selectedPromptId === prompt.id }"
@click="selectPrompt(prompt)"
>
<span class="prompt-tag-name">{{ prompt.name }}</span>
</div>
</div>
<div v-if="hasMore && showMoreButton" class="prompt-more-button">
<a-button size="small" type="link" @click="showAllPromptsModal = true" :disabled="allPrompts.length === 0">
更多 ({{ allPrompts.length - displayCount }})
</a-button>
</div>
</div>
<!-- 加载状态 -->
<div v-if="loading && allPrompts.length === 0" class="prompt-loading">
<a-spin size="small" />
<span class="loading-text">加载中...</span>
</div>
<!-- 空状态 -->
<div v-else-if="!loading && allPrompts.length === 0" class="prompt-empty-state">
<div class="prompt-empty-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<circle cx="8.5" cy="8.5" r="1.5"></circle>
<polyline points="21 15 16 10 5 21"></polyline>
</svg>
</div>
<p class="prompt-empty-text">没有找到提示词</p>
</div>
<!-- 更多提示词弹窗 -->
<div v-if="showAllPromptsModal" class="prompt-modal-mask" @click.self="showAllPromptsModal = false">
<div class="prompt-modal">
<div class="prompt-modal-header">
<h3 class="prompt-modal-title">选择提示词</h3>
<button class="prompt-modal-close" @click="showAllPromptsModal = false">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<!-- 搜索框 -->
<div class="prompt-modal-search">
<svg class="prompt-search-icon" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
<input
v-model="promptSearchKeyword"
type="text"
placeholder="搜索提示词..."
class="prompt-search-input"
/>
<button class="refresh-button" @click="refreshUserPrompts" :disabled="loading">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="23 4 23 10 17 10"></polyline>
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path>
</svg>
</button>
</div>
<!-- 提示词列表 -->
<div class="prompt-modal-content">
<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': selectedPromptId === prompt.id }"
@click="selectPrompt(prompt)"
>
<span class="all-prompt-tag-name">{{ prompt.name }}</span>
<span v-if="prompt.status === 1" class="all-prompt-tag-status">启用</span>
<span v-else class="all-prompt-tag-status all-prompt-tag-status-disabled">禁用</span>
</div>
</div>
<div v-else class="prompt-empty-state">
<div class="prompt-empty-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<circle cx="8.5" cy="8.5" r="1.5"></circle>
<polyline points="21 15 16 10 5 21"></polyline>
</svg>
</div>
<p class="prompt-empty-text">没有找到匹配的提示词</p>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
import { useUserStore } from '@/stores/user'
import { usePromptStore } from '@/stores/prompt'
import { message } from 'ant-design-vue'
import { setJSON, getJSON } from '@/utils/storage'
// Props
const props = defineProps({
// 当前选中的提示词ID
modelValue: {
type: Number,
default: null
},
// 展示模式tags标签或 select下拉选择
displayMode: {
type: String,
default: 'select' // 默认为select模式因为用户反馈标签模式不好看
},
// 展示数量(仅标签模式有效)
displayCount: {
type: Number,
default: 6
},
// 是否显示"更多"按钮
showMoreButton: {
type: Boolean,
default: true
},
// 本地存储键名前缀,用于保存和恢复选择
storageKey: {
type: String,
default: 'prompt_selector'
}
})
// Emits
const emit = defineEmits(['update:modelValue', 'change'])
// Stores
const userStore = useUserStore()
const promptStore = usePromptStore()
// Refs
const showAllPromptsModal = ref(false)
const promptSearchKeyword = ref('')
const selectedPromptId = ref(props.modelValue)
// 使用 store 中的数据
const allPrompts = computed(() => promptStore.promptList)
const loading = computed(() => promptStore.promptListLoading)
// 计算属性:展示的部分提示词
const displayPrompts = computed(() => {
if (props.displayMode !== 'tags') return []
return allPrompts.value.slice(0, props.displayCount)
})
// 计算属性:是否有更多提示词
const hasMore = computed(() => {
return allPrompts.value.length > props.displayCount
})
// 计算属性:过滤后的全部提示词(用于"更多"弹窗)
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))
)
})
// 监听 selectedPromptId 变化,同步到父组件
watch(selectedPromptId, (newValue) => {
emit('update:modelValue', newValue)
saveSelectedPromptId(newValue)
})
// 监听 props.modelValue 变化,更新内部状态
watch(() => props.modelValue, (newValue) => {
selectedPromptId.value = newValue
})
// 加载用户提示词
async function loadUserPrompts() {
// 检查用户是否登录
if (!userStore.userId) {
console.warn('用户未登录,无法加载提示词')
return
}
try {
// 使用 store 加载数据
const prompts = await promptStore.loadPromptList(userStore.userId)
// 如果有选中ID但当前选中的提示词不在列表中清空选择
if (selectedPromptId.value && !prompts.find(p => p.id === selectedPromptId.value)) {
selectedPromptId.value = null
}
// 如果没有选中ID且有提示词默认选中第一个
else if (!selectedPromptId.value && prompts.length > 0) {
selectedPromptId.value = prompts[0].id
}
// 尝试从本地存储恢复选中状态
await restoreSelectedPromptId()
} catch (error) {
console.error('加载提示词失败:', error)
}
}
// 保存选中的提示词ID到本地存储
async function saveSelectedPromptId(promptId) {
if (!promptId) return
try {
await setJSON(`${props.storageKey}_selected`, promptId)
} catch (error) {
console.error('保存提示词选择失败:', error)
}
}
// 从本地存储恢复选中的提示词ID
async function restoreSelectedPromptId() {
try {
const savedId = await getJSON(`${props.storageKey}_selected`, null)
if (savedId) {
// 检查保存的ID是否在当前列表中
const prompt = allPrompts.value.find(p => p.id === savedId)
if (prompt) {
selectedPromptId.value = savedId
}
}
} catch (error) {
console.error('恢复提示词选择失败:', error)
}
}
// 选择提示词
function selectPrompt(prompt) {
if (!prompt || !prompt.content) {
console.warn('提示词内容为空')
return
}
selectedPromptId.value = prompt.id
emit('change', prompt)
showAllPromptsModal.value = false
}
// 处理下拉选择变化
function handleSelectChange(value) {
const prompt = allPrompts.value.find(p => p.id === value)
if (prompt) {
emit('change', prompt)
}
}
// 刷新用户提示词
async function refreshUserPrompts() {
try {
await promptStore.refreshPromptList(userStore.userId)
await restoreSelectedPromptId()
} catch (error) {
console.error('刷新提示词失败:', error)
message.error('刷新提示词失败')
}
}
// 组件挂载时加载提示词
onMounted(() => {
loadUserPrompts()
})
</script>
<style scoped>
/* 基础样式 */
.prompt-selector {
width: 100%;
}
/* 下拉选择器样式 */
.prompt-select-container {
width: 100%;
}
.prompt-option {
display: flex;
justify-content: space-between;
align-items: center;
}
.prompt-option-name {
font-weight: 500;
}
/* 标签模式 */
.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;
font-size: 14px;
font-weight: 500;
color: var(--color-text);
}
.prompt-tag:hover {
border-color: var(--color-primary);
background: rgba(24, 144, 255, 0.08);
}
.prompt-tag-selected {
border-color: var(--color-primary);
background: var(--color-primary);
color: #fff;
}
.prompt-tag-name {
white-space: nowrap;
user-select: none;
}
.prompt-more-button {
margin-top: 8px;
text-align: right;
}
/* 加载状态 */
.prompt-loading {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 20px;
color: var(--color-text-secondary);
}
.loading-text {
font-size: 14px;
}
/* 空状态 */
.prompt-empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
color: var(--color-text-secondary);
}
.prompt-empty-icon {
color: var(--color-text-secondary);
opacity: 0.5;
margin-bottom: 12px;
}
.prompt-empty-text {
font-size: 14px;
margin: 0;
}
/* 弹窗 */
.prompt-modal-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.prompt-modal {
background: var(--color-surface);
border-radius: var(--radius-card);
width: 600px;
max-height: 80vh;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: var(--shadow-lg);
}
.prompt-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid var(--color-border);
}
.prompt-modal-title {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--color-text);
}
.prompt-modal-close {
background: transparent;
border: none;
cursor: pointer;
color: var(--color-text-secondary);
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 50%;
}
.prompt-modal-close:hover {
background: var(--color-bg);
color: var(--color-text);
}
.prompt-modal-search {
position: relative;
padding: 16px 20px;
border-bottom: 1px solid var(--color-border);
display: flex;
gap: 12px;
}
.prompt-search-icon {
position: absolute;
left: 36px;
top: 50%;
transform: translateY(-50%);
color: var(--color-text-secondary);
pointer-events: none;
}
.prompt-search-input {
flex: 1;
padding: 10px 12px 10px 40px;
font-size: 14px;
color: var(--color-text);
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: 6px;
}
.prompt-search-input:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(24, 144, 255, 0.1);
}
.refresh-button {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: 6px;
color: var(--color-text);
cursor: pointer;
}
.refresh-button:hover:not(:disabled) {
background: var(--color-primary);
color: #fff;
border-color: var(--color-primary);
}
.refresh-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.prompt-modal-content {
overflow-y: auto;
max-height: calc(80vh - 180px);
padding: 16px 0;
}
/* 提示词列表 */
.all-prompts-grid {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 0 16px;
}
.all-prompt-tag {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 16px;
cursor: pointer;
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);
}
.all-prompt-tag-selected {
border-color: var(--color-primary);
background: var(--color-primary);
color: #fff;
}
.all-prompt-tag-name {
white-space: nowrap;
user-select: none;
}
.all-prompt-tag-status {
font-size: 12px;
padding: 2px 6px;
border-radius: 10px;
background: rgba(16, 185, 129, 0.1);
color: #10b981;
}
.all-prompt-tag-status-disabled {
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
}
</style>