项目中心

This commit is contained in:
wangzzpeng
2026-09-28 17:47:07 +08:00
parent 43c2dc0d8d
commit b014fffacf
23 changed files with 1630 additions and 1577 deletions

View File

@@ -1,10 +1,15 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { AfterSales } from '@/types/business/project'
import { ElMessage } from 'element-plus'
import { FileList } from '@/components'
import type { FileInfo } from '@/types/api'
import type { AfterSales, ProjectStatus } from '@/types/business/project'
interface Props {
/** 售后工单;`undefined` 或 `status === '无'` 表示没有进行中的工单 */
afterSales?: AfterSales
projectId: string
/** 项目状态 —— 决定售后区的三态展示(见下方模板注释) */
projectStatus: ProjectStatus
}
const props = defineProps<Props>()
@@ -14,47 +19,131 @@ const emit = defineEmits<{
cancel: []
}>()
const isProcessing = computed(() => props.afterSales?.status === '处理中')
/**
* 售后页签的三态(设计稿便签原文):
* 1. 项目**未完成** → 不展示售后信息;
* 2. 已完成 + **未提交**售后 → 内容为空,只给「申请售后」;
* 3. 已完成 + **已提交**售后 → 展示售后信息 + 「取消售后」。
*
* <p>「已完成」按详情页状态口径判定(后端 `COMPLETED` 经 projectVo 映射成「已完成」)。
* 后端 `requestAfterSale` 也会校验这一点,前端只是提前把入口藏掉。</p>
*/
const isCompleted = computed(() => props.projectStatus === '已完成')
/** 存在进行中的售后工单(撤回后的记录在映射层已归为「无」) */
const hasTicket = computed(() => props.afterSales?.status === '处理中')
/**
* 预览 / 下载直接走附件自身的 OSS 地址(后端下发的 `url` 就是可访问地址);
* 删除没有接口 —— 售后附件只在「申请 / 完成售后」时整体写入,不支持单独删。
*/
function previewFile(file: FileInfo): void {
if (!file.url) {
ElMessage.info('该附件没有可访问的地址')
return
}
window.open(file.url, '_blank', 'noopener')
}
function downloadFile(file: FileInfo): void {
if (!file.url) {
ElMessage.info('该附件没有可访问的地址')
return
}
const link = document.createElement('a')
link.href = file.url
link.download = file.name
link.target = '_blank'
link.rel = 'noopener'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
function onDeleteFile(): void {
// TODO(api): 售后工单附件无单独删除接口(后端只在申请 / 完成售后时整体写入)
ElMessage.info('售后附件暂不支持单独删除')
}
</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">
工程师正在处理您的修改需求,请耐心等待
<!-- 规则 1:项目未完成 → 不展示售后信息 -->
<div v-if="!isCompleted" class="project-after-sales__blank">
<p class="project-after-sales__blank-text text-secondary">
项目完成后可申请售后
</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>
<!-- 规则 3:已完成 + 已提交售后 → 工单横幅 + 售后信息 + 取消售后 -->
<template v-else-if="hasTicket && afterSales">
<div class="project-after-sales__banner">
<span class="project-after-sales__banner-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="22" height="22" fill="currentColor">
<path d="M6 2h7l5 5v13a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2Zm7 1.6V8h4.4L13 3.6ZM8 12.2h8v1.6H8v-1.6Zm0 3.6h8v1.6H8v-1.6Z" />
</svg>
</span>
<div class="project-after-sales__banner-text">
<h3 class="project-after-sales__banner-title">
售后工单处理中
</h3>
<p class="project-after-sales__banner-desc">
工程师正在处理您的修改需求,请耐心等待
</p>
</div>
</div>
<p v-if="afterSales.description" class="project-after-sales__desc">
{{ afterSales.description }}
</p>
<h2 class="project-after-sales__title">
售后信息
</h2>
<div class="project-after-sales__field">
<span class="project-after-sales__label">售后类型</span>
<div class="project-after-sales__readonly">
{{ afterSales.type || '—' }}
</div>
</div>
<div class="project-after-sales__field">
<span class="project-after-sales__label">问题描述</span>
<div class="project-after-sales__readonly project-after-sales__readonly--block">
{{ afterSales.description || '—' }}
</div>
</div>
<div class="project-after-sales__field">
<div class="project-after-sales__field-head">
<span class="project-after-sales__label">附件上传</span>
<span class="project-after-sales__field-hint text-secondary">支持图片/文档/压缩包,大小不超过1G</span>
</div>
<FileList
v-if="afterSales.attachments.length > 0"
:files="afterSales.attachments"
@preview="previewFile"
@download="downloadFile"
@delete="onDeleteFile"
/>
<p v-else class="project-after-sales__none text-secondary">
暂无附件
</p>
</div>
<div class="project-after-sales__actions">
<button
class="project-after-sales__btn project-after-sales__btn--default"
class="project-after-sales__btn project-after-sales__btn--primary"
@click="emit('cancel')"
>
取消售后
</button>
</div>
</div>
</template>
<!-- 空状态 -->
<div v-else class="project-after-sales__card project-after-sales__card--empty">
<p class="project-after-sales__empty text-secondary">
暂无售后工单
</p>
<!-- 规则 2:已完成 + 未提交售后 → 内容为空 + 申请售后 -->
<template v-else>
<div class="project-after-sales__blank">
<p class="project-after-sales__blank-text text-secondary">
暂无售后工单
</p>
</div>
<div class="project-after-sales__actions">
<button
class="project-after-sales__btn project-after-sales__btn--primary"
@@ -63,79 +152,125 @@ const isProcessing = computed(() => props.afterSales?.status === '处理中')
申请售后
</button>
</div>
</div>
</template>
</section>
</template>
<style lang="less" scoped>
.project-after-sales {
.flex(column, flex-start, stretch);
gap: @spacing-base;
gap: @spacing-lg;
padding: @spacing-lg @spacing-xl;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
&__card {
// ── 顶部工单横幅:浅红底 + 红色图标块(设计稿) ──
&__banner {
.flex(row, flex-start, center);
gap: @spacing-base;
padding: @spacing-base @spacing-lg;
border-radius: @radius-base;
background: fade(@danger-color, 8%);
}
&__banner-icon {
.flex(row, center);
flex-shrink: 0;
width: 44px;
height: 44px;
border-radius: @radius-base;
background: @danger-color;
color: @white;
}
&__banner-text {
.flex(column, flex-start, stretch);
gap: 6px;
min-width: 0;
}
&__banner-title {
font-size: @font-size-lg;
font-weight: 600;
color: @heading-color;
line-height: 1.2;
}
&__banner-desc {
font-size: @font-size-sm;
color: @text-color-secondary;
}
// ── 「售后信息」区块标题:竖条 + 15px 缩进(与其它页签一致) ──
&__title {
.section-title();
margin-top: @spacing-xs;
}
&__field {
.flex(column, flex-start, stretch);
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);
&__field-head {
.flex(row, flex-start, baseline);
gap: @spacing-sm;
}
&__badge {
padding: 2px @spacing-sm;
border-radius: @radius-sm;
background: fade(@warning-color, 12%);
color: @warning-color;
&__field-hint {
font-size: @font-size-sm;
}
&__card-title {
font-size: @font-size-lg;
&__label {
font-size: @font-size-base;
font-weight: 600;
color: @heading-color;
}
&__desc {
font-size: @font-size-base;
// 只读展示(设计稿里是灰底带边框的框,不是输入控件)
&__readonly {
min-height: 40px;
padding: 0 @spacing-base;
border: 1px solid @border-color;
border-radius: @radius-base;
background: @bg-color-light;
color: @text-color;
line-height: 1.6;
font-size: @font-size-base;
line-height: 40px;
&--block {
min-height: 96px;
padding: @spacing-sm @spacing-base;
line-height: 1.7;
white-space: pre-wrap;
word-break: break-word;
}
}
&__meta {
.flex(row, flex-start);
flex-wrap: wrap;
gap: @spacing-base;
&__none {
padding: @spacing-sm 0;
font-size: @font-size-sm;
}
&__empty {
padding: @spacing-lg 0;
text-align: center;
&__blank {
.flex(row, center);
padding: @spacing-xl 0;
}
&__blank-text {
font-size: @font-size-base;
}
&__actions {
.flex(row, flex-end);
.flex(row, flex-end, center);
gap: @spacing-sm;
}
&__btn {
height: 34px;
padding: 0 @spacing-base;
height: 36px;
padding: 0 @spacing-lg;
border-radius: @radius-base;
font-size: @font-size-base;
transition: all 0.2s;
@@ -149,18 +284,6 @@ const isProcessing = computed(() => props.afterSales?.status === '处理中')
background: @primary-hover;
}
}
&--default {
border: 1px solid @border-color-dark;
background: @white;
color: @text-color;
&:hover {
border-color: @primary-color;
color: @primary-color;
}
}
}
}
</style>