Paginator.vue复用组件(客户端切片,不发新IPC,数据量小前瞻基建) Tasks/Ideas/Projects.vue接store列表切片(pagedProjects/pagedIdeas) 向后兼容铁律:pageSize=0整组件v-if隐藏+切片返全量,等价改造前行为 旧调用方(store.loadTasks等签名)零改动,分页状态纯前端ref 筛选/排序变化page重置1防越界空页;i18n双语pagination键齐备
573 lines
20 KiB
Vue
573 lines
20 KiB
Vue
<template>
|
||
<div class="ideas">
|
||
<header class="page-header">
|
||
<h1>{{ $t('ideas.title') }}</h1>
|
||
<div class="header-actions">
|
||
<button class="btn btn-ghost" @click="refresh">{{ $t('ideas.refresh') }}</button>
|
||
<button class="btn btn-primary" @click="openCaptureModal">{{ $t('ideas.capture') }}</button>
|
||
</div>
|
||
</header>
|
||
|
||
<!-- 搜索和筛选栏 -->
|
||
<div class="filter-bar">
|
||
<div class="search-box">
|
||
<input
|
||
v-model="searchQuery"
|
||
type="text"
|
||
:placeholder="$t('ideas.searchPlaceholder')"
|
||
/>
|
||
</div>
|
||
<button
|
||
v-for="f in filters"
|
||
:key="f.key"
|
||
class="filter-btn"
|
||
:class="{ active: activeFilter === f.key }"
|
||
@click="activeFilter = f.key"
|
||
>
|
||
{{ f.icon }} {{ $t(f.labelKey) }}
|
||
</button>
|
||
<!-- 排序切换:score 高分在前 / time 新在前 -->
|
||
<div class="sort-group">
|
||
<button
|
||
class="filter-btn"
|
||
:class="{ active: sortMode === 'score' }"
|
||
@click="sortMode = 'score'"
|
||
>{{ $t('ideas.sortByScore') }}</button>
|
||
<button
|
||
class="filter-btn"
|
||
:class="{ active: sortMode === 'time' }"
|
||
@click="sortMode = 'time'"
|
||
>{{ $t('ideas.sortByTime') }}</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 两栏布局 -->
|
||
<div class="ideas-layout">
|
||
<!-- 左侧:灵感列表 -->
|
||
<section class="idea-list-panel">
|
||
<div class="idea-list">
|
||
<!-- 三态:加载/错误/空(对齐 Tasks.vue:43-45) -->
|
||
<div v-if="store.loading" class="empty-state">{{ $t('common.loading') }}</div>
|
||
<div v-else-if="store.error" class="empty-state">{{ store.error }}</div>
|
||
<div v-else-if="filteredIdeas.length === 0" class="empty-state">{{ $t('ideas.listEmpty') }}</div>
|
||
<template v-else>
|
||
<div
|
||
v-for="idea in pagedIdeas"
|
||
:key="idea.id"
|
||
class="idea-card"
|
||
:class="{ selected: selectedId === idea.id }"
|
||
@click="selectedId = idea.id"
|
||
>
|
||
<div class="idea-card-header">
|
||
<span class="idea-title">{{ idea.title }}</span>
|
||
<span class="idea-score" :class="scoreClass(idea.score)">{{ idea.score ?? '-' }}</span>
|
||
</div>
|
||
<p class="idea-desc-preview">{{ stripMd(idea.description).slice(0, 60) }}{{ stripMd(idea.description).length > 60 ? '...' : '' }}</p>
|
||
<div class="idea-card-footer">
|
||
<span class="status-tag" :class="'status-' + idea.status">{{ $t(statusLabelKey(idea.status)) }}</span>
|
||
<span class="idea-date">{{ formatDate(idea.created_at) }}</span>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
<!-- 分页器(F-260621-02 P3):pageSize=0 不分页(全量,等价旧行为);用户选 N 条触发客户端切片 -->
|
||
<Paginator
|
||
v-model:page="page"
|
||
v-model:pageSize="pageSize"
|
||
:total="filteredIdeas.length"
|
||
/>
|
||
</section>
|
||
|
||
<!-- 右侧:灵感详情 -->
|
||
<IdeaDetail
|
||
v-if="currentIdea"
|
||
:idea="currentIdea"
|
||
:evaluating="evaluating"
|
||
:eval-error="evalError"
|
||
:promoting="promoting"
|
||
:deleting="deleting"
|
||
:status-options="statusOptions"
|
||
@evaluate="evaluateCurrentIdea"
|
||
@promote="promoteToProject"
|
||
@delete="deleteCurrentIdea"
|
||
@status-change="onStatusChange"
|
||
@update-desc="onUpdateDesc"
|
||
@update-related="onUpdateRelated"
|
||
/>
|
||
|
||
<!-- 未选择 -->
|
||
<section class="idea-detail-panel idea-empty" v-else>
|
||
<div class="empty-state">
|
||
<div class="empty-icon">💡</div>
|
||
<p>{{ $t('ideas.emptyState') }}</p>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
<!-- 捕捉灵感模态框 -->
|
||
<div class="modal-overlay" v-if="showCaptureModal" @click.self="showCaptureModal = false">
|
||
<div class="modal-box">
|
||
<h3>{{ $t('ideas.captureTitle') }}</h3>
|
||
<label style="font-size:12px;color:var(--df-text-secondary);margin-bottom:4px;display:block">{{ $t('ideas.fieldTitle') }}</label>
|
||
<input v-model="newIdeaTitle" :placeholder="$t('ideas.titlePlaceholder')" @keyup.enter="confirmCapture" />
|
||
<label style="font-size:12px;color:var(--df-text-secondary);margin-bottom:4px;display:block">{{ $t('ideas.fieldDesc') }}</label>
|
||
<textarea v-model="newIdeaDesc" :placeholder="$t('ideas.descPlaceholder')" rows="3" style="resize:vertical"></textarea>
|
||
<div class="modal-actions">
|
||
<button class="btn-cancel" @click="showCaptureModal = false">{{ $t('common.cancel') }}</button>
|
||
<button class="btn-confirm" :disabled="creating" @click="confirmCapture">
|
||
{{ creating ? $t('ideas.creating') : $t('common.confirm') }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 确认弹层(删除灵感,替代原生 window.confirm) -->
|
||
<ConfirmDialog :visible="confirmState.visible" :msg="confirmState.msg" @result="answerConfirm" />
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed, onMounted, watch } from 'vue'
|
||
import { useRouter, useRoute } from 'vue-router'
|
||
import { useI18n } from 'vue-i18n'
|
||
import { Message } from '@arco-design/web-vue'
|
||
import { useProjectStore } from '../stores/project'
|
||
import type { IdeaStatus, IdeaQuery } from '../api/types'
|
||
import { formatDate } from '../utils/time'
|
||
import { stripMd } from '../utils/markdown'
|
||
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
||
import IdeaDetail from '../components/ideas/IdeaDetail.vue'
|
||
import { useConfirm } from '../composables/useConfirm'
|
||
import Paginator from '../components/Paginator.vue'
|
||
|
||
const { t } = useI18n()
|
||
const router = useRouter()
|
||
const route = useRoute()
|
||
const store = useProjectStore()
|
||
|
||
// 确认弹层状态机抽至 composables/useConfirm(原 4 视图重复:Projects/ProjectDetail/Ideas/Settings)
|
||
const { confirmState, confirmDialog, answerConfirm } = useConfirm()
|
||
|
||
type FilterKey = 'all' | 'hot' | 'pending' | 'promoted'
|
||
|
||
const activeFilter = ref<FilterKey>('all')
|
||
const selectedId = ref<string | null>(null)
|
||
const searchQuery = ref('')
|
||
// 列表排序:score 高分在前 / time 新在前(默认 score,避免高分灵感被淹没)
|
||
const sortMode = ref<'score' | 'time'>('score')
|
||
|
||
// 分页(F-260621-02 P3):pageSize=0 → 全量(向后兼容,等价改造前);用户选 N → 客户端切片
|
||
const page = ref(1)
|
||
const pageSize = ref(0)
|
||
|
||
// ── 新建灵感模态框 ──
|
||
const showCaptureModal = ref(false)
|
||
const newIdeaTitle = ref('')
|
||
const newIdeaDesc = ref('')
|
||
|
||
// ── 异步按钮禁用态(防双击重复提交,对齐 ProviderPanel.vue saving ref) ──
|
||
const creating = ref(false)
|
||
const deleting = ref(false)
|
||
const promoting = ref(false)
|
||
|
||
const filters: { key: FilterKey; labelKey: string; icon: string }[] = [
|
||
{ key: 'all', labelKey: 'ideas.filter.all', icon: '📋' },
|
||
{ key: 'hot', labelKey: 'ideas.filter.hot', icon: '🔥' },
|
||
{ key: 'pending', labelKey: 'ideas.filter.pending', icon: '⏳' },
|
||
{ key: 'promoted', labelKey: 'ideas.filter.promoted', icon: '🚀' },
|
||
]
|
||
|
||
// ── 状态映射(单一数据源,列表/详情/下拉共用) ──
|
||
const statusOptions: { value: IdeaStatus; labelKey: string }[] = [
|
||
{ value: 'draft', labelKey: 'ideas.status.draft' },
|
||
{ value: 'pending_review', labelKey: 'ideas.status.pending_review' },
|
||
{ value: 'approved', labelKey: 'ideas.status.approved' },
|
||
{ value: 'promoted', labelKey: 'ideas.status.promoted' },
|
||
{ value: 'rejected', labelKey: 'ideas.status.rejected' },
|
||
]
|
||
|
||
// 任意 status 字符串 → 对应 i18n key;未知状态回退到原值显示
|
||
function statusLabelKey(status: IdeaStatus): string {
|
||
return statusOptions.find(s => s.value === status)?.labelKey ?? status
|
||
}
|
||
|
||
// ── 后端查询构造(F-260621-02:status/keyword/order_by 下沉后端 WHERE) ──
|
||
//
|
||
// status 下沉:单一状态(all→不限 / promoted→promoted)走后端精确匹配;
|
||
// hot(按 score≥80)与 pending(多状态 draft+pending_review)无法映射单一后端 status,
|
||
// 仍在前端 filter(轻量,数据量小;后端 status 仅单值,扩多值属过度设计)。
|
||
// keyword/title+description LIKE、order_by(排序)全下沉后端,避免前端全表扫描/重排。
|
||
function buildIdeaQuery(): IdeaQuery {
|
||
const q: IdeaQuery = {}
|
||
// 排序下沉后端(score/time)
|
||
q.order_by = sortMode.value === 'score' ? 'score' : 'created_at'
|
||
// keyword 下沉后端(title/description LIKE)
|
||
if (searchQuery.value.trim()) {
|
||
q.keyword = searchQuery.value.trim()
|
||
}
|
||
// 单一状态映射:promoted → status=promoted;all/hot/pending 不走单一 status
|
||
if (activeFilter.value === 'promoted') {
|
||
q.status = 'promoted'
|
||
}
|
||
return q
|
||
}
|
||
|
||
// 按当前筛选/搜索/排序触发后端加载(防抖仅对 keyword 输入,筛选/排序即时)。
|
||
// 让 store.loading 驱动「加载中」空态显示(对齐 Tasks.vue 三态)。
|
||
let keywordDebounce: ReturnType<typeof setTimeout> | null = null
|
||
async function reloadIdeas(immediate = false) {
|
||
if (keywordDebounce) {
|
||
clearTimeout(keywordDebounce)
|
||
keywordDebounce = null
|
||
}
|
||
const run = () => store.loadIdeas(buildIdeaQuery())
|
||
if (immediate) {
|
||
await run()
|
||
} else {
|
||
keywordDebounce = setTimeout(() => { void run() }, 300)
|
||
}
|
||
}
|
||
|
||
// 筛选/排序变化即时重载;keyword 输入防抖重载
|
||
watch(activeFilter, () => { page.value = 1; void reloadIdeas(true) })
|
||
watch(sortMode, () => { page.value = 1; void reloadIdeas(true) })
|
||
watch(searchQuery, () => { page.value = 1; void reloadIdeas(false) })
|
||
|
||
const filteredIdeas = computed(() => {
|
||
let ideas = store.ideas
|
||
|
||
// hot(按 score≥80)/pending(多状态 draft+pending_review)保留前端 filter:
|
||
// 后端 status 仅单值,无法表达 OR,扩多值属过度设计;数据量小,前端 filter 轻量。
|
||
switch (activeFilter.value) {
|
||
case 'hot': ideas = ideas.filter(i => (i.score ?? 0) >= 80); break
|
||
case 'pending': ideas = ideas.filter(i => i.status === 'pending_review' || i.status === 'draft'); break
|
||
default: break // all / promoted:后端已过滤,前端不再 filter
|
||
}
|
||
|
||
// 排序与 keyword 已下沉后端(buildIdeaQuery),前端不再重排/重过滤。
|
||
return ideas
|
||
})
|
||
|
||
// 分页视图(F-260621-02 P3):pageSize=0 → 全量(等价改造前);否则取当前页窗口。
|
||
const pagedIdeas = computed(() => {
|
||
if (pageSize.value <= 0) return filteredIdeas.value
|
||
const start = (page.value - 1) * pageSize.value
|
||
return filteredIdeas.value.slice(start, start + pageSize.value)
|
||
})
|
||
|
||
const currentIdea = computed(() => {
|
||
if (!selectedId.value) return null
|
||
return store.ideas.find(i => i.id === selectedId.value) ?? null
|
||
})
|
||
|
||
// B-260615-25:灵感描述 Markdown 渲染已下沉至 IdeaDetail 子组件(共享模块单例渲染器)
|
||
|
||
function scoreClass(score: number | null) {
|
||
const s = score ?? 0
|
||
if (s >= 80) return 'score-high'
|
||
if (s >= 60) return 'score-mid'
|
||
return 'score-low'
|
||
}
|
||
|
||
// formatDate 由 ../utils/time 提供(统一毫秒字符串解析,根治 Invalid Date)
|
||
|
||
function openCaptureModal() {
|
||
newIdeaTitle.value = ''
|
||
newIdeaDesc.value = ''
|
||
showCaptureModal.value = true
|
||
}
|
||
|
||
async function confirmCapture() {
|
||
if (!newIdeaTitle.value.trim() || creating.value) return
|
||
creating.value = true
|
||
try {
|
||
await store.createIdea({
|
||
title: newIdeaTitle.value.trim(),
|
||
description: newIdeaDesc.value.trim() || undefined,
|
||
})
|
||
showCaptureModal.value = false
|
||
} finally {
|
||
creating.value = false
|
||
}
|
||
}
|
||
|
||
async function deleteCurrentIdea() {
|
||
if (!currentIdea.value || deleting.value) return
|
||
if (!await confirmDialog(t('ideas.confirmDelete', { title: currentIdea.value.title }))) return
|
||
deleting.value = true
|
||
try {
|
||
await store.deleteIdea(currentIdea.value.id)
|
||
selectedId.value = null
|
||
} finally {
|
||
deleting.value = false
|
||
}
|
||
}
|
||
|
||
async function promoteToProject() {
|
||
if (!currentIdea.value || promoting.value) return
|
||
promoting.value = true
|
||
try {
|
||
const res = await store.promoteIdea(currentIdea.value.id)
|
||
router.push(`/projects/${res.project_id}`)
|
||
} catch (e: any) {
|
||
const msg = e?.toString() ?? t('ideas.promoteFailed')
|
||
console.error(t('ideas.promoteFailed'), e)
|
||
Message.error(msg)
|
||
} finally {
|
||
promoting.value = false
|
||
}
|
||
}
|
||
|
||
async function refresh() {
|
||
await store.loadIdeas(buildIdeaQuery())
|
||
}
|
||
|
||
const evaluating = ref(false)
|
||
const evalError = ref('')
|
||
|
||
async function evaluateCurrentIdea() {
|
||
if (!currentIdea.value || evaluating.value) return
|
||
|
||
evaluating.value = true
|
||
evalError.value = ''
|
||
try {
|
||
await store.evaluateIdea(currentIdea.value.id)
|
||
} catch (e: any) {
|
||
evalError.value = e?.toString() ?? t('ideas.evalFailed')
|
||
console.error(t('ideas.evalFailed'), e)
|
||
} finally {
|
||
evaluating.value = false
|
||
}
|
||
}
|
||
|
||
async function onStatusChange(newStatus: IdeaStatus) {
|
||
if (!currentIdea.value) return
|
||
await store.updateIdea(currentIdea.value.id, 'status', newStatus)
|
||
}
|
||
|
||
// 接收 IdeaDetail 子组件 emit 的 'update-desc'(描述编辑保存)
|
||
async function onUpdateDesc(desc: string) {
|
||
if (!currentIdea.value) return
|
||
await store.updateIdea(currentIdea.value.id, 'description', desc)
|
||
}
|
||
|
||
// 接收 IdeaDetail 子组件 emit 的 'update-related'(关联灵感编辑保存)
|
||
async function onUpdateRelated(ids: string[]) {
|
||
if (!currentIdea.value) return
|
||
await store.updateIdea(currentIdea.value.id, 'related_ids', JSON.stringify(ids))
|
||
}
|
||
|
||
onMounted(async () => {
|
||
await store.loadIdeas(buildIdeaQuery())
|
||
// 从路由参数恢复选中灵感(修复 /ideas/:id 直接访问空白页 B-260615-36)
|
||
if (route.params.id) {
|
||
const id = route.params.id as string
|
||
const exists = store.ideas.some(i => i.id === id)
|
||
if (!exists) {
|
||
Message.warning(t('ideas.notFound'))
|
||
}
|
||
selectedId.value = id
|
||
}
|
||
})
|
||
|
||
// 同组件内路由变化时同步 selectedId(列表点击跳 /ideas/:id)
|
||
watch(() => route.params.id, (id) => {
|
||
if (id) {
|
||
const ideaId = id as string
|
||
const exists = store.ideas.some(i => i.id === ideaId)
|
||
if (!exists) {
|
||
Message.warning(t('ideas.notFound'))
|
||
}
|
||
selectedId.value = ideaId
|
||
}
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.ideas { padding: 16px 20px 20px; }
|
||
|
||
.page-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: var(--df-gap-page);
|
||
}
|
||
.page-header h1 { font-size: 24px; font-weight: 500; color: var(--df-text); }
|
||
.header-actions { display: flex; gap: 10px; }
|
||
|
||
/* ===== 按钮 ===== */
|
||
.btn {
|
||
padding: 8px 16px;
|
||
border: none;
|
||
border-radius: var(--df-radius-sm);
|
||
font-size: 13px;
|
||
cursor: pointer;
|
||
transition: all 0.15s;
|
||
}
|
||
.btn-primary { background: var(--df-accent); color: #fff; }
|
||
.btn-primary:hover { background: var(--df-accent-hover); }
|
||
.btn-ghost { background: transparent; color: var(--df-text-secondary); border: 0.5px solid var(--df-border); }
|
||
.btn-ghost:hover { background: var(--df-bg-card); color: var(--df-text); }
|
||
|
||
/* ===== 筛选栏 ===== */
|
||
.filter-bar {
|
||
display: flex;
|
||
gap: 8px;
|
||
margin-bottom: var(--df-gap-page);
|
||
align-items: center;
|
||
}
|
||
|
||
.search-box {
|
||
flex: 1;
|
||
max-width: 300px;
|
||
}
|
||
|
||
.search-box input {
|
||
width: 100%;
|
||
padding: 6px 12px;
|
||
border: 0.5px solid var(--df-border);
|
||
border-radius: var(--df-radius-sm);
|
||
background: var(--df-bg);
|
||
color: var(--df-text);
|
||
font-size: 13px;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.search-box input:focus {
|
||
outline: none;
|
||
border-color: var(--df-accent);
|
||
background: var(--df-bg-raised);
|
||
}
|
||
.filter-btn {
|
||
padding: 6px 14px;
|
||
border: 0.5px solid var(--df-border);
|
||
border-radius: var(--df-radius-sm);
|
||
background: transparent;
|
||
color: var(--df-text-secondary);
|
||
font-size: 13px;
|
||
cursor: pointer;
|
||
transition: all 0.15s;
|
||
}
|
||
.filter-btn:hover { background: var(--df-bg-card); color: var(--df-text); }
|
||
.filter-btn.active {
|
||
background: var(--df-accent);
|
||
color: #fff;
|
||
border-color: var(--df-accent);
|
||
}
|
||
|
||
/* 排序按钮组(与状态筛选按钮视觉分隔) */
|
||
.sort-group {
|
||
display: flex;
|
||
gap: 4px;
|
||
margin-left: auto;
|
||
}
|
||
|
||
/* ===== 两栏布局 ===== */
|
||
.ideas-layout {
|
||
display: grid;
|
||
grid-template-columns: 280px 1fr;
|
||
gap: var(--df-gap-grid);
|
||
}
|
||
|
||
/* ===== 左侧列表 ===== */
|
||
.idea-list-panel {
|
||
background: var(--df-bg-card);
|
||
border: 0.5px solid var(--df-border);
|
||
border-radius: var(--df-radius-lg);
|
||
padding: 6px;
|
||
max-height: calc(100vh - 200px);
|
||
overflow-y: auto;
|
||
}
|
||
.idea-card {
|
||
padding: 14px;
|
||
border-radius: var(--df-radius);
|
||
cursor: pointer;
|
||
transition: all 0.15s;
|
||
margin-bottom: 4px;
|
||
}
|
||
.idea-card:hover { background: rgba(108, 99, 255, 0.06); }
|
||
.idea-card.selected { background: rgba(108, 99, 255, 0.12); border: 0.5px solid var(--df-accent); }
|
||
|
||
.idea-card-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 6px;
|
||
}
|
||
.idea-title { font-size: 14px; font-weight: 500; color: var(--df-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
|
||
.idea-score {
|
||
font-size: 13px; font-weight: 500;
|
||
min-width: 32px; height: 26px;
|
||
display: flex; align-items: center; justify-content: center;
|
||
border-radius: var(--df-radius-sm);
|
||
}
|
||
.score-high { background: rgba(100,255,218,0.15); color: var(--df-success); }
|
||
.score-mid { background: rgba(255,217,61,0.15); color: var(--df-warning); }
|
||
.score-low { background: rgba(255,107,107,0.15); color: var(--df-danger); }
|
||
|
||
.idea-desc-preview { font-size: 12px; color: var(--df-text-dim); margin-bottom: 8px; line-height: 1.4; }
|
||
|
||
.idea-card-footer {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
.idea-date { font-size: 11px; color: var(--df-text-dim); }
|
||
|
||
/* ===== 状态标签 ===== */
|
||
.status-tag {
|
||
font-size: 11px; font-weight: 500; padding: 2px 8px;
|
||
border-radius: var(--df-radius-xs);
|
||
}
|
||
.status-draft { background: rgba(90,99,128,0.2); color: var(--df-text-dim); }
|
||
.status-pending_review { background: rgba(100,181,246,0.2); color: var(--df-info); }
|
||
.status-approved { background: rgba(100,255,218,0.15); color: var(--df-success); }
|
||
.status-promoted { background: rgba(108,99,255,0.2); color: var(--df-accent); }
|
||
.status-rejected { background: rgba(255,107,107,0.2); color: var(--df-danger); }
|
||
|
||
/* ===== 右侧详情(详情内容由 IdeaDetail 子组件渲染,父级仅保留未选中空态壳) ===== */
|
||
.idea-detail-panel {
|
||
background: var(--df-bg-card);
|
||
border: 0.5px solid var(--df-border);
|
||
border-radius: var(--df-radius-lg);
|
||
padding: var(--df-pad-panel);
|
||
max-height: calc(100vh - 200px);
|
||
overflow-y: auto;
|
||
}
|
||
.idea-empty {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
.empty-state { text-align: center; color: var(--df-text-dim); }
|
||
.empty-icon { font-size: 48px; margin-bottom: 12px; }
|
||
.empty-state p { font-size: 14px; }
|
||
|
||
/* ===== 响应式 ===== */
|
||
@media (max-width: 900px) {
|
||
.ideas { padding: 16px; }
|
||
.ideas-layout {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
.filter-bar {
|
||
flex-wrap: wrap;
|
||
}
|
||
.search-box {
|
||
max-width: 100%;
|
||
order: -1;
|
||
width: 100%;
|
||
margin-bottom: 8px;
|
||
}
|
||
}
|
||
|
||
/* ===== 模态框 ===== */
|
||
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 100; }
|
||
.modal-box { background: var(--df-bg-card); border: 0.5px solid var(--df-border); border-radius: var(--df-radius-lg); padding: 24px; min-width: 360px; max-width: 90vw; }
|
||
.modal-box h3 { color: var(--df-text); margin-bottom: var(--df-gap-head); }
|
||
.modal-box input, .modal-box textarea { width: 100%; padding: 8px 12px; border: 0.5px solid var(--df-border); border-radius: var(--df-radius-sm); background: var(--df-bg); color: var(--df-text); font-size: 13px; margin-bottom: 12px; box-sizing: border-box; font-family: inherit; }
|
||
.modal-box .modal-actions { display: flex; gap: 8px; justify-content: flex-end; }
|
||
.modal-box .btn-cancel { padding: 6px 16px; border: 0.5px solid var(--df-border); border-radius: var(--df-radius-sm); background: transparent; color: var(--df-text-secondary); cursor: pointer; }
|
||
.modal-box .btn-confirm { padding: 6px 16px; border: none; border-radius: var(--df-radius-sm); background: var(--df-accent); color: #fff; cursor: pointer; }
|
||
</style>
|