RamEx-Flow / front /src /views /Projects.vue
zdy10046's picture
add backend code
57528c5
Raw
History Blame Contribute Delete
19.7 kB
<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>