294 lines
11 KiB
Vue
294 lines
11 KiB
Vue
|
|
<script setup lang="ts">
|
|||
|
|
import { ref, onMounted, computed } from 'vue';
|
|||
|
|
import { ArrowLeft, Rocket, Plus } from 'lucide-vue-next';
|
|||
|
|
import { useRouter, useRoute } from 'vue-router';
|
|||
|
|
import { createTask, updateTask, getTaskDetail } from '@/api/tasks';
|
|||
|
|
import { uploadFileToMinIO } from '@/api/index';
|
|||
|
|
import { ElMessage } from 'element-plus';
|
|||
|
|
|
|||
|
|
import { useAuthStore } from '@/stores/auth';
|
|||
|
|
import api from '@/api';
|
|||
|
|
|
|||
|
|
const authStore = useAuthStore();
|
|||
|
|
const router = useRouter();
|
|||
|
|
const route = useRoute();
|
|||
|
|
const isLoading = ref(false);
|
|||
|
|
|
|||
|
|
// Detect if we are in admin or enterprise context for navigation
|
|||
|
|
const routePrefix = computed(() => route.path.startsWith('/admin') ? '/admin' : '/enterprise');
|
|||
|
|
|
|||
|
|
const isEdit = computed(() => !!route.params.id);
|
|||
|
|
const teamMembers = ref<any[]>([]);
|
|||
|
|
|
|||
|
|
const form = ref({
|
|||
|
|
title: '',
|
|||
|
|
description: '',
|
|||
|
|
skill_tags: [] as string[],
|
|||
|
|
budget_min: null as number | null,
|
|||
|
|
budget_max: null as number | null,
|
|||
|
|
deadline: '',
|
|||
|
|
task_type: 'OUTSOURCE',
|
|||
|
|
attachments: [] as string[],
|
|||
|
|
status: 'OPEN',
|
|||
|
|
contact_user: null as string | null,
|
|||
|
|
contact_name: '',
|
|||
|
|
contact_phone: '',
|
|||
|
|
contact_email: '',
|
|||
|
|
contact_wechat: ''
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const fileList = ref<any[]>([]);
|
|||
|
|
|
|||
|
|
onMounted(async () => {
|
|||
|
|
fetchSkills();
|
|||
|
|
try {
|
|||
|
|
const res: any = await api.get('/enterprise-members/');
|
|||
|
|
teamMembers.value = res.results || res || [];
|
|||
|
|
} catch (e) {
|
|||
|
|
console.error('Failed to load team members', e);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (isEdit.value) {
|
|||
|
|
try {
|
|||
|
|
const res: any = await getTaskDetail(route.params.id as string);
|
|||
|
|
form.value = {
|
|||
|
|
title: res.title,
|
|||
|
|
description: res.description,
|
|||
|
|
skill_tags: res.skill_tags || [],
|
|||
|
|
budget_min: res.budget_min,
|
|||
|
|
budget_max: res.budget_max,
|
|||
|
|
deadline: res.deadline,
|
|||
|
|
task_type: res.task_type || 'OUTSOURCE',
|
|||
|
|
attachments: res.attachments || [],
|
|||
|
|
status: res.status,
|
|||
|
|
contact_user: res.contact_user,
|
|||
|
|
contact_name: res.contact_name || '',
|
|||
|
|
contact_phone: res.contact_phone || '',
|
|||
|
|
contact_email: res.contact_email || '',
|
|||
|
|
contact_wechat: res.contact_wechat || ''
|
|||
|
|
};
|
|||
|
|
if (res.attachments && Array.isArray(res.attachments)) {
|
|||
|
|
fileList.value = res.attachments.map((url: string, index: number) => ({
|
|||
|
|
name: `附件_${index + 1}`,
|
|||
|
|
url,
|
|||
|
|
uid: index
|
|||
|
|
}));
|
|||
|
|
}
|
|||
|
|
} catch (e: any) {
|
|||
|
|
ElMessage.error('无法加载任务数据');
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
// Default to current user
|
|||
|
|
if (authStore.user) {
|
|||
|
|
form.value.contact_name = authStore.user.nickname || authStore.user.username || '';
|
|||
|
|
form.value.contact_email = authStore.user.email || '';
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const handleContactSelect = (userId: string) => {
|
|||
|
|
const member = teamMembers.value.find(m => m.user === userId);
|
|||
|
|
if (member) {
|
|||
|
|
form.value.contact_name = member.user_details?.nickname || member.user_details?.username || '';
|
|||
|
|
form.value.contact_email = member.user_details?.email || '';
|
|||
|
|
form.value.contact_phone = member.user_details?.phone || '';
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handleSave = async (statusOverride?: string) => {
|
|||
|
|
if (statusOverride) form.value.status = statusOverride;
|
|||
|
|
if (!form.value.title) {
|
|||
|
|
ElMessage.warning('请输入任务名称');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
isLoading.value = true;
|
|||
|
|
try {
|
|||
|
|
const uploadedUrls = [];
|
|||
|
|
for (const file of fileList.value) {
|
|||
|
|
if (file.raw) {
|
|||
|
|
const url = await uploadFileToMinIO(file.raw, 'tasks');
|
|||
|
|
uploadedUrls.push(url);
|
|||
|
|
} else {
|
|||
|
|
uploadedUrls.push(file.url);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
form.value.attachments = uploadedUrls;
|
|||
|
|
|
|||
|
|
if (isEdit.value) {
|
|||
|
|
await updateTask(route.params.id as string, form.value);
|
|||
|
|
} else {
|
|||
|
|
await createTask(form.value);
|
|||
|
|
}
|
|||
|
|
ElMessage.success(isEdit.value ? '任务已更新' : '任务已发布');
|
|||
|
|
router.push('/enterprise/tasks');
|
|||
|
|
} catch (e: any) {
|
|||
|
|
ElMessage.error(e.response?.data?.detail || '保存失败');
|
|||
|
|
} finally {
|
|||
|
|
isLoading.value = false;
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const skillOptions = ref<string[]>([]);
|
|||
|
|
const fetchSkills = async () => {
|
|||
|
|
try {
|
|||
|
|
const res: any = await api.get('/skills/');
|
|||
|
|
const items = res.results || res || [];
|
|||
|
|
skillOptions.value = items.map((s: any) => s.name);
|
|||
|
|
} catch (e) {
|
|||
|
|
console.error('Failed to fetch skills', e);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const taskTypes = [
|
|||
|
|
{ id: 'OUTSOURCE', name: '项目外包' },
|
|||
|
|
{ id: 'CONSULT', name: '专家咨询' },
|
|||
|
|
{ id: 'CONTENT', name: '内容创作' },
|
|||
|
|
{ id: 'DATA_LABEL', name: '数据标注' },
|
|||
|
|
{ id: 'OTHER', name: '其他' },
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
const tagInput = ref('');
|
|||
|
|
const addCustomTag = () => {
|
|||
|
|
if (tagInput.value && !form.value.skill_tags.includes(tagInput.value)) {
|
|||
|
|
form.value.skill_tags.push(tagInput.value);
|
|||
|
|
tagInput.value = '';
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const removeSkill = (skill: string) => {
|
|||
|
|
form.value.skill_tags = form.value.skill_tags.filter(s => s !== skill);
|
|||
|
|
};
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<template>
|
|||
|
|
<div class="max-w-5xl mx-auto space-y-5">
|
|||
|
|
<!-- Header -->
|
|||
|
|
<div class="flex items-center gap-3">
|
|||
|
|
<el-button @click="router.back()" :icon="ArrowLeft" circle />
|
|||
|
|
<div>
|
|||
|
|
<h1 class="text-2xl font-bold text-gray-800">{{ isEdit ? '编辑任务' : '发布新任务' }}</h1>
|
|||
|
|
<p class="text-sm text-gray-400 mt-1">为 OPC 专家创建新的合作机会</p>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<el-row :gutter="16">
|
|||
|
|
<!-- Left: Form -->
|
|||
|
|
<el-col :span="16">
|
|||
|
|
<div class="space-y-4">
|
|||
|
|
<!-- Basic Info -->
|
|||
|
|
<el-card shadow="never">
|
|||
|
|
<template #header><span class="font-semibold">基本信息</span></template>
|
|||
|
|
<el-form label-position="top">
|
|||
|
|
<el-form-item label="任务名称" required>
|
|||
|
|
<el-input v-model="form.title" placeholder="例如:大语言模型 RLHF 数据标注项目" size="large" />
|
|||
|
|
</el-form-item>
|
|||
|
|
<el-form-item label="任务描述" required>
|
|||
|
|
<el-input v-model="form.description" type="textarea" :rows="10" placeholder="请详细说明任务背景、质量要求、交付标准等..." />
|
|||
|
|
</el-form-item>
|
|||
|
|
</el-form>
|
|||
|
|
</el-card>
|
|||
|
|
|
|||
|
|
<!-- Skills -->
|
|||
|
|
<el-card shadow="never">
|
|||
|
|
<template #header><span class="font-semibold">技能要求</span></template>
|
|||
|
|
<div class="flex flex-wrap gap-2 mb-3">
|
|||
|
|
<el-check-tag
|
|||
|
|
v-for="skill in skillOptions"
|
|||
|
|
:key="skill"
|
|||
|
|
:checked="form.skill_tags.includes(skill)"
|
|||
|
|
@change="(checked: boolean) => checked ? form.skill_tags.push(skill) : removeSkill(skill)"
|
|||
|
|
>{{ skill }}</el-check-tag>
|
|||
|
|
</div>
|
|||
|
|
<div class="flex gap-2">
|
|||
|
|
<el-input v-model="tagInput" placeholder="自定义标签" size="small" style="width: 200px" @keyup.enter="addCustomTag" />
|
|||
|
|
<el-button size="small" @click="addCustomTag" :icon="Plus">添加</el-button>
|
|||
|
|
</div>
|
|||
|
|
<div v-if="form.skill_tags.length > 0" class="mt-3 flex flex-wrap gap-1">
|
|||
|
|
<el-tag v-for="tag in form.skill_tags" :key="tag" closable @close="removeSkill(tag)" type="primary" effect="plain">{{ tag }}</el-tag>
|
|||
|
|
</div>
|
|||
|
|
</el-card>
|
|||
|
|
|
|||
|
|
<!-- Contact Person -->
|
|||
|
|
<el-card shadow="never">
|
|||
|
|
<template #header><span class="font-semibold">项目联系人</span></template>
|
|||
|
|
<el-form label-position="top">
|
|||
|
|
<div class="grid grid-cols-2 gap-4">
|
|||
|
|
<el-form-item label="选择项目经理">
|
|||
|
|
<el-select v-model="form.contact_user" placeholder="默认当前用户" clearable @change="handleContactSelect">
|
|||
|
|
<el-option
|
|||
|
|
v-for="member in teamMembers"
|
|||
|
|
:key="member.user"
|
|||
|
|
:label="member.user_details?.nickname || member.user_details?.username"
|
|||
|
|
:value="member.user"
|
|||
|
|
/>
|
|||
|
|
</el-select>
|
|||
|
|
</el-form-item>
|
|||
|
|
<el-form-item label="联系人姓名">
|
|||
|
|
<el-input v-model="form.contact_name" placeholder="将展示给专家" />
|
|||
|
|
</el-form-item>
|
|||
|
|
<el-form-item label="手机号码">
|
|||
|
|
<el-input v-model="form.contact_phone" placeholder="选填" />
|
|||
|
|
</el-form-item>
|
|||
|
|
<el-form-item label="微信号">
|
|||
|
|
<el-input v-model="form.contact_wechat" placeholder="选填,方便沟通" />
|
|||
|
|
</el-form-item>
|
|||
|
|
<el-form-item label="联系邮箱" class="col-span-2">
|
|||
|
|
<el-input v-model="form.contact_email" placeholder="选填" />
|
|||
|
|
</el-form-item>
|
|||
|
|
</div>
|
|||
|
|
</el-form>
|
|||
|
|
</el-card>
|
|||
|
|
|
|||
|
|
<!-- Attachments -->
|
|||
|
|
<el-card shadow="never">
|
|||
|
|
<template #header><span class="font-semibold">相关附件(可选)</span></template>
|
|||
|
|
<el-upload v-model:file-list="fileList" drag action="" :auto-upload="false" multiple>
|
|||
|
|
<div class="py-4">
|
|||
|
|
<div class="text-4xl text-gray-300 mb-2">📥</div>
|
|||
|
|
<div class="text-sm text-gray-500">拖拽文件到此处,或<em class="text-blue-500">点击上传</em></div>
|
|||
|
|
<div class="text-xs text-gray-400 mt-1">支持 PDF / ZIP / 视频 (最大 50MB)</div>
|
|||
|
|
</div>
|
|||
|
|
</el-upload>
|
|||
|
|
</el-card>
|
|||
|
|
</div>
|
|||
|
|
</el-col>
|
|||
|
|
|
|||
|
|
<!-- Right: Sidebar -->
|
|||
|
|
<el-col :span="8">
|
|||
|
|
<el-card shadow="never" class="sticky top-20">
|
|||
|
|
<template #header><span class="font-semibold">发布设置</span></template>
|
|||
|
|
<el-form label-position="top">
|
|||
|
|
<el-form-item label="任务类型">
|
|||
|
|
<el-radio-group v-model="form.task_type" class="!flex flex-col gap-2">
|
|||
|
|
<el-radio v-for="type in taskTypes" :key="type.id" :value="type.id" border class="!mr-0 !w-full">{{ type.name }}</el-radio>
|
|||
|
|
</el-radio-group>
|
|||
|
|
</el-form-item>
|
|||
|
|
|
|||
|
|
<el-form-item label="预算区间 (CNY)">
|
|||
|
|
<div class="flex items-center gap-2 w-full">
|
|||
|
|
<el-input-number v-model="form.budget_min" :min="0" placeholder="最低" controls-position="right" class="flex-1" />
|
|||
|
|
<span class="text-gray-300">—</span>
|
|||
|
|
<el-input-number v-model="form.budget_max" :min="0" placeholder="最高" controls-position="right" class="flex-1" />
|
|||
|
|
</div>
|
|||
|
|
</el-form-item>
|
|||
|
|
|
|||
|
|
<el-form-item label="截止日期">
|
|||
|
|
<el-date-picker v-model="form.deadline" type="date" placeholder="选择日期" class="!w-full" value-format="YYYY-MM-DD" />
|
|||
|
|
</el-form-item>
|
|||
|
|
</el-form>
|
|||
|
|
|
|||
|
|
<el-divider />
|
|||
|
|
|
|||
|
|
<div class="space-y-2">
|
|||
|
|
<el-button type="primary" class="w-full" size="large" :loading="isLoading" @click="handleSave('OPEN')">
|
|||
|
|
<Rocket class="w-4 h-4 mr-2" v-if="!isLoading" />
|
|||
|
|
{{ isEdit ? '保存并更新' : '立即发布招募' }}
|
|||
|
|
</el-button>
|
|||
|
|
<el-button v-if="!isEdit" class="w-full" :loading="isLoading" @click="handleSave('DRAFT')">保存至草稿箱</el-button>
|
|||
|
|
</div>
|
|||
|
|
</el-card>
|
|||
|
|
</el-col>
|
|||
|
|
</el-row>
|
|||
|
|
</div>
|
|||
|
|
</template>
|