项目中心

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,105 +0,0 @@
<script setup lang="ts">
import type { BusinessType } from '@/types/api'
import type { Demand } from '@/types/business/demand'
interface Props {
demand: Demand
}
defineProps<Props>()
const emit = defineEmits<{ select: [businessType: BusinessType] }>()
interface BusinessOption {
value: BusinessType
name: string
desc: string
}
const options: BusinessOption[] = [
{ value: 'internet', name: '互联网软件', desc: 'Web/小程序/APP/二次开发/桌面工具等' },
{ value: 'iot', name: '物联网全案', desc: '软硬件开发+云端一体化/定制化开发等' },
{ value: 'manufacturing', name: '生产制造', desc: 'PCB制版/SMT/整机装配/定制生产等' },
{ value: 'hardware', name: '硬件电路', desc: '原理图设计/PCB设计/电气效验等' },
{ value: 'other', name: '其他', desc: '若没有对应的服务内容请选择该选项' },
]
</script>
<template>
<div class="demand-step1">
<p class="demand-step1__title">
请选择业务类型
</p>
<div class="demand-step1__grid">
<button
v-for="option in options"
:key="option.value"
class="demand-step1__card"
:class="{ 'is-active': demand.businessType === option.value }"
@click="emit('select', option.value)"
>
<span class="demand-step1__name">{{ option.name }}</span>
<span class="demand-step1__desc">{{ option.desc }}</span>
</button>
</div>
</div>
</template>
<style lang="less" scoped>
.demand-step1 {
.flex(column, flex-start);
gap: @spacing-base;
&__title {
font-size: @font-size-lg;
font-weight: 600;
color: @heading-color;
}
&__grid {
.flex(row, flex-start);
flex-wrap: wrap;
gap: @spacing-base;
}
&__card {
.flex(column, flex-start);
gap: @spacing-xs;
width: calc((100% - 32px) / 3);
min-width: 220px;
padding: @spacing-base;
border: 1px solid @border-color;
border-radius: @radius-base;
background: @white;
text-align: left;
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: @primary-color;
}
&.is-active {
border-color: @primary-color;
background: @primary-light;
box-shadow: @shadow-base;
}
}
&__name {
font-size: @font-size-lg;
font-weight: 600;
color: @heading-color;
}
&__desc {
font-size: @font-size-sm;
color: @text-color-secondary;
line-height: 1.5;
}
&__card.is-active &__name {
color: @primary-color;
}
}
</style>

View File

@@ -1,229 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { ElMessage } from 'element-plus'
import { FileList, UploadArea } from '@/components'
import type { FileInfo } from '@/types/api'
import type { BudgetRange, Demand } from '@/types/business/demand'
interface Props {
demand: Demand
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:demand': [partial: Partial<Demand>]
upload: [file: File]
'remove-file': [file: FileInfo]
}>()
const budgetOptions: Array<{ label: string; value: Exclude<BudgetRange, ''> }> = [
{ label: '1万以下', value: 'lt1w' },
{ label: '1-5万', value: '1to5w' },
{ label: '5-10万', value: '5to10w' },
{ label: '10万+', value: 'gt10w' },
]
const descriptionLength = computed(() => props.demand.coreDescription.length)
function onProjectName(event: Event): void {
const target = event.target as HTMLInputElement
emit('update:demand', { projectName: target.value })
}
function onCoreDescription(event: Event): void {
const target = event.target as HTMLTextAreaElement
emit('update:demand', { coreDescription: target.value })
}
function onDuration(event: Event): void {
const target = event.target as HTMLInputElement
const value = target.value === '' ? '' : Number(target.value)
emit('update:demand', { expectedDuration: value })
}
function onUpload(file: File): void {
emit('upload', file)
}
function onRemoveFile(file: FileInfo): void {
emit('remove-file', file)
}
function onFileAction(action: 'preview' | 'download', file: FileInfo): void {
ElMessage.info(`「${file.name}」${action === 'preview' ? '预览' : '下载'}功能待接口接入`)
}
</script>
<template>
<div class="demand-step2">
<div class="demand-step2__field">
<label class="demand-step2__label">项目名称</label>
<input
class="demand-step2__input"
type="text"
:value="demand.projectName"
placeholder="请输入项目名称"
@input="onProjectName"
>
</div>
<div class="demand-step2__field">
<label class="demand-step2__label">相关附件</label>
<UploadArea @upload="onUpload" />
<FileList
:files="demand.attachments"
@preview="(file) => onFileAction('preview', file)"
@download="(file) => onFileAction('download', file)"
@delete="onRemoveFile"
/>
</div>
<div class="demand-step2__field">
<label class="demand-step2__label">核心需求描述</label>
<div class="demand-step2__textarea-wrap">
<textarea
class="demand-step2__textarea"
:value="demand.coreDescription"
maxlength="500"
placeholder="请描述您的核心需求(业务目标、功能范围、交付期望等)"
@input="onCoreDescription"
/>
<span class="demand-step2__counter">{{ descriptionLength }}/500</span>
</div>
</div>
<div class="demand-step2__field">
<label class="demand-step2__label">预计开发周期</label>
<input
class="demand-step2__input"
type="number"
:value="demand.expectedDuration"
min="1"
placeholder="请输入预计开发周期(工作日)"
@input="onDuration"
>
</div>
<div class="demand-step2__field">
<label class="demand-step2__label">预算范围</label>
<div class="demand-step2__budgets">
<button
v-for="option in budgetOptions"
:key="option.value"
class="demand-step2__budget"
:class="{ 'is-active': demand.budgetRange === option.value }"
@click="emit('update:demand', { budgetRange: option.value })"
>
{{ option.label }}
</button>
</div>
</div>
</div>
</template>
<style lang="less" scoped>
.demand-step2 {
.flex(column, flex-start);
gap: @spacing-lg;
&__field {
.flex(column, flex-start);
gap: @spacing-sm;
width: 100%;
}
&__label {
font-size: @font-size-base;
font-weight: 600;
color: @heading-color;
}
&__input {
width: 100%;
height: 40px;
padding: 0 @spacing-base;
border: 1px solid @border-color;
border-radius: @radius-base;
background: @bg-color-light;
font-size: @font-size-base;
color: @heading-color;
outline: none;
transition: border-color 0.2s;
&:focus {
border-color: @primary-color;
}
&::placeholder {
color: @text-color-secondary;
}
}
&__textarea-wrap {
position: relative;
width: 100%;
}
&__textarea {
width: 100%;
min-height: 120px;
padding: @spacing-sm @spacing-base 28px;
border: 1px solid @border-color;
border-radius: @radius-base;
background: @bg-color-light;
font-size: @font-size-base;
color: @heading-color;
line-height: 1.6;
outline: none;
resize: vertical;
transition: border-color 0.2s;
box-sizing: border-box;
&:focus {
border-color: @primary-color;
}
&::placeholder {
color: @text-color-secondary;
}
}
&__counter {
position: absolute;
right: @spacing-sm;
bottom: @spacing-sm;
font-size: @font-size-sm;
color: @text-color-secondary;
}
&__budgets {
.flex(row, flex-start);
gap: @spacing-sm;
flex-wrap: wrap;
}
&__budget {
height: 36px;
padding: 0 @spacing-base;
border: 1px solid @border-color;
border-radius: @radius-base;
background: @white;
color: @text-color;
font-size: @font-size-base;
transition: all 0.2s;
&:hover {
border-color: @primary-color;
color: @primary-color;
}
&.is-active {
border-color: @primary-color;
background: @primary-light;
color: @primary-color;
font-weight: 600;
}
}
}
</style>

View File

@@ -1,172 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { Demand } from '@/types/business/demand'
interface Props {
demand: Demand
}
const props = defineProps<Props>()
const emit = defineEmits<{ 'update:demand': [partial: Partial<Demand>] }>()
const remarkLength = computed(() => props.demand.remark.length)
function onUserName(event: Event): void {
const target = event.target as HTMLInputElement
emit('update:demand', { userName: target.value })
}
function onUserPhone(event: Event): void {
const target = event.target as HTMLInputElement
emit('update:demand', { userPhone: target.value })
}
function onRemark(event: Event): void {
const target = event.target as HTMLTextAreaElement
emit('update:demand', { remark: target.value })
}
</script>
<template>
<div class="demand-step3">
<div class="demand-step3__row">
<div class="demand-step3__field">
<label class="demand-step3__label">用户名称</label>
<input
class="demand-step3__input"
type="text"
:value="demand.userName"
placeholder="请输入用户名称"
@input="onUserName"
>
</div>
<div class="demand-step3__field">
<label class="demand-step3__label">用户手机号</label>
<input
class="demand-step3__input"
type="text"
:value="demand.userPhone"
placeholder="请输入用户手机号"
@input="onUserPhone"
>
</div>
</div>
<div class="demand-step3__field">
<label class="demand-step3__label">补充备注</label>
<div class="demand-step3__textarea-wrap">
<textarea
class="demand-step3__textarea"
:value="demand.remark"
maxlength="500"
placeholder="可补充其他说明(选填)"
@input="onRemark"
/>
<span class="demand-step3__counter">{{ remarkLength }}/500</span>
</div>
</div>
<p class="demand-step3__tip">
提交后我们的专业顾问将在一个工作日内与您联系,为您提供一对一服务
</p>
</div>
</template>
<style lang="less" scoped>
.demand-step3 {
.flex(column, flex-start);
gap: @spacing-lg;
&__row {
.flex(row, flex-start);
gap: @spacing-base;
width: 100%;
.demand-step3__field {
flex: 1;
min-width: 0;
}
}
&__field {
.flex(column, flex-start);
gap: @spacing-sm;
width: 100%;
}
&__label {
font-size: @font-size-base;
font-weight: 600;
color: @heading-color;
}
&__input {
width: 100%;
height: 40px;
padding: 0 @spacing-base;
border: 1px solid @border-color;
border-radius: @radius-base;
background: @bg-color-light;
font-size: @font-size-base;
color: @heading-color;
outline: none;
transition: border-color 0.2s;
box-sizing: border-box;
&:focus {
border-color: @primary-color;
}
&::placeholder {
color: @text-color-secondary;
}
}
&__textarea-wrap {
position: relative;
width: 100%;
}
&__textarea {
width: 100%;
min-height: 120px;
padding: @spacing-sm @spacing-base 28px;
border: 1px solid @border-color;
border-radius: @radius-base;
background: @bg-color-light;
font-size: @font-size-base;
color: @heading-color;
line-height: 1.6;
outline: none;
resize: vertical;
transition: border-color 0.2s;
box-sizing: border-box;
&:focus {
border-color: @primary-color;
}
&::placeholder {
color: @text-color-secondary;
}
}
&__counter {
position: absolute;
right: @spacing-sm;
bottom: @spacing-sm;
font-size: @font-size-sm;
color: @text-color-secondary;
}
&__tip {
padding: @spacing-sm @spacing-base;
border-radius: @radius-base;
background: @primary-light;
font-size: @font-size-sm;
color: @primary-color;
line-height: 1.6;
}
}
</style>

View File

@@ -3,11 +3,11 @@ import { computed, onMounted, ref } from 'vue'
import { storeToRefs } from 'pinia'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { AppButton, StepForm, UploadArea } from '@/components'
import { AppButton, FileList } from '@/components'
// 页面容器:数据只经 store,不直接 import api
import { useProjectStore } from '@/stores/modules/project'
import type { BusinessType, FileInfo } from '@/types/api'
import { formatDate } from '@/utils/format'
import type { FileInfo } from '@/types/api'
import type { AfterSalesAttachment } from '@/types/business/project'
const route = useRoute()
const router = useRouter()
@@ -16,58 +16,61 @@ const { current } = storeToRefs(projectStore)
const projectId = computed(() => String(route.params.id))
const STEP_TITLES = ['选择业务类型', '填写问题描述', '补充备注']
const step = ref(0)
const businessTypeOptions: Array<{ label: string; value: BusinessType }> = [
{ label: '互联网软件', value: 'internet' },
{ label: '硬件电路', value: 'hardware' },
{ label: '物联网全案', value: 'iot' },
{ label: '生产制造', value: 'manufacturing' },
/**
* 售后类型候选(**下拉框的值域**)。
*
* <p>⚠️ 库内**没有「售后类型」字典**(`aftersales_status` 是售后状态、`vm_requirement_business_type`
* 是项目业务类型,都不是它)⇒ 先在前端固定这 8 项。后端补上字典 / 选项接口后,
* 只需把这里换成接口驱动(`TYPE_OPTIONS` 改为 ref 接收接口数据),其余逻辑不用动。</p>
*/
const TYPE_OPTIONS = [
'软件 BUG 修复',
'软件 / 固件功能迭代',
'硬件 PCB & 电路改版调整',
'硬件故障排查维修',
'成品不良品返修',
'元器件物料替换变更',
'物联网整机联调与版本升级',
'交付资料补齐与技术答疑',
]
const typeChips = ['BUG问题', '功能新增', '其他']
const businessType = ref<BusinessType | ''>('')
const projectName = ref('')
const type = ref('')
/** 选中的项目(= 需求 id;提交时下发给后端,由它按此定位项目) */
const selectedProjectId = ref(projectId.value)
const description = ref('')
const remark = ref('')
const attachments = ref<FileInfo[]>([])
/** 已上传到 OSS 的附件(携带 ossId,提交时进 `attachmentOssIds`) */
const attachments = ref<AfterSalesAttachment[]>([])
const uploading = ref(false)
const submitting = ref(false)
const fileInputRef = ref<HTMLInputElement | null>(null)
const projectNamePlaceholder = computed(() => current.value?.projectName || '默认当前项目')
const descriptionCount = computed(() => `${description.value.length}/500`)
const remarkCount = computed(() => `${remark.value.length}/500`)
function nextStep(): void {
if (step.value < STEP_TITLES.length - 1) step.value += 1
}
function prevStep(): void {
if (step.value > 0) step.value -= 1
}
function pickType(chip: string): void {
type.value = chip
}
function onUpload(file: File): void {
attachments.value.push({
name: file.name,
size: file.size,
uploadTime: formatDate(new Date()),
url: '',
})
}
function removeAttachment(index: number): void {
attachments.value.splice(index, 1)
}
/** 项目名称下拉的候选:先取**已完成项目**,再把当前项目兜底并入 */
const projectOptions = computed(() => {
const options = projectStore.completedProjects.map((item) => ({
id: String(item.id),
// 项目名称为空时退回编号,避免下拉出现空白项
label: item.projectName || item.projectNo || '未命名项目',
}))
// 当前项目理论上必然已完成(详情页只有已完成才给「申请售后」入口);
// 但「列表接口结果」与「详情状态」可能因刚变更而不同步 ⇒ 兜底并入,
// 否则 v-model 命不中任何 option,下拉会显示成空白。
if (projectId.value && !options.some((item) => item.id === projectId.value)) {
options.unshift({
id: projectId.value,
label: current.value?.projectName || '当前项目',
})
}
return options
})
function resetForm(): void {
step.value = 0
businessType.value = ''
projectName.value = ''
// 项目名称回到默认(当前项目),其余清空
selectedProjectId.value = projectId.value
type.value = ''
description.value = ''
remark.value = ''
@@ -78,170 +81,255 @@ function onCancel(): void {
void router.push(`/project/${projectId.value}`)
}
function pickFiles(): void {
fileInputRef.value?.click()
}
/**
* 选完文件后逐个上传到 OSS。
*
* <p>上传接口一次只收一个 `file` 字段,所以串行上传;全部成功后才进列表
* (失败的文件不会留下半条记录)。</p>
*/
async function onFilesPicked(list: FileList | null): Promise<void> {
if (!list || list.length === 0) return
uploading.value = true
try {
for (const file of Array.from(list)) {
attachments.value.push(await projectStore.uploadAfterSalesFile(file))
}
ElMessage.success('附件上传成功')
}
catch (error) {
console.warn('[after-sales] 附件上传失败', error)
}
finally {
uploading.value = false
// 清空 input,允许再次选择同一个文件
if (fileInputRef.value) fileInputRef.value.value = ''
}
}
function removeAttachment(file: FileInfo): void {
// FileList 回传的就是列表里的同一个对象引用;用引用定位,避免同名同大小文件删错一条
const index = attachments.value.indexOf(file as AfterSalesAttachment)
if (index >= 0) attachments.value.splice(index, 1)
}
function previewAttachment(file: FileInfo): void {
if (!file.url) {
ElMessage.info('该附件还没有可访问的地址')
return
}
window.open(file.url, '_blank', 'noopener')
}
function downloadAttachment(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)
}
/**
* 提交申请。
*
* <p>提交的 `id` 是「项目名称」下拉**选中的项目**(默认当前项目)——后端只按这个需求 id 定位
* 项目并校验「已完成」,所以切换项目后提交的就是那个项目的售后。附件下发 `ossId` 列表,由后端转存。</p>
*/
async function onSubmit(): Promise<void> {
const effectiveName = projectName.value || current.value?.projectName || ''
if (!effectiveName) {
ElMessage.warning('请输入项目名称')
if (!selectedProjectId.value) {
ElMessage.warning('请选择项目名称')
return
}
if (!type.value) {
ElMessage.warning('请选择或输入售后类型')
if (!type.value.trim()) {
ElMessage.warning('请选择售后类型')
return
}
if (!description.value) {
if (!description.value.trim()) {
ElMessage.warning('请填写问题描述')
return
}
await projectStore.submitAfterSales({
type: type.value,
description: description.value,
remark: remark.value,
attachments: attachments.value,
})
await router.push(`/project/${projectId.value}`)
submitting.value = true
try {
await projectStore.submitAfterSales(selectedProjectId.value, {
type: type.value.trim(),
reason: description.value.trim(),
note: remark.value.trim(),
attachmentOssIds: attachments.value.map((item) => item.ossId),
})
await router.push(`/project/${selectedProjectId.value}`)
}
catch (error) {
// 统一 request 层已弹错误提示(如「只有已完成的项目可以申请售后」),这里只恢复按钮
console.warn('[after-sales] 售后申请提交失败', error)
}
finally {
submitting.value = false
}
}
onMounted(() => {
if (!current.value || current.value.id !== projectId.value) {
void projectStore.fetchDetail(projectId.value)
}
// 「项目名称」下拉的值域(已完成项目);每次进页面都重拉,项目状态会变
void projectStore.fetchCompletedProjects()
})
</script>
<template>
<div class="container after-sales">
<nav class="after-sales__breadcrumb text-secondary">
<RouterLink to="/project" class="link">
项目中心
</RouterLink>
<span> &gt; </span>
<RouterLink :to="`/project/${projectId}`" class="link">
项目详情
</RouterLink>
<span> &gt; </span>
<span>申请售后</span>
</nav>
<StepForm :titles="STEP_TITLES" :current="step">
<!-- 第一步:选择业务类型 -->
<div v-if="step === 0" class="after-sales__section">
<div class="after-sales__field">
<label class="after-sales__label">业务类型</label>
<div class="after-sales__options">
<button
v-for="option in businessTypeOptions"
:key="option.value"
class="after-sales__option"
:class="{ 'is-active': businessType === option.value }"
@click="businessType = option.value"
>
{{ option.label }}
</button>
</div>
</div>
<div class="after-sales__field">
<label class="after-sales__label" for="after-sales-project">项目名称</label>
<input
id="after-sales-project"
v-model="projectName"
class="after-sales__input"
:placeholder="projectNamePlaceholder"
>
</div>
</div>
<!-- 第二步:填写问题描述 -->
<div v-if="step === 1" class="after-sales__section">
<div class="after-sales__field">
<label class="after-sales__label" for="after-sales-type">售后类型</label>
<input
id="after-sales-type"
v-model="type"
class="after-sales__input"
placeholder="选择下方推荐的售后类型或手动输入售后类型"
>
<div class="after-sales__options">
<button
v-for="chip in typeChips"
:key="chip"
class="after-sales__option"
:class="{ 'is-active': type === chip }"
@click="pickType(chip)"
>
{{ chip }}
</button>
</div>
</div>
<div class="after-sales__field">
<label class="after-sales__label" for="after-sales-desc">问题描述</label>
<textarea
id="after-sales-desc"
v-model="description"
class="after-sales__textarea"
maxlength="500"
rows="5"
placeholder="请详细描述您的问题..."
/>
<span class="after-sales__count text-secondary">{{ descriptionCount }}</span>
</div>
<div class="after-sales__field">
<label class="after-sales__label">附件上传</label>
<UploadArea hint="支持图片/文档/压缩包,大小不超过1G" @upload="onUpload" />
<ul v-if="attachments.length > 0" class="after-sales__files">
<li
v-for="(file, index) in attachments"
:key="`${file.name}-${index}`"
class="after-sales__file"
>
<span class="after-sales__file-name" :title="file.name">{{ file.name }}</span>
<button
class="after-sales__file-remove"
@click="removeAttachment(index)"
>
移除
</button>
</li>
</ul>
</div>
</div>
<!-- 第三步:补充备注 -->
<div v-if="step === 2" class="after-sales__section">
<div class="after-sales__field">
<label class="after-sales__label" for="after-sales-remark">补充备注</label>
<textarea
id="after-sales-remark"
v-model="remark"
class="after-sales__textarea"
maxlength="500"
rows="5"
placeholder="其他需要说明的事项..."
/>
<span class="after-sales__count text-secondary">{{ remarkCount }}</span>
<p class="after-sales__tip text-secondary">
备注为选填项,如有与问题描述相关的补充信息可在此说明,便于工程师更快定位问题。
</p>
</div>
</div>
</StepForm>
<div class="after-sales__footer">
<AppButton v-if="step > 0" @click="prevStep">
上一步
</AppButton>
<AppButton v-if="step < STEP_TITLES.length - 1" @click="nextStep">
下一步
</AppButton>
<AppButton v-if="step === STEP_TITLES.length - 1" type="primary" @click="onSubmit">
<header class="after-sales__header">
<h1 class="after-sales__title">
申请售后
</AppButton>
<AppButton @click="resetForm">
重置
</AppButton>
<AppButton @click="onCancel">
取消
</AppButton>
</div>
</h1>
<p class="after-sales__subtitle text-secondary">
填写问题描述并上传相关资料,系统自动生成售后工单,全程线上跟踪处理进度。
</p>
</header>
<!-- ① 选择业务类型 -->
<section class="after-sales__card">
<h2 class="after-sales__card-title">
<span class="after-sales__step">1</span>
选择业务类型
</h2>
<div class="after-sales__field">
<label class="after-sales__label" for="after-sales-type">
售后类型<span class="after-sales__required">*</span>
</label>
<select
id="after-sales-type"
v-model="type"
class="after-sales__select"
>
<option value="">
请选择售后类型
</option>
<option v-for="item in TYPE_OPTIONS" :key="item" :value="item">
{{ item }}
</option>
</select>
</div>
</section>
<!-- ② 填写问题描述 -->
<section class="after-sales__card">
<h2 class="after-sales__card-title">
<span class="after-sales__step">2</span>
填写问题描述
</h2>
<div class="after-sales__field">
<label class="after-sales__label" for="after-sales-project">
项目名称<span class="after-sales__required">*</span>
</label>
<select
id="after-sales-project"
v-model="selectedProjectId"
class="after-sales__select"
>
<option value="">
请选择项目
</option>
<option v-for="item in projectOptions" :key="item.id" :value="item.id">
{{ item.label }}
</option>
</select>
</div>
<div class="after-sales__field">
<label class="after-sales__label" for="after-sales-desc">
问题描述<span class="after-sales__required">*</span>
</label>
<textarea
id="after-sales-desc"
v-model="description"
class="after-sales__textarea"
maxlength="500"
rows="5"
placeholder="请详细描述您的问题..."
/>
<span class="after-sales__count text-secondary">{{ descriptionCount }}字</span>
</div>
<div class="after-sales__field">
<div class="after-sales__field-head">
<label class="after-sales__label">附件上传</label>
<span class="after-sales__field-hint text-secondary">支持图片/文档/压缩包,大小不超过1G</span>
<AppButton
class="after-sales__upload-btn"
size="sm"
:loading="uploading"
@click="pickFiles"
>
上传文件
</AppButton>
</div>
<input
ref="fileInputRef"
type="file"
multiple
class="after-sales__file-input"
@change="onFilesPicked(($event.target as HTMLInputElement).files)"
>
<FileList
v-if="attachments.length > 0"
:files="attachments"
@preview="previewAttachment"
@download="downloadAttachment"
@delete="removeAttachment"
/>
</div>
</section>
<!-- ③ 补充备注 -->
<section class="after-sales__card">
<h2 class="after-sales__card-title">
<span class="after-sales__step">3</span>
补充备注
</h2>
<div class="after-sales__field">
<label class="after-sales__label" for="after-sales-remark">补充备注</label>
<textarea
id="after-sales-remark"
v-model="remark"
class="after-sales__textarea"
maxlength="500"
rows="4"
placeholder="其他需要说明的事项..."
/>
<span class="after-sales__count text-secondary">{{ remarkCount }}字</span>
</div>
</section>
<!-- 底部操作条(设计稿:左侧提示 + 右侧 取消 / 重置 / 申请售后) -->
<footer class="after-sales__footer">
<p class="after-sales__footer-tip text-secondary">
<span class="after-sales__footer-icon" aria-hidden="true">i</span>
提交后我们的专业顾问将在一个工作日内与您联系,为您提供一对一服务
</p>
<div class="after-sales__footer-actions">
<AppButton @click="onCancel">
取消
</AppButton>
<AppButton @click="resetForm">
重置
</AppButton>
<AppButton type="primary" :loading="submitting" @click="onSubmit">
申请售后
</AppButton>
</div>
</footer>
</div>
</template>
@@ -252,13 +340,49 @@ onMounted(() => {
padding-top: @spacing-lg;
padding-bottom: @spacing-xxl;
&__breadcrumb {
font-size: @font-size-base;
&__header {
.flex(column, flex-start, stretch);
gap: @spacing-xs;
padding: @spacing-sm 0 @spacing-base;
}
&__section {
&__title {
font-size: @font-size-title;
font-weight: 600;
color: @heading-color;
}
&__subtitle {
font-size: @font-size-sm;
}
// ── 三段卡片(设计稿:白底圆角 + 序号圆标标题) ──
&__card {
.flex(column, flex-start, stretch);
gap: @spacing-lg;
padding: @spacing-lg @spacing-xl;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
}
&__card-title {
.flex(row, flex-start, center);
gap: @spacing-sm;
font-size: @font-size-lg;
font-weight: 600;
color: @heading-color;
}
&__step {
.flex(row, center);
width: 26px;
height: 26px;
border-radius: @radius-round;
background: @primary-color;
color: @white;
font-size: @font-size-sm;
font-weight: 600;
}
&__field {
@@ -266,48 +390,49 @@ onMounted(() => {
gap: @spacing-sm;
}
&__field-head {
.flex(row, flex-start, center);
gap: @spacing-sm;
}
&__field-hint {
font-size: @font-size-sm;
}
// 「上传文件」推到行尾
&__upload-btn {
margin-left: auto;
}
&__label {
font-size: @font-size-base;
font-weight: 600;
color: @heading-color;
}
&__options {
.flex(row, flex-start, center);
flex-wrap: wrap;
gap: @spacing-sm;
&__required {
margin-left: 2px;
color: @danger-color;
font-weight: 400;
}
&__option {
height: 30px;
padding: 0 @spacing-base;
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;
}
}
&__input {
width: 360px;
height: 36px;
padding: 0 @spacing-base;
&__select {
width: 100%;
height: 40px;
padding: 0 36px 0 @spacing-base;
border: 1px solid @border-color;
border-radius: @radius-base;
background: @white;
background-color: @bg-color-light;
color: @text-color;
font-size: @font-size-base;
appearance: none;
outline: none;
cursor: pointer;
// 自定义下拉箭头(原生 select 不支持 ::after)
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'%3E%3Cpath d='M2 4.5 6 8.5l4-4' fill='none' stroke='%238c8c8c' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
background-size: 12px 12px;
transition: border-color 0.2s;
&:focus {
@@ -315,12 +440,14 @@ onMounted(() => {
}
}
// 「项目名称」与「售后类型」共用同一个下拉样式(见上方 &__select)
&__textarea {
width: 100%;
padding: @spacing-sm @spacing-base;
border: 1px solid @border-color;
border-radius: @radius-base;
background: @white;
background: @bg-color-light;
color: @text-color;
font-size: @font-size-base;
line-height: 1.6;
resize: vertical;
@@ -337,41 +464,46 @@ onMounted(() => {
font-size: @font-size-sm;
}
&__tip {
font-size: @font-size-sm;
line-height: 1.6;
}
&__files {
.flex(column, flex-start, stretch);
gap: @spacing-xs;
}
&__file {
.flex(row, space-between);
gap: @spacing-base;
width: 360px;
padding: @spacing-xs @spacing-sm;
border: 1px solid @border-color;
border-radius: @radius-sm;
background: @bg-color-light;
}
&__file-name {
font-size: @font-size-sm;
color: @text-color;
.ellipsis();
}
&__file-remove {
flex-shrink: 0;
font-size: @font-size-sm;
color: @danger-color;
&__file-input {
display: none;
}
// ── 底部固定操作条 ──
&__footer {
.flex(row, flex-start);
position: sticky;
bottom: 0;
z-index: 10;
.flex(row, space-between, center);
gap: @spacing-base;
margin-top: @spacing-sm;
padding: @spacing-base @spacing-xl;
border-top: 1px solid @border-color;
background: @white;
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.04);
}
&__footer-tip {
.flex(row, flex-start, center);
gap: @spacing-xs;
font-size: @font-size-sm;
}
&__footer-icon {
.flex(row, center);
flex-shrink: 0;
width: 16px;
height: 16px;
border: 1px solid currentColor;
border-radius: @radius-round;
font-size: 11px;
font-style: normal;
line-height: 1;
}
&__footer-actions {
.flex(row, flex-end, center);
gap: @spacing-sm;
flex-shrink: 0;
}
}
</style>

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>

View File

@@ -1,5 +1,4 @@
<script setup lang="ts">
import { UploadArea } from '@/components'
import type { FileInfo } from '@/types/api'
import { formatFileSize } from '@/utils/format'
@@ -9,8 +8,8 @@ interface Props {
defineProps<Props>()
// ⚠️ 详情页只「展示」文件,不提供上传入口(上传在需求提报页)。
const emit = defineEmits<{
upload: [file: File]
delete: [index: number]
preview: [index: number]
download: [index: number]
@@ -23,11 +22,6 @@ const emit = defineEmits<{
项目文件
</h2>
<UploadArea
hint="支持图片/文档/压缩包,大小不超过1G"
@upload="emit('upload', $event)"
/>
<ul class="project-files__list">
<li
v-for="(file, index) in files"
@@ -72,9 +66,7 @@ const emit = defineEmits<{
box-shadow: @shadow-base;
&__title {
font-size: @font-size-lg;
font-weight: 600;
color: @heading-color;
.section-title();
}
&__list {

View File

@@ -10,8 +10,8 @@ import type { ProjectQuery, ProjectTypeCount } from '@/types/business/project'
* 的 `businessTypeCounts`,全量口径),组件内不定义业务值域;
* 只有后端未下发的组(项目状态 / 提交时间)才用 `utils/projectMeta.ts` 里的前端常量。</p>
*
* <p>「不限」统一用**空串**表示(不是 'all'):空串不会出现在 querystring 里,
* 正好对上后端 `eqIfText` 的「有值才等值匹配」。</p>
* <p>「不限」统一用**空值**表示(不是 'all'):业务类型用空数组、其余用空串,
* 都不会出现在 querystring 里,正好对上后端「有值才匹配」的语义。</p>
*/
interface Props {
@@ -45,10 +45,11 @@ const rangeLabel = computed(() => {
const chips = computed<Chip[]>(() => {
const list: Chip[] = []
if (props.query.businessType) {
const found = props.typeCounts.find((item) => item.value === props.query.businessType)
list.push({ key: 'businessType', label: found?.label ?? props.query.businessType })
}
// 业务类型是**多选**:每个已选类型各出一个 chip,key 带 id 以便单独移除
props.query.businessTypes.forEach((value) => {
const found = props.typeCounts.find((item) => item.value === value)
list.push({ key: `businessType:${value}`, label: found?.label ?? value })
})
if (props.query.status) {
list.push({ key: 'status', label: PROJECT_STATUS_LABEL[props.query.status] ?? props.query.status })
}
@@ -66,6 +67,27 @@ function patch(partial: Partial<ProjectQuery>): void {
emit('update:query', { ...props.query, ...partial })
}
/** 业务类型行是否处于选中态(「全部」= 一个都没勾) */
function isTypeSelected(value: string): boolean {
return value === 'all'
? props.query.businessTypes.length === 0
: props.query.businessTypes.includes(value)
}
/** 业务类型多选切换:点「全部」清空,点具体行按勾选状态增删(后端口径是 IN,多选即并集) */
function onToggleType(value: string): void {
if (value === 'all') {
patch({ businessTypes: [] })
return
}
const selected = props.query.businessTypes
patch({
businessTypes: selected.includes(value)
? selected.filter((item) => item !== value)
: [...selected, value],
})
}
/** 快捷区间与自选区间互斥:点快捷区间会清掉自选日期 */
function onPickShortcut(value: ProjectQuery['timeShortcut']): void {
rangeOpen.value = false
@@ -78,7 +100,10 @@ function onPickDate(partial: { startDate?: string; endDate?: string }): void {
}
function onRemoveChip(key: string): void {
if (key === 'businessType') patch({ businessType: '' })
if (key.startsWith('businessType:')) {
const value = key.slice('businessType:'.length)
patch({ businessTypes: props.query.businessTypes.filter((item) => item !== value) })
}
else if (key === 'status') patch({ status: '' })
else if (key === 'time') patch({ timeShortcut: 'all' })
else if (key === 'range') patch({ startDate: '', endDate: '' })
@@ -87,7 +112,7 @@ function onRemoveChip(key: string): void {
function onClearChips(): void {
emit('update:query', {
...props.query,
businessType: '',
businessTypes: [],
status: '',
timeShortcut: 'all',
startDate: '',
@@ -102,7 +127,7 @@ function onClearChips(): void {
筛选条件
</h2>
<!-- 业务类型:复选外观的单选列表(值域 / 件数均来自后端统计) -->
<!-- 业务类型:**多选**(值域 / 件数均来自后端统计;「全部」= 清空选择,可单选或多选) -->
<section class="project-filter__group">
<h3 class="project-filter__group-title">
业务类型
@@ -112,8 +137,8 @@ function onClearChips(): void {
<button
type="button"
class="project-filter__row"
:class="{ 'is-active': query.businessType === item.value }"
@click="patch({ businessType: item.value === 'all' ? '' : item.value })"
:class="{ 'is-active': isTypeSelected(item.value) }"
@click="onToggleType(item.value)"
>
<span class="project-filter__box" aria-hidden="true" />
<span class="project-filter__row-label">{{ item.label }}</span>
@@ -289,6 +314,7 @@ function onClearChips(): void {
}
&__box {
position: relative;
flex-shrink: 0;
width: 14px;
height: 14px;
@@ -296,6 +322,31 @@ function onClearChips(): void {
border: 1px solid @border-color-dark;
border-radius: 2px;
background: @white;
transition: background-color 0.2s, border-color 0.2s;
// 勾:用两条边框旋转 45° 画出来,不引额外图标
&::after {
content: '';
position: absolute;
top: 1px;
left: 4px;
width: 3px;
height: 6px;
border: solid @white;
border-width: 0 1.5px 1.5px 0;
transform: rotate(45deg) scale(0);
transition: transform 0.15s;
}
}
// 选中行:方框实心主色 + 白色勾(多选下每行独立显隐)
&__row.is-active &__box {
border-color: @primary-color;
background: @primary-color;
&::after {
transform: rotate(45deg) scale(1);
}
}
&__row-label {

View File

@@ -85,7 +85,7 @@ const BILL_VARIANTS: Record<ProjectBill['status'], 'primary' | 'success' | 'warn
<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 }}
{{ file.size ? formatFileSize(file.size) + ' · ' : '' }}{{ file.uploadTime }}
</span>
</div>
<div class="project-finance__file-actions">
@@ -110,7 +110,7 @@ const BILL_VARIANTS: Record<ProjectBill['status'], 'primary' | 'success' | 'warn
<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 }}
{{ contractFile.size ? formatFileSize(contractFile.size) + ' · ' : '' }}{{ contractFile.uploadTime }}
</span>
</div>
<div class="project-finance__file-actions">

View File

@@ -1,5 +1,4 @@
<script setup lang="ts">
import { StatusTag } from '@/components'
import { formatBusinessType } from '@/utils/format'
import type { Project, ProjectStatus } from '@/types/business/project'
@@ -9,79 +8,148 @@ interface Props {
defineProps<Props>()
const STATUS_VARIANTS: Record<ProjectStatus, 'primary' | 'success' | 'warning' | 'danger' | 'info'> = {
'待立项': 'info',
'进行中': 'primary',
'待验收': 'warning',
'待内部验收': 'warning',
'已完成': 'success',
'售后中': 'warning',
'已取消': 'info',
/**
* 状态文字配色(项目详情设计稿:状态是**卡片右侧的纯文字**,不是胶囊标签)。
*
* <p>设计稿只画了「进行中」= @project-accent,其余状态按语义就近归组;
* 颜色一律走 `styles/variables.less`,组件内不写魔法色值。</p>
*/
const STATUS_CLASS: Record<ProjectStatus, string> = {
'待立项': 'is-idle',
'进行中': 'is-active',
'待验收': 'is-warning',
'待内部验收': 'is-warning',
'已完成': 'is-done',
'售后中': 'is-warning',
'已取消': 'is-idle',
}
/** 业务类型:优先用后端原样下发的名称(它来自提报表,接口能下发就照显),兜底才查前端枚举 */
function businessTypeText(project: Project): string {
return project.businessTypeName || formatBusinessType(project.businessType)
}
</script>
<template>
<div class="project-info-card">
<div class="project-info-card__head">
<StatusTag :label="project.status" :variant="STATUS_VARIANTS[project.status]" />
<section class="project-info-card">
<!--
项目标识:设计稿是「白底圆角方块 + 蓝色描边徽标(内含 </>)」。
⚠️ 接口没有 logo 字段(ProjectRequirementBaseVo 只下发名称/编号/类型/状态/时间),
所以这里按设计稿渲染占位徽标,不是真实项目图标。
-->
<span class="project-info-card__logo" aria-hidden="true">
<span class="project-info-card__logo-badge">&lt;/&gt;</span>
</span>
<div class="project-info-card__main">
<h1 class="project-info-card__name">
{{ project.projectName }}
{{ project.projectName || '—' }}
</h1>
<p class="project-info-card__meta">
<span class="project-info-card__meta-item">
<em class="project-info-card__label">项目编号:</em>{{ project.projectNo || '—' }}
</span>
<span class="project-info-card__meta-item">
<em class="project-info-card__label">业务类型:</em>{{ businessTypeText(project) }}
</span>
<span class="project-info-card__meta-item">
<em class="project-info-card__label">提交时间:</em>{{ project.submitTime || '—' }}
</span>
</p>
</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>
<span class="project-info-card__status" :class="STATUS_CLASS[project.status]">
{{ project.status }}
</span>
</section>
</template>
<style lang="less" scoped>
.project-info-card {
padding: @spacing-lg @spacing-xl;
.flex(row, flex-start, center);
gap: @spacing-base;
padding: @spacing-lg;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
&__head {
.flex(row, flex-start, center);
gap: @spacing-base;
&__logo {
.flex(row, center, center);
flex-shrink: 0;
width: 56px;
height: 56px;
border-radius: 14px;
background: @white;
box-shadow: @shadow-base;
}
&__logo-badge {
.flex(row, center, center);
width: 34px;
height: 34px;
border: 2px solid @project-accent;
border-radius: 10px;
color: @project-accent;
font-family: Consolas, Menlo, 'Courier New', monospace;
font-size: @font-size-base;
font-weight: 700;
letter-spacing: -1px;
}
&__main {
.flex(column, flex-start, stretch);
flex: 1;
min-width: 0;
gap: @spacing-sm;
}
&__name {
font-size: @font-size-xxl;
font-size: @font-size-title;
font-weight: 600;
line-height: 1.2;
color: @heading-color;
.ellipsis();
}
// 设计稿:三个字段是**单行**「标签:值」,等距排布
&__meta {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: @spacing-base;
margin-top: @spacing-base;
.flex(row, flex-start, center);
flex-wrap: wrap;
gap: @spacing-lg;
font-size: @font-size-sm;
color: @heading-color;
}
&__meta-item {
.flex(column, flex-start, stretch);
gap: @spacing-xs;
.flex(row, flex-start, center);
max-width: 100%;
}
&__label {
font-size: @font-size-sm;
color: @text-color-secondary;
font-style: normal;
}
&__value {
font-size: @font-size-base;
color: @heading-color;
&__status {
flex-shrink: 0;
font-size: @font-size-title;
font-weight: 600;
&.is-active {
color: @project-accent;
}
&.is-done {
color: @success-color;
}
&.is-warning {
color: @warning-color;
}
&.is-idle {
color: @text-color-secondary;
}
}
}
</style>

View File

@@ -2,185 +2,168 @@
import type { Milestone } from '@/types/business/project'
interface Props {
progress: number
milestones: Milestone[]
startTime?: string
endTime?: string
}
defineProps<Props>()
const props = defineProps<Props>()
/** 三态 → 修饰类(设计稿:已完成=绿实心+白勾 / 进行中=白底粗蓝环 / 待开始=白底灰环) */
function stateClass(status: Milestone['status']): string {
if (status === '已完成') return 'is-done'
if (status === '进行中') return 'is-active'
return 'is-pending'
}
/**
* 连接线是否走「已完成」的绿色。
*
* <p>设计稿实测:只有<b>下一个节点已完成</b>时该段才是绿色(第二个节点已完成 → 第 1 段绿;
* 第三个节点进行中 → 第 2 段仍是灰色,不随「进行中」变蓝)。</p>
*/
function linkDone(index: number): boolean {
return props.milestones[index + 1]?.status === '已完成'
}
</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>
<h2 class="project-milestones__title">
项目进度(里程碑)
</h2>
<ol class="project-milestones__timeline">
<ol v-if="props.milestones.length > 0" class="project-milestones__track">
<li
v-for="node in milestones"
:key="node.name"
v-for="(node, index) in props.milestones"
:key="`${node.name}-${index}`"
class="project-milestones__node"
:class="`is-${node.status === '已完成' ? 'done' : node.status === '进行中' ? 'active' : 'pending'}`"
:class="[stateClass(node.status), { 'is-link-done': linkDone(index) }]"
>
<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">
暂无里程碑数据
<span class="project-milestones__dot">
<svg
v-if="node.status === '已完成'"
class="project-milestones__check"
viewBox="0 0 16 16"
aria-hidden="true"
>
<path d="M3.5 8.4 6.6 11.5 12.5 4.8" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</span>
<span class="project-milestones__name">{{ node.name }}</span>
<!-- 设计稿:已完成节点显示完成时间,其余显示状态文案 -->
<span class="project-milestones__sub text-secondary">{{ node.time || node.status }}</span>
</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>
<p v-else class="project-milestones__empty text-secondary">
暂无里程碑数据
</p>
</section>
</template>
<style lang="less" scoped>
// 节点列宽 = 设计稿相邻节点中心距(237 设计px × 0.77 ≈ 182px)
@milestone-node-width: 182px;
@milestone-dot-size: 32px;
.project-milestones {
padding: @spacing-lg @spacing-xl;
.flex(column, flex-start, stretch);
padding: @spacing-lg;
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;
.section-title();
}
// 设计稿:轨迹从卡片左侧正文位起、**固定节距**左对齐(不铺满整宽)
&__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 {
.flex(row, flex-start, flex-start);
margin-top: @spacing-lg;
padding-left: @spacing-xs;
padding-bottom: @spacing-xs;
overflow-x: auto;
.scrollbar();
}
&__node {
position: relative;
padding: 0 0 @spacing-base @spacing-lg;
border-left: 2px solid @border-color;
.flex(column, flex-start, center);
flex: 0 0 @milestone-node-width;
min-width: 0;
&:last-of-type {
border-left-color: transparent;
padding-bottom: 0;
// 圆点之间的连接线:从本节点圆点右缘连到下一节点圆点左缘
&:not(:last-child)::after {
content: '';
position: absolute;
top: (@milestone-dot-size / 2 - 1px);
left: calc(50% + @milestone-dot-size / 2);
width: calc(100% - @milestone-dot-size);
height: 2px;
background: @milestone-idle;
}
&.is-done {
border-left-color: @success-color;
}
&.is-active {
border-left-color: @primary-color;
&.is-link-done:not(:last-child)::after {
background: @milestone-done;
}
}
&__dot {
position: absolute;
top: 2px;
left: -7px;
width: 12px;
height: 12px;
border: 2px solid @border-color-dark;
.flex(row, center, center);
position: relative;
z-index: 1;
width: @milestone-dot-size;
height: @milestone-dot-size;
border: 2px solid @milestone-idle;
border-radius: @radius-round;
background: @white;
box-sizing: border-box;
.is-done & {
border-color: @success-color;
background: @success-color;
border-color: @milestone-done;
background: @milestone-done;
}
.is-active & {
border-color: @primary-color;
background: @primary-color;
border-width: 3px;
border-color: @project-accent;
}
}
&__body {
.flex(column, flex-start, stretch);
gap: 2px;
}
&__row {
.flex(row, center);
gap: @spacing-sm;
&__check {
width: 16px;
height: 16px;
}
&__name {
margin-top: @spacing-sm;
max-width: 100%;
font-size: @font-size-base;
color: @heading-color;
font-weight: 600;
}
&__status {
font-size: @font-size-sm;
text-align: center;
color: @text-color-secondary;
.multi-ellipsis(2);
.is-done & {
color: @success-color;
color: @milestone-done;
}
.is-active & {
color: @primary-color;
color: @project-accent;
}
}
&__time {
&__sub {
margin-top: @spacing-xs;
max-width: 100%;
font-size: @font-size-sm;
text-align: center;
.multi-ellipsis(2);
}
&__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;
padding-left: 21px;
font-size: @font-size-base;
}
}
</style>

View File

@@ -48,9 +48,20 @@ function typeLabel(value: string | null | undefined): string {
function statusLabel(value: string | null | undefined): string {
if (!value) return '—'
// 后端已用字典翻成中文(「进行中」…);码值也能认,认不出就原样显示
return PROJECT_STATUS_LABEL[value] ?? value
}
/**
* 是否已完成(决定是否给「申请售后」入口)。
*
* <p>⚠️ 后端状态自字典翻译后下发的是**中文**「已完成」,码值 `COMPLETED` 也可能出现
* (字典缺项 / 直连子表)⇒ 两种都认。只比码值会让售后按钮永远不出现。</p>
*/
function isCompleted(value: string | null | undefined): boolean {
return value === 'COMPLETED' || value === '已完成'
}
function progressLabel(value: number | null | undefined): string {
return value == null ? '—' : `${value}%`
}
@@ -82,7 +93,7 @@ function progressLabel(value: number | null | undefined): string {
<td>{{ statusLabel(item.status) }}</td>
<td class="project-table__ops">
<button
v-if="item.status === 'COMPLETED'"
v-if="isCompleted(item.status)"
type="button"
class="project-table__link"
@click="emit('afterSales', item.id)"

View File

@@ -13,80 +13,86 @@ defineProps<Props>()
<h2 class="project-team__title">
项目团队
</h2>
<div class="project-team__list">
<div
<ul v-if="team.length > 0" class="project-team__list">
<li
v-for="(member, index) in team"
:key="`${member.name}-${index}`"
class="project-team__card"
class="project-team__item"
>
<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>
<span class="project-team__name">{{ member.name || '—' }}</span>
<span class="project-team__role text-secondary">{{ member.role }}</span>
</li>
</ul>
<p v-else class="project-team__empty text-secondary">
暂无团队成员
</p>
</section>
</template>
<style lang="less" scoped>
// 设计稿实测:头像直径 90 设计px、相邻头像中心距 183 设计px → ×0.77 得 69px / 141px
@team-avatar-size: 68px;
@team-avatar-gap: 72px;
.project-team {
padding: @spacing-lg @spacing-xl;
.flex(column, flex-start, stretch);
padding: @spacing-lg;
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;
.section-title();
}
&__list {
.flex(row, flex-start, center);
.flex(row, flex-start, flex-start);
flex-wrap: wrap;
gap: @spacing-base;
gap: @spacing-base @team-avatar-gap;
margin-top: @spacing-lg;
padding-left: 18px;
}
&__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;
&__item {
.flex(column, flex-start, center);
width: @team-avatar-size;
}
&__avatar {
.flex(row, center);
width: 40px;
height: 40px;
.flex(row, center, center);
width: @team-avatar-size;
height: @team-avatar-size;
border-radius: @radius-round;
background: @primary-light;
color: @primary-color;
font-size: @font-size-lg;
font-weight: 600;
}
&__info {
.flex(column, flex-start, stretch);
gap: 2px;
}
&__role {
font-size: @font-size-sm;
background: @team-avatar-bg;
color: @white;
font-size: @font-size-title;
font-weight: 500;
}
&__name {
font-size: @font-size-base;
margin-top: @spacing-sm;
max-width: 100%;
font-size: @font-size-lg;
font-weight: 600;
color: @heading-color;
text-align: center;
.ellipsis();
}
&__role {
margin-top: @spacing-xs;
max-width: 100%;
font-size: @font-size-sm;
text-align: center;
.ellipsis();
}
&__empty {
margin-top: @spacing-base;
padding-left: 18px;
font-size: @font-size-base;
}
}

View File

@@ -8,8 +8,9 @@ import type { FileInfo } from '@/types/api'
// 页面容器:数据只经 store,不直接 import api
import { useProjectStore } from '@/stores/modules/project'
import ProjectInfoCard from './cpns/ProjectInfoCard.vue'
import ProjectTeam from './cpns/ProjectTeam.vue'
import ProjectProgress from './cpns/ProjectProgress.vue'
import ProjectMilestones from './cpns/ProjectMilestones.vue'
import ProjectTeam from './cpns/ProjectTeam.vue'
import ProjectFiles from './cpns/ProjectFiles.vue'
import ProjectFinance from './cpns/ProjectFinance.vue'
import ProjectAfterSales from './cpns/ProjectAfterSales.vue'
@@ -19,12 +20,13 @@ type DetailTab = 'progress' | 'files' | 'finance' | 'afterSales'
const route = useRoute()
const router = useRouter()
const projectStore = useProjectStore()
const { current } = storeToRefs(projectStore)
const { current, loadingTab } = storeToRefs(projectStore)
const projectId = computed(() => String(route.params.id))
const activeTab = ref<DetailTab>('progress')
const cancelVisible = ref(false)
// 设计稿:页签是「文字 + 激活项蓝色下划线」(不是胶囊按钮组),顺序固定
const tabs: Array<{ label: string; value: DetailTab }> = [
{ label: '项目进度', value: 'progress' },
{ label: '项目文件', value: 'files' },
@@ -37,16 +39,26 @@ function load(): void {
}
/**
* ⚠️ 上传 / 删除附件、售后提交都还没有后端实现
* (`ProjectRequirementUserController` 只有 create / list / get / serviceTypeList)。
* 所以这里只提示「待接入」,**不往详情里塞本地假数据** —— 详情数据现在来自真实接口,
* 混入前端自造条目会让刷新前后不一致。
* 点击页签:**每次都调一次接口**,取该页签的最新数据。
*
* <p>后端详情本就是「4 个页签 4 个端点」,所以点一次 = 一次请求,且只请求被点的那一支;
* 重复点击当前页签也会重取(用于拿到别处改动后的最新值)。竞态由 store 的请求序号兜住
* ——切项目、连点同一页签都不会把旧响应盖回页面。</p>
*/
function onUpload(file: File): void {
void file
ElMessage.info('附件上传接口待接入')
function onTabClick(tab: DetailTab): void {
activeTab.value = tab
void projectStore.refreshTab(tab)
}
/**
* 写操作的现状(2026-09-28):
* · **售后已接真实接口** —— 申请跳 `/project/:id/after-sales`(`/afterSales/request`),
* 取消走 `/afterSales/withdraw`;两者成功后都会重拉售后页签;
* · **需求附件的删除 / 预览 / 下载仍无用户端接口**(上传入口在需求提报页),
* 所以这里只提示「待接入」,**不往详情里塞本地假数据** —— 详情数据来自真实接口,
* 混入前端自造条目会让刷新前后不一致。
* 另外详情页「只展示文件」:上传入口在需求提报页,这里不提供。
*/
function onDeleteFile(index: number): void {
void index
ElMessage.info('附件删除接口待接入')
@@ -82,7 +94,8 @@ function onApplyAfterSales(): void {
}
function onConfirmCancel(): void {
void projectStore.cancelAfterSales()
// 撤回接口收的是**需求 id**(后端按需求校验归属)
void projectStore.cancelAfterSales(projectId.value)
}
watch(projectId, load)
@@ -102,33 +115,37 @@ onMounted(load)
<template v-if="current">
<ProjectInfoCard :project="current" />
<ProjectTeam :team="current.team" />
<!-- 本地 Tab 切换 -->
<!-- 本地 Tab 切换(设计稿:文字 + 激活项下划线) -->
<div class="project-detail__tabs">
<button
v-for="tab in tabs"
:key="tab.value"
class="project-detail__tab"
:class="{ 'is-active': activeTab === tab.value }"
@click="activeTab = tab.value"
:class="{
'is-active': activeTab === tab.value,
'is-loading': loadingTab === tab.value,
}"
@click="onTabClick(tab.value)"
>
{{ tab.label }}
</button>
</div>
<ProjectMilestones
v-if="activeTab === 'progress'"
:progress="current.progress"
:milestones="current.milestones"
:start-time="current.startTime"
:end-time="current.endTime"
/>
<!-- 「项目进度」页签 = 开发进度 → 里程碑 → 项目团队(顺序按设计稿) -->
<template v-if="activeTab === 'progress'">
<ProjectProgress
:progress="current.progress"
:start-time="current.startTime"
:end-time="current.endTime"
/>
<ProjectMilestones :milestones="current.milestones" />
<ProjectTeam :team="current.team" />
</template>
<ProjectFiles
v-if="activeTab === 'files'"
:files="current.files"
@upload="onUpload"
@delete="onDeleteFile"
@preview="onPreviewFile"
@download="onDownloadFile"
@@ -144,10 +161,11 @@ onMounted(load)
@download="onFinanceDownload"
/>
<!-- 售后页签三形态:项目未完成 / 已完成未提交 / 已完成已提交(详见组件内注释) -->
<ProjectAfterSales
v-if="activeTab === 'afterSales'"
:after-sales="current.afterSales"
:project-id="current.id"
:project-status="current.status"
@apply="onApplyAfterSales"
@cancel="cancelVisible = true"
/>
@@ -178,36 +196,49 @@ onMounted(load)
padding-bottom: @spacing-xxl;
&__breadcrumb {
font-size: @font-size-base;
font-size: @font-size-sm;
}
// 设计稿:页签无白底容器,仅一行文字 + 激活项 2px 下划线;相邻间距实测 ≈50px
&__tabs {
.flex(row, flex-start);
gap: @spacing-xs;
padding: @spacing-xs;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
.flex(row, flex-start, center);
gap: 50px;
padding: 0 @spacing-base;
}
&__tab {
height: 34px;
padding: 0 @spacing-base;
position: relative;
padding: 0 0 @spacing-sm;
border: none;
border-radius: @radius-base;
background: transparent;
color: @text-color;
font-size: @font-size-base;
transition: all 0.2s;
line-height: 20px;
transition: color 0.2s;
&:hover {
color: @primary-color;
color: @project-accent;
}
// 刷新中:轻微变淡 + 进度光标(本地接口很快,做太重的 loading 反而闪屏)
&.is-loading {
opacity: 0.6;
cursor: progress;
}
&.is-active {
background: @primary-light;
color: @primary-color;
font-weight: 600;
color: @project-accent;
&::after {
content: '';
position: absolute;
right: 0;
bottom: 0;
left: 0;
height: 2px;
border-radius: 1px;
background: @project-accent;
}
}
}

View File

@@ -17,7 +17,7 @@ const { query, list, total, typeCounts } = storeToRefs(projectStore)
/** 是否处于筛选态(决定空态文案:没筛过 = 本来就没项目,筛过 = 没有符合条件的) */
const filtered = computed(() => Boolean(
query.value.keyword
|| query.value.businessType
|| query.value.businessTypes.length > 0
|| query.value.status
|| query.value.timeShortcut !== 'all'
|| query.value.startDate