first
This commit is contained in:
166
src/views/project/cpns/ProjectAfterSales.vue
Normal file
166
src/views/project/cpns/ProjectAfterSales.vue
Normal 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>
|
||||
134
src/views/project/cpns/ProjectFiles.vue
Normal file
134
src/views/project/cpns/ProjectFiles.vue
Normal 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>
|
||||
191
src/views/project/cpns/ProjectFilter.vue
Normal file
191
src/views/project/cpns/ProjectFilter.vue
Normal 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>
|
||||
275
src/views/project/cpns/ProjectFinance.vue
Normal file
275
src/views/project/cpns/ProjectFinance.vue
Normal 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>
|
||||
86
src/views/project/cpns/ProjectInfoCard.vue
Normal file
86
src/views/project/cpns/ProjectInfoCard.vue
Normal 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>
|
||||
186
src/views/project/cpns/ProjectMilestones.vue
Normal file
186
src/views/project/cpns/ProjectMilestones.vue
Normal 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>
|
||||
183
src/views/project/cpns/ProjectTable.vue
Normal file
183
src/views/project/cpns/ProjectTable.vue
Normal 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>
|
||||
93
src/views/project/cpns/ProjectTeam.vue
Normal file
93
src/views/project/cpns/ProjectTeam.vue
Normal 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>
|
||||
Reference in New Issue
Block a user