This commit is contained in:
wangzzpeng
2026-09-24 10:10:43 +08:00
commit 7bc84bbe5e
194 changed files with 22571 additions and 0 deletions

View File

@@ -0,0 +1,166 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { AfterSales } from '@/types/business/project'
interface Props {
afterSales?: AfterSales
projectId: string
}
const props = defineProps<Props>()
const emit = defineEmits<{
apply: []
cancel: []
}>()
const isProcessing = computed(() => props.afterSales?.status === '处理中')
</script>
<template>
<section class="project-after-sales">
<!-- 处理中状态卡片 -->
<div
v-if="isProcessing && afterSales"
class="project-after-sales__card project-after-sales__card--active"
>
<div class="project-after-sales__card-head">
<span class="project-after-sales__badge">处理中</span>
<h3 class="project-after-sales__card-title">
售后工单处理中
</h3>
</div>
<p class="project-after-sales__desc">
工程师正在处理您的修改需求,请耐心等待
</p>
<div class="project-after-sales__meta text-secondary">
<span v-if="afterSales.type">类型:{{ afterSales.type }}</span>
<span v-if="afterSales.createTime">提交时间:{{ afterSales.createTime }}</span>
</div>
<p v-if="afterSales.description" class="project-after-sales__desc">
{{ afterSales.description }}
</p>
<div class="project-after-sales__actions">
<button
class="project-after-sales__btn project-after-sales__btn--default"
@click="emit('cancel')"
>
取消售后
</button>
</div>
</div>
<!-- 空状态 -->
<div v-else class="project-after-sales__card project-after-sales__card--empty">
<p class="project-after-sales__empty text-secondary">
暂无售后工单
</p>
<div class="project-after-sales__actions">
<button
class="project-after-sales__btn project-after-sales__btn--primary"
@click="emit('apply')"
>
申请售后
</button>
</div>
</div>
</section>
</template>
<style lang="less" scoped>
.project-after-sales {
.flex(column, flex-start);
gap: @spacing-base;
padding: @spacing-lg @spacing-xl;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
&__card {
.flex(column, flex-start);
gap: @spacing-sm;
padding: @spacing-base;
border: 1px solid @border-color;
border-radius: @radius-base;
background: @bg-color-light;
&--active {
border-color: fade(@warning-color, 40%);
background: fade(@warning-color, 6%);
}
}
&__card-head {
.flex(row, center);
gap: @spacing-sm;
}
&__badge {
padding: 2px @spacing-sm;
border-radius: @radius-sm;
background: fade(@warning-color, 12%);
color: @warning-color;
font-size: @font-size-sm;
}
&__card-title {
font-size: @font-size-lg;
font-weight: 600;
color: @heading-color;
}
&__desc {
font-size: @font-size-base;
color: @text-color;
line-height: 1.6;
}
&__meta {
.flex(row, flex-start);
flex-wrap: wrap;
gap: @spacing-base;
font-size: @font-size-sm;
}
&__empty {
padding: @spacing-lg 0;
text-align: center;
font-size: @font-size-base;
}
&__actions {
.flex(row, flex-end);
gap: @spacing-sm;
}
&__btn {
height: 34px;
padding: 0 @spacing-base;
border-radius: @radius-base;
font-size: @font-size-base;
transition: all 0.2s;
&--primary {
border: none;
background: @primary-color;
color: @white;
&:hover {
background: @primary-hover;
}
}
&--default {
border: 1px solid @border-color-dark;
background: @white;
color: @text-color;
&:hover {
border-color: @primary-color;
color: @primary-color;
}
}
}
}
</style>

View File

@@ -0,0 +1,134 @@
<script setup lang="ts">
import { UploadArea } from '@/components'
import type { FileInfo } from '@/types/api'
import { formatFileSize } from '@/utils/format'
interface Props {
files: FileInfo[]
}
defineProps<Props>()
const emit = defineEmits<{
upload: [file: File]
delete: [index: number]
preview: [index: number]
download: [index: number]
}>()
</script>
<template>
<section class="project-files">
<h2 class="project-files__title">
项目文件
</h2>
<UploadArea
hint="支持图片/文档/压缩包,大小不超过1G"
@upload="emit('upload', $event)"
/>
<ul class="project-files__list">
<li
v-for="(file, index) in files"
:key="`${file.name}-${index}`"
class="project-files__item"
>
<div class="project-files__info">
<span class="project-files__name" :title="file.name">{{ file.name }}</span>
<span class="project-files__meta text-secondary">
{{ formatFileSize(file.size) }} · {{ file.uploadTime }}
</span>
</div>
<div class="project-files__actions">
<button class="project-files__btn" @click="emit('preview', index)">
预览
</button>
<button class="project-files__btn" @click="emit('download', index)">
下载
</button>
<button
class="project-files__btn project-files__btn--danger"
@click="emit('delete', index)"
>
删除
</button>
</div>
</li>
<li v-if="files.length === 0" class="project-files__empty text-secondary">
暂无项目文件
</li>
</ul>
</section>
</template>
<style lang="less" scoped>
.project-files {
.flex(column, flex-start);
gap: @spacing-base;
padding: @spacing-lg @spacing-xl;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
&__title {
font-size: @font-size-lg;
font-weight: 600;
color: @heading-color;
}
&__list {
.flex(column, flex-start);
gap: @spacing-sm;
}
&__item {
.flex(row, space-between);
gap: @spacing-base;
padding: @spacing-sm @spacing-base;
border: 1px solid @border-color;
border-radius: @radius-base;
background: @bg-color-light;
}
&__info {
.flex(column, flex-start);
gap: 2px;
min-width: 0;
}
&__name {
font-size: @font-size-base;
color: @heading-color;
.ellipsis();
}
&__meta {
font-size: @font-size-sm;
}
&__actions {
.flex(row, center);
gap: @spacing-sm;
flex-shrink: 0;
}
&__btn {
font-size: @font-size-sm;
color: @primary-color;
transition: opacity 0.2s;
&:hover {
opacity: 0.75;
}
&--danger {
color: @danger-color;
}
}
&__empty {
font-size: @font-size-base;
}
}
</style>

View File

@@ -0,0 +1,191 @@
<script setup lang="ts">
import { computed } from 'vue'
import { FilterPanel, type FilterChip } from '@/components'
import { formatBusinessType } from '@/utils/format'
import type { BusinessType } from '@/types/api'
import type { ProjectQuery, ProjectStatus } from '@/types/business/project'
interface Props {
query: ProjectQuery
total: number
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:query': [query: ProjectQuery]
search: []
reset: []
}>()
const businessTypeOptions: Array<{ label: string; value: BusinessType | 'all' }> = [
{ label: '全部', value: 'all' },
{ label: '互联网软件', value: 'internet' },
{ label: '硬件电路', value: 'hardware' },
{ label: '物联网全案', value: 'iot' },
{ label: '生产制造', value: 'manufacturing' },
]
const statusOptions: Array<{ label: string; value: ProjectStatus | 'all' }> = [
{ label: '全部', value: 'all' },
{ label: '待立项', value: '待立项' },
{ label: '进行中', value: '进行中' },
{ label: '待验收', value: '待验收' },
{ label: '已完成', value: '已完成' },
{ label: '售后中', value: '售后中' },
{ label: '已取消', value: '已取消' },
]
const timeShortcuts: Array<{ label: string; value: ProjectQuery['timeShortcut'] }> = [
{ label: '全部', value: 'all' },
{ label: '近3个月', value: '3m' },
{ label: '近半年', value: '6m' },
{ label: '近一年', value: '1y' },
]
const keyword = computed({
get: () => props.query.keyword,
set: (value: string) => emit('update:query', { ...props.query, keyword: value }),
})
const chips = computed<FilterChip[]>(() => {
const list: FilterChip[] = []
if (props.query.businessType !== 'all') {
list.push({ key: 'businessType', label: formatBusinessType(props.query.businessType) })
}
if (props.query.status !== 'all') {
list.push({ key: 'status', label: props.query.status })
}
if (props.query.timeShortcut !== 'all') {
const label = timeShortcuts.find((item) => item.value === props.query.timeShortcut)?.label ?? ''
list.push({ key: 'time', label })
}
return list
})
function patch(partial: Partial<ProjectQuery>): void {
emit('update:query', { ...props.query, ...partial })
}
function onRemoveChip(key: string): void {
if (key === 'businessType') patch({ businessType: 'all' })
else if (key === 'status') patch({ status: 'all' })
else if (key === 'time') patch({ timeShortcut: 'all' })
}
function onClearChips(): void {
emit('update:query', {
...props.query,
businessType: 'all',
status: 'all',
timeShortcut: 'all',
})
}
</script>
<template>
<FilterPanel
v-model:keyword="keyword"
:chips="chips"
:show-result-count="true"
:result-count="total"
search-label="确定筛选"
@search="emit('search')"
@reset="emit('reset')"
@remove-chip="onRemoveChip"
@clear-chips="onClearChips"
>
<!-- 业务类型(单选) -->
<div class="project-filter__group">
<span class="project-filter__group-title">业务类型</span>
<div class="project-filter__options">
<button
v-for="option in businessTypeOptions"
:key="option.value"
class="project-filter__option"
:class="{ 'is-active': query.businessType === option.value }"
@click="patch({ businessType: option.value })"
>
{{ option.label }}
</button>
</div>
</div>
<!-- 项目状态(单选) -->
<div class="project-filter__group">
<span class="project-filter__group-title">项目状态</span>
<div class="project-filter__options">
<button
v-for="option in statusOptions"
:key="option.value"
class="project-filter__option"
:class="{ 'is-active': query.status === option.value }"
@click="patch({ status: option.value })"
>
{{ option.label }}
</button>
</div>
</div>
<!-- 提交时间(快捷区间) -->
<div class="project-filter__group">
<span class="project-filter__group-title">提交时间</span>
<div class="project-filter__options">
<button
v-for="shortcut in timeShortcuts"
:key="shortcut.value"
class="project-filter__option"
:class="{ 'is-active': query.timeShortcut === shortcut.value }"
@click="patch({ timeShortcut: shortcut.value })"
>
{{ shortcut.label }}
</button>
</div>
</div>
</FilterPanel>
</template>
<style lang="less" scoped>
.project-filter {
&__group {
.flex(row, flex-start);
gap: @spacing-base;
}
&__group-title {
flex-shrink: 0;
width: 70px;
padding-top: 5px;
font-size: @font-size-base;
font-weight: 600;
color: @heading-color;
}
&__options {
.flex(row, flex-start, center);
flex-wrap: wrap;
gap: @spacing-sm;
}
&__option {
height: 28px;
padding: 0 @spacing-sm;
border: 1px solid transparent;
border-radius: @radius-sm;
background: @bg-color;
color: @text-color;
font-size: @font-size-sm;
transition: all 0.2s;
&:hover {
color: @primary-color;
}
&.is-active {
border-color: @primary-color;
background: @primary-light;
color: @primary-color;
}
}
}
</style>

View File

@@ -0,0 +1,275 @@
<script setup lang="ts">
import { StatusTag } from '@/components'
import type { FileInfo } from '@/types/api'
import type { ProjectBill } from '@/types/business/project'
import { formatFileSize, formatMoney } from '@/utils/format'
interface Props {
paymentPlans: ProjectBill[]
invoiceRecords: FileInfo[]
contractFile?: FileInfo
}
defineProps<Props>()
const emit = defineEmits<{
pay: [billId: string]
preview: [file: FileInfo]
download: [file: FileInfo]
}>()
const BILL_VARIANTS: Record<ProjectBill['status'], 'primary' | 'success' | 'warning' | 'danger' | 'info'> = {
'已支付': 'success',
'待支付': 'warning',
'未到期': 'info',
}
</script>
<template>
<section class="project-finance">
<!-- 付款计划 -->
<h2 class="project-finance__title">
付款计划
</h2>
<div class="project-finance__plans">
<article
v-for="bill in paymentPlans"
:key="bill.id"
class="project-finance__plan"
>
<div class="project-finance__plan-head">
<span class="project-finance__plan-name">{{ bill.planName }}</span>
<StatusTag :label="bill.status" :variant="BILL_VARIANTS[bill.status]" />
</div>
<div class="project-finance__plan-meta">
<div class="project-finance__field">
<span class="project-finance__label text-secondary">关联项目</span>
<span class="project-finance__value">{{ bill.projectName }}</span>
</div>
<div class="project-finance__field">
<span class="project-finance__label text-secondary">账单号</span>
<span class="project-finance__value">{{ bill.billNo }}</span>
</div>
</div>
<div class="project-finance__amount">
¥{{ formatMoney(bill.amount) }}
</div>
<div class="project-finance__plan-foot">
<span v-if="bill.status === '已支付' && bill.tradeNo" class="project-finance__trade text-secondary">
交易流水号:{{ bill.tradeNo }}
</span>
<button
v-if="bill.status === '待支付'"
class="project-finance__pay"
@click="emit('pay', bill.id)"
>
立即支付
</button>
</div>
</article>
<p v-if="paymentPlans.length === 0" class="project-finance__empty text-secondary">
暂无付款计划
</p>
</div>
<!-- 发票记录 -->
<h2 class="project-finance__title">
发票记录
</h2>
<ul class="project-finance__files">
<li
v-for="(file, index) in invoiceRecords"
:key="`invoice-${index}`"
class="project-finance__file"
>
<div class="project-finance__file-info">
<span class="project-finance__file-name" :title="file.name">{{ file.name }}</span>
<span class="project-finance__file-meta text-secondary">
{{ formatFileSize(file.size) }} · {{ file.uploadTime }}
</span>
</div>
<div class="project-finance__file-actions">
<button class="project-finance__link" @click="emit('preview', file)">
预览
</button>
<button class="project-finance__link" @click="emit('download', file)">
下载
</button>
</div>
</li>
<li v-if="invoiceRecords.length === 0" class="project-finance__empty text-secondary">
暂无发票记录
</li>
</ul>
<!-- 合同文件 -->
<h2 class="project-finance__title">
合同文件
</h2>
<div v-if="contractFile" class="project-finance__contract">
<div class="project-finance__file-info">
<span class="project-finance__file-name" :title="contractFile.name">{{ contractFile.name }}</span>
<span class="project-finance__file-meta text-secondary">
{{ formatFileSize(contractFile.size) }} · {{ contractFile.uploadTime }}
</span>
</div>
<div class="project-finance__file-actions">
<button class="project-finance__link" @click="emit('preview', contractFile)">
预览
</button>
<button class="project-finance__link" @click="emit('download', contractFile)">
下载
</button>
</div>
</div>
<p v-else class="project-finance__empty text-secondary">
暂无合同文件
</p>
</section>
</template>
<style lang="less" scoped>
.project-finance {
.flex(column, flex-start);
gap: @spacing-base;
padding: @spacing-lg @spacing-xl;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
&__title {
font-size: @font-size-lg;
font-weight: 600;
color: @heading-color;
}
&__plans {
.flex(row, flex-start);
flex-wrap: wrap;
gap: @spacing-base;
}
&__plan {
.flex(column, flex-start);
gap: @spacing-sm;
width: 320px;
padding: @spacing-base;
border: 1px solid @border-color;
border-radius: @radius-base;
background: @bg-color-light;
}
&__plan-head {
.flex(row, space-between, center);
gap: @spacing-sm;
}
&__plan-name {
font-size: @font-size-base;
font-weight: 600;
color: @heading-color;
}
&__plan-meta {
.flex(column, flex-start);
gap: @spacing-xs;
}
&__field {
.flex(row, flex-start);
gap: @spacing-sm;
font-size: @font-size-sm;
}
&__label {
flex-shrink: 0;
}
&__value {
color: @text-color;
.ellipsis();
}
&__amount {
font-size: @font-size-xl;
font-weight: 600;
color: @heading-color;
}
&__plan-foot {
.flex(row, space-between, center);
min-height: 28px;
}
&__trade {
font-size: @font-size-sm;
.ellipsis();
}
&__pay {
height: 28px;
padding: 0 @spacing-base;
border: none;
border-radius: @radius-sm;
background: @primary-color;
color: @white;
font-size: @font-size-sm;
transition: background 0.2s;
&:hover {
background: @primary-hover;
}
}
&__files {
.flex(column, flex-start);
gap: @spacing-sm;
}
&__file,
&__contract {
.flex(row, space-between);
gap: @spacing-base;
padding: @spacing-sm @spacing-base;
border: 1px solid @border-color;
border-radius: @radius-base;
background: @bg-color-light;
}
&__file-info {
.flex(column, flex-start);
gap: 2px;
min-width: 0;
}
&__file-name {
font-size: @font-size-base;
color: @heading-color;
.ellipsis();
}
&__file-meta {
font-size: @font-size-sm;
}
&__file-actions {
.flex(row, center);
gap: @spacing-sm;
flex-shrink: 0;
}
&__link {
font-size: @font-size-sm;
color: @primary-color;
transition: opacity 0.2s;
&:hover {
opacity: 0.75;
}
}
&__empty {
font-size: @font-size-base;
}
}
</style>

View File

@@ -0,0 +1,86 @@
<script setup lang="ts">
import { StatusTag } from '@/components'
import { formatBusinessType } from '@/utils/format'
import type { Project, ProjectStatus } from '@/types/business/project'
interface Props {
project: Project
}
defineProps<Props>()
const STATUS_VARIANTS: Record<ProjectStatus, 'primary' | 'success' | 'warning' | 'danger' | 'info'> = {
'待立项': 'info',
'进行中': 'primary',
'待验收': 'warning',
'已完成': 'success',
'售后中': 'warning',
'已取消': 'info',
}
</script>
<template>
<div class="project-info-card">
<div class="project-info-card__head">
<StatusTag :label="project.status" :variant="STATUS_VARIANTS[project.status]" />
<h1 class="project-info-card__name">
{{ project.projectName }}
</h1>
</div>
<div class="project-info-card__meta">
<div class="project-info-card__meta-item">
<span class="project-info-card__label text-secondary">项目编号</span>
<span class="project-info-card__value">{{ project.projectNo }}</span>
</div>
<div class="project-info-card__meta-item">
<span class="project-info-card__label text-secondary">业务类型</span>
<span class="project-info-card__value">{{ formatBusinessType(project.businessType) }}</span>
</div>
<div class="project-info-card__meta-item">
<span class="project-info-card__label text-secondary">提交时间</span>
<span class="project-info-card__value">{{ project.submitTime }}</span>
</div>
</div>
</div>
</template>
<style lang="less" scoped>
.project-info-card {
padding: @spacing-lg @spacing-xl;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
&__head {
.flex(row, flex-start, center);
gap: @spacing-base;
}
&__name {
font-size: @font-size-xxl;
font-weight: 600;
color: @heading-color;
}
&__meta {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: @spacing-base;
margin-top: @spacing-base;
}
&__meta-item {
.flex(column, flex-start);
gap: @spacing-xs;
}
&__label {
font-size: @font-size-sm;
}
&__value {
font-size: @font-size-base;
color: @heading-color;
}
}
</style>

View File

@@ -0,0 +1,186 @@
<script setup lang="ts">
import type { Milestone } from '@/types/business/project'
interface Props {
progress: number
milestones: Milestone[]
startTime?: string
endTime?: string
}
defineProps<Props>()
</script>
<template>
<section class="project-milestones">
<div class="project-milestones__head">
<h2 class="project-milestones__title">
总体进度
</h2>
<span class="project-milestones__percent">{{ progress }}%</span>
</div>
<div class="project-milestones__track">
<div
class="project-milestones__bar"
:style="{ width: `${progress}%` }"
/>
</div>
<ol class="project-milestones__timeline">
<li
v-for="node in milestones"
:key="node.name"
class="project-milestones__node"
:class="`is-${node.status === '已完成' ? 'done' : node.status === '进行中' ? 'active' : 'pending'}`"
>
<span class="project-milestones__dot" />
<div class="project-milestones__body">
<div class="project-milestones__row">
<span class="project-milestones__name">{{ node.name }}</span>
<span class="project-milestones__status">{{ node.status }}</span>
</div>
<span v-if="node.time" class="project-milestones__time text-secondary">{{ node.time }}</span>
</div>
</li>
<li v-if="milestones.length === 0" class="project-milestones__empty text-secondary">
暂无里程碑数据
</li>
</ol>
<div class="project-milestones__range">
<span v-if="startTime" class="text-secondary">开始时间:{{ startTime }}</span>
<span v-if="endTime" class="text-secondary">结束时间:{{ endTime }}</span>
</div>
</section>
</template>
<style lang="less" scoped>
.project-milestones {
padding: @spacing-lg @spacing-xl;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
&__head {
.flex(row, space-between, center);
}
&__title {
font-size: @font-size-lg;
font-weight: 600;
color: @heading-color;
}
&__percent {
font-size: @font-size-xl;
font-weight: 600;
color: @primary-color;
}
&__track {
height: 8px;
margin-top: @spacing-base;
border-radius: @radius-round;
background: @bg-color;
overflow: hidden;
}
&__bar {
height: 100%;
border-radius: @radius-round;
background: @primary-color;
transition: width 0.3s;
}
&__timeline {
margin-top: @spacing-lg;
padding-left: @spacing-xs;
}
&__node {
position: relative;
padding: 0 0 @spacing-base @spacing-lg;
border-left: 2px solid @border-color;
&:last-of-type {
border-left-color: transparent;
padding-bottom: 0;
}
&.is-done {
border-left-color: @success-color;
}
&.is-active {
border-left-color: @primary-color;
}
}
&__dot {
position: absolute;
top: 2px;
left: -7px;
width: 12px;
height: 12px;
border: 2px solid @border-color-dark;
border-radius: @radius-round;
background: @white;
.is-done & {
border-color: @success-color;
background: @success-color;
}
.is-active & {
border-color: @primary-color;
background: @primary-color;
}
}
&__body {
.flex(column, flex-start);
gap: 2px;
}
&__row {
.flex(row, center);
gap: @spacing-sm;
}
&__name {
font-size: @font-size-base;
color: @heading-color;
font-weight: 600;
}
&__status {
font-size: @font-size-sm;
color: @text-color-secondary;
.is-done & {
color: @success-color;
}
.is-active & {
color: @primary-color;
}
}
&__time {
font-size: @font-size-sm;
}
&__empty {
font-size: @font-size-base;
}
&__range {
.flex(row, flex-start, center);
gap: @spacing-lg;
margin-top: @spacing-base;
padding-top: @spacing-base;
border-top: 1px solid @border-color;
font-size: @font-size-sm;
}
}
</style>

View File

@@ -0,0 +1,183 @@
<script setup lang="ts">
import { StatusTag } from '@/components'
import { formatBusinessType } from '@/utils/format'
import type { Project, ProjectStatus } from '@/types/business/project'
interface Props {
projects: Project[]
}
defineProps<Props>()
const emit = defineEmits<{
view: [id: string]
afterSales: [id: string]
}>()
const STATUS_VARIANTS: Record<ProjectStatus, 'primary' | 'success' | 'warning' | 'danger' | 'info'> = {
'待立项': 'info',
'进行中': 'primary',
'待验收': 'warning',
'已完成': 'success',
'售后中': 'warning',
'已取消': 'info',
}
</script>
<template>
<div class="project-table">
<table class="project-table__grid">
<thead>
<tr>
<th>序号</th>
<th>项目编号</th>
<th>项目名称</th>
<th>业务类型</th>
<th>进度</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, index) in projects" :key="item.id">
<td>{{ index + 1 }}</td>
<td>{{ item.projectNo }}</td>
<td class="project-table__name" :title="item.projectName">
{{ item.projectName }}
</td>
<td>{{ formatBusinessType(item.businessType) }}</td>
<td>
<div class="project-table__progress">
<div class="project-table__track">
<div
class="project-table__bar"
:style="{ width: `${item.progress}%` }"
/>
</div>
<span class="project-table__percent">{{ item.progress }}%</span>
</div>
</td>
<td>
<StatusTag :label="item.status" :variant="STATUS_VARIANTS[item.status]" />
</td>
<td class="project-table__actions">
<button class="project-table__btn" @click="emit('view', item.id)">
详情
</button>
<button
v-if="item.status === '已完成'"
class="project-table__btn project-table__btn--accent"
@click="emit('afterSales', item.id)"
>
申请售后
</button>
</td>
</tr>
<tr v-if="projects.length === 0">
<td colspan="7" class="project-table__empty text-secondary">
暂无符合条件的项目
</td>
</tr>
</tbody>
</table>
</div>
</template>
<style lang="less" scoped>
.project-table {
overflow-x: auto;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
.scrollbar();
&__grid {
width: 100%;
border-collapse: collapse;
font-size: @font-size-base;
th,
td {
padding: @spacing-base @spacing-base;
text-align: left;
border-bottom: 1px solid @border-color;
white-space: nowrap;
}
th {
background: @bg-color-light;
color: @heading-color;
font-weight: 600;
}
td {
color: @text-color;
}
tbody tr:last-child td {
border-bottom: none;
}
tbody tr:hover td {
background: @bg-color-light;
}
}
&__name {
max-width: 220px;
color: @heading-color;
.ellipsis();
}
&__progress {
.flex(row, center);
gap: @spacing-sm;
min-width: 140px;
}
&__track {
flex: 1;
height: 6px;
border-radius: @radius-round;
background: @bg-color;
overflow: hidden;
}
&__bar {
height: 100%;
border-radius: @radius-round;
background: @primary-color;
transition: width 0.3s;
}
&__percent {
flex-shrink: 0;
font-size: @font-size-sm;
color: @text-color-secondary;
}
&__actions {
.flex(row, center);
gap: @spacing-sm;
}
&__btn {
font-size: @font-size-base;
color: @primary-color;
transition: opacity 0.2s;
&:hover {
opacity: 0.75;
}
&--accent {
color: @warning-color;
}
}
&__empty {
padding: @spacing-xl 0;
text-align: center;
}
}
</style>

View File

@@ -0,0 +1,93 @@
<script setup lang="ts">
import type { TeamMember } from '@/types/business/project'
interface Props {
team: TeamMember[]
}
defineProps<Props>()
</script>
<template>
<section class="project-team">
<h2 class="project-team__title">
项目团队
</h2>
<div class="project-team__list">
<div
v-for="(member, index) in team"
:key="`${member.name}-${index}`"
class="project-team__card"
>
<span class="project-team__avatar">{{ member.avatarChar }}</span>
<div class="project-team__info">
<span class="project-team__role text-secondary">{{ member.role }}</span>
<span class="project-team__name">{{ member.name }}</span>
</div>
</div>
<p v-if="team.length === 0" class="project-team__empty text-secondary">
暂无团队成员
</p>
</div>
</section>
</template>
<style lang="less" scoped>
.project-team {
padding: @spacing-lg @spacing-xl;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
&__title {
margin-bottom: @spacing-base;
font-size: @font-size-lg;
font-weight: 600;
color: @heading-color;
}
&__list {
.flex(row, flex-start, center);
flex-wrap: wrap;
gap: @spacing-base;
}
&__card {
.flex(row, center);
gap: @spacing-sm;
padding: @spacing-sm @spacing-base;
border: 1px solid @border-color;
border-radius: @radius-base;
background: @bg-color-light;
}
&__avatar {
.flex(row, center);
width: 40px;
height: 40px;
border-radius: @radius-round;
background: @primary-light;
color: @primary-color;
font-size: @font-size-lg;
font-weight: 600;
}
&__info {
.flex(column, flex-start);
gap: 2px;
}
&__role {
font-size: @font-size-sm;
}
&__name {
font-size: @font-size-base;
color: @heading-color;
}
&__empty {
font-size: @font-size-base;
}
}
</style>