qiangdanxitong/frontend/src/views/CreateTask.vue

4018 lines
123 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<DefaultLayout>
<div class="create-task-page">
<div class="container">
<!-- 积分余额提示 -->
<div class="score-balance-card chart-card">
<div class="balance-info">
<div class="balance-label">团队积分余额</div>
<div class="balance-value">{{ teamScore }} 分</div>
</div>
<div v-if="publishingDepartmentOptions.length > 0" class="publisher-dept-card">
<div class="publisher-dept-label">发起部门</div>
<a-select
v-model:value="selectedPublishingDeptId"
size="large"
class="publisher-dept-select"
:disabled="editMode || formData.isPrimaryTaskEdit || publishingDepartmentOptions.length === 1"
:placeholder="publishingDepartmentOptions.length > 1 ? '请选择发起部门' : '当前发起部门'"
>
<a-select-option
v-for="dept in publishingDepartmentOptions"
:key="dept.deptId"
:value="dept.deptId"
>
{{ dept.deptName }}{{ dept.deptType === 'primary_demand' ? '(一级需求)' : '' }}
</a-select-option>
</a-select>
<div class="publisher-dept-tip">任务积分和审批流按当前发起部门计算</div>
</div>
<div class="balance-tip">
<span class="tip-icon">
<Icon name="info" size="sm" color="#31f08b" />
</span>
<span v-if="isAdmin" style="color: #31f08b;">管理员身份,无需积分即可发起任务</span>
<span v-else>本次任务预估消耗: <strong>{{ estimatedScore }}</strong> 分</span>
</div>
<!-- 时效预估 -->
<div v-if="estimatedTotalHours > 0" class="time-estimate">
<div class="estimate-item">
<span class="estimate-label">累计时效:</span>
<span class="estimate-value">{{ formattedEstimatedTime }}</span>
</div>
<div class="estimate-item">
<span class="estimate-label">预计完成:</span>
<span class="estimate-value">{{ estimatedDeadline }}</span>
</div>
<div class="estimate-tip">
<Icon name="info" size="xs" style="margin-right: 4px; opacity: 0.6;" />
<span>按{{ workHoursPerDay }}小时/工作日计算</span>
</div>
</div>
</div>
<!-- 任务表单 -->
<div class="task-form chart-card">
<a-form
ref="formRef"
:model="formData"
:rules="formRules"
layout="vertical"
>
<!-- 任务标题(自动生成,不可编辑) -->
<a-form-item label="任务标题" name="title" required>
<a-input
v-model:value="formData.title"
placeholder="填写下方信息后自动生成标题"
size="large"
disabled
class="auto-generated-title"
/>
<div class="title-auto-tip">
<Icon name="info" size="xs" style="margin-right: 4px;" />
<span>标题将根据SPU、品牌、平台自动生成</span>
</div>
</a-form-item>
<!-- 一级需求部门不允许填写任务明细时显示提示 -->
<a-form-item v-if="isPrimaryDeptUser && !shouldShowTaskDetail" label="任务明细" style="display: block !important;">
<a-alert
message="任务明细将由需求部门填写"
description="一级需求部门发起的任务,任务明细将由对应的需求部门成员在审核通过后完善。您只需填写基本信息(品牌、需求描述、预估积分等)。"
type="info"
show-icon
/>
</a-form-item>
<!-- 流水线任务说明 -->
<a-form-item v-if="shouldShowTaskDetail" label="任务模式" style="display: block !important;">
<div style="padding: 12px; background: rgba(49, 240, 139, 0.05); border-radius: 6px; font-size: 13px; color: #8a8a8a; line-height: 1.6;">
<strong style="color: #31f08b;">顺序流水线模式:</strong>子任务按顺序完成,前一个完成后才能开始下一个。适合有先后依赖的任务(如:出图 → 修图 → 排版)。流水线任务可由多个设计师协作完成,每个设计师负责不同的子任务。
</div>
</a-form-item>
<!-- 任务类型列表 -->
<a-form-item v-if="shouldShowTaskDetail" label="任务类型" required>
<div class="task-types-list">
<div
v-for="(item, index) in formData.taskTypes"
:key="item.id || index"
class="task-type-item"
:class="{ 'dragging': dragIndex === index, 'drag-over': dragOverIndex === index }"
@dragover.prevent="handleDragOver($event, index)"
@dragleave="handleDragLeave"
@drop="handleDrop($event, index)"
>
<div class="task-type-row">
<!-- 拖拽手柄 -->
<div
class="drag-handle"
title="拖拽调整顺序"
draggable="true"
@dragstart="handleDragStart($event, index)"
@dragend="handleDragEnd"
>
<Icon name="menu" size="sm" />
</div>
<div class="task-type-select task-type-cascader">
<a-cascader
v-model:value="item.taskTypePath"
:options="taskTypeCascaderOptions"
placeholder="请选择任务类型(一级→二级→三级)"
size="large"
:loading="loading"
allow-clear
expand-trigger="hover"
:show-search="{ filter: cascaderFilter }"
@change="(value: any) => onTaskTypeChange(value, item, index)"
popup-class-name="task-type-cascader-popup"
>
<template #displayRender="{ labels }">
<span>{{ labels.join(' / ') }}</span>
</template>
</a-cascader>
<div v-if="item.taskTypeId && getTaskTypeInfo(item.taskTypeId)" class="task-type-score-tip">
<span class="score-badge">{{ getTaskTypeInfo(item.taskTypeId)?.unitScore }}分/个</span>
<span class="hour-badge">{{ getTaskTypeInfo(item.taskTypeId)?.estimatedHours }}小时</span>
</div>
</div>
<div class="task-type-quantity">
<a-input-number
v-model:value="item.quantity"
:min="1"
:max="10000"
placeholder="数量"
size="large"
style="width: 100%"
@change="calculateScore"
/>
</div>
<!-- 部门选择器:当任务类型有多个接单部门时显示 -->
<div v-if="getTaskTypeDeptOptions(item).length > 1" class="task-type-dept">
<a-select
v-model:value="item.selectedDeptId"
placeholder="选择接单部门"
style="width: 220px"
size="large"
@change="calculateScore"
>
<a-select-option
v-for="dept in getTaskTypeDeptOptions(item)"
:key="dept.id"
:value="dept.id"
>
{{ dept.displayName }}
</a-select-option>
</a-select>
</div>
<div v-if="formData.releaseType === 'assigned'" class="task-type-receiver">
<a-select
v-model:value="item.receiverId"
placeholder="选择设计师(可留空)"
style="width: 200px"
show-search
allow-clear
size="large"
:filter-option="filterDesignerOption"
option-filter-prop="label"
@focus="loadDesignersForItem(item)"
>
<a-select-option
v-for="designer in getDesignersForItem(item)"
:key="designer.id"
:value="designer.id"
:label="designer.realName"
>
<div class="designer-option">
<span class="designer-name">{{ designer.realName }}</span>
<span class="designer-workload">进行中: {{ designer.inProgressCount || 0 }}</span>
</div>
</a-select-option>
</a-select>
</div>
<div class="task-type-actions">
<a-button
v-if="formData.taskTypes.length > 1"
danger
size="large"
@click="removeTaskType(index)"
>
<Icon name="trash" size="sm" />
删除
</a-button>
</div>
</div>
</div>
<a-button
type="dashed"
size="large"
block
@click="addTaskType"
class="add-task-type-btn"
>
<Icon name="plus" size="sm" style="margin-right: 8px;" />
添加任务类型
</a-button>
<!-- 流水线任务分组预览 -->
<div v-if="pipelineGroups.length > 0" class="pipeline-preview">
<div class="preview-header">
<Icon name="layers" size="sm" style="margin-right: 6px;" />
<span>子任务分组预览</span>
<span class="preview-mode">(顺序执行)</span>
</div>
<div class="groups-container">
<div
v-for="(group, groupIndex) in pipelineGroups"
:key="groupIndex"
class="group-card"
>
<div class="group-header">
<div class="group-title">
<span class="group-badge">组 {{ groupIndex + 1 }}</span>
<span class="group-dept">{{ group.deptName || '所有部门' }}</span>
<span class="group-count">({{ group.items.length }}个任务)</span>
</div>
<div class="group-summary">
<span class="summary-item">{{ formatScore(group.totalScore) }}分</span>
<span class="summary-divider">|</span>
<span class="summary-item">{{ formatWorkHours(group.totalHours) }}</span>
</div>
</div>
<div class="group-items">
<div
v-for="(item, itemIndex) in group.items"
:key="itemIndex"
class="group-item"
>
<span class="item-icon">└─</span>
<span class="item-name">{{ item.name }}</span>
<span class="item-quantity">× {{ item.quantity }}</span>
<span class="item-score">= {{ formatScore(item.totalScore) }}分</span>
</div>
</div>
</div>
</div>
<div class="preview-footer">
<span>总计:</span>
<span class="footer-score">{{ totalPipelineScore }}分</span>
<span class="footer-divider">|</span>
<span class="footer-hours">{{ formatWorkHours(Number(totalPipelineHours)) }}</span>
<span class="footer-divider">|</span>
<span class="footer-groups">{{ pipelineGroups.length }}个子任务组</span>
</div>
</div>
</div>
</a-form-item>
<!-- 任务子类型 -->
<a-form-item label="任务子类型" name="taskSubType" required>
<a-radio-group
v-model:value="formData.taskSubType"
button-style="solid"
size="large"
>
<a-radio-button
v-for="subType in taskSubTypes"
:key="subType.id"
:value="subType.name"
>
{{ subType.name }}
</a-radio-button>
</a-radio-group>
</a-form-item>
<!-- 商品条码(当任务子类型需要检查库存时显示) -->
<a-form-item
v-if="selectedSubTypeNeedsInventoryCheck"
label="商品条码"
required
>
<!-- 一级需求任务编辑模式:商品条码只读 -->
<template v-if="formData.isPrimaryTaskEdit && formData.productBarcodes && formData.productBarcodes.length > 0">
<div class="barcode-list readonly">
<div class="barcode-list-title">
<span>商品条码 ({{ formData.productBarcodes.length }}个) - 由一级需求发起人填写,不可修改</span>
</div>
<div class="barcode-tags">
<a-tag
v-for="(barcode, index) in formData.productBarcodes"
:key="index"
color="blue"
>
{{ barcode }}
</a-tag>
</div>
</div>
</template>
<!-- 普通模式:可编辑 -->
<template v-else>
<!-- 已添加的条码列表 -->
<div v-if="formData.productBarcodes && formData.productBarcodes.length > 0" class="barcode-list">
<div class="barcode-list-title">
<span>已添加条码 ({{ formData.productBarcodes.length }}个)</span>
</div>
<div class="barcode-tags">
<a-tag
v-for="(barcode, index) in formData.productBarcodes"
:key="index"
color="green"
closable
@close="removeBarcode(index)"
>
{{ barcode }}
</a-tag>
</div>
</div>
<!-- 添加新条码 -->
<div style="margin-top: 8px; display: flex; gap: 10px;">
<a-input
v-model:value="newBarcodeInput"
placeholder="请输入商品条码,验证库存后添加"
size="large"
style="flex: 1;"
@pressEnter="handleAddBarcode"
/>
<a-button
type="primary"
size="large"
@click="handleAddBarcode"
:loading="checkingInventory"
>
验证并添加
</a-button>
</div>
</template>
<!-- 库存检查结果(临时显示) -->
<div v-if="inventoryCheckResult" class="inventory-result" :class="{ 'success': inventoryCheckResult.canPublish, 'error': !inventoryCheckResult.canPublish }">
<a-alert
:type="inventoryCheckResult.canPublish ? 'success' : 'error'"
:message="inventoryCheckResult.canPublish ? '库存充足,已添加' : '库存不足'"
:description="inventoryCheckResult.message"
show-icon
/>
<div v-if="inventoryCheckResult.found && !inventoryCheckResult.canPublish" class="inventory-details">
<div class="detail-item">
<span class="label">商品名称:</span>
<span class="value">{{ inventoryCheckResult.name || '-' }}</span>
</div>
<div class="detail-item">
<span class="label">可用库存:</span>
<span class="value low-stock">{{ inventoryCheckResult.availableQty }}</span>
</div>
</div>
</div>
</a-form-item>
<!-- 品牌和平台 -->
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="品牌" name="brandId" required>
<a-select
v-model:value="formData.brandId"
placeholder="请选择品牌"
size="large"
show-search
option-filter-prop="label"
>
<a-select-option
v-for="brand in brands"
:key="brand.id"
:value="brand.id"
:label="brand.name"
>
{{ brand.name }}
</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="平台" name="platformId" required>
<a-select
v-model:value="formData.platformId"
placeholder="请选择平台"
size="large"
show-search
option-filter-prop="label"
>
<a-select-option
v-for="platform in platforms"
:key="platform.id"
:value="platform.id"
:label="platform.name"
>
{{ platform.name }}
</a-select-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<!-- 类目 -->
<a-form-item label="叶子类目" name="categoryId" required>
<a-select
v-model:value="formData.categoryId"
placeholder="请选择叶子类目"
size="large"
show-search
option-filter-prop="label"
>
<a-select-option
v-for="category in categories"
:key="category.id"
:value="category.id"
:label="category.name"
>
{{ category.name }}
</a-select-option>
</a-select>
</a-form-item>
<!-- SPU信息 -->
<a-form-item label="SPU信息" name="spuInfo">
<a-input
v-model:value="formData.spuInfo"
placeholder="请输入SPU编号(支持大写字母、数字和横杠)"
size="large"
@input="handleSpuInput"
/>
</a-form-item>
<!-- 预览图上传 -->
<a-form-item
label="预览图"
name="previewImage"
:required="isPrimaryDeptUser"
>
<div
class="preview-upload-area"
@paste="handlePasteImage"
tabindex="0"
style="outline: none;"
>
<a-upload
v-model:file-list="previewFileList"
:before-upload="beforePreviewUpload"
:custom-request="handlePreviewUpload"
list-type="picture-card"
:max-count="1"
accept="image/*"
@remove="handlePreviewRemove"
>
<div v-if="!formData.previewImage" class="upload-button">
<Icon name="plus" size="lg" />
<div class="upload-text">上传预览图</div>
<div class="paste-hint">或按 Ctrl+V 粘贴图片</div>
</div>
</a-upload>
<div class="preview-tip">
<span class="tip-icon">
<Icon name="image" size="sm" color="#31f08b" />
</span>
<span v-if="isPrimaryDeptUser" style="color: #ff4d4f;">
<Icon name="warning" size="xs" style="margin-right: 4px;" />
一级需求部门发起任务必须上传预览图
</span>
<span v-else>建议上传正方形图片,系统将自动压缩处理,显示在任务卡片上。支持 Ctrl+V 粘贴图片</span>
</div>
</div>
</a-form-item>
<!-- 上传附件 -->
<a-form-item label="需求表格附件" name="attachmentUrl">
<div class="upload-area">
<a-upload
v-model:file-list="fileList"
:before-upload="beforeUpload"
:custom-request="handleUpload"
:remove="handleRemove"
@change="handleFileChange"
accept=".xls,.xlsx,.doc,.docx,.pdf,.zip"
:max-count="5"
list-type="text"
>
<a-button size="large">
<Icon name="upload" size="sm" style="margin-right: 8px;" />
点击上传或拖拽文件到此处
</a-button>
</a-upload>
<div class="upload-tip">
<span class="tip-icon">
<Icon name="document" size="sm" color="#31f08b" />
</span>
<span>支持格式:Excel (.xlsx/.xls)、Word (.docx/.doc)、PDF、ZIP,单文件最大500MB,最多上传5个文件</span>
</div>
<div v-if="uploadProgress > 0 && uploadProgress < 100" class="upload-progress">
<a-progress :percent="uploadProgress" :show-info="true" />
</div>
</div>
</a-form-item>
<!-- 任务发布类型 -->
<a-form-item label="任务发布类型" name="releaseType" required>
<div class="release-type-buttons">
<button
v-if="releaseTypeConfig.default"
type="button"
:class="['release-type-button', { active: formData.releaseType === 'default' }]"
@click="formData.releaseType = 'default'"
>
<div class="button-icon">
<Icon name="arrowPath" size="md" />
</div>
<div class="button-content">
<div class="button-title">默认模式(推荐)</div>
<div class="button-desc">系统自动分配给主管,由主管负责分配</div>
</div>
<div v-if="formData.releaseType === 'default'" class="selected-indicator">
<Icon name="checkCircle" size="sm" />
</div>
</button>
<!-- 招募任务模式 -->
<button
v-if="releaseTypeConfig.recruitment"
type="button"
:class="['release-type-button', { active: formData.releaseType === 'recruitment' }]"
@click="formData.releaseType = 'recruitment'"
>
<div class="button-icon">
<Icon name="bell" size="md" />
</div>
<div class="button-content">
<div class="button-title">招募任务</div>
<div class="button-desc">发布到任务大厅,设计师可自行抢单</div>
</div>
<div v-if="formData.releaseType === 'recruitment'" class="selected-indicator">
<Icon name="checkCircle" size="sm" />
</div>
</button>
<!-- 指定任务模式 -->
<button
v-if="releaseTypeConfig.assigned"
type="button"
:class="['release-type-button', { active: formData.releaseType === 'assigned' }]"
@click="formData.releaseType = 'assigned'"
>
<div class="button-icon">
<Icon name="star" size="md" />
</div>
<div class="button-content">
<div class="button-title">指定任务</div>
<div class="button-desc">指定特定设计师接收任务</div>
</div>
<div v-if="formData.releaseType === 'assigned'" class="selected-indicator">
<Icon name="checkCircle" size="sm" />
</div>
</button>
</div>
</a-form-item>
<!-- 是否紧急 -->
<a-form-item label="优先级">
<a-switch
v-model:checked="formData.isUrgent"
checked-children="紧急"
un-checked-children="普通"
/>
<span class="text-tertiary urgent-tip">
紧急任务会优先显示和处理
</span>
</a-form-item>
<!-- 提交按钮 -->
<div class="form-actions">
<a-button size="large" @click="handleCancel">
取消
</a-button>
<a-button
size="large"
:loading="savingDraft"
@click="handleSaveDraft"
>
<Icon name="save" size="sm" style="margin-right: 4px;" />
保存草稿
</a-button>
<a-button
type="primary"
size="large"
:loading="submitting"
@click="handleSubmit"
>
{{ editMode ? '重新提交' : '发起任务' }}
</a-button>
</div>
</a-form>
</div>
</div>
</div>
</DefaultLayout>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { message } from 'ant-design-vue'
import type { UploadFile, UploadProps } from 'ant-design-vue'
import DefaultLayout from '@/layouts/DefaultLayout.vue'
import Icon from '@/components/Icon.vue'
import * as userApi from '@/api/user'
import * as taskApi from '@/api/task'
import * as configApi from '@/api/config'
import * as uploadApi from '@/api/upload'
import * as departmentApi from '@/api/department'
import { getSystemConfigsMapByGroup, getWorkHoursConfig } from '@/api/system'
import { uploadFilesToS3 } from '@/api/file'
import JSZip from 'jszip'
import type { FormInstance } from 'ant-design-vue'
import type { DemandType, Brand, Platform, Category, TaskSubType } from '@/api/config'
import { checkInventoryForTask, type InventoryResult } from '@/api/thirdPartyDb'
import { useAuthStore } from '@/stores/auth'
import { useConfigStore } from '@/stores/config'
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const TASK_CREATE_PUBLISHER_DEPT_KEY = 'task_create_publisher_dept_id'
// 编辑模式
const editMode = ref(false)
const editTaskId = ref<number | null>(null)
// 全局配置store
const configStore = useConfigStore()
// 表单ref
const formRef = ref<FormInstance>()
// 提交状态
const submitting = ref(false)
const savingDraft = ref(false)
const loading = ref(false)
// 团队积分余额
const teamScore = ref(0)
const selectedPublishingDeptId = ref<number | undefined>(undefined)
const editingPublishingDept = ref<PublishingDepartmentOption | null>(null)
// 判断是否是管理员(管理员跳过积分检查)
const isAdmin = computed(() => {
const userInfo = authStore.userInfo
if (!userInfo) return false
// 检查 isAdmin 字段
if (userInfo.isAdmin === 1 || userInfo.isAdmin === true) return true
// 检查角色
if (userInfo.roleIds) {
const roleIds = userInfo.roleIds.split(',').map((id: string) => id.trim())
if (roleIds.includes('1') || roleIds.includes('2')) return true // 1=SUPER_ADMIN, 2=ADMIN
}
return false
})
interface PublishingDepartmentOption {
deptId: number
deptName: string
deptType: string
isPrimary?: number
}
const availablePublishingDepartments = computed<PublishingDepartmentOption[]>(() => {
const userInfo = authStore.userInfo
if (!userInfo) return []
const result: PublishingDepartmentOption[] = []
const seen = new Set<number>()
const departments = Array.isArray(userInfo.departments) ? userInfo.departments : []
departments.forEach((dept) => {
if (!dept?.deptId || seen.has(dept.deptId)) return
if (dept.deptType !== 'demand' && dept.deptType !== 'primary_demand') return
if (!isDepartmentActiveWithAncestors(dept.deptId)) return
result.push({
deptId: dept.deptId,
deptName: dept.deptName,
deptType: dept.deptType,
isPrimary: dept.isPrimary
})
seen.add(dept.deptId)
})
const managedDepartments = Array.isArray(userInfo.managedDepartments) ? userInfo.managedDepartments : []
managedDepartments.forEach((dept) => {
if (!dept?.deptId || seen.has(dept.deptId)) return
if (dept.deptType !== 'demand' && dept.deptType !== 'primary_demand') return
if (!isDepartmentActiveWithAncestors(dept.deptId)) return
result.push({
deptId: dept.deptId,
deptName: dept.deptName,
deptType: dept.deptType,
isPrimary: 0
})
seen.add(dept.deptId)
})
if (
userInfo.deptId &&
!seen.has(userInfo.deptId) &&
isDepartmentActiveWithAncestors(userInfo.deptId) &&
(userInfo.deptType === 'demand' || userInfo.deptType === 'primary_demand')
) {
result.unshift({
deptId: userInfo.deptId,
deptName: userInfo.deptName,
deptType: userInfo.deptType,
isPrimary: 1
})
seen.add(userInfo.deptId)
}
return result
})
const publishingDepartmentOptions = computed<PublishingDepartmentOption[]>(() => {
const departments = [...availablePublishingDepartments.value]
const editDept = editingPublishingDept.value
if (editDept && !departments.some(dept => dept.deptId === editDept.deptId)) {
departments.unshift(editDept)
}
return departments
})
const activePublishingDept = computed(() => {
return publishingDepartmentOptions.value.find(dept => dept.deptId === selectedPublishingDeptId.value)
})
const canPublishFromSelectedDept = computed(() => {
if (!selectedPublishingDeptId.value) return false
return availablePublishingDepartments.value.some(dept => dept.deptId === selectedPublishingDeptId.value)
})
// 任务子类型列表
const taskSubTypes = ref<TaskSubType[]>([])
// 发布类型配置(控制哪些按钮显示)
const releaseTypeConfig = ref({
default: true,
recruitment: true,
assigned: true
})
// 一级需求部门配置
const primaryDeptConfig = ref({
canFillDetail: false // 一级需求部门是否可以填写任务明细
})
// 判断当前用户是否是一级需求部门用户
const isPrimaryDeptUser = computed(() => {
// 编辑他人发布的一级需求任务时,不应误判为“一级需求部门用户”
return canPublishFromSelectedDept.value && activePublishingDept.value?.deptType === 'primary_demand'
})
// 判断是否应该显示任务明细区域
// 对于一级需求部门用户,需要根据系统配置决定是否显示
const shouldShowTaskDetail = computed(() => {
// 一级需求任务被指派到成员补充明细时,始终显示明细编辑区
if (formData.isPrimaryTaskEdit) {
return true
}
// 如果是一级需求部门用户
if (isPrimaryDeptUser.value) {
// 根据系统配置决定是否显示任务明细
const result = primaryDeptConfig.value.canFillDetail
console.log('[任务明细显示] 一级需求部门用户, canFillDetail:', result, 'shouldShow:', result)
return result
}
// 其他部门用户始终显示
return true
})
// 文件上传相关
const fileList = ref<UploadFile[]>([])
const uploadProgress = ref(0)
// 预览图上传相关
const previewFileList = ref<UploadFile[]>([])
const previewUploading = ref(false)
// 任务类型项接口
interface TaskTypeItem {
taskTypeId: number | undefined
taskTypePath?: (string | number)[] // 级联选择路径 [level1Id, level2Id, level3Id]
quantity: number
receiverId?: number // 指定接收人ID
selectedDeptId?: number // 当任务类型有多个接单部门时,用户选择的部门ID
}
// 级联选择器选项接口
interface CascaderOption {
value: number
label: string
children?: CascaderOption[]
unitScore?: number
estimatedHours?: number
}
// 设计师列表缓存 { taskType_{id}: designers[] }
const designersCache = reactive<Record<string, any[]>>({})
// 拖拽排序相关
const dragIndex = ref<number | null>(null)
const dragOverIndex = ref<number | null>(null)
// 表单数据
const formData = reactive({
title: '',
isPipeline: true, // 固定为流水线任务
pipelineMode: 'sequential' as 'sequential' | 'parallel', // 固定为顺序流水线
taskTypes: [{ taskTypeId: undefined, taskTypePath: undefined, quantity: 1, receiverId: undefined, selectedDeptId: undefined }] as TaskTypeItem[], // 任务类型数组
taskSubType: '', // 动态加载,初始为空
brandId: undefined as number | undefined,
platformId: undefined as number | undefined,
categoryId: undefined as number | undefined,
spuInfo: '',
attachmentUrl: '',
previewImage: '', // 预览图URL
remark: '', // 备注信息
releaseType: 'default',
productBarcodes: [] as string[], // 商品条码列表(已验证库存)
// receiverId: undefined, // 已移除,改用子任务指定
isUrgent: false,
// 一级需求任务编辑相关
isPrimaryTaskEdit: false, // 是否是一级需求任务明细编辑模式
primaryPublisherId: undefined as number | undefined,
primaryPublisherDeptId: undefined as number | undefined,
description: '', // 任务描述
score: undefined as number | undefined, // 积分
taskTypeId: undefined as number | undefined, // 任务类型ID
taskTypeIds: [] as number[], // 任务类型ID数组
demandTypeId: undefined as number | undefined, // 需求类型ID
deadline: undefined as string | undefined, // 截止时间
timeLimit: undefined as number | undefined // 时效要求
})
const initializePublishingDept = (preferredDeptId?: number) => {
const departments = publishingDepartmentOptions.value
if (!departments.length) {
selectedPublishingDeptId.value = undefined
return
}
if (preferredDeptId && departments.some(dept => dept.deptId === preferredDeptId)) {
selectedPublishingDeptId.value = preferredDeptId
return
}
if (selectedPublishingDeptId.value && departments.some(dept => dept.deptId === selectedPublishingDeptId.value)) {
return
}
const currentDeptId = authStore.userInfo?.deptId
const currentDeptType = authStore.userInfo?.deptType
if (
currentDeptId &&
(currentDeptType === 'demand' || currentDeptType === 'primary_demand') &&
departments.some(dept => dept.deptId === currentDeptId)
) {
selectedPublishingDeptId.value = currentDeptId
return
}
const primaryPublishingDept = departments.find(dept => dept.isPrimary === 1)
selectedPublishingDeptId.value = primaryPublishingDept?.deptId || departments[0].deptId
}
const syncEditingPublishingDept = (task: any) => {
const deptId = task?.publisherDeptId || task?.primaryPublisherDeptId
const deptName = task?.publisherDeptName || task?.primaryPublisherDeptName
if (!deptId || !deptName) {
editingPublishingDept.value = null
return
}
if (availablePublishingDepartments.value.some(dept => dept.deptId === deptId)) {
editingPublishingDept.value = null
return
}
const deptType = task?.isPrimaryTask === 1 && task?.primaryPublisherDeptId === deptId
? 'primary_demand'
: 'demand'
editingPublishingDept.value = {
deptId,
deptName,
deptType,
isPrimary: 0
}
}
const getPreferredPublishingDeptId = () => {
const routeDeptId = route.query.publisherDeptId ? Number(route.query.publisherDeptId) : undefined
if (routeDeptId) return routeDeptId
const storedDeptId = sessionStorage.getItem(TASK_CREATE_PUBLISHER_DEPT_KEY)
if (!storedDeptId) return undefined
const parsedDeptId = Number(storedDeptId)
return Number.isNaN(parsedDeptId) ? undefined : parsedDeptId
}
// 新条码输入框
const newBarcodeInput = ref('')
// 库存检查相关
const checkingInventory = ref(false)
const inventoryCheckResult = ref<InventoryResult | null>(null)
// 判断当前选择的任务子类型是否需要检查库存
const selectedSubTypeNeedsInventoryCheck = computed(() => {
if (!formData.taskSubType) return false
const selectedSubType = taskSubTypes.value.find(st => st.name === formData.taskSubType)
return selectedSubType?.checkInventory === 1
})
// 验证并添加条码
const handleAddBarcode = async () => {
const barcode = newBarcodeInput.value.trim()
if (!barcode) {
message.warning('请输入商品条码')
return
}
// 检查是否已添加
if (formData.productBarcodes.includes(barcode)) {
message.warning('该条码已添加')
return
}
checkingInventory.value = true
inventoryCheckResult.value = null
try {
const response = await checkInventoryForTask(barcode)
inventoryCheckResult.value = response.data
if (response.data.canPublish) {
// 库存充足,添加到列表
formData.productBarcodes.push(barcode)
newBarcodeInput.value = '' // 清空输入框
message.success(`条码 ${barcode} 库存充足,已添加`)
// 2秒后清除结果提示
setTimeout(() => {
inventoryCheckResult.value = null
}, 2000)
} else {
message.error(response.data.message || '库存不足,无法添加该条码')
}
} catch (error: any) {
console.error('检查库存失败:', error)
message.error(error.response?.data?.message || '检查库存失败')
} finally {
checkingInventory.value = false
}
}
// 移除条码
const removeBarcode = (index: number) => {
formData.productBarcodes.splice(index, 1)
}
// 兼容旧函数名
const handleCheckInventory = handleAddBarcode
// 表单验证规则
const formRules = computed(() => ({
title: [
{ required: true, message: '请输入任务标题', trigger: 'blur' },
{ min: 2, max: 100, message: '标题长度在2-100个字符', trigger: 'blur' }
],
taskTypeIds: [
{
required: true,
type: 'array',
min: 1,
message: '请选择至少一个任务类型',
trigger: 'change'
}
],
quantity: [
{ required: true, message: '请输入数量', trigger: 'change' }
],
taskSubType: [
{ required: true, message: '请选择任务子类型', trigger: 'change' }
],
brandId: [
{ required: true, message: '请选择品牌', trigger: 'change' }
],
platformId: [
{ required: true, message: '请选择平台', trigger: 'change' }
],
categoryId: [
{ required: true, message: '请选择类目', trigger: 'change' }
],
releaseType: [
{ required: true, message: '请选择发布类型', trigger: 'change' }
],
previewImage: [
{
required: isPrimaryDeptUser.value,
message: '一级需求部门发起任务必须上传预览图',
trigger: 'change',
validator: (_rule: any, value: string) => {
if (isPrimaryDeptUser.value && !value) {
return Promise.reject('一级需求部门发起任务必须上传预览图')
}
return Promise.resolve()
}
}
]
}))
// 使用全局配置store的数据
const taskTypes = computed(() => configStore.demandTypes)
const brands = computed(() => configStore.brands)
const platforms = computed(() => configStore.platforms)
const categories = computed(() => configStore.categories)
// 工作时间配置(用于时效格式化)
const workHoursPerDay = ref(9) // 默认9小时
// 层级任务类型数据
const hierarchicalTaskTypes = ref<any[]>([])
// 部门列表数据
const departmentList = ref<departmentApi.DepartmentOverview[]>([])
const receivingBrandDeptRelations = ref<departmentApi.DeptBrandRelation[]>([])
// 加载层级任务类型数据
const loadHierarchicalTaskTypes = async () => {
try {
const response = await configApi.getDemandTypesTree()
if (response && response.data) {
hierarchicalTaskTypes.value = response.data
}
} catch (error) {
console.error('加载层级任务类型失败:', error)
}
}
// 加载部门列表数据
const loadDepartmentList = async () => {
try {
const response = await departmentApi.getDepartmentList()
if (response && response.data) {
departmentList.value = response.data
}
} catch (error) {
console.error('加载部门列表失败:', error)
}
}
const loadReceivingBrandDeptRelations = async () => {
try {
const response = await departmentApi.getAllReceivingBrandDeptRelations()
receivingBrandDeptRelations.value = response?.data || []
} catch (error) {
console.error('加载接单部门品牌关联失败:', error)
receivingBrandDeptRelations.value = []
}
}
const getDepartmentById = (deptId?: number | null) => {
if (!deptId) return undefined
return departmentList.value.find(d => d.id === deptId)
}
const isDepartmentActiveWithAncestors = (deptId?: number | null) => {
if (!deptId) return false
if (!departmentList.value.length) return true
let currentDept = getDepartmentById(deptId)
if (!currentDept) return false
while (currentDept) {
if (currentDept.status !== undefined && currentDept.status !== 1) {
return false
}
if ((currentDept as any).deleted === 1) {
return false
}
if (!currentDept.parentId) {
break
}
const parentDept = getDepartmentById(currentDept.parentId)
if (!parentDept) {
return false
}
currentDept = parentDept
}
return true
}
const getDepartmentDisplayName = (
dept?: departmentApi.DepartmentOverview | null,
duplicateNames?: Set<string>
) => {
if (!dept) return '未知部门'
const parentDept = getDepartmentById(dept.parentId)
const shouldShowParent = duplicateNames?.has(dept.deptName) ?? false
const isThirdLevelOrDeeper = !!parentDept && parentDept.parentId !== undefined && parentDept.parentId !== null && parentDept.parentId !== 0
if (!parentDept || (!shouldShowParent && !isThirdLevelOrDeeper)) {
return dept.deptName
}
return `${parentDept.deptName} / ${dept.deptName}`
}
const getDepartmentAndChildIds = (deptId?: number | null): number[] => {
if (!deptId) return []
const result = new Set<number>()
const visit = (currentDeptId: number) => {
if (result.has(currentDeptId)) return
result.add(currentDeptId)
departmentList.value
.filter(dept => dept.parentId === currentDeptId)
.forEach(child => {
if (child.id) {
visit(child.id)
}
})
}
visit(deptId)
return Array.from(result)
}
const getAllReceivingDeptIds = () => {
return departmentList.value
.filter(dept => dept.deptType === 'receiving' && dept.status !== 0 && dept.id)
.map(dept => dept.id!)
}
const parseDeptIds = (deptIdsText?: string) => {
if (!deptIdsText) return []
return deptIdsText
.split(',')
.map(id => parseInt(id.trim()))
.filter(id => !isNaN(id))
}
const getBrandRestrictedReceivingDeptIds = (brandId?: number) => {
if (!brandId) return []
const directDeptIds = receivingBrandDeptRelations.value
.filter(relation => relation.brandId === brandId && relation.deptId)
.map(relation => relation.deptId)
if (!directDeptIds.length) return []
const expandedDeptIds = new Set<number>()
directDeptIds.forEach(deptId => {
getDepartmentAndChildIds(deptId).forEach(id => expandedDeptIds.add(id))
})
return Array.from(expandedDeptIds)
}
const getEffectiveTaskTypeDeptIds = (typeInfo: any): number[] => {
const typeDeptIds = parseDeptIds(getTypeAllowedDeptIds(typeInfo))
const expandedTypeDeptIds = typeDeptIds.length
? Array.from(new Set(typeDeptIds.flatMap(id => getDepartmentAndChildIds(id))))
: []
const brandDeptIds = getBrandRestrictedReceivingDeptIds(formData.brandId)
if (!brandDeptIds.length) {
return expandedTypeDeptIds
}
if (!expandedTypeDeptIds.length) {
return brandDeptIds
}
const brandDeptIdSet = new Set(brandDeptIds)
return expandedTypeDeptIds.filter(id => brandDeptIdSet.has(id))
}
const hasTaskTypeDeptRestriction = (typeInfo: any) => {
return parseDeptIds(getTypeAllowedDeptIds(typeInfo)).length > 0 || getBrandRestrictedReceivingDeptIds(formData.brandId).length > 0
}
const syncTaskTypeDeptSelection = (item: TaskTypeItem) => {
if (!item.taskTypeId) {
item.selectedDeptId = undefined
return
}
const typeInfo = getTaskTypeInfo(item.taskTypeId)
if (!typeInfo) {
item.selectedDeptId = undefined
return
}
if (!hasTaskTypeDeptRestriction(typeInfo)) {
return
}
const effectiveDeptIds = getEffectiveTaskTypeDeptIds(typeInfo)
if (!effectiveDeptIds.length) {
item.selectedDeptId = undefined
item.receiverId = undefined
return
}
if (item.selectedDeptId && effectiveDeptIds.includes(item.selectedDeptId)) {
return
}
item.selectedDeptId = effectiveDeptIds.length === 1 ? effectiveDeptIds[0] : undefined
item.receiverId = undefined
}
// 构建级联选择器选项(三级结构)
const taskTypeCascaderOptions = computed<CascaderOption[]>(() => {
const buildOptions = (items: any[]): CascaderOption[] => {
return items.map(item => {
const option: CascaderOption = {
value: item.id,
label: item.name,
unitScore: item.unitScore,
estimatedHours: item.estimatedHours
}
// 如果有子级,递归构建
if (item.children && item.children.length > 0) {
option.children = buildOptions(item.children)
}
return option
})
}
return buildOptions(hierarchicalTaskTypes.value)
})
// 级联选择器搜索过滤
const cascaderFilter = (inputValue: string, path: CascaderOption[]) => {
return path.some(option =>
option.label.toLowerCase().indexOf(inputValue.toLowerCase()) > -1
)
}
// 任务类型级联选择变更
const onTaskTypeChange = (value: (string | number)[] | undefined, item: TaskTypeItem, index: number) => {
if (value && value.length > 0) {
// 取选择路径中的最后一个ID(叶子节点)
const lastId = value[value.length - 1] as number
// 检查该节点是否是叶子节点(没有子分类)
const selectedNode = findTaskTypeById(lastId)
if (selectedNode && (!selectedNode.children || selectedNode.children.length === 0)) {
// 是叶子节点,可以设置taskTypeId
item.taskTypeId = lastId
} else {
// 不是叶子节点,还需要继续选择
item.taskTypeId = undefined
}
item.taskTypePath = value
} else {
// 清空选择
item.taskTypeId = undefined
item.taskTypePath = undefined
}
syncTaskTypeDeptSelection(item)
calculateScore()
}
// 获取任务类型信息(根据ID递归查找层级数据)
const getTaskTypeInfo = (taskTypeId: number) => {
// 先从层级数据中查找(包含完整的分值信息)
const found = findTaskTypeById(taskTypeId, hierarchicalTaskTypes.value)
if (found) return found
// 兼容:如果层级数据中找不到,从平铺列表中查找
return taskTypes.value.find((t: any) => t.id === taskTypeId)
}
// 从平铺列表中根据ID查找任务类型(递归查找层级数据)
const findTaskTypeById = (id: number, items: any[] = hierarchicalTaskTypes.value): any => {
for (const item of items) {
if (item.id === id) return item
if (item.children) {
const found = findTaskTypeById(id, item.children)
if (found) return found
}
}
return null
}
// 根据任务类型ID查找完整的级联路径
const findTaskTypePath = (targetId: number, items: any[] = hierarchicalTaskTypes.value, currentPath: number[] = []): number[] | null => {
for (const item of items) {
const newPath = [...currentPath, item.id]
if (item.id === targetId) {
return newPath
}
if (item.children && item.children.length > 0) {
const found = findTaskTypePath(targetId, item.children, newPath)
if (found) return found
}
}
return null
}
// 加载设计师列表(根据任务类型ID)
const loadDesignersForItem = async (item: TaskTypeItem) => {
if (!item.taskTypeId) return
const cacheKey = `taskType_${item.taskTypeId}_brand_${formData.brandId || 0}`
if (!designersCache[cacheKey]) {
try {
// 使用 taskTypeId + brandId 调用后端 API,按任务类型与品牌共同过滤可选设计师
const designers = await userApi.getDesignersWithWorkload(undefined, item.taskTypeId, formData.brandId)
designersCache[cacheKey] = designers
} catch (error) {
console.error('加载设计师失败:', error)
}
}
}
// 获取当前任务类型对应的设计师列表
const getDesignersForItem = (item: TaskTypeItem) => {
if (!item.taskTypeId) return []
const cacheKey = `taskType_${item.taskTypeId}_brand_${formData.brandId || 0}`
return designersCache[cacheKey] || []
}
// 添加任务类型
const addTaskType = () => {
formData.taskTypes.push({
taskTypeId: undefined,
taskTypePath: undefined,
quantity: 1,
receiverId: undefined,
selectedDeptId: undefined
})
}
// 删除任务类型
const removeTaskType = (index: number) => {
if (formData.taskTypes.length > 1) {
formData.taskTypes.splice(index, 1)
calculateScore()
}
}
// 拖拽开始
const handleDragStart = (e: DragEvent, index: number) => {
dragIndex.value = index
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', String(index))
e.dataTransfer.setData('application/json', JSON.stringify({ index }))
// 设置拖拽图像为整个 item
const target = e.target as HTMLElement
const itemElement = target.closest('.task-type-item') as HTMLElement
if (itemElement) {
// 创建一个临时元素作为拖拽图像
const dragImage = itemElement.cloneNode(true) as HTMLElement
dragImage.style.opacity = '0.8'
dragImage.style.position = 'absolute'
dragImage.style.top = '-1000px'
document.body.appendChild(dragImage)
e.dataTransfer.setDragImage(dragImage, e.offsetX, e.offsetY)
setTimeout(() => document.body.removeChild(dragImage), 0)
}
}
}
// 拖拽结束
const handleDragEnd = () => {
dragIndex.value = null
dragOverIndex.value = null
}
// 拖拽经过
const handleDragOver = (e: DragEvent, index: number) => {
e.preventDefault()
if (dragIndex.value !== null && dragIndex.value !== index) {
dragOverIndex.value = index
}
}
// 拖拽离开
const handleDragLeave = () => {
dragOverIndex.value = null
}
// 放置
const handleDrop = (e: DragEvent, targetIndex: number) => {
e.preventDefault()
e.stopPropagation()
// 从拖拽数据中获取源索引
let sourceIndex = dragIndex.value
if (sourceIndex === null) {
// 尝试从 dataTransfer 中读取
const data = e.dataTransfer?.getData('text/plain')
if (data) {
sourceIndex = parseInt(data, 10)
} else {
handleDragEnd()
return
}
}
if (sourceIndex === null || sourceIndex === targetIndex || sourceIndex < 0 || targetIndex < 0) {
handleDragEnd()
return
}
console.log('拖拽排序:', sourceIndex, '->', targetIndex)
// 交换位置 - 使用 splice 原地修改数组以确保响应式更新
const [removed] = formData.taskTypes.splice(sourceIndex, 1)
formData.taskTypes.splice(targetIndex, 0, removed)
// 重新计算积分(pipelineGroups 是 computed,会自动更新)
calculateScore()
message.success('任务顺序已调整')
handleDragEnd()
}
// 处理SPU输入(只允许大写字母、数字和横杠)
const handleSpuInput = (e: Event) => {
const input = e.target as HTMLInputElement
// 移除非大写字母、数字和横杠的字符,同时将小写字母转为大写
const filtered = input.value.toUpperCase().replace(/[^A-Z0-9\-]/g, '')
formData.spuInfo = filtered
}
// 自动生成任务标题(SPU + 品牌名 + 平台名)
const generateTitle = () => {
const parts: string[] = []
// 1. SPU信息
if (formData.spuInfo && formData.spuInfo.trim()) {
parts.push(formData.spuInfo.trim())
}
// 2. 品牌名称
if (formData.brandId) {
const brand = brands.value.find((b: any) => b.id === formData.brandId)
if (brand) {
parts.push(brand.name)
}
}
// 3. 平台名称
if (formData.platformId) {
const platform = platforms.value.find((p: any) => p.id === formData.platformId)
if (platform) {
parts.push(platform.name)
}
}
// 生成标题
if (parts.length > 0) {
formData.title = parts.join('-')
}
}
// 监听相关字段变化,自动生成标题(SPU + 品牌 + 平台)
watch(
() => [
formData.spuInfo,
formData.brandId,
formData.platformId
],
() => {
generateTitle()
}
)
// 格式化积分(保留两位小数,修复浮点数精度问题)
const formatScore = (score: number | null | undefined): string => {
if (score === null || score === undefined) return '0.00'
// 先乘以100,四舍五入取整,再除以100,避免浮点数精度问题
return (Math.round(Number(score) * 100) / 100).toFixed(2)
}
// 计算预估积分(累加所有任务类型)
const estimatedScore = computed(() => {
let totalScore = 0
for (const item of formData.taskTypes) {
if (item.taskTypeId && item.quantity > 0) {
const selectedType = getTaskTypeInfo(item.taskTypeId)
if (selectedType) {
totalScore += (selectedType.unitScore || 0) * item.quantity
}
}
}
return formatScore(totalScore)
})
// 计算累计时效(小时)
const estimatedTotalHours = computed(() => {
let totalHours = 0
for (const item of formData.taskTypes) {
if (item.taskTypeId && item.quantity > 0) {
const selectedType = getTaskTypeInfo(item.taskTypeId)
if (selectedType && selectedType.estimatedHours) {
totalHours += selectedType.estimatedHours * item.quantity
}
}
}
return totalHours
})
// 格式化时效显示(基于工作时间配置)
const formattedEstimatedTime = computed(() => {
const hours = estimatedTotalHours.value
if (hours === 0) return '--'
if (hours < 1) {
return `${Math.round(hours * 60)}分钟`
} else {
// 使用formatWorkHours函数来格式化(基于工作时间配置)
return formatWorkHours(hours)
}
})
// 计算预计完成时间(基于工作时间配置)
const estimatedDeadline = computed(() => {
const totalHours = estimatedTotalHours.value
if (totalHours === 0) return '--'
const now = new Date()
const workHours = workHoursPerDay.value || 9 // 默认9小时
// 计算需要多少个工作日
const totalDays = Math.ceil(totalHours / workHours)
// 按自然日计算(每工作日对应1个自然日)
const deadline = new Date(now.getTime() + totalDays * 24 * 60 * 60 * 1000)
return formatDeadline(deadline)
})
// 格式化截止时间
const formatDeadline = (date: Date) => {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hour = String(date.getHours()).padStart(2, '0')
const minute = String(date.getMinutes()).padStart(2, '0')
return `${year}-${month}-${day} ${hour}:${minute}`
}
// 获取任务类型的部门配置(三级分类从二级父类继承)
const getTypeAllowedDeptIds = (typeInfo: any): string => {
// 如果当前类型有配置,直接返回
if (typeInfo.allowedDeptIds) {
return typeInfo.allowedDeptIds
}
// 如果有父级ID,查找父级的配置(三级分类继承二级分类)
if (typeInfo.parentId) {
const parentInfo = findTaskTypeById(typeInfo.parentId, hierarchicalTaskTypes.value)
if (parentInfo && parentInfo.allowedDeptIds) {
return parentInfo.allowedDeptIds
}
}
return ''
}
// 计算流水线分组
const pipelineGroups = computed(() => {
if (formData.taskTypes.length === 0) {
return []
}
const groups: Array<{
deptName: string
deptIds: string
items: Array<{
name: string
quantity: number
unitScore: number
totalScore: number
estimatedHours: number
}>
totalScore: number
totalHours: number
}> = []
let currentGroup: any = null
for (const taskType of formData.taskTypes) {
if (!taskType.taskTypeId || !taskType.quantity) continue
const typeInfo = getTaskTypeInfo(taskType.taskTypeId)
if (!typeInfo) continue
// 获取接单部门配置(任务类型可接单部门与品牌负责接单部门共同作用)
let deptIds = getEffectiveTaskTypeDeptIds(typeInfo)
let deptName = ''
// 如果用户选择了具体部门,使用用户选择的部门
if (taskType.selectedDeptId) {
deptIds = [taskType.selectedDeptId]
const selectedDept = getDepartmentById(taskType.selectedDeptId)
deptName = getDepartmentDisplayName(selectedDept)
} else {
deptName = getDeptNameFromIdList(deptIds)
}
// 判断是否需要创建新分组
const deptKey = deptIds.join(',')
if (!currentGroup || currentGroup.deptIds !== deptKey) {
// 保存之前的分组
if (currentGroup) {
groups.push(currentGroup)
}
// 创建新分组
currentGroup = {
deptName,
deptIds: deptKey,
items: [],
totalScore: 0,
totalHours: 0
}
}
// 添加到当前分组
const itemScore = typeInfo.unitScore * taskType.quantity
const itemHours = typeInfo.estimatedHours * taskType.quantity
currentGroup.items.push({
name: typeInfo.name,
quantity: taskType.quantity,
unitScore: typeInfo.unitScore,
totalScore: itemScore,
estimatedHours: typeInfo.estimatedHours
})
currentGroup.totalScore += itemScore
currentGroup.totalHours += itemHours
}
// 添加最后一个分组
if (currentGroup && currentGroup.items.length > 0) {
groups.push(currentGroup)
}
return groups
})
// 获取任务类型可选的部门列表
const getTaskTypeDeptOptions = (item: TaskTypeItem): Array<{ id: number, deptName: string, displayName: string }> => {
if (!item.taskTypeId) return []
const typeInfo = getTaskTypeInfo(item.taskTypeId)
if (!typeInfo) return []
const deptIds = getEffectiveTaskTypeDeptIds(typeInfo)
if (!deptIds.length) return []
if (deptIds.length <= 1) return [] // 只有一个部门时不需要选择
const depts = deptIds
.map(id => getDepartmentById(id))
.filter((dept): dept is departmentApi.DepartmentOverview => !!dept)
const duplicateNames = new Set(
depts
.map(dept => dept.deptName)
.filter((name, index, names) => names.indexOf(name) !== index)
)
// 同名三级部门展示为“二级部门 / 三级部门”,避免用户无法区分
return depts.map(dept => ({
id: dept.id!,
deptName: dept.deptName,
displayName: getDepartmentDisplayName(dept, duplicateNames)
}))
}
const validateTaskTypeDeptSelections = (items: TaskTypeItem[]) => {
for (const item of items) {
if (!item.taskTypeId) continue
const typeInfo = getTaskTypeInfo(item.taskTypeId)
if (!typeInfo) continue
if (!hasTaskTypeDeptRestriction(typeInfo)) {
continue
}
const effectiveDeptIds = getEffectiveTaskTypeDeptIds(typeInfo)
if (!effectiveDeptIds.length) {
message.error(`任务类型「${typeInfo.name}」在当前品牌下没有可用的接单部门,请先检查品牌与接单部门配置`)
return false
}
if (effectiveDeptIds.length > 1 && !item.selectedDeptId) {
message.error(`任务类型「${typeInfo.name}」存在多个可用接单部门,请先选择具体部门`)
return false
}
if (item.selectedDeptId && !effectiveDeptIds.includes(item.selectedDeptId)) {
message.error(`任务类型「${typeInfo.name}」已选择的接单部门不在当前品牌可用范围内,请重新选择`)
return false
}
}
return true
}
// 根据部门ID获取部门名称
const getDeptNameFromIds = (deptIds: string): string => {
return getDeptNameFromIdList(parseDeptIds(deptIds))
}
const getDeptNameFromIdList = (deptIds: number[]): string => {
if (!deptIds.length) return '所有部门'
const depts = deptIds.map(id => getDepartmentById(id)).filter((dept): dept is departmentApi.DepartmentOverview => !!dept)
if (depts.length === 0) return '所有部门'
// 如果只有一个部门,显示部门名称
if (depts.length === 1) {
return getDepartmentDisplayName(depts[0])
}
// 检查是否所有部门属于同一个父部门
const parentIds = depts.map(d => d!.parentId).filter(id => id !== undefined && id !== null)
if (parentIds.length === depts.length) {
// 所有部门都有父部门
const uniqueParentIds = [...new Set(parentIds)]
if (uniqueParentIds.length === 1) {
// 所有部门属于同一个父部门,显示父部门名称
const parentDept = getDepartmentById(uniqueParentIds[0])
if (parentDept) {
return parentDept.deptName
}
}
}
// 部门属于不同父部门,显示具体名称
const duplicateNames = new Set(
depts
.map(dept => dept.deptName)
.filter((name, index, names) => names.indexOf(name) !== index)
)
const deptNames = depts.map(dept => getDepartmentDisplayName(dept, duplicateNames))
if (deptNames.length <= 3) {
return deptNames.join('、')
}
return `${deptNames.slice(0, 2).join('、')}等${deptNames.length}个部门`
}
// 流水线总积分
const totalPipelineScore = computed(() => {
return pipelineGroups.value.reduce((sum, group) => sum + group.totalScore, 0)
})
// 流水线总工时
const totalPipelineHours = computed(() => {
return pipelineGroups.value.reduce((sum, group) => sum + group.totalHours, 0).toFixed(1)
})
// 格式化工时显示(基于工作时间配置)
const formatWorkHours = (hours: number): string => {
if (hours === 0) return '0小时'
const workHours = workHoursPerDay.value || 9 // 默认9小时
const days = Math.floor(hours / workHours)
// 处理浮点数精度问题,保留两位小数
let remainingHours = Math.round((hours % workHours) * 100) / 100
// 格式化小时显示
const formatHours = (h: number): string => {
if (Number.isInteger(h)) return `${h}小时`
return `${h.toFixed(2).replace(/\.?0+$/, '')}小时`
}
if (days === 0) {
return formatHours(remainingHours)
} else if (remainingHours === 0) {
return `${days}天`
} else {
return `${days}天${formatHours(remainingHours)}`
}
}
// 计算积分
const calculateScore = () => {
// 计算已在computed中完成
const score = estimatedScore.value
// 管理员跳过积分检查
if (!isAdmin.value && score > teamScore.value) {
message.warning('团队积分余额不足,无法发起此任务')
}
}
// 筛选设计师选项
const filterDesignerOption = (input: string, option: any) => {
// 搜索设计师姓名
const designerName = option.label || ''
return designerName.toLowerCase().includes(input.toLowerCase())
}
// 取消
const handleCancel = () => {
router.back()
}
// 保存草稿
const handleSaveDraft = async () => {
savingDraft.value = true
try {
// 基本验证:至少需要标题
if (!formData.title || formData.title.trim() === '') {
message.warning('请至少填写任务标题')
savingDraft.value = false
return
}
// 构建附件URL(如果有上传的文件)
const attachmentUrls = fileList.value
.filter(file => {
return file.status === 'done' && (
file.response?.fileUrl ||
file.response?.data?.[0] ||
file.url
)
})
.map(file => {
return file.response?.fileUrl ||
file.response?.data?.[0] ||
file.url ||
''
})
.filter(url => url)
const attachmentUrl = attachmentUrls.length > 0 ? attachmentUrls.join(',') : formData.attachmentUrl
// 获取有效的任务类型(允许部分填写)
const validTaskTypes = formData.taskTypes.filter(item => item.taskTypeId)
// 构建草稿数据
const draftData = {
title: formData.title,
isPipeline: true,
pipelineMode: 'sequential',
publisherDeptId: selectedPublishingDeptId.value,
items: validTaskTypes.map((item, index) => ({
taskTypeId: item.taskTypeId!,
quantity: item.quantity || 1,
sortOrder: index + 1,
selectedDesignerId: item.selectedDesignerId || undefined,
selectedDeptId: item.selectedDeptId || undefined
})),
brandId: formData.brandId || undefined,
categoryId: formData.categoryId || undefined,
platformId: formData.platformId || undefined,
spuInfo: formData.spuInfo || undefined,
taskSubType: formData.taskSubType || undefined,
attachmentUrl: attachmentUrl || undefined,
previewImage: formData.previewImage || undefined,
releaseType: formData.releaseType || 'recruitment',
asDraft: true // 标记为草稿
}
let response
if (editMode.value && editTaskId.value) {
// 编辑模式:更新草稿
response = await taskApi.updateTask(editTaskId.value, { ...draftData, status: 'draft' })
} else {
// 新建模式:创建草稿
response = await taskApi.createPipelineTask(draftData)
}
if (response.code === 200) {
message.success('草稿保存成功')
// 如果是新建,更新为编辑模式
if (!editMode.value && response.data?.id) {
router.replace({
path: '/tasks/create',
query: { taskId: response.data.id, mode: 'edit' }
})
}
} else {
message.error(response.msg || '保存草稿失败')
}
} catch (error: any) {
console.error('保存草稿失败:', error)
message.error(error.msg || error.message || '保存草稿失败')
} finally {
savingDraft.value = false
}
}
// 提交
const handleSubmit = async () => {
if (!formRef.value) return
try {
// 验证表单
await formRef.value.validate()
if (!isAdmin.value && !selectedPublishingDeptId.value) {
message.error('请选择发起部门')
return
}
// 一级需求部门用户必须上传预览图
if (isPrimaryDeptUser.value && !formData.previewImage) {
message.error('一级需求部门发起任务必须上传预览图')
return
}
// 验证任务类型(一级需求部门且关闭任务明细时跳过验证)
const validTaskTypes = formData.taskTypes.filter(item => item.taskTypeId && item.quantity > 0)
if (shouldShowTaskDetail.value) {
if (validTaskTypes.length === 0) {
message.error('请至少添加一个任务类型')
return
}
if (!validateTaskTypeDeptSelections(validTaskTypes)) {
return
}
}
// 检查库存(如果需要)
if (selectedSubTypeNeedsInventoryCheck.value) {
if (!formData.productBarcodes || formData.productBarcodes.length === 0) {
message.error('请至少添加一个已验证库存的商品条码')
return
}
}
// 检查积分余额(管理员跳过)
if (!isAdmin.value && estimatedScore.value > teamScore.value) {
message.error('团队积分余额不足')
return
}
// 检查指定接收人(指定模式下,允许部分任务类型不指定设计师,由接单部门负责人分配)
// 不再强制所有任务类型都必须选择接收人
submitting.value = true
// 检查是否有正在上传的文件
const uploadingFiles = fileList.value.filter(file =>
file.status === 'uploading' || (file.status !== 'done' && file.status !== 'error')
)
if (uploadingFiles.length > 0) {
console.warn('[提交任务] 检测到正在上传的文件,等待上传完成...')
message.warning('文件正在上传中,请等待上传完成后再提交任务')
submitting.value = false
return
}
// 构建附件URL(如果有上传的文件)- 使用S3返回的fileUrl
console.log('[提交任务] 文件列表:', fileList.value)
console.log('[提交任务] 文件列表详情:', fileList.value.map(f => ({
name: f.name,
status: f.status,
url: f.url,
response: f.response
})))
const attachmentUrls = fileList.value
.filter(file => {
const hasUrl = file.status === 'done' && (
file.response?.fileUrl ||
file.response?.data?.[0] ||
file.url
)
console.log(`[提交任务] 文件 ${file.name}: status=${file.status}, hasUrl=${!!hasUrl}`)
return hasUrl
})
.map(file => {
// 尝试从多个可能的字段获取URL
const url = file.response?.fileUrl ||
file.response?.data?.[0] ||
file.url ||
''
console.log(`[提交任务] 文件 ${file.name} 的URL:`, url)
return url
})
.filter(url => url)
console.log('[提交任务] 提取的附件URLs:', attachmentUrls)
const attachmentUrl = attachmentUrls.length > 0 ? attachmentUrls.join(',') : formData.attachmentUrl
console.log('[提交任务] 最终attachmentUrl:', attachmentUrl)
// 判断是否是一级需求部门用户且不填写任务明细
const isPrimaryDeptWithoutDetail = isPrimaryDeptUser.value && !shouldShowTaskDetail.value
// 流水线任务(固定使用顺序流水线模式)
const pipelineData = {
title: formData.title,
isPipeline: !isPrimaryDeptWithoutDetail, // 一级需求部门不填明细时,不标记为流水线任务
pipelineMode: 'sequential', // 固定为顺序流水线
publisherDeptId: selectedPublishingDeptId.value,
items: validTaskTypes.map((item, index) => ({
taskTypeId: item.taskTypeId!,
quantity: item.quantity,
sortOrder: index + 1,
receiverId: formData.releaseType === 'assigned' ? item.receiverId : undefined,
selectedDeptId: item.selectedDeptId // 用户选择的接单部门
})),
brandId: formData.brandId,
platformId: formData.platformId,
categoryId: formData.categoryId,
spuInfo: formData.spuInfo,
taskSubType: formData.taskSubType,
attachmentUrl: attachmentUrl,
remark: formData.remark || '',
priority: formData.isUrgent ? 3 : 1, // 0-低 1-普通 2-高 3-紧急
releaseType: formData.releaseType, // 发布类型
receiverId: undefined, // 主任务不设接收人,由子任务指定
productBarcodes: formData.productBarcodes, // 商品条码列表
previewImage: formData.previewImage // 预览图URL
}
let response: any
if (editMode.value && editTaskId.value) {
// 编辑模式:更新任务并重新提交审核
if (isPrimaryDeptWithoutDetail) {
// 一级需求部门不填写任务明细时,需要设置 isPipeline=true 来触发审核流程
const taskData = {
title: formData.title,
publisherDeptId: selectedPublishingDeptId.value,
brandId: formData.brandId,
platformId: formData.platformId,
categoryId: formData.categoryId,
spuInfo: formData.spuInfo,
taskSubType: formData.taskSubType,
attachmentUrl: attachmentUrl,
remark: formData.remark || '',
priority: formData.isUrgent ? 3 : 1,
releaseType: formData.releaseType,
productBarcodes: formData.productBarcodes,
previewImage: formData.previewImage,
isPipeline: true, // 🔥 设置为 true 以触发 updatePipelineTask 审核流程
pipelineMode: 'sequential', // 🔥 必须设置流水线模式
items: [], // 空数组,表示一级需求部门任务不填写明细
id: editTaskId.value
}
response = await taskApi.updateTask(editTaskId.value, taskData as any)
} else {
// 流水线任务更新
const updateData = {
...pipelineData,
id: editTaskId.value
}
response = await taskApi.updateTask(editTaskId.value, updateData)
}
if (response.code === 200) {
message.success('任务已重新提交审核')
setTimeout(() => {
router.push('/tasks')
}, 1000)
}
} else {
// 新建模式
if (isPrimaryDeptWithoutDetail) {
// 一级需求部门不填写任务明细时,使用普通任务创建接口
// 任务明细将由需求部门在审核时填写
const taskData = {
title: formData.title,
publisherDeptId: selectedPublishingDeptId.value,
brandId: formData.brandId,
platformId: formData.platformId,
categoryId: formData.categoryId,
spuInfo: formData.spuInfo,
taskSubType: formData.taskSubType,
attachmentUrl: attachmentUrl,
remark: formData.remark || '',
priority: formData.isUrgent ? 3 : 1,
releaseType: formData.releaseType,
productBarcodes: formData.productBarcodes,
previewImage: formData.previewImage,
isPrimaryDeptTask: true // 标记为一级需求部门任务
}
response = await taskApi.createTask(taskData as any)
if (response.code === 200) {
message.success('任务已提交,等待需求部门完善明细')
setTimeout(() => {
router.push('/tasks')
}, 1000)
}
} else {
// 普通流水线任务
response = await taskApi.createPipelineTask(pipelineData)
if (response.code === 200) {
message.success('流水线任务创建成功')
setTimeout(() => {
router.push('/tasks')
}, 1000)
}
}
}
} catch (error: any) {
console.error('发起任务失败:', error)
if (error.errorFields) {
// 表单验证错误
message.error('请检查表单填写')
} else {
message.error(error.message || '发起任务失败')
}
} finally {
submitting.value = false
}
}
// 加载配置数据(从全局store加载)
const loadConfigData = async () => {
loading.value = true
try {
await configStore.loadAllConfigs()
} catch (error) {
console.error('加载配置数据失败:', error)
message.error('加载配置数据失败')
} finally {
loading.value = false
}
}
// 加载任务子类型
const loadTaskSubTypes = async () => {
try {
const response = await configApi.getEnabledTaskSubTypes()
taskSubTypes.value = response.data || []
// 设置默认选中第一个子类型
if (taskSubTypes.value.length > 0 && !formData.taskSubType) {
formData.taskSubType = taskSubTypes.value[0].name
}
} catch (error) {
console.error('加载任务子类型失败:', error)
message.error('加载任务子类型失败')
}
}
// 监听配置更新事件
const handleConfigUpdate = async (event: CustomEvent) => {
console.log('配置已更新,刷新配置数据:', event.detail)
await Promise.all([
loadConfigData(),
loadDepartmentList(),
loadReceivingBrandDeptRelations()
])
formData.taskTypes.forEach(item => syncTaskTypeDeptSelection(item))
}
// 加载团队积分余额
const loadTeamScore = async () => {
console.log('[loadTeamScore] 开始加载积分')
console.log('[loadTeamScore] 当前用户信息:', authStore.userInfo)
// 确保用户信息是最新的
if (!authStore.userInfo) {
console.log('[loadTeamScore] 用户信息缺失或无部门ID,重新获取用户信息')
await authStore.fetchUserInfo()
console.log('[loadTeamScore] 重新获取后的用户信息:', authStore.userInfo)
}
const deptId = selectedPublishingDeptId.value
console.log('[loadTeamScore] 部门ID:', deptId)
if (!deptId) {
// 如果用户没有部门,不显示积分或者显示为0
console.warn('[loadTeamScore] 用户没有部门ID,设置积分为0')
teamScore.value = 0
return
}
try {
console.log('[loadTeamScore] 调用API获取积分池,部门ID:', deptId)
const response = await configApi.getScorePoolByDeptId(deptId)
console.log('[loadTeamScore] API响应:', response)
// request 拦截器返回的是 { code: 200, data: {...} } 格式
// 如果 code === 200,拦截器返回整个 res 对象
if (response && response.data) {
console.log('[loadTeamScore] 积分池数据:', response.data)
console.log('[loadTeamScore] 余额:', response.data.balance)
teamScore.value = response.data.balance || 0
} else {
console.warn('[loadTeamScore] 响应格式异常,设置积分为0')
teamScore.value = 0
}
console.log('[loadTeamScore] 最终积分值:', teamScore.value)
} catch (error) {
console.error('[loadTeamScore] 加载团队积分失败:', error)
teamScore.value = 0
}
}
// 文件上传前验证
const beforeUpload = (file: File) => {
console.log('[文件验证] 验证文件:', file.name, file.size, file.type)
const isValidType = ['.xls', '.xlsx', '.doc', '.docx', '.pdf', '.zip'].some(ext =>
file.name.toLowerCase().endsWith(ext)
)
if (!isValidType) {
console.error('[文件验证] 文件类型不支持:', file.name)
message.error('只支持 Excel、Word、PDF、ZIP 格式的文件')
return false
}
const isLt500M = file.size / 1024 / 1024 < 500
if (!isLt500M) {
console.error('[文件验证] 文件大小超限:', file.size)
message.error('文件大小不能超过 500MB')
return false
}
console.log('[文件验证] 文件验证通过,将使用custom-request上传')
// 返回 true 让组件继续处理,但使用 customRequest 自定义上传逻辑
return true
}
// 文件列表变化处理(监听文件添加,确保上传被触发)
const handleFileChange = (info: any) => {
console.log('[文件变化] onChange事件触发:', info)
const { file } = info
// 如果文件刚被添加且还没有开始上传(status 为 undefined 或 'ready')
if ((!file.status || file.status === 'ready') && file.originFileObj) {
console.log('[文件变化] 检测到新文件,文件状态:', file.status)
// customRequest 应该会自动调用,这里只是记录
}
}
// 压缩文件的阈值(50MB以上的非zip文件自动压缩)
const COMPRESS_THRESHOLD = 50 * 1024 * 1024
// 需要压缩的文件扩展名
const COMPRESSIBLE_EXTENSIONS = ['.xls', '.xlsx', '.doc', '.docx', '.pdf']
// 压缩文件函数
const compressFile = async (file: File, onProgress?: (percent: number) => void): Promise<File> => {
const zip = new JSZip()
zip.file(file.name, file)
const compressedBlob = await zip.generateAsync(
{
type: 'blob',
compression: 'DEFLATE',
compressionOptions: { level: 6 }
},
(metadata) => {
if (onProgress) {
onProgress(Math.round(metadata.percent))
}
}
)
// 创建新的文件名(原名.zip)
const zipFileName = file.name + '.zip'
return new File([compressedBlob], zipFileName, { type: 'application/zip' })
}
// 检查文件是否需要压缩
const shouldCompressFile = (file: File): boolean => {
// 如果文件已经是zip格式,不压缩
if (file.name.toLowerCase().endsWith('.zip')) {
return false
}
// 文件大于阈值且是可压缩类型
const isCompressible = COMPRESSIBLE_EXTENSIONS.some(ext =>
file.name.toLowerCase().endsWith(ext)
)
return file.size > COMPRESS_THRESHOLD && isCompressible
}
// 自定义上传处理(使用S3上传)
const handleUpload = async (options: any) => {
const { file, onSuccess, onError, onProgress } = options
console.log('[文件上传] 开始上传文件:', file.name, file.size)
try {
uploadProgress.value = 0
let fileToUpload: File = file as File
let wasCompressed = false
// 检查是否需要压缩
if (shouldCompressFile(file)) {
const originalSizeMB = (file.size / 1024 / 1024).toFixed(1)
message.loading({ content: `正在压缩文件 (${originalSizeMB}MB),请稍候...`, key: 'compress', duration: 0 })
console.log('[文件压缩] 文件较大,开始压缩:', file.name, originalSizeMB + 'MB')
try {
fileToUpload = await compressFile(file, (percent) => {
message.loading({ content: `正在压缩文件 ${percent}%`, key: 'compress', duration: 0 })
})
wasCompressed = true
const compressedSizeMB = (fileToUpload.size / 1024 / 1024).toFixed(1)
const ratio = ((1 - fileToUpload.size / file.size) * 100).toFixed(0)
console.log('[文件压缩] 压缩完成:', fileToUpload.name, compressedSizeMB + 'MB', '压缩率:' + ratio + '%')
message.success({ content: `文件已压缩 (${originalSizeMB}MB → ${compressedSizeMB}MB,节省${ratio}%)`, key: 'compress' })
} catch (compressError) {
console.error('[文件压缩] 压缩失败,使用原文件上传:', compressError)
message.warning({ content: '文件压缩失败,将上传原文件', key: 'compress' })
fileToUpload = file as File
}
}
// 使用S3上传接口
console.log('[文件上传] 调用 uploadFilesToS3 API, 文件:', fileToUpload.name, fileToUpload.size)
const fileUrls = await uploadFilesToS3(
[fileToUpload],
'task', // relatedType
undefined, // relatedId,创建任务时还没有ID
(progress) => {
uploadProgress.value = progress
if (onProgress) {
onProgress({ percent: progress })
}
}
)
console.log('[文件上传] 上传成功,返回URLs:', fileUrls)
if (fileUrls && fileUrls.length > 0) {
const fileUrl = fileUrls[0]
// 确定最终显示的文件名(压缩后的文件名或原文件名)
const finalFileName = wasCompressed ? fileToUpload.name : file.name
console.log('[文件上传] 准备更新文件列表,URL:', fileUrl, '文件名:', finalFileName)
// 更新文件列表(显示名使用压缩后的文件名)
const fileIndex = fileList.value.findIndex(f => f.uid === file.uid)
if (fileIndex !== -1) {
const updatedFile = {
...fileList.value[fileIndex],
name: finalFileName, // 显示压缩后的文件名
status: 'done',
response: { fileUrl: fileUrl, data: fileUrls, fileName: finalFileName },
url: fileUrl,
percent: 100
} as UploadFile
fileList.value[fileIndex] = updatedFile
console.log('[文件上传] 文件列表已更新:', fileList.value[fileIndex])
}
uploadProgress.value = 0
if (onSuccess) {
// onSuccess 的第一个参数是响应数据
// 注意:不传原始 file 对象,避免组件用原文件名覆盖
onSuccess({ fileUrl: fileUrl, data: fileUrls, fileName: finalFileName })
}
// 使用 nextTick 确保文件名在组件更新后仍然正确
if (wasCompressed) {
nextTick(() => {
const idx = fileList.value.findIndex(f => f.uid === file.uid)
if (idx !== -1 && fileList.value[idx].name !== finalFileName) {
fileList.value[idx] = { ...fileList.value[idx], name: finalFileName }
console.log('[文件上传] nextTick 强制更新文件名为:', finalFileName)
}
})
}
message.success(wasCompressed ? '文件压缩并上传成功' : '文件上传成功')
} else {
throw new Error('上传失败:未返回文件URL')
}
} catch (error: any) {
uploadProgress.value = 0
const fileIndex = fileList.value.findIndex(f => f.uid === file.uid)
if (fileIndex !== -1) {
fileList.value[fileIndex] = {
...fileList.value[fileIndex],
status: 'error'
} as UploadFile
}
if (onError) {
onError(error)
}
}
}
// 删除文件
const handleRemove = (file: UploadFile) => {
const index = fileList.value.findIndex(f => f.uid === file.uid)
if (index !== -1) {
fileList.value.splice(index, 1)
}
return true
}
// 预览图上传前验证
const beforePreviewUpload = (file: File) => {
const isImage = file.type.startsWith('image/')
if (!isImage) {
message.error('只能上传图片文件')
return false
}
const isLt10M = file.size / 1024 / 1024 < 10
if (!isLt10M) {
message.error('图片大小不能超过 10MB')
return false
}
return true
}
// 预览图上传处理
const handlePreviewUpload = async (options: any) => {
const { file, onSuccess, onError } = options
try {
previewUploading.value = true
const formDataObj = new FormData()
formDataObj.append('file', file)
const response = await fetch('/api/file/upload-preview', {
method: 'POST',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: formDataObj
})
const result = await response.json()
if (result.code === 200 && result.data) {
formData.previewImage = result.data
previewFileList.value = [{
uid: file.uid,
name: file.name,
status: 'done',
url: result.data
} as UploadFile]
if (onSuccess) {
onSuccess(result.data)
}
message.success('预览图上传成功')
} else {
throw new Error(result.msg || '上传失败')
}
} catch (error: any) {
console.error('预览图上传失败:', error)
if (onError) {
onError(error)
}
message.error('预览图上传失败: ' + error.message)
} finally {
previewUploading.value = false
}
}
// 删除预览图
const handlePreviewRemove = () => {
formData.previewImage = ''
previewFileList.value = []
return true
}
// 处理粘贴图片
const handlePasteImage = async (e: ClipboardEvent) => {
e.preventDefault()
// 如果已经有预览图,不处理粘贴
if (formData.previewImage) {
return
}
const items = e.clipboardData?.items
if (!items) return
// 查找图片数据
for (let i = 0; i < items.length; i++) {
const item = items[i]
if (item.type.indexOf('image') !== -1) {
const file = item.getAsFile()
if (!file) continue
// 验证文件大小
const isLt10M = file.size / 1024 / 1024 < 10
if (!isLt10M) {
message.error('图片大小不能超过 10MB')
return
}
// 创建 File 对象用于上传
const uploadFile: UploadFile = {
uid: `paste-${Date.now()}`,
name: `粘贴图片-${Date.now()}.png`,
status: 'uploading',
originFileObj: file
}
previewFileList.value = [uploadFile]
// 调用上传处理函数
try {
previewUploading.value = true
const formDataObj = new FormData()
formDataObj.append('file', file)
const response = await fetch('/api/file/upload-preview', {
method: 'POST',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: formDataObj
})
const result = await response.json()
if (result.code === 200 && result.data) {
formData.previewImage = result.data
previewFileList.value = [{
uid: uploadFile.uid,
name: uploadFile.name,
status: 'done',
url: result.data
} as UploadFile]
message.success('图片粘贴成功')
} else {
throw new Error(result.msg || '上传失败')
}
} catch (error: any) {
console.error('粘贴图片上传失败:', error)
previewFileList.value = []
message.error('粘贴图片上传失败: ' + error.message)
} finally {
previewUploading.value = false
}
break
}
}
}
// 加载任务数据用于编辑
const loadTaskForEdit = async (taskId: number) => {
try {
loading.value = true
const response = await taskApi.getTaskById(taskId)
const task = response.data
if (task) {
console.log('加载任务数据:', task)
syncEditingPublishingDept(task)
initializePublishingDept(task.publisherDeptId || task.primaryPublisherDeptId)
// 填充基本表单数据
formData.title = task.title || ''
formData.taskSubType = task.taskSubType || ''
formData.brandId = task.brandId
formData.platformId = task.platformId
formData.categoryId = task.categoryId
formData.spuInfo = task.spuInfo || ''
formData.remark = task.remark || ''
formData.releaseType = task.releaseType || 'recruitment'
formData.isUrgent = task.isUrgent === 1 || task.isUrgent === true
// 处理预览图
if (task.previewImage) {
formData.previewImage = task.previewImage
previewFileList.value = [{
uid: 'existing-preview',
name: '预览图',
status: 'done' as const,
url: task.previewImage
}]
}
// 处理任务类型和数量 - 优先从子任务的typeDetails中获取
if (task.isPipeline === 1 && task.subTasks && task.subTasks.length > 0) {
// 流水线任务:从子任务的typeDetails中提取任务类型信息
console.log('流水线任务子任务:', task.subTasks)
const taskTypeItems: TaskTypeItem[] = []
for (const subTask of task.subTasks) {
// 优先从typeDetails解析(包含完整的任务类型明细)
if (subTask.typeDetails) {
try {
const details = typeof subTask.typeDetails === 'string'
? JSON.parse(subTask.typeDetails)
: subTask.typeDetails
for (const detail of details) {
taskTypeItems.push({
taskTypeId: detail.typeId,
quantity: detail.quantity || 1,
receiverId: detail.receiverId || subTask.receiverId,
selectedDeptId: undefined // 不预设部门,让用户重新选择或自动匹配
})
}
} catch (e) {
console.warn('解析typeDetails失败:', e)
// 解析失败时使用子任务的基本信息
taskTypeItems.push({
taskTypeId: subTask.taskTypeId,
quantity: subTask.quantity || 1,
receiverId: subTask.receiverId,
selectedDeptId: undefined
})
}
} else {
// 没有typeDetails时使用子任务的基本信息
taskTypeItems.push({
taskTypeId: subTask.taskTypeId,
quantity: subTask.quantity || 1,
receiverId: subTask.receiverId,
selectedDeptId: undefined
})
}
}
formData.taskTypes = taskTypeItems
console.log('解析后的任务类型:', formData.taskTypes)
} else if (task.taskTypeIds) {
// 非流水线任务:从taskTypeIds字符串解析
const typeIds = task.taskTypeIds.split(',').map((id: string) => parseInt(id.trim()))
formData.taskTypes = typeIds.map((typeId: number, index: number) => ({
taskTypeId: typeId,
quantity: index === 0 ? (task.quantity || 1) : 1,
receiverId: task.receiverId
}))
} else if (task.taskTypeId) {
// 兼容单任务类型
formData.taskTypes = [{
taskTypeId: task.taskTypeId,
quantity: task.quantity || 1,
receiverId: task.receiverId
}]
}
// 为每个任务类型设置级联路径(需要在层级数据加载后执行)
// 使用 nextTick 确保层级数据已加载
nextTick(() => {
for (const item of formData.taskTypes) {
if (item.taskTypeId) {
const path = findTaskTypePath(item.taskTypeId)
if (path) {
item.taskTypePath = path
console.log(`任务类型 ${item.taskTypeId} 的路径:`, path)
}
}
}
})
// 处理附件 - 完整加载原有附件
if (task.attachmentUrl) {
formData.attachmentUrl = task.attachmentUrl
// 将附件URL转换为fileList格式
const urls = task.attachmentUrl.split(',').filter((u: string) => u.trim())
fileList.value = urls.map((url: string, index: number) => {
const fileName = url.split('/').pop() || `附件${index + 1}`
return {
uid: `existing-${index}`,
name: fileName,
status: 'done' as const,
url: url.trim()
}
})
}
editMode.value = true
editTaskId.value = taskId
// 显示驳回原因提示(如果是被驳回的任务)
if (task.rejectReason) {
message.warning(`驳回原因:${task.rejectReason},请修改后重新提交`)
}
console.log('任务数据加载成功,formData.taskTypes:', formData.taskTypes)
await loadTeamScore()
}
} catch (error) {
console.error('加载任务数据失败:', error)
message.error('加载任务数据失败')
} finally {
loading.value = false
}
}
// 加载发布类型配置
const loadReleaseTypeConfig = async () => {
try {
const res = await getSystemConfigsMapByGroup('task')
if (res.data) {
releaseTypeConfig.value = {
default: res.data['task.release_type.default'] !== 'false',
recruitment: res.data['task.release_type.recruitment'] !== 'false',
assigned: res.data['task.release_type.assigned'] !== 'false'
}
// 如果当前选择的类型被禁用,自动切换到第一个可用的类型
if (!releaseTypeConfig.value[formData.releaseType as keyof typeof releaseTypeConfig.value]) {
if (releaseTypeConfig.value.default) {
formData.releaseType = 'default'
} else if (releaseTypeConfig.value.recruitment) {
formData.releaseType = 'recruitment'
} else if (releaseTypeConfig.value.assigned) {
formData.releaseType = 'assigned'
}
}
// 加载一级需求部门配置
const configValue = res.data['task.primary_dept_can_fill_detail']
console.log('[一级需求部门配置] 原始值:', configValue, '类型:', typeof configValue)
primaryDeptConfig.value = {
canFillDetail: configValue === 'true'
}
console.log('[一级需求部门配置] canFillDetail:', primaryDeptConfig.value.canFillDetail)
}
// 加载工作时间配置
try {
const workHoursResponse = await getWorkHoursConfig()
if (workHoursResponse.data && workHoursResponse.data.workHoursPerDay) {
workHoursPerDay.value = workHoursResponse.data.workHoursPerDay
}
} catch (error) {
console.error('加载工作时间配置失败:', error)
// 使用默认值
}
} catch (error) {
console.error('加载发布类型配置失败:', error)
}
}
// 全局粘贴事件处理(当预览图区域可见时)
const handleGlobalPaste = (e: ClipboardEvent) => {
// 如果预览图区域可见且没有图片,处理粘贴
if (!formData.previewImage) {
handlePasteImage(e)
}
}
// 加载一级需求任务进行明细编辑
const loadPrimaryTaskForEdit = async () => {
try {
const editDataStr = sessionStorage.getItem('editPrimaryTaskData')
if (!editDataStr) {
message.error('未找到任务数据')
router.push('/tasks')
return
}
const editData = JSON.parse(editDataStr)
const taskId = editData.taskId
// 清除 sessionStorage
sessionStorage.removeItem('editPrimaryTaskData')
// 从API获取完整的任务数据
loading.value = true
const response = await taskApi.getTaskById(taskId)
const task = response.data
if (!task) {
message.error('任务不存在')
router.push('/tasks')
return
}
console.log('加载一级需求任务数据:', task)
syncEditingPublishingDept(task)
initializePublishingDept(task.publisherDeptId || task.primaryPublisherDeptId)
// 设置编辑模式
editMode.value = true
editTaskId.value = taskId
// 填充基本表单数据
formData.title = task.title || ''
formData.taskSubType = task.taskSubType || ''
formData.brandId = task.brandId
formData.platformId = task.platformId
formData.categoryId = task.categoryId
formData.spuInfo = task.spuInfo || ''
formData.remark = task.remark || ''
formData.releaseType = task.releaseType || 'recruitment'
formData.isUrgent = task.isUrgent === 1 || task.isUrgent === true
// 加载商品条码(一级需求发起人填写的)
if (task.productBarcodes) {
formData.productBarcodes = task.productBarcodes.split(',').filter((b: string) => b.trim())
}
// 处理预览图
if (task.previewImage) {
formData.previewImage = task.previewImage
previewFileList.value = [{
uid: 'existing-preview',
name: '预览图',
status: 'done' as const,
url: task.previewImage
}]
}
// 处理任务类型和数量 - 优先从子任务的typeDetails中获取
if (task.isPipeline === 1 && task.subTasks && task.subTasks.length > 0) {
// 流水线任务:从子任务的typeDetails中提取任务类型信息
console.log('流水线任务子任务:', task.subTasks)
const taskTypeItems: TaskTypeItem[] = []
for (const subTask of task.subTasks) {
// 优先从typeDetails解析(包含完整的任务类型明细)
if (subTask.typeDetails) {
try {
const details = typeof subTask.typeDetails === 'string'
? JSON.parse(subTask.typeDetails)
: subTask.typeDetails
for (const detail of details) {
taskTypeItems.push({
taskTypeId: detail.typeId,
quantity: detail.quantity || 1,
receiverId: detail.receiverId || subTask.receiverId,
selectedDeptId: undefined
})
}
} catch (e) {
console.warn('解析typeDetails失败:', e)
taskTypeItems.push({
taskTypeId: subTask.taskTypeId,
quantity: subTask.quantity || 1,
receiverId: subTask.receiverId,
selectedDeptId: undefined
})
}
} else {
taskTypeItems.push({
taskTypeId: subTask.taskTypeId,
quantity: subTask.quantity || 1,
receiverId: subTask.receiverId,
selectedDeptId: undefined
})
}
}
formData.taskTypes = taskTypeItems
console.log('解析后的任务类型:', formData.taskTypes)
} else if (task.taskTypeIds) {
// 非流水线任务:从taskTypeIds字符串解析
const typeIds = task.taskTypeIds.split(',').map((id: string) => parseInt(id.trim()))
formData.taskTypes = typeIds.map((typeId: number, index: number) => ({
taskTypeId: typeId,
quantity: index === 0 ? (task.quantity || 1) : 1,
receiverId: task.receiverId
}))
} else if (task.taskTypeId) {
// 兼容单任务类型
formData.taskTypes = [{
taskTypeId: task.taskTypeId,
quantity: task.quantity || 1,
receiverId: task.receiverId
}]
}
// 为每个任务类型设置级联路径(需要在层级数据加载后执行)
nextTick(() => {
for (const item of formData.taskTypes) {
if (item.taskTypeId) {
const path = findTaskTypePath(item.taskTypeId)
if (path) {
item.taskTypePath = path
}
}
}
})
// 标记为一级需求任务编辑模式
formData.isPrimaryTaskEdit = true
formData.primaryPublisherId = task.primaryPublisherId
formData.primaryPublisherDeptId = task.primaryPublisherDeptId
message.info('请完善任务明细后提交')
await loadTeamScore()
} catch (error) {
console.error('加载一级需求任务失败:', error)
message.error('加载任务数据失败')
router.push('/tasks')
}
}
// 组件挂载
onMounted(async () => {
// 添加全局粘贴事件监听
document.addEventListener('paste', handleGlobalPaste as EventListener)
// 先刷新用户信息,确保部门信息最新
try {
await authStore.fetchUserInfo()
console.log('用户信息已刷新:', authStore.userInfo)
initializePublishingDept(getPreferredPublishingDeptId())
} catch (error) {
console.error('刷新用户信息失败:', error)
}
// 然后加载配置和积分
await Promise.all([
loadConfigData(),
loadTeamScore(),
loadTaskSubTypes(),
loadReleaseTypeConfig(),
loadHierarchicalTaskTypes(),
loadDepartmentList(),
loadReceivingBrandDeptRelations()
])
// 检查是否是编辑模式
// 支持三种方式:
// 1. editId 参数(驳回任务重新编辑)
// 2. taskId + mode=edit 参数(草稿箱继续编辑)
// 3. editPrimaryTask 参数(一级需求任务完善明细)
const editId = route.query.editId || (route.query.mode === 'edit' ? route.query.taskId : null)
if (editId) {
await loadTaskForEdit(Number(editId))
} else if (route.query.editPrimaryTask === 'true') {
// 一级需求任务完善明细
await loadPrimaryTaskForEdit()
}
// 监听配置更新事件
window.addEventListener('configUpdated', handleConfigUpdate as EventListener)
})
watch(publishingDepartmentOptions, (departments) => {
if (!departments.length) {
selectedPublishingDeptId.value = undefined
return
}
if (!selectedPublishingDeptId.value || !departments.some(dept => dept.deptId === selectedPublishingDeptId.value)) {
initializePublishingDept(getPreferredPublishingDeptId())
}
}, { immediate: true })
watch(selectedPublishingDeptId, async (deptId, oldDeptId) => {
if (deptId === oldDeptId) return
if (deptId) {
sessionStorage.setItem(TASK_CREATE_PUBLISHER_DEPT_KEY, String(deptId))
} else {
sessionStorage.removeItem(TASK_CREATE_PUBLISHER_DEPT_KEY)
}
await loadTeamScore()
})
watch(() => formData.brandId, () => {
Object.keys(designersCache).forEach(key => {
delete designersCache[key]
})
formData.taskTypes.forEach(item => syncTaskTypeDeptSelection(item))
calculateScore()
})
// 组件卸载
onUnmounted(() => {
window.removeEventListener('configUpdated', handleConfigUpdate as EventListener)
document.removeEventListener('paste', handleGlobalPaste as EventListener)
})
</script>
<style lang="scss" scoped>
@import "@/styles/variables.scss";
.create-task-page {
min-height: 100vh;
padding: $space-xl 0;
background: $bg-primary;
}
.container {
max-width: $container-max-width;
margin: 0 auto;
padding: 0 $space-xl;
@media (max-width: $breakpoint-tablet) {
padding: 0 $space-lg;
}
@media (max-width: $breakpoint-mobile) {
padding: 0 $space-md;
}
}
// 积分余额卡片 - Cursor 风格
.score-balance-card {
margin-bottom: $space-xl;
background: $bg-card;
border: 1px solid $border-primary;
border-radius: $radius-lg;
padding: $space-xl;
transition: all $transition-base $ease-out;
&:hover {
border-color: $border-color;
background: $bg-card-hover;
}
}
.balance-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: $space-md;
}
.balance-label {
font-size: $font-size-base;
color: $text-secondary;
}
.balance-value {
font-size: $font-size-3xl;
font-weight: $font-weight-bold;
color: $brand-primary;
}
.balance-tip {
display: flex;
align-items: center;
gap: $space-sm;
padding: $space-sm $space-md;
background: rgba($brand-primary, 0.1);
border-radius: $radius-small;
font-size: $font-size-sm;
color: $text-secondary;
.tip-icon {
font-size: $font-size-lg;
}
strong {
color: $brand-primary;
}
}
.publisher-dept-card {
margin-bottom: $space-md;
}
.publisher-dept-label {
margin-bottom: $space-xs;
font-size: $font-size-sm;
color: $text-secondary;
}
.publisher-dept-select {
width: 100%;
}
.publisher-dept-tip {
margin-top: $space-xs;
font-size: $font-size-xs;
color: $text-tertiary;
}
.time-estimate {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: $space-md;
margin-top: $space-md;
padding: $space-md;
background: rgba(255, 255, 255, 0.03);
border-radius: $radius-small;
border: 1px solid rgba(255, 255, 255, 0.06);
.estimate-item {
display: flex;
align-items: center;
gap: $space-xs;
.estimate-label {
color: $text-tertiary;
font-size: $font-size-sm;
}
.estimate-value {
color: $brand-primary;
font-weight: 600;
font-size: $font-size-base;
}
}
.estimate-tip {
flex-basis: 100%;
display: flex;
align-items: center;
font-size: $font-size-xs;
color: $text-tertiary;
margin-top: $space-xs;
font-weight: $font-weight-semibold;
}
}
// 任务表单 - Cursor 风格
.task-form {
background: $bg-card;
border: 1px solid $border-primary;
border-radius: $radius-lg;
padding: $space-xl;
transition: all $transition-base $ease-out;
&:hover {
border-color: $border-color;
background: $bg-card-hover;
}
@media (max-width: $breakpoint-mobile) {
padding: $space-lg;
}
}
// 上传区域
.upload-area {
.upload-tip {
display: flex;
align-items: center;
gap: $space-sm;
margin-top: $space-sm;
padding: $space-sm $space-md;
background: rgba($color-info, 0.1);
border-radius: $radius-small;
font-size: $font-size-sm;
color: $text-secondary;
.tip-icon {
font-size: $font-size-base;
}
}
// 上传文件列表样式
:deep(.ant-upload-list) {
color: $text-primary;
margin-top: 12px;
.ant-upload-list-item {
color: $text-primary;
border-color: rgba(255, 255, 255, 0.15);
background: rgba(255, 255, 255, 0.03);
border-radius: 6px;
padding: 8px 12px;
margin-top: 8px;
&:hover {
background: rgba(255, 255, 255, 0.06);
border-color: rgba(255, 255, 255, 0.25);
}
.ant-upload-list-item-name {
color: $text-primary;
flex: 1;
&:hover {
color: $brand-primary;
}
}
// 删除按钮样式增强
.ant-upload-list-item-card-actions,
.ant-upload-list-item-actions {
display: flex !important;
align-items: center;
.anticon,
.ant-btn {
color: #ff6b6b !important;
font-size: 16px;
cursor: pointer;
transition: all 0.2s;
&:hover {
color: #ff4d4f !important;
transform: scale(1.1);
}
}
// 删除图标按钮
.anticon-delete,
.ant-upload-list-item-card-actions-btn {
padding: 4px 8px;
border-radius: 4px;
background: rgba(255, 77, 79, 0.1);
&:hover {
background: rgba(255, 77, 79, 0.2);
}
}
}
}
.ant-upload-list-item-error {
border-color: rgba(255, 77, 79, 0.3);
.ant-upload-list-item-name {
color: $color-error;
}
}
// 上传完成状态的文件项
.ant-upload-list-item-done {
border-color: rgba(49, 240, 139, 0.2);
.ant-upload-list-item-name {
color: $brand-primary;
}
}
}
}
// 发布类型选项
// 任务发布类型按钮组
.release-type-buttons {
display: flex;
flex-direction: column;
gap: 12px;
}
.release-type-button {
position: relative;
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 14px 16px;
background: $bg-secondary;
border: 2px solid $border-primary;
border-radius: $radius-md;
cursor: pointer;
transition: all 0.2s ease;
text-align: left;
&:hover {
background: $bg-tertiary;
border-color: rgba($brand-primary, 0.4);
transform: translateY(-1px);
}
&.active {
background: rgba($brand-primary, 0.1);
border-color: $brand-primary;
.button-icon {
color: $brand-primary;
background: rgba($brand-primary, 0.15);
}
.button-title {
color: $brand-primary;
}
}
.button-icon {
flex-shrink: 0;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: $bg-tertiary;
border-radius: $radius-sm;
color: $text-secondary;
transition: all 0.2s ease;
}
.button-content {
flex: 1;
min-width: 0;
}
.button-title {
font-size: 14px;
font-weight: $font-weight-semibold;
color: $text-primary;
margin-bottom: 4px;
transition: color 0.2s ease;
}
.button-desc {
font-size: 13px;
color: $text-secondary;
line-height: 1.4;
}
.selected-indicator {
flex-shrink: 0;
color: $brand-primary;
animation: scaleIn 0.3s ease;
}
}
@keyframes scaleIn {
from {
opacity: 0;
transform: scale(0.5);
}
to {
opacity: 1;
transform: scale(1);
}
}
.urgent-tip {
margin-left: $space-md;
font-size: $font-size-sm;
color: $text-secondary;
}
// 任务类型列表样式
.task-types-list {
.task-type-item {
margin-bottom: $space-md;
padding: 12px;
background: rgba(255, 255, 255, 0.02);
border: 1px solid transparent;
border-radius: 8px;
transition: all 0.2s ease;
cursor: grab;
overflow: hidden; // 防止内容溢出
width: 100%;
&:last-child {
margin-bottom: $space-lg;
}
&:hover {
background: rgba(49, 240, 139, 0.05);
border-color: rgba(49, 240, 139, 0.2);
}
&.dragging {
opacity: 0.5;
cursor: grabbing;
border-color: $brand-primary;
background: rgba(49, 240, 139, 0.1);
}
&.drag-over {
border-color: $brand-primary;
border-style: dashed;
background: rgba(49, 240, 139, 0.15);
transform: scale(1.02);
}
}
.drag-handle {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 40px;
color: #666;
cursor: grab;
border-radius: 4px;
transition: all 0.2s ease;
flex-shrink: 0;
&:hover {
color: $brand-primary;
background: rgba(49, 240, 139, 0.1);
}
&:active {
cursor: grabbing;
}
}
.task-type-row {
display: flex;
gap: $space-md;
align-items: flex-start;
flex-wrap: wrap; // 允许换行
width: 100%;
min-width: 0; // 允许 flex 子元素收缩
}
.task-type-select {
flex: 2;
min-width: 200px; // 设置最小宽度,确保在小屏幕上也能正常显示
max-width: 100%; // 防止超出父容器
overflow: hidden; // 防止内容溢出
}
.task-type-cascader {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0; // 允许收缩
overflow: hidden; // 防止溢出
:deep(.ant-cascader) {
width: 100%;
min-width: 0; // 允许收缩
.ant-input {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.task-type-score-tip {
display: flex;
gap: 8px;
font-size: 12px;
.score-badge {
color: $brand-primary;
background: rgba($brand-primary, 0.1);
padding: 2px 8px;
border-radius: 4px;
}
.hour-badge {
color: $text-secondary;
background: rgba(255, 255, 255, 0.05);
padding: 2px 8px;
border-radius: 4px;
}
}
}
.task-type-quantity {
flex: 0 0 auto; // 不缩放,保持原始大小
min-width: 120px; // 减少最小宽度
max-width: 150px; // 限制最大宽度
flex-shrink: 0; // 防止被压缩
}
.task-type-dept {
flex-shrink: 0;
min-width: 220px; // 给“二级部门 / 三级部门”留出足够空间
width: 220px;
}
.task-type-actions {
flex-shrink: 0; // 防止删除按钮被压缩
min-width: 90px;
width: 90px; // 固定宽度
}
.task-type-receiver {
flex-shrink: 0; // 防止设计师选择器被压缩
min-width: 200px;
}
.add-task-type-btn {
border-style: dashed;
border-color: $border-primary;
color: $text-secondary;
background: transparent;
margin-top: $space-md;
&:hover {
border-color: #31f08b;
color: #31f08b;
background: rgba(49, 240, 139, 0.05);
}
}
}
// 表单标签和文字颜色统一
:deep(.ant-form-item-label > label) {
color: $text-primary;
}
:deep(.ant-form-item-required::before) {
color: $brand-primary;
}
:deep(.ant-input),
:deep(.ant-select-selector),
:deep(.ant-input-number),
:deep(.ant-input-password),
:deep(.ant-picker),
:deep(.ant-radio-button-wrapper),
:deep(.ant-switch) {
color: $text-primary;
}
// 确保开关选中状态的文字是黑色
:deep(.ant-switch.ant-switch-checked) {
.ant-switch-inner {
color: #000000 !important;
}
.ant-switch-handle::before {
background: #000000 !important;
}
}
:deep(.ant-input::placeholder),
:deep(.ant-select-selection-placeholder),
:deep(.ant-picker-input input::placeholder) {
color: $text-tertiary;
}
// 表单操作
.form-actions {
display: flex;
justify-content: flex-end;
gap: $space-md;
margin-top: $space-xxl;
padding-top: $space-xl;
border-top: 1px solid $border-primary;
}
// 流水线任务样式
.pipeline-option {
display: flex;
align-items: center;
gap: 12px;
}
.pipeline-mode-select {
:deep(.ant-radio-button-wrapper) {
height: auto;
padding: 16px 20px;
line-height: 1.4;
border-color: rgba($brand-primary, 0.2);
&:hover {
color: $brand-primary;
border-color: $brand-primary;
}
&.ant-radio-button-wrapper-checked {
background: rgba($brand-primary, 0.1);
border-color: $brand-primary;
color: $brand-primary;
&:hover {
background: rgba($brand-primary, 0.15);
}
}
}
:deep(.ant-radio-group) {
display: flex;
gap: 12px;
width: 100%;
}
:deep(.ant-radio-button-wrapper) {
flex: 1;
text-align: left;
}
}
// 流水线分组预览
.pipeline-preview {
margin-top: 20px;
padding: 16px;
background: rgba($brand-primary, 0.03);
border: 1px solid rgba($brand-primary, 0.15);
border-radius: $radius-md;
.preview-header {
display: flex;
align-items: center;
margin-bottom: 16px;
font-size: 14px;
font-weight: $font-weight-semibold;
color: $brand-primary;
.preview-mode {
margin-left: 8px;
font-size: 13px;
color: $text-secondary;
font-weight: normal;
}
}
.groups-container {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 16px;
}
.group-card {
background: $bg-secondary;
border: 1px solid $border-primary;
border-radius: $radius-sm;
padding: 12px;
transition: all 0.2s ease;
&:hover {
border-color: rgba($brand-primary, 0.3);
box-shadow: 0 2px 8px rgba($brand-primary, 0.1);
}
.group-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
padding-bottom: 10px;
border-bottom: 1px solid $border-primary;
.group-title {
display: flex;
align-items: center;
gap: 8px;
.group-badge {
padding: 2px 8px;
background: rgba($brand-primary, 0.15);
color: $brand-primary;
border-radius: 4px;
font-size: 12px;
font-weight: $font-weight-semibold;
}
.group-dept {
font-size: 14px;
font-weight: $font-weight-medium;
color: $text-primary;
}
.group-count {
font-size: 12px;
color: $text-secondary;
}
}
.group-summary {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
.summary-item {
color: $brand-primary;
font-weight: $font-weight-medium;
}
.summary-divider {
color: $text-tertiary;
}
}
}
.group-items {
display: flex;
flex-direction: column;
gap: 6px;
.group-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
background: rgba($brand-primary, 0.03);
border-radius: 4px;
font-size: 13px;
.item-icon {
color: $text-tertiary;
font-family: monospace;
}
.item-name {
flex: 1;
color: $text-primary;
}
.item-quantity {
color: $text-secondary;
}
.item-score {
color: $brand-primary;
font-weight: $font-weight-medium;
}
}
}
}
.preview-footer {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
background: rgba($brand-primary, 0.08);
border-radius: $radius-sm;
font-size: 14px;
font-weight: $font-weight-semibold;
color: $text-primary;
.footer-score {
color: $brand-primary;
font-size: 16px;
}
.footer-hours,
.footer-groups {
color: $text-primary;
}
.footer-divider {
color: $text-tertiary;
}
}
}
// 任务标题自动生成提示
.title-auto-tip {
margin-top: 8px;
font-size: 12px;
color: $text-tertiary;
display: flex;
align-items: center;
}
// 自动生成的标题输入框样式
.auto-generated-title {
:deep(.ant-input-disabled) {
background: rgba(49, 240, 139, 0.05) !important;
border-color: rgba(49, 240, 139, 0.3) !important;
color: $text-primary !important;
cursor: default;
&::placeholder {
color: $text-tertiary;
}
}
}
// 预览图上传区域
.preview-upload-area {
.upload-button {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: $text-tertiary;
.upload-text {
margin-top: 8px;
font-size: 12px;
}
.paste-hint {
margin-top: 4px;
font-size: 11px;
color: #666;
opacity: 0.7;
}
}
// 当区域获得焦点时显示提示
&:focus-within {
.paste-hint {
opacity: 1;
color: $brand-primary;
}
}
.preview-tip {
margin-top: 8px;
font-size: 12px;
color: $text-tertiary;
display: flex;
align-items: center;
.tip-icon {
margin-right: 6px;
}
}
:deep(.ant-upload-list-picture-card) {
.ant-upload-list-item {
border-color: rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.02);
&:hover {
border-color: $brand-primary;
}
}
}
:deep(.ant-upload-select-picture-card) {
width: 104px;
height: 104px;
background: rgba(255, 255, 255, 0.02);
border: 1px dashed rgba(255, 255, 255, 0.2);
border-radius: 8px;
&:hover {
border-color: $brand-primary;
}
}
}
// 条码列表样式
.barcode-list {
padding: 12px;
background: rgba(49, 240, 139, 0.05);
border: 1px solid rgba(49, 240, 139, 0.2);
border-radius: 8px;
margin-bottom: 8px;
.barcode-list-title {
font-size: 12px;
color: $text-tertiary;
margin-bottom: 8px;
}
.barcode-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
:deep(.ant-tag) {
margin: 0;
padding: 4px 10px;
font-size: 13px;
border-radius: 4px;
background: rgba(49, 240, 139, 0.15);
border-color: rgba(49, 240, 139, 0.3);
color: #31f08b;
.anticon-close {
color: rgba(255, 255, 255, 0.6);
&:hover {
color: #ff4d4f;
}
}
}
}
}
// 库存检查结果样式
.inventory-result {
margin-top: 12px;
.inventory-details {
margin-top: 12px;
padding: 12px;
background: rgba(255, 255, 255, 0.02);
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.1);
.detail-item {
display: flex;
margin-bottom: 8px;
&:last-child {
margin-bottom: 0;
}
.label {
color: $text-tertiary;
width: 100px;
flex-shrink: 0;
}
.value {
color: $text-primary;
&.low-stock {
color: #ff4d4f;
font-weight: 600;
}
}
}
}
}
</style>
<style lang="scss">
@import '@/styles/variables.scss';
// 自定义级联选择器下拉弹窗样式(挂载在body下,需全局覆盖)
.task-type-cascader-popup {
background-color: #1f1f1f !important; // 强制深色背景
border: 1px solid #303030 !important;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.6) !important;
border-radius: 8px !important;
padding: 4px !important;
.ant-cascader-menu {
background: transparent !important;
border-right: 1px solid #303030 !important;
height: 280px !important; // 增加高度以便容纳更多选项
// 自定义滚动条样式
&::-webkit-scrollbar {
width: 6px;
height: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
border-radius: 3px;
}
&::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
border-radius: 3px;
&:hover {
background: rgba(255, 255, 255, 0.25);
}
}
&:last-child {
border-right: none !important;
}
.ant-cascader-menu-item {
color: #a0a0a0; // 普通文字颜色
padding: 10px 16px;
margin: 2px 4px;
border-radius: 6px;
transition: all 0.2s;
&:hover {
background-color: rgba(255, 255, 255, 0.08) !important;
color: #e5e5e5;
}
// 激活/选中状态
&-active {
background-color: rgba(49, 240, 139, 0.15) !important; // 品牌色背景
color: #31f08b !important; // 品牌色文字
font-weight: 600;
// 覆盖箭头颜色
.ant-cascader-menu-item-expand-icon {
color: #31f08b !important;
}
}
// 展开图标
&-expand-icon {
color: #555;
}
}
}
// 搜索结果高亮
.ant-cascader-menu-item-keyword {
color: #31f08b !important;
}
}
// 设计师选择器选项样式
.designer-option {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
.designer-name {
color: #e5e5e5;
font-weight: 500;
}
.designer-workload {
color: #8a8a8a;
font-size: 12px;
}
}
</style>