Files
DevFlow/src/components/project/ProjectCard.vue
T

242 lines
9.0 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="project-card" :class="{ 'project-card--list': view === 'list' }" @click="router.push('/projects/' + project.id)">
<div class="card-top">
<div class="card-title-row">
<h2 class="card-name">{{ project.name }}</h2>
</div>
<div class="card-top-actions">
<span class="stage-badge" :class="stageClass(project.status)">{{ $t(statusLabel(project.status)) }}</span>
<button class="card-del-btn" :title="$t('projects.archiveTitle')" @click.stop="emit('archive', project)">📦</button>
<button class="card-del-btn" :title="$t('projects.deleteTitle')" @click.stop="emit('delete', project)">🗑</button>
</div>
</div>
<!-- 描述:max-height + 省略号,展开/收起;list 视图更紧凑(2 行),card 视图宽松(3 行) -->
<div class="card-desc-wrap" :class="{ 'is-expanded': descExpanded }">
<p ref="descRef" class="card-desc">{{ stripMd(project.description) }}</p>
<button
v-if="descOverflow"
class="card-desc-toggle"
@click.stop="descExpanded = !descExpanded"
>{{ descExpanded ? $t('common.collapse') : $t('common.expand') }}</button>
</div>
<!-- 底部信息 -->
<div class="card-footer">
<div class="footer-stat">
<span class="stat-icon">📁</span>
<!-- UX走查 P3-6: 工程数异步入场,加载中显骨架条占位(对齐 GitChanges shimmer 范式),
防未加载数先显 0 再跳变——真 0 与"还没加载"不可混淆 -->
<span v-if="moduleCountLoading" class="stat-skeleton" aria-hidden="true"></span>
<span v-else>{{ moduleCount }}</span>
</div>
<div class="footer-stat">
<span class="stat-icon">📅</span>
<span>{{ formatDate(project.created_at) }}</span>
</div>
<div class="footer-stat">
<span class="stat-icon">🕐</span>
<span>{{ formatDate(project.last_active_at ?? project.updated_at) }}</span>
</div>
</div>
<!-- 技术栈标签 -->
<div class="card-tags" v-if="parseStack(project.stack).length">
<span class="tech-tag" v-for="t in parseStack(project.stack)" :key="t">{{ t }}</span>
</div>
</div>
</template>
<script setup lang="ts">
// 项目列表卡片 — 从 Projects.vue 抽出(单卡片渲染 + 导航 + 删除触发)。
// 依赖均为无副作用纯函数 + 共享常量;删除走 emit 交回父组件处理(confirm 弹层 + store 调用)。
// view prop:'list'(紧凑,默认)/'card'(宽松);描述 max-height 截断 + 展开/收起。
import { useRouter } from 'vue-router'
import { ref, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
import { formatDate } from '@/utils/time'
import { parseStack } from '@/utils/project'
import { stripMd } from '@/utils/markdown'
import { moduleApi } from '@/api/module'
import { projectStatusLabel as statusLabel, projectBadgeClass as stageClass } from '@/constants/project'
import type { ProjectRecord } from '@/api/types'
const props = withDefaults(defineProps<{
project: ProjectRecord
/** 视图模式:'list'(紧凑,默认)/'card'(宽松);仅影响描述行数阈值与内边距 */
view?: 'list' | 'card'
}>(), {
view: 'list',
})
const emit = defineEmits<{
(e: 'delete', project: ProjectRecord): void
(e: 'archive', project: ProjectRecord): void
}>()
const router = useRouter()
// 工程数(异步拉取,展示在卡片底部)
const moduleCount = ref(0)
// UX走查 P3-6: 加载态标记,模板据此显骨架占位而非 0(成功/失败都收 loading,失败仍显 0 属既有约定)
const moduleCountLoading = ref(true)
onMounted(async () => {
try {
const list = await moduleApi.listProjectModules(props.project.id)
moduleCount.value = list.length
} catch { /* 老项目无工程记录不报错 */ }
moduleCountLoading.value = false
// DOM 渲染完成后测描述是否溢出(决定是否显示「展开」)
await nextTick()
measureDesc()
})
// ── 描述截断/展开 ──
// descOverflow=true 才渲染「展开/收起」按钮;阈值由 CSS max-height 控制(-webkit-line-clamp),
// 这里只负责测量实际内容高度是否超出 max-height。
const descRef = ref<HTMLParagraphElement | null>(null)
const descOverflow = ref(false)
const descExpanded = ref(false)
function measureDesc() {
const el = descRef.value
if (!el) return
// 展开态无行截断,测出的一律是"未溢出",会误收起按钮致无法「收起」;展开期间维持原判定
if (descExpanded.value) return
// scrollHeight > clientHeight 说明被 max-height 截断了
descOverflow.value = el.scrollHeight - el.clientHeight > 1
}
// 描述/视图模式变化后重新测量(描述异步入库或切 view 行数阈值变,按钮显隐需重算)
watch(() => props.project.description, () => nextTick(measureDesc))
watch(() => props.view, () => nextTick(measureDesc))
// 收起后重测:展开期间判定被冻结,若窗口已放宽到不溢出,收起时自校正防残留多余「展开」按钮
watch(descExpanded, (v) => { if (!v) nextTick(measureDesc) })
// UX走查 P3-6: 窗口尺寸变化会改描述可用宽度,截断与否需重测,否则「展开」按钮显隐失准
function onWinResize() { measureDesc() }
onMounted(() => window.addEventListener('resize', onWinResize))
onBeforeUnmount(() => window.removeEventListener('resize', onWinResize))
</script>
<style scoped>
/* 仅迁入卡片自身样式;列表网格/页面壳样式留 Projects.vue(网格布局属列表职责) */
.project-card {
background: var(--df-bg-card);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-lg);
padding: var(--df-pad-panel);
transition: border-color 0.15s;
cursor: pointer;
}
.project-card:hover {
border-color: var(--df-accent);
}
.card-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.card-title-row {
display: flex;
align-items: center;
gap: 8px;
}
.card-name { font-size: 17px; font-weight: 500; color: var(--df-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.stage-badge {
font-size: 12px; font-weight: 500;
padding: 3px 10px;
border-radius: var(--df-radius-sm);
}
/* 阶段色对齐 ProjectDetail 语义 token:design=info / testing=warning / release=success */
.stage-design { background: var(--df-info-bg); color: var(--df-info); }
.stage-coding { background: var(--df-accent-soft); color: var(--df-accent); }
.stage-testing { background: var(--df-warning-bg); color: var(--df-warning); }
.stage-release { background: var(--df-success-bg); color: var(--df-success); }
/* WK-1:暂停/取消置灰(PROJECT_STATUS_BADGE_CLASS 映射) */
.stage-dim { background: color-mix(in srgb, var(--df-text-dim) 15%, transparent); color: var(--df-text-dim); }
.card-desc-wrap {
position: relative;
margin-bottom: 16px;
}
.card-desc {
font-size: 13px;
color: var(--df-text-secondary);
line-height: 1.5;
margin: 0;
/* 默认截断:2 行(list 视图紧凑,单屏多张)。展开态取消截断。 */
display: -webkit-box;
-webkit-box-orient: vertical;
overflow: hidden;
-webkit-line-clamp: 2;
}
/* card 视图略宽松:3 行 */
.project-card--list .card-desc { -webkit-line-clamp: 2; }
.project-card:not(.project-card--list) .card-desc { -webkit-line-clamp: 3; }
.card-desc-wrap.is-expanded .card-desc {
-webkit-line-clamp: unset;
display: block;
overflow: visible;
}
/* 展开/收起按钮:右对齐、低调,避免抢卡片点击 */
.card-desc-toggle {
display: inline-block;
margin-top: 4px;
padding: 0;
background: transparent;
border: none;
color: var(--df-accent);
font-size: 12px;
cursor: pointer;
line-height: 1.4;
}
.card-desc-toggle:hover { text-decoration: underline; }
/* list 视图:padding 同 card(1× panel,文字不贴边);list 紧凑靠 line-clamp 2 + grid 单列,不靠缩 padding */
.project-card--list { padding: var(--df-pad-panel); }
.card-footer {
display: flex;
gap: var(--df-gap-grid);
margin-bottom: 14px;
}
.footer-stat {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--df-text-dim);
}
.stat-icon { font-size: 13px; }
/* UX走查 P3-6: 工程数加载占位骨架条(shimmer 配方对齐 GitChanges .gc-skeleton-row),
宽度约两位数字占位,高度对齐 12px 字号,防 0 起显跳变 */
.stat-skeleton {
display: inline-block;
width: 18px;
height: 12px;
border-radius: 3px;
background: linear-gradient(90deg, rgba(255,255,255,0.04) 0%, rgba(255,255,255,0.08) 50%, rgba(255,255,255,0.04) 100%);
background-size: 200% 100%;
animation: pc-shimmer 1.5s infinite linear;
}
@keyframes pc-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.card-top-actions { display: flex; align-items: center; gap: 8px; }
.card-del-btn {
background: transparent; border: none; cursor: pointer;
font-size: 14px; padding: 2px 4px; opacity: 0;
transition: opacity 0.15s;
}
/* hover 态依赖 .project-card 父级 — scoped 下 :deep 不适用于兄弟伪类,改用 group 思路:卡片 hover 时按钮可见 */
.project-card:hover .card-del-btn { opacity: 0.55; }
.card-del-btn:hover { opacity: 1 !important; }
</style>