Spaces:
Sleeping
Sleeping
| <template> | |
| <div class="projects-page"> | |
| <div class="page-container"> | |
| <div class="page-header"> | |
| <h1 class="page-title">{{ t('projects.title') }}</h1> | |
| <button | |
| type="button" | |
| class="new-project-btn" | |
| @click="openCreateModal" | |
| :disabled="creatingProject" | |
| > | |
| <svg viewBox="0 0 24 24" fill="none"> | |
| <path d="M12 5v14M5 12h14" stroke="currentColor" stroke-width="2" stroke-linecap="round"/> | |
| </svg> | |
| {{ t('projects.newProject') }} | |
| </button> | |
| </div> | |
| <!-- 项目列表 --> | |
| <div class="projects-section"> | |
| <div v-if="projectsError" class="error-banner"> | |
| <p>{{ projectsError }}</p> | |
| <button type="button" class="retry-btn" @click="loadProjects">{{ t('common.retry') }}</button> | |
| </div> | |
| <div v-if="projectsLoading" class="loading-banner">{{ t('projects.loadingList') }}</div> | |
| <div class="projects-grid"> | |
| <button | |
| type="button" | |
| class="project-card project-card--new" | |
| :disabled="creatingProject" | |
| @click="openCreateModal" | |
| > | |
| <div class="project-icon project-icon--new"> | |
| <svg viewBox="0 0 24 24" fill="none"> | |
| <path d="M12 5v14M5 12h14" stroke="currentColor" stroke-width="2" stroke-linecap="round"/> | |
| </svg> | |
| </div> | |
| <div class="project-info project-info--new"> | |
| <h4 class="project-name">{{ t('projects.newCardTitle') }}</h4> | |
| <p class="project-description">{{ t('projects.newCardDesc') }}</p> | |
| <div class="project-meta"> | |
| <span class="project-date new-card-hint">{{ t('projects.newCardHint') }}</span> | |
| </div> | |
| </div> | |
| </button> | |
| <div | |
| class="project-card" | |
| v-for="project in projects" | |
| :key="project.id" | |
| @click="goToProjectDetail(project)" | |
| > | |
| <div class="project-icon"> | |
| <svg viewBox="0 0 24 24" fill="none"> | |
| <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" stroke="currentColor" stroke-width="2"/> | |
| <polyline points="14,2 14,8 20,8" stroke="currentColor" stroke-width="2"/> | |
| </svg> | |
| </div> | |
| <div class="project-info"> | |
| <h4 class="project-name">{{ project.name }}</h4> | |
| <p class="project-description">{{ project.description || t('common.noDescription') }}</p> | |
| <div class="project-meta"> | |
| <span class="project-date">{{ formatDate(project.createdAt) }}</span> | |
| <span class="project-status" :class="project.status"> | |
| {{ getStatusText(project.status) }} | |
| </span> | |
| </div> | |
| <div class="project-actions"> | |
| <button | |
| type="button" | |
| class="detail-btn" | |
| @click.stop="goToProjectDetail(project)" | |
| > | |
| {{ t('projects.viewDetail') }} | |
| </button> | |
| <button | |
| type="button" | |
| class="delete-btn" | |
| :disabled="deletingProjectId === project.id" | |
| @click.stop="onDeleteProject(project)" | |
| > | |
| {{ deletingProjectId === project.id ? t('common.deleting') : t('common.delete') }} | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- 新建项目:与项目卡片同风格的表单弹层 --> | |
| <Teleport to="body"> | |
| <div v-if="showCreateModal" class="create-modal-root"> | |
| <div class="create-modal-backdrop" @click="closeCreateModal" /> | |
| <div | |
| ref="createModalRef" | |
| class="create-modal" | |
| role="dialog" | |
| aria-modal="true" | |
| aria-labelledby="create-project-title" | |
| tabindex="-1" | |
| @click.stop | |
| @keydown.escape.prevent="closeCreateModal" | |
| > | |
| <div class="create-modal-header"> | |
| <div class="create-modal-title-wrap"> | |
| <div class="create-modal-icon" aria-hidden="true"> | |
| <svg viewBox="0 0 24 24" fill="none"> | |
| <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" stroke="currentColor" stroke-width="2"/> | |
| <polyline points="14,2 14,8 20,8" stroke="currentColor" stroke-width="2"/> | |
| </svg> | |
| </div> | |
| <h2 id="create-project-title" class="create-modal-title">{{ t('projects.modal.title') }}</h2> | |
| </div> | |
| <button type="button" class="create-modal-close" :aria-label="t('common.close')" @click="closeCreateModal"> | |
| <svg viewBox="0 0 24 24" fill="none"> | |
| <path d="M18 6L6 18M6 6l12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/> | |
| </svg> | |
| </button> | |
| </div> | |
| <div class="create-modal-body"> | |
| <p v-if="createFormError" class="create-form-error">{{ createFormError }}</p> | |
| <div class="create-field"> | |
| <label class="create-label" for="new-project-name">{{ t('projects.modal.nameLabel') }} <span class="required">{{ t('projects.modal.nameRequired') }}</span></label> | |
| <input | |
| id="new-project-name" | |
| ref="nameInputRef" | |
| v-model="newProjectName" | |
| type="text" | |
| class="create-input" | |
| :placeholder="t('projects.modal.namePlaceholder')" | |
| maxlength="200" | |
| autocomplete="off" | |
| @keyup.enter="submitCreateProject" | |
| > | |
| </div> | |
| <div class="create-field"> | |
| <label class="create-label" for="new-project-desc">{{ t('projects.modal.descLabel') }}</label> | |
| <textarea | |
| id="new-project-desc" | |
| v-model="newProjectDescription" | |
| class="create-textarea" | |
| :placeholder="t('projects.modal.descPlaceholder')" | |
| rows="3" | |
| maxlength="2000" | |
| /> | |
| </div> | |
| </div> | |
| <div class="create-modal-footer"> | |
| <button type="button" class="create-btn create-btn--ghost" @click="closeCreateModal" :disabled="creatingProject"> | |
| {{ t('common.cancel') }} | |
| </button> | |
| <button | |
| type="button" | |
| class="create-btn create-btn--primary" | |
| :disabled="creatingProject || !newProjectName.trim()" | |
| @click="submitCreateProject" | |
| > | |
| <span v-if="!creatingProject">{{ t('projects.modal.create') }}</span> | |
| <span v-else class="create-btn-loading">{{ t('projects.modal.creating') }}</span> | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| </Teleport> | |
| </div> | |
| </template> | |
| <script setup lang="ts"> | |
| import { ref, onMounted, nextTick } from 'vue' | |
| import { useI18n } from 'vue-i18n' | |
| import { useRouter } from 'vue-router' | |
| import { projectStatusText } from '../utils/i18nHelpers' | |
| import { | |
| ApiError, | |
| type BackendProject, | |
| createProject, | |
| deleteProject, | |
| getProjects | |
| } from '../api/http' | |
| interface Project { | |
| id: string | |
| name: string | |
| description: string | |
| createdAt: Date | |
| status: string | |
| } | |
| const { t, locale } = useI18n() | |
| const router = useRouter() | |
| const projects = ref<Project[]>([]) | |
| const projectsLoading = ref(false) | |
| const projectsError = ref('') | |
| const creatingProject = ref(false) | |
| const deletingProjectId = ref<string | null>(null) | |
| const showCreateModal = ref(false) | |
| const newProjectName = ref('') | |
| const newProjectDescription = ref('') | |
| const createFormError = ref('') | |
| const nameInputRef = ref<HTMLInputElement | null>(null) | |
| const createModalRef = ref<HTMLElement | null>(null) | |
| const mapBackendProject = (p: BackendProject): Project => ({ | |
| id: p.id, | |
| name: p.name, | |
| description: p.description ?? '', | |
| createdAt: new Date(p.created_at || Date.now()), | |
| status: p.status | |
| }) | |
| const loadProjects = async () => { | |
| projectsLoading.value = true | |
| projectsError.value = '' | |
| try { | |
| const list = await getProjects() | |
| projects.value = list.map(mapBackendProject) | |
| } catch (e) { | |
| const message = e instanceof ApiError ? e.message : t('common.errors.loadProjects') | |
| projectsError.value = message | |
| projects.value = [] | |
| } finally { | |
| projectsLoading.value = false | |
| } | |
| } | |
| onMounted(() => { | |
| void loadProjects() | |
| }) | |
| const defaultNewProjectName = () => | |
| t('projects.defaultName', { n: projects.value.length + 1 }) | |
| const openCreateModal = () => { | |
| createFormError.value = '' | |
| newProjectName.value = defaultNewProjectName() | |
| newProjectDescription.value = '' | |
| showCreateModal.value = true | |
| void nextTick(() => { | |
| createModalRef.value?.focus() | |
| nameInputRef.value?.focus() | |
| nameInputRef.value?.select() | |
| }) | |
| } | |
| const closeCreateModal = () => { | |
| if (creatingProject.value) return | |
| showCreateModal.value = false | |
| createFormError.value = '' | |
| } | |
| const submitCreateProject = async () => { | |
| const name = newProjectName.value.trim() | |
| if (!name) { | |
| createFormError.value = t('common.errors.projectNameRequired') | |
| return | |
| } | |
| creatingProject.value = true | |
| createFormError.value = '' | |
| try { | |
| const desc = newProjectDescription.value.trim() | |
| const created = await createProject({ | |
| name, | |
| ...(desc ? { description: desc } : {}) | |
| }) | |
| const mapped = mapBackendProject(created) | |
| projects.value.unshift(mapped) | |
| showCreateModal.value = false | |
| newProjectDescription.value = '' | |
| newProjectName.value = defaultNewProjectName() | |
| void router.push({ name: 'project-detail', params: { id: mapped.id } }) | |
| } catch (e) { | |
| const message = e instanceof ApiError ? e.message : t('common.errors.createProject') | |
| createFormError.value = message | |
| } finally { | |
| creatingProject.value = false | |
| } | |
| } | |
| const goToProjectDetail = (project: Project) => { | |
| void router.push({ name: 'project-detail', params: { id: project.id } }) | |
| } | |
| const onDeleteProject = async (project: Project) => { | |
| if (deletingProjectId.value) return | |
| const name = project.name?.trim() || t('common.projectFallback') | |
| if (!confirm(t('common.confirm.deleteProject', { name }))) return | |
| deletingProjectId.value = project.id | |
| try { | |
| await deleteProject(project.id) | |
| projects.value = projects.value.filter((p) => p.id !== project.id) | |
| } catch (e) { | |
| alert(e instanceof ApiError ? e.message : t('common.errors.deleteProject')) | |
| } finally { | |
| deletingProjectId.value = null | |
| } | |
| } | |
| const formatDate = (date: Date) => { | |
| return date.toLocaleDateString(locale.value === 'en' ? 'en-US' : 'zh-CN') | |
| } | |
| const getStatusText = (status: string) => projectStatusText(t, status) | |
| </script> | |
| <style scoped> | |
| .projects-page { | |
| min-height: 100vh; | |
| background: transparent; | |
| padding: 2rem 1.5rem 3rem; | |
| } | |
| .page-container { | |
| max-width: 1100px; | |
| margin: 0 auto; | |
| background: #fff; | |
| border-radius: 2px; | |
| border: 1px solid #e2e8f0; | |
| box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06); | |
| overflow: hidden; | |
| } | |
| .page-header { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| padding: 1.5rem 2rem; | |
| border-bottom: 1px solid #e2e8f0; | |
| background: #fafbfc; | |
| } | |
| .page-title { | |
| font-size: 1.35rem; | |
| font-weight: 650; | |
| letter-spacing: -0.02em; | |
| color: #0f172a; | |
| margin: 0; | |
| } | |
| .new-project-btn { | |
| display: flex; | |
| align-items: center; | |
| gap: 0.5rem; | |
| padding: 0.55rem 1.15rem; | |
| background: #0f172a; | |
| color: #fff; | |
| border: none; | |
| border-radius: 2px; | |
| font-size: 0.875rem; | |
| font-weight: 600; | |
| cursor: pointer; | |
| transition: background 0.15s ease, opacity 0.15s ease; | |
| } | |
| .new-project-btn:hover:not(:disabled) { | |
| background: #1e293b; | |
| } | |
| .new-project-btn svg { | |
| width: 16px; | |
| height: 16px; | |
| } | |
| .projects-section { | |
| padding: 2rem; | |
| } | |
| .projects-grid { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); | |
| gap: 1rem; | |
| } | |
| .project-card { | |
| background: #fafbfc; | |
| border: 1px solid #e8ecf0; | |
| border-radius: 2px; | |
| padding: 1.35rem 1.5rem; | |
| cursor: pointer; | |
| transition: border-color 0.15s ease, box-shadow 0.15s ease, background 0.15s ease; | |
| } | |
| .project-card:hover { | |
| background: #fff; | |
| border-color: #cbd5e1; | |
| box-shadow: 0 2px 8px rgba(15, 23, 42, 0.06); | |
| } | |
| button.project-card { | |
| display: block; | |
| width: 100%; | |
| text-align: left; | |
| font: inherit; | |
| color: inherit; | |
| } | |
| .project-card--new { | |
| border-style: dashed; | |
| border-color: #cbd5e1; | |
| background: #fafbfc; | |
| } | |
| .project-card--new:hover:not(:disabled) { | |
| border-color: #94a3b8; | |
| background: #fff; | |
| box-shadow: 0 2px 8px rgba(15, 23, 42, 0.06); | |
| } | |
| .project-card--new:disabled { | |
| opacity: 0.65; | |
| cursor: wait; | |
| } | |
| .project-icon--new { | |
| background: #e2e8f0; | |
| color: #475569; | |
| } | |
| .project-info--new .project-name { | |
| color: #0f172a; | |
| } | |
| .new-card-hint { | |
| color: #64748b; | |
| font-weight: 500; | |
| } | |
| .create-modal-root { | |
| position: fixed; | |
| inset: 0; | |
| z-index: 12000; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| padding: 1.5rem; | |
| } | |
| .create-modal-backdrop { | |
| position: absolute; | |
| inset: 0; | |
| background: rgba(15, 23, 42, 0.45); | |
| backdrop-filter: blur(6px); | |
| } | |
| .create-modal { | |
| position: relative; | |
| width: 100%; | |
| max-width: 440px; | |
| background: #fff; | |
| border-radius: 2px; | |
| box-shadow: 0 24px 64px rgba(0, 0, 0, 0.18); | |
| border: 1px solid #e2e8f0; | |
| overflow: hidden; | |
| animation: createModalIn 0.25s ease-out; | |
| } | |
| @keyframes createModalIn { | |
| from { | |
| opacity: 0; | |
| transform: scale(0.96) translateY(8px); | |
| } | |
| to { | |
| opacity: 1; | |
| transform: scale(1) translateY(0); | |
| } | |
| } | |
| .create-modal-header { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| gap: 1rem; | |
| padding: 1.25rem 1.5rem; | |
| background: #fafbfc; | |
| border-bottom: 1px solid #e2e8f0; | |
| } | |
| .create-modal-title-wrap { | |
| display: flex; | |
| align-items: center; | |
| gap: 0.75rem; | |
| min-width: 0; | |
| } | |
| .create-modal-icon { | |
| width: 44px; | |
| height: 44px; | |
| border-radius: 2px; | |
| background: #0f172a; | |
| color: #fff; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| flex-shrink: 0; | |
| } | |
| .create-modal-icon svg { | |
| width: 22px; | |
| height: 22px; | |
| } | |
| .create-modal-title { | |
| margin: 0; | |
| font-size: 1.1rem; | |
| font-weight: 650; | |
| color: #0f172a; | |
| } | |
| .create-modal-close { | |
| width: 40px; | |
| height: 40px; | |
| border: none; | |
| border-radius: 4px; | |
| background: #fff; | |
| color: #64748b; | |
| cursor: pointer; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| transition: background 0.2s ease, color 0.2s ease; | |
| } | |
| .create-modal-close:hover { | |
| background: #f1f5f9; | |
| color: #334155; | |
| } | |
| .create-modal-close svg { | |
| width: 20px; | |
| height: 20px; | |
| } | |
| .create-modal-body { | |
| padding: 1.5rem; | |
| } | |
| .create-form-error { | |
| margin: 0 0 1rem; | |
| padding: 0.65rem 0.85rem; | |
| border-radius: 4px; | |
| background: #fef2f2; | |
| border: 1px solid #fecaca; | |
| color: #991b1b; | |
| font-size: 0.875rem; | |
| } | |
| .create-field { | |
| margin-bottom: 1.25rem; | |
| } | |
| .create-field:last-child { | |
| margin-bottom: 0; | |
| } | |
| .create-label { | |
| display: block; | |
| font-size: 0.7rem; | |
| font-weight: 700; | |
| letter-spacing: 0.06em; | |
| text-transform: uppercase; | |
| color: #64748b; | |
| margin-bottom: 0.5rem; | |
| } | |
| .create-label .required { | |
| color: #dc2626; | |
| } | |
| .create-input, | |
| .create-textarea { | |
| width: 100%; | |
| box-sizing: border-box; | |
| padding: 0.75rem 1rem; | |
| border: 1px solid #e2e8f0; | |
| border-radius: 4px; | |
| font-size: 0.95rem; | |
| color: #1e293b; | |
| background: #f8fafc; | |
| transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease; | |
| } | |
| .create-input:focus, | |
| .create-textarea:focus { | |
| outline: none; | |
| border-color: var(--ramex-accent); | |
| background: var(--ramex-bg); | |
| box-shadow: 0 0 0 3px var(--ramex-accent-muted); | |
| } | |
| .create-textarea { | |
| resize: vertical; | |
| min-height: 88px; | |
| line-height: 1.5; | |
| } | |
| .create-modal-footer { | |
| display: flex; | |
| justify-content: flex-end; | |
| gap: 0.75rem; | |
| padding: 1rem 1.5rem 1.25rem; | |
| border-top: 1px solid #f1f5f9; | |
| background: #fafbfc; | |
| } | |
| .create-btn { | |
| padding: 0.65rem 1.35rem; | |
| border-radius: 4px; | |
| font-weight: 600; | |
| font-size: 0.9rem; | |
| cursor: pointer; | |
| transition: all 0.2s ease; | |
| border: none; | |
| } | |
| .create-btn--ghost { | |
| background: #fff; | |
| border: 1px solid #e2e8f0; | |
| color: #475569; | |
| } | |
| .create-btn--ghost:hover:not(:disabled) { | |
| background: #f8fafc; | |
| border-color: #cbd5e1; | |
| } | |
| .create-btn--primary { | |
| background: #0f172a; | |
| color: #fff; | |
| } | |
| .create-btn--primary:hover:not(:disabled) { | |
| background: #1e293b; | |
| } | |
| .create-btn:disabled { | |
| opacity: 0.55; | |
| cursor: not-allowed; | |
| transform: none; | |
| box-shadow: none; | |
| } | |
| .create-btn-loading { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 0.35rem; | |
| } | |
| .error-banner, | |
| .loading-banner { | |
| padding: 1rem 1.25rem; | |
| border-radius: 4px; | |
| margin-bottom: 1.5rem; | |
| font-size: 0.95rem; | |
| } | |
| .error-banner { | |
| background: #fef2f2; | |
| border: 1px solid #fecaca; | |
| color: #991b1b; | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| gap: 1rem; | |
| flex-wrap: wrap; | |
| } | |
| .loading-banner { | |
| background: #f8fafc; | |
| border: 1px solid #e2e8f0; | |
| color: #475569; | |
| } | |
| .retry-btn { | |
| padding: 0.4rem 1rem; | |
| border-radius: 3px; | |
| border: 1px solid #991b1b; | |
| background: white; | |
| color: #991b1b; | |
| font-weight: 600; | |
| cursor: pointer; | |
| } | |
| .retry-btn:hover { | |
| background: #fef2f2; | |
| } | |
| .new-project-btn:disabled { | |
| opacity: 0.55; | |
| cursor: not-allowed; | |
| transform: none; | |
| box-shadow: none; | |
| } | |
| .project-icon { | |
| width: 48px; | |
| height: 48px; | |
| background: #0f172a; | |
| border-radius: 2px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| margin-bottom: 1rem; | |
| color: white; | |
| } | |
| .project-icon svg { | |
| width: 24px; | |
| height: 24px; | |
| } | |
| .project-name { | |
| font-size: 1rem; | |
| font-weight: 600; | |
| color: #0f172a; | |
| margin-bottom: 0.4rem; | |
| letter-spacing: -0.01em; | |
| } | |
| .project-description { | |
| color: #64748b; | |
| font-size: 0.875rem; | |
| margin-bottom: 0.85rem; | |
| line-height: 1.55; | |
| } | |
| .project-meta { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| margin-bottom: 0.75rem; | |
| } | |
| .project-actions { | |
| display: flex; | |
| gap: 0.5rem; | |
| margin-top: 0.25rem; | |
| } | |
| .detail-btn, | |
| .delete-btn { | |
| flex: 1; | |
| padding: 0.45rem 0.75rem; | |
| font-size: 0.8125rem; | |
| font-weight: 600; | |
| border-radius: 2px; | |
| cursor: pointer; | |
| transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease; | |
| } | |
| .detail-btn { | |
| color: #0f172a; | |
| background: #fff; | |
| border: 1px solid #cbd5e1; | |
| } | |
| .detail-btn:hover { | |
| background: #f8fafc; | |
| border-color: #94a3b8; | |
| color: #020617; | |
| } | |
| .delete-btn { | |
| color: #dc2626; | |
| background: #fff; | |
| border: 1px solid #fca5a5; | |
| } | |
| .delete-btn:hover:not(:disabled) { | |
| background: #fef2f2; | |
| border-color: #f87171; | |
| } | |
| .delete-btn:disabled { | |
| opacity: 0.55; | |
| cursor: not-allowed; | |
| } | |
| .project-date { | |
| color: #94a3b8; | |
| font-size: 0.75rem; | |
| } | |
| .project-status { | |
| padding: 0.2rem 0.5rem; | |
| border-radius: 2px; | |
| font-size: 0.75rem; | |
| font-weight: 500; | |
| } | |
| .project-status.active { | |
| background: #c6f6d5; | |
| color: #22543d; | |
| } | |
| .project-status.completed { | |
| background: #bee3f8; | |
| color: #2a4365; | |
| } | |
| .project-status.pending { | |
| background: #faf089; | |
| color: #744210; | |
| } | |
| .project-status.initialized { | |
| background: #e0e7ff; | |
| color: #3730a3; | |
| } | |
| .project-status.data_loaded { | |
| background: #dbeafe; | |
| color: #1e40af; | |
| } | |
| .project-status.preprocessing { | |
| background: #ffedd5; | |
| color: #9a3412; | |
| } | |
| .project-status.preprocessing_completed { | |
| background: #d1fae5; | |
| color: #065f46; | |
| } | |
| .project-status.analysis { | |
| background: #ede9fe; | |
| color: #5b21b6; | |
| } | |
| .project-status.error { | |
| background: #fee2e2; | |
| color: #991b1b; | |
| } | |
| </style> | |