Files
DevFlow/src/views/Projects.vue
T
lxy 25ec9cf69a 优化: 前端u-agent令牌对齐与组件拆分续批
- 全局样式类(df-sec-h/df-toast族)替换散装实现,主题token去硬编码兜底
- 拆分子组件:ai-chat/queue-panel(队列时钟与编辑随迁)、
  conversation-sidebar/ConversationList、settings/provider-panel
- 父组件死副本清理(队列编辑/时钟/键位表,TS6133清零)并补
  fill-draft父侧转发(重构半成品断链修复)
- 验收:vue-tsc EXIT 0
2026-10-18 23:15:57 +08:00

643 lines
26 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="projects">
<header class="page-header">
<h1>{{ $t('projects.title') }}</h1>
<div class="header-actions">
<!-- 视图模式切换(默认 list 紧凑;card 宽松):切换持久化 localStorage -->
<div class="view-toggle" :title="$t('projects.viewModeTitle')">
<button
class="view-toggle-btn"
:class="{ active: viewMode === 'list' }"
:title="$t('projects.viewList')"
:aria-label="$t('projects.viewList')"
@click="viewMode = 'list'"
>☰</button>
<button
class="view-toggle-btn"
:class="{ active: viewMode === 'card' }"
:title="$t('projects.viewCard')"
:aria-label="$t('projects.viewCard')"
@click="viewMode = 'card'"
>▦</button>
</div>
<button class="btn btn-ghost" @click="openImportModal">{{ $t('projects.importHistory') }}</button>
<button class="btn btn-ghost" @click="openArchiveBox">{{ $t('projects.archiveBox') }}</button>
<button class="btn btn-ghost" @click="openTrash">{{ $t('projects.trash') }}</button>
<button class="btn btn-primary" @click="showCreateModal = true">{{ $t('projects.create') }}</button>
</div>
</header>
<!-- 错误条:消费 store.error(加载/创建/删除失败对用户可见,对齐 Knowledge error-banner) -->
<div v-if="store.error" class="error-banner">
<span class="error-text">{{ store.error }}</span>
<button class="error-dismiss" @click="store.clearError()" :aria-label="$t('common.close')">✕</button>
</div>
<!-- 新建项目模态框 -->
<div v-if="showCreateModal" class="modal-overlay" @click.self="showCreateModal = false">
<div class="modal-box">
<h3 class="modal-title">{{ $t('projects.createTitle') }}</h3>
<div class="modal-field">
<label>{{ $t('projects.nameLabel') }}</label>
<input v-model="newName" :placeholder="$t('projects.namePlaceholder')" @keyup.enter="submitCreate" />
</div>
<div class="modal-field">
<label>{{ $t('projects.descLabel') }}</label>
<textarea v-model="newDesc" :placeholder="$t('projects.descPlaceholder')" rows="3"></textarea>
</div>
<div class="modal-field">
<label>{{ $t('projects.codeDirLabel') }}{{ $t('projects.codeDirOptionalHint') }}</label>
<div class="dir-row">
<input v-model="newPath" :placeholder="$t('projects.codeDirPlaceholder')" readonly />
<button class="btn btn-ghost btn-sm" type="button" @click="pickDir">{{ $t('projects.selectDir') }}</button>
<button v-if="newPath" class="btn btn-ghost btn-sm" type="button" @click="clearDir">{{ $t('projects.clearDir') }}</button>
</div>
<div v-if="pathWarning" class="path-warning"><span aria-hidden="true">⚠</span> {{ pathWarning }}</div>
<div v-if="newPath" class="scan-row">
<button class="btn btn-ghost btn-sm" type="button" @click="aiScan" :disabled="scanning">
{{ scanning ? $t('projects.aiScanning') : $t('projects.aiScanFill') }}
</button>
<span v-if="scanProjectType" class="tech-tag">{{ scanProjectType }}</span>
</div>
<div v-if="scanError" class="path-warning"><span aria-hidden="true">⚠</span> {{ scanError }}</div>
<div v-if="detectedStack.length" class="card-tags" style="margin-top: 8px">
<span class="tech-tag" v-for="t in detectedStack" :key="t">{{ t }}</span>
</div>
</div>
<div class="modal-actions">
<button class="btn btn-ghost" @click="showCreateModal = false">{{ $t('common.cancel') }}</button>
<button class="btn btn-primary" @click="submitCreate" :disabled="submitting || !newName.trim()">{{ $t('projects.confirmCreate') }}</button>
</div>
</div>
</div>
<!-- 回收站模态框 -->
<div v-if="showTrashModal" class="modal-overlay" @click.self="showTrashModal = false">
<div class="modal-box" style="width: 520px">
<h3 class="modal-title">{{ $t('projects.trashTitle') }}</h3>
<div v-if="store.deletedProjects.length === 0" class="empty-state" style="padding: 30px 0">{{ $t('projects.trashEmpty') }}</div>
<div v-else class="trash-list">
<div class="trash-item" v-for="p in store.deletedProjects" :key="p.id">
<div class="trash-info">
<span class="trash-name">{{ p.name }}</span>
<span class="trash-date">{{ formatDate(p.updated_at) }} {{ $t('projects.movedIn') }}</span>
</div>
<div class="trash-actions">
<button class="btn btn-ghost btn-sm" :disabled="!!restoringId || !!purgingId" @click="handleRestore(p.id)">{{ $t('projects.restore') }}</button>
<button class="btn btn-danger btn-sm" :disabled="!!restoringId || !!purgingId" @click="handlePurge(p)">{{ $t('projects.purge') }}</button>
</div>
</div>
</div>
<div class="modal-actions">
<button class="btn btn-ghost" @click="showTrashModal = false">{{ $t('common.close') }}</button>
</div>
</div>
</div>
<!-- 归档箱模态框(归档功能 P2,V48;对齐回收站 Modal 模式:列表+恢复,无彻底删除——归档可逆) -->
<div v-if="showArchiveModal" class="modal-overlay" @click.self="showArchiveModal = false">
<div class="modal-box" style="width: 520px">
<h3 class="modal-title">{{ $t('projects.archiveBoxTitle') }}</h3>
<div v-if="store.archivedProjects.length === 0" class="empty-state" style="padding: 30px 0">{{ $t('projects.archiveBoxEmpty') }}</div>
<div v-else class="trash-list">
<div class="trash-item" v-for="p in store.archivedProjects" :key="p.id">
<div class="trash-info">
<span class="trash-name">{{ p.name }}</span>
<span class="trash-date">{{ $t('projects.archivedAt') }} {{ formatDate(p.updated_at) }}</span>
</div>
<div class="trash-actions">
<button class="btn btn-ghost btn-sm" :disabled="!!unarchivingId" @click="handleUnarchive(p.id)">{{ $t('projects.unarchive') }}</button>
</div>
</div>
</div>
<div class="modal-actions">
<button class="btn btn-ghost" @click="showArchiveModal = false">{{ $t('common.close') }}</button>
</div>
</div>
</div>
<!-- 导入历史项目模态框(F-260614-06 scan 第二步) -->
<div v-if="showImportModal" class="modal-overlay" @click.self="closeImportModal">
<div class="modal-box import-box">
<h3 class="modal-title">{{ $t('projects.importTitle') }}</h3>
<!-- 选根目录 + 扫描 -->
<div class="modal-field">
<div class="dir-row">
<input v-model="importRootPath" :placeholder="$t('projects.importSelectRoot')" readonly />
<button class="btn btn-ghost btn-sm" type="button" @click="pickImportRoot">{{ $t('projects.selectDir') }}</button>
</div>
</div>
<!-- 扫描结果表格(只读预览 + 勾选) -->
<div v-if="importScanning" class="import-status">{{ $t('projects.importScanning') }}</div>
<div v-else-if="importError" class="path-warning"><span aria-hidden="true">⚠</span> {{ importError }}</div>
<div v-else-if="scannedItems.length === 0 && importRootPath" class="import-status">{{ $t('projects.importEmpty') }}</div>
<div v-if="scannedItems.length" class="import-table-wrap">
<table class="import-table">
<thead>
<tr>
<th class="col-check">
<input type="checkbox" :checked="allImportSelected" :indeterminate.prop="someImportSelected" @change="toggleSelectAll(($event.target as HTMLInputElement).checked)" />
</th>
<th>{{ $t('projects.importColName') }}</th>
<th>{{ $t('projects.importColStack') }}</th>
<th>{{ $t('projects.importColPath') }}</th>
<th>{{ $t('projects.importColBound') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="item in scannedItems" :key="item.path" :class="{ 'row-disabled': item.already_bound }">
<td class="col-check">
<input type="checkbox" :disabled="item.already_bound" :checked="isImportSelected(item.path)" @change="toggleSelect(item.path, ($event.target as HTMLInputElement).checked)" />
</td>
<td>
<span class="imp-name">{{ item.name }}</span>
<span v-if="item.is_monorepo" class="mono-tag">{{ $t('projects.importMonoHint') }}</span>
</td>
<td>
<span class="tech-tag" v-for="s in item.stack" :key="s">{{ s }}</span>
</td>
<td class="col-path" :title="item.path">{{ item.path }}</td>
<td>
<span v-if="item.already_bound" class="bound-mark">✓</span>
</td>
</tr>
</tbody>
</table>
</div>
<div class="modal-actions">
<span class="selection-count" v-if="scannedItems.length">{{ $t('projects.importSelected', { n: selectedImportPaths.size }) }}</span>
<button class="btn btn-ghost" @click="closeImportModal">{{ $t('common.cancel') }}</button>
<button class="btn btn-primary" @click="runImport" :disabled="importRunning || selectedImportPaths.size === 0">
{{ importRunning ? $t('projects.importRunning') : $t('projects.importRun') }}
</button>
</div>
</div>
</div>
<!-- 项目卡片网格(单卡片渲染抽至 components/project/ProjectCard.vue,删除走 emit 回父处理) -->
<!-- viewMode='list' → 单列紧凑;viewMode='card' → 双列宽松(默认改造前形态) -->
<div class="project-grid" :class="'grid-' + viewMode">
<ProjectCard
v-for="project in pagedProjects"
:key="project.id"
:project="project"
:view="viewMode"
@delete="handleDelete"
@archive="handleArchive"
/>
</div>
<!-- 分页器(F-260621-02 P3):pageSize=0 不分页(全量,等价旧行为);用户选 N 条触发客户端切片 -->
<Paginator
v-model:page="page"
v-model:pageSize="pageSize"
:total="store.projects.length"
/>
<!-- 空状态 -->
<div v-if="!store.loading && store.projects.length === 0" class="empty-state">
{{ $t('projects.empty') }}
</div>
<!-- 确认弹层(删除/彻底删除,替代原生 window.confirm) -->
<ConfirmDialog :visible="confirmState.visible" :msg="confirmState.msg" @result="answerConfirm" />
<!-- 批量导入结果 toast(全局 .df-toast 族+底部变体;error 映射 danger) -->
<div v-if="toast.visible" class="df-toast df-toast--bottom" :class="toast.type === 'error' ? 'df-toast--danger' : 'df-toast--' + toast.type">{{ toast.msg }}</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { open } from '@tauri-apps/plugin-dialog'
import { useProjectStore } from '@/stores/project'
import { projectApi } from '@/api'
import { formatDate } from '@/utils/time'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import ProjectCard from '@/components/project/ProjectCard.vue'
import Paginator from '@/components/Paginator.vue'
import { useConfirm } from '@/composables/useConfirm'
import { useToast } from '@/composables/useToast'
import { usePersistedRef } from '@/composables/usePersistedRef'
import type { ProjectRecord } from '@/api/types'
import type { ScannedProjectItem } from '@/api/project'
const store = useProjectStore()
const { t } = useI18n()
// 分页(F-260621-02 P3):pageSize=0 → 全量(向后兼容,等价改造前);用户选 N → 客户端切片。
// page/pageSize 持久化 localStorage(刷新/重开记住用户选择)。
const page = usePersistedRef('projects.page', 1)
const pageSize = usePersistedRef('projects.pageSize', 0)
// 视图模式:'list'(紧凑单列,默认)/'card'(宽松双列)。持久化 localStorage。
const viewMode = usePersistedRef<'list' | 'card'>('projects.viewMode', 'list')
// 分页视图:pageSize=0 → 全量;否则取当前页窗口
const pagedProjects = computed(() => {
if (pageSize.value <= 0) return store.projects
const start = (page.value - 1) * pageSize.value
return store.projects.slice(start, start + pageSize.value)
})
// 分页越界自愈(删除/回收后 store.projects 数量变化,page 超界 → 钳制到最后一页,防空白页)
watch([() => store.projects, pageSize], () => {
if (pageSize.value <= 0) return
const maxPage = Math.max(1, Math.ceil(store.projects.length / pageSize.value))
if (page.value > maxPage) page.value = maxPage
})
// 状态映射统一走 ../constants/project(与 ProjectDetail/Tasks/Dashboard 一致)
// formatDate 由 ../utils/time 提供(统一毫秒字符串解析,根治 Invalid Date)
// 确认弹层状态机抽至 composables/useConfirm(原 4 视图重复:Projects/ProjectDetail/Ideas/Settings)
const { confirmState, confirmDialog, answerConfirm } = useConfirm()
// ── 新建项目 ──
const showCreateModal = ref(false)
const newName = ref('')
const newDesc = ref('')
const newPath = ref('')
const detectedStack = ref<string[]>([])
const pathWarning = ref('')
const scanning = ref(false)
const scanError = ref('')
const scanProjectType = ref('')
// 创建提交中(防双击重复提交,对齐 ProviderPanel.saving 模式)
const submitting = ref(false)
// 选择目录 → 探测技术栈 + 查重
async function pickDir() {
try {
const selected = await open({ directory: true, multiple: false })
if (!selected || Array.isArray(selected)) return
newPath.value = selected as string
detectedStack.value = []
pathWarning.value = ''
// 探测技术栈(失败不阻塞,后端创建时再探)
try { detectedStack.value = await projectApi.scanStack(selected as string) } catch { /* ignore */ }
// 查重(已被其他项目绑定则警告,禁止创建)
try {
const conflict = await projectApi.checkBinding(selected as string)
if (conflict) pathWarning.value = t('projects.dirConflict', { name: conflict.name })
} catch { /* ignore */ }
} catch (e) {
console.error('选择目录失败:', e)
}
}
function clearDir() {
newPath.value = ''
detectedStack.value = []
pathWarning.value = ''
scanning.value = false
scanError.value = ''
scanProjectType.value = ''
}
// AI 扫描目录 → 自动填描述/技术栈/项目类型(LLM 失败降级规则探测)
async function aiScan() {
if (!newPath.value) return
scanning.value = true
scanError.value = ''
scanProjectType.value = ''
try {
const result = await projectApi.scanWithAi(newPath.value)
detectedStack.value = result.stack
if (result.description) newDesc.value = result.description
scanProjectType.value = result.project_type ?? ''
if (!result.description) scanError.value = t('projects.aiScanNoDesc')
} catch (e: any) {
scanError.value = e?.toString() ?? t('projects.aiScanFailed')
} finally {
scanning.value = false
}
}
async function submitCreate() {
if (!newName.value.trim()) return
if (pathWarning.value) return // 目录冲突,禁止创建
if (submitting.value) return // 防双击重复提交
submitting.value = true
try {
const stack = detectedStack.value.length ? JSON.stringify(detectedStack.value) : undefined
const r = await store.createProject(newName.value.trim(), newDesc.value.trim(), undefined, newPath.value || undefined, stack)
if (!r) return // 失败已 toast,保持弹窗不关
showCreateModal.value = false
newName.value = ''
newDesc.value = ''
clearDir()
} finally {
submitting.value = false
}
}
// ── 删除 / 回收站 ──
const showTrashModal = ref(false)
// 回收站操作 pending 守卫:操作中禁用按钮,防双击重复提交/并发互踩
const restoringId = ref<string | null>(null)
const purgingId = ref<string | null>(null)
async function handleDelete(project: ProjectRecord) {
if (!await confirmDialog(t('projects.confirmDelete', { name: project.name }))) return
await store.deleteProject(project.id)
}
async function openTrash() {
await store.loadDeletedProjects()
showTrashModal.value = true
}
async function handleRestore(id: string) {
if (restoringId.value || purgingId.value) return
restoringId.value = id
try {
await store.restoreProject(id)
} finally {
restoringId.value = null
}
}
async function handlePurge(project: ProjectRecord) {
if (!await confirmDialog(t('projects.confirmPurge', { name: project.name }))) return
if (restoringId.value || purgingId.value) return
purgingId.value = project.id
try {
await store.purgeProject(project.id)
} finally {
purgingId.value = null
}
}
// ── 归档箱(归档功能 P2,V48;卡片 📦 归档 + 归档箱 Modal 恢复,对齐回收站动线) ──
const showArchiveModal = ref(false)
const unarchivingId = ref<string | null>(null)
/** 归档项目:确认后退出主列表(可从归档箱恢复) */
async function handleArchive(project: ProjectRecord) {
if (!await confirmDialog(t('projects.confirmArchive', { name: project.name }))) return
await store.archiveProject(project.id, true)
showToast(t('projects.archivedToast'), 'info')
}
async function openArchiveBox() {
await store.loadArchivedProjects()
showArchiveModal.value = true
}
/** 归档箱内恢复:回主列表 */
async function handleUnarchive(id: string) {
if (unarchivingId.value) return
unarchivingId.value = id
try {
await store.unarchiveProject(id)
showToast(t('projects.unarchivedToast'), 'success')
} finally {
unarchivingId.value = null
}
}
// ── 加载 ──
onMounted(() => {
store.loadProjects()
})
// toast timer 清理由 useToast composable 的 onUnmounted 自动管理
// ── 导入历史项目(F-260614-06 scan 第二步) ──
const showImportModal = ref(false)
const importRootPath = ref('')
const scannedItems = ref<ScannedProjectItem[]>([])
const importScanning = ref(false)
const importError = ref('')
const importRunning = ref(false)
const selectedImportPaths = ref<Set<string>>(new Set())
const { toast, showToast } = useToast(4000) // Projects 导入操作 toast 用 4000ms(操作类提示)
const allImportSelected = computed(() =>
scannedItems.value.length > 0
&& scannedItems.value.filter(i => !i.already_bound).every(i => selectedImportPaths.value.has(i.path))
)
const someImportSelected = computed(() =>
selectedImportPaths.value.size > 0 && !allImportSelected.value
)
function isImportSelected(path: string) {
return selectedImportPaths.value.has(path)
}
function toggleSelect(path: string, checked: boolean) {
const next = new Set(selectedImportPaths.value)
if (checked) next.add(path)
else next.delete(path)
selectedImportPaths.value = next
}
function toggleSelectAll(checked: boolean) {
if (checked) {
selectedImportPaths.value = new Set(
scannedItems.value.filter(i => !i.already_bound).map(i => i.path)
)
} else {
selectedImportPaths.value = new Set()
}
}
function openImportModal() {
showImportModal.value = true
importError.value = ''
scannedItems.value = []
selectedImportPaths.value = new Set()
}
function closeImportModal() {
showImportModal.value = false
importRootPath.value = ''
scannedItems.value = []
selectedImportPaths.value = new Set()
importError.value = ''
importScanning.value = false
}
async function pickImportRoot() {
try {
const selected = await open({ directory: true, multiple: false })
if (!selected || Array.isArray(selected)) return
importRootPath.value = selected as string
await runScan()
} catch (e) {
console.error('选择根目录失败:', e)
}
}
async function runScan() {
if (!importRootPath.value) return
importScanning.value = true
importError.value = ''
scannedItems.value = []
selectedImportPaths.value = new Set()
try {
scannedItems.value = await projectApi.scanDirectoryForProjects(importRootPath.value)
} catch (e: any) {
importError.value = e?.toString() ?? t('projects.importScanFailed')
} finally {
importScanning.value = false
}
}
async function runImport() {
if (selectedImportPaths.value.size === 0) {
showToast(t('projects.importNoSelection'), 'error')
return
}
importRunning.value = true
try {
const items = Array.from(selectedImportPaths.value).map(p => {
const found = scannedItems.value.find(i => i.path === p)
return { path: p, name: found?.name }
})
const result = await projectApi.importProjectsBatch(items)
// 刷新列表(批量入库后)
await store.loadProjects()
showToast(t('projects.importDone', { imported: result.imported, skipped: result.skipped }), result.imported > 0 ? 'info' : 'error')
if (result.imported > 0) {
closeImportModal()
} else {
// 全失败:保留弹窗 + 结果,让用户看失败项;重新扫描刷新已绑定态
await runScan()
}
} catch (e: any) {
showToast(e?.toString() ?? t('projects.importFailed'), 'error')
} finally {
importRunning.value = false
}
}
</script>
<style scoped>
.projects { padding: 16px 20px 20px; }
/* page-header / btn / modal 系列已提取到 global.css 全局 */
/* 本页特有:.btn-danger(全局尚未提供)、.btn:disabled(全局仅 .btn-primary:disabled) */
.btn-danger { background: transparent; color: var(--df-danger); border: 0.5px solid var(--df-danger-bd); }
.btn-danger:hover { background: var(--df-danger-bg); }
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
/* ===== 卡片网格 ===== */
/* 默认改造前为双列;viewMode 驱动 list(单列紧凑)/card(双列宽松) */
.project-grid {
display: grid;
gap: var(--df-gap-grid);
}
.project-grid.grid-card {
grid-template-columns: repeat(2, 1fr);
}
.project-grid.grid-list {
grid-template-columns: 1fr;
}
/* ===== 视图模式切换 ===== */
.view-toggle {
display: inline-flex;
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm);
overflow: hidden;
}
.view-toggle-btn {
min-width: 30px;
padding: 6px 10px;
background: transparent;
border: none;
color: var(--df-text-dim);
font-size: 14px;
line-height: 1;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.view-toggle-btn:hover { background: var(--df-bg-card); color: var(--df-text); }
.view-toggle-btn.active {
background: var(--df-accent-soft);
color: var(--df-accent);
}
/* 进度条族(.card-progress/.progress-*)已随卡片渲染迁至 ProjectCard.vue,本页残留定义已删(2026-10-18 整洁波) */
/* ===== 技术栈标签:基础样式已收敛至全局 components.css(DRY 收口 ),
创建/导入模态与本页 ProjectCard 渲染的标签均走全局 .tech-tag/.card-tags。 ===== */
/* ===== 目录选择行 ===== */
.dir-row { display: flex; gap: 8px; align-items: center; }
.dir-row input { flex: 1; cursor: pointer; }
.path-warning { margin-top: 6px; font-size: 12px; color: var(--df-danger); }
.scan-row { display: flex; gap: 8px; align-items: center; margin-top: 8px; }
/* ===== 模态框 ===== */
/* page-header / btn / modal 系列已提取到 global.css 全局 */
/* ===== 空状态 ===== */
/* page-header / btn / modal 系列已提取到 global.css 全局 */
/* ===== 错误条(消费 store.error,对齐 Knowledge error-banner) ===== */
.error-banner {
display: flex; align-items: center; justify-content: space-between; gap: 12px;
padding: 8px 12px; margin-bottom: var(--df-gap-page);
background: var(--df-danger-bg); border: 0.5px solid var(--df-danger);
border-radius: var(--df-radius-sm); color: var(--df-danger); font-size: 12px;
}
.error-text { word-break: break-word; }
.error-dismiss {
flex-shrink: 0; background: transparent; border: none; color: var(--df-danger);
font-size: 14px; cursor: pointer; line-height: 1; padding: 0 2px;
}
.error-dismiss:hover { opacity: 0.7; }
/* ===== 回收站(卡片删除按钮样式随 ProjectCard.vue 迁出) ===== */
.trash-list { display: flex; flex-direction: column; max-height: 400px; overflow-y: auto; }
.trash-item {
display: flex; justify-content: space-between; align-items: center;
padding: 10px 0; border-bottom: 0.5px solid var(--df-border);
}
.trash-item:last-child { border-bottom: none; }
.trash-info { display: flex; flex-direction: column; gap: 2px; }
.trash-name { font-size: 14px; color: var(--df-text); font-weight: 500; }
.trash-date { font-size: 11px; color: var(--df-text-dim); }
.trash-actions { display: flex; gap: 8px; }
/* ===== 响应式 ===== */
@media (max-width: 768px) {
.projects { padding: 16px; }
/* 移动端:card 视图也降为单列;list 本就单列 */
.project-grid.grid-card {
grid-template-columns: 1fr;
}
}
/* ===== 导入历史项目 ===== */
.import-box { width: 720px; max-height: 80vh; display: flex; flex-direction: column; }
.import-status { font-size: 13px; color: var(--df-text-dim); padding: 16px 0; text-align: center; }
.import-table-wrap { flex: 1; overflow: auto; border: 0.5px solid var(--df-border); border-radius: var(--df-radius-sm); margin-bottom: 8px; }
.import-table { width: 100%; border-collapse: collapse; font-size: 12px; }
.import-table thead th {
position: sticky; top: 0; background: var(--df-bg);
text-align: left; padding: 8px 10px; font-weight: 500;
color: var(--df-text-dim); border-bottom: 0.5px solid var(--df-border);
}
.import-table tbody td { padding: 8px 10px; border-bottom: 0.5px solid var(--df-border); vertical-align: top; color: var(--df-text-secondary); }
.import-table tbody tr:last-child td { border-bottom: none; }
.import-table .col-check { width: 32px; text-align: center; }
.import-table .col-path { max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--df-font-mono, monospace); font-size: 11px; }
.row-disabled { opacity: 0.5; }
.imp-name { font-weight: 500; color: var(--df-text); }
.mono-tag { margin-left: 6px; font-size: 10px; padding: var(--df-pad-chip); border-radius: var(--df-radius-xs); background: var(--df-warning-bg); color: var(--df-warning); border: 0.5px solid var(--df-border); }
.bound-mark { color: var(--df-success); font-weight: 600; }
.selection-count { margin-right: auto; font-size: 12px; color: var(--df-text-dim); }
.import-box .modal-actions { align-items: center; }
/* toast 样式已统一到全局 global.css .df-toast 族(+--bottom 底部变体) */
</style>