242 lines
9.0 KiB
Vue
242 lines
9.0 KiB
Vue
<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>
|