开发了多角色登录功能:实现了普通用户、企业用户和管理员可以分别通过不同入口登录系统,并且支持用账号、邮箱或手机号登录。
开发了权限分配功能:实现了一个可以在后台勾选页面的功能,通过给角色勾选菜单,就能直接控制不同身份的人登录后能看到哪些页面。 开发了实名认证功能:实现了企业可以提交营业执照认证,个人可以提交身份证件和技能认证的功能,管理员在后台可以进行审核。 开发了任务大厅功能:实现了企业可以发布需要做的任务,个人用户能在任务大厅里看到这些任务,并且可以点击申请接单,大家都能看到任务是“进行中”还是“已完成”状态。 开发了专家库与邀约功能:实现了企业可以去专家库里搜索合适的人才,并且可以直接给他们发送工作邀约。 开发了平台数据大屏展示功能:实现了在首页和各自的工作台页面,展示任务数量、收益金额等核心数据的概览面板。
This commit is contained in:
18
src/App.vue
Normal file
18
src/App.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterView, useRouter } from 'vue-router';
|
||||
const router = useRouter();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterView />
|
||||
<el-backtop :right="40" :bottom="40">
|
||||
<div class="h-full w-full bg-white rounded-full shadow-md flex items-center justify-center text-blue-500 hover:bg-blue-50 transition-colors border border-gray-100">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-up"><path d="m5 12 7-7 7 7"/><path d="M12 19V5"/></svg>
|
||||
</div>
|
||||
</el-backtop>
|
||||
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* Global styles are in index.css */
|
||||
</style>
|
||||
40
src/api/certifications.ts
Normal file
40
src/api/certifications.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import api from './index';
|
||||
|
||||
// OPC认证接口
|
||||
/**
|
||||
* @author xujl
|
||||
* Api说明: getCertifications 接口
|
||||
*/
|
||||
export const getCertifications = (params?: any) => api.get('/certifications/', { params });
|
||||
/**
|
||||
* @author xujl
|
||||
* Api说明: getCertificationDetail 接口
|
||||
*/
|
||||
export const getCertificationDetail = (id: string) => api.get(`/certifications/${id}/`);
|
||||
/**
|
||||
* @author xujl
|
||||
* Api说明: submitCertification 接口
|
||||
*/
|
||||
export const submitCertification = (data: { real_name: string; id_card?: string; skills?: string[]; experience?: string; resume_url?: string; attachments?: any[] }) => api.post('/certifications/', data);
|
||||
/**
|
||||
* @author xujl
|
||||
* Api说明: updateCertification 接口
|
||||
*/
|
||||
export const updateCertification = (id: string, data: any) => api.put(`/certifications/${id}/`, data);
|
||||
/**
|
||||
* @author xujl
|
||||
* Api说明: deleteCertification 接口
|
||||
*/
|
||||
export const deleteCertification = (id: string) => api.delete(`/certifications/${id}/`);
|
||||
|
||||
// 管理员审核操作
|
||||
/**
|
||||
* @author xujl
|
||||
* Api说明: approveCertification 接口
|
||||
*/
|
||||
export const approveCertification = (id: string) => api.post(`/certifications/${id}/approve/`);
|
||||
/**
|
||||
* @author xujl
|
||||
* Api说明: rejectCertification 接口
|
||||
*/
|
||||
export const rejectCertification = (id: string, reject_reason: string) => api.post(`/certifications/${id}/reject/`, { reject_reason });
|
||||
25
src/api/enterprise.ts
Normal file
25
src/api/enterprise.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import api from './index';
|
||||
|
||||
/**
|
||||
* @author xujl
|
||||
* Api说明: registerEnterprise 接口
|
||||
*/
|
||||
export const registerEnterprise = (data: any) => {
|
||||
return api.post('/enterprises/', data);
|
||||
};
|
||||
|
||||
/**
|
||||
* @author xujl
|
||||
* Api说明: getEnterpriseProfile 接口
|
||||
*/
|
||||
export const getEnterpriseProfile = () => {
|
||||
return api.get('/enterprises/me/');
|
||||
};
|
||||
|
||||
/**
|
||||
* @author xujl
|
||||
* Api说明: getEnterpriseDetail 接口
|
||||
*/
|
||||
export const getEnterpriseDetail = (id: string) => {
|
||||
return api.get(`/enterprises/${id}/`);
|
||||
};
|
||||
62
src/api/index.ts
Normal file
62
src/api/index.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000/api/v1',
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('access_token');
|
||||
if (token && config.headers) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response) => response.data,
|
||||
async (error) => {
|
||||
const originalRequest = error.config;
|
||||
// Skip interceptor if the request is for login endpoints
|
||||
if (originalRequest.url?.includes('/auth/login') || originalRequest.url?.includes('/auth/enterprise/login')) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
originalRequest._retry = true;
|
||||
try {
|
||||
const refreshToken = localStorage.getItem('refresh_token');
|
||||
if (!refreshToken) throw new Error('No refresh token');
|
||||
|
||||
const res = await axios.post(`${api.defaults.baseURL}/auth/refresh`, { refresh: refreshToken });
|
||||
const newAccessToken = res.data.access;
|
||||
localStorage.setItem('access_token', newAccessToken);
|
||||
|
||||
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
|
||||
return api(originalRequest);
|
||||
} catch (e) {
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
window.location.href = '/login';
|
||||
return Promise.reject(e);
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* @author xujl
|
||||
* Api说明: uploadFileToMinIO 接口
|
||||
*/
|
||||
export const uploadFileToMinIO = async (file: File, folder: string = 'general'): Promise<string> => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('folder', folder);
|
||||
const res: any = await api.post('/upload/', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
});
|
||||
return res.url;
|
||||
};
|
||||
|
||||
export default api;
|
||||
47
src/api/reservations.ts
Normal file
47
src/api/reservations.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import api from './index';
|
||||
|
||||
// 资源接口 (门禁、会议室)
|
||||
/**
|
||||
* @author xujl
|
||||
* Api说明: getResources 接口
|
||||
*/
|
||||
export const getResources = (params?: any) => api.get('/resources/', { params });
|
||||
/**
|
||||
* @author xujl
|
||||
* Api说明: getResourceDetail 接口
|
||||
*/
|
||||
export const getResourceDetail = (id: string) => api.get(`/resources/${id}/`);
|
||||
|
||||
// 订单接口
|
||||
/**
|
||||
* @author xujl
|
||||
* Api说明: getOrders 接口
|
||||
*/
|
||||
export const getOrders = (params?: any) => api.get('/orders/', { params });
|
||||
/**
|
||||
* @author xujl
|
||||
* Api说明: getOrderDetail 接口
|
||||
*/
|
||||
export const getOrderDetail = (id: string) => api.get(`/orders/${id}/`);
|
||||
/**
|
||||
* @author xujl
|
||||
* Api说明: createOrder 接口
|
||||
*/
|
||||
export const createOrder = (data: { resource: string; start_time: string; end_time: string; quantity?: number }) => api.post('/orders/', data);
|
||||
/**
|
||||
* @author xujl
|
||||
* Api说明: updateOrder 接口
|
||||
*/
|
||||
export const updateOrder = (id: string, data: any) => api.put(`/orders/${id}/`, data);
|
||||
/**
|
||||
* @author xujl
|
||||
* Api说明: deleteOrder 接口
|
||||
*/
|
||||
export const deleteOrder = (id: string) => api.delete(`/orders/${id}/`);
|
||||
|
||||
// 支付网关 (模拟)
|
||||
/**
|
||||
* @author xujl
|
||||
* Api说明: payOrder 接口
|
||||
*/
|
||||
export const payOrder = (id: string) => api.post(`/orders/${id}/pay/`);
|
||||
58
src/api/system.ts
Normal file
58
src/api/system.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import api from './index';
|
||||
|
||||
// 公告接口
|
||||
/**
|
||||
* @author xujl
|
||||
* Api说明: getAnnouncements 接口
|
||||
*/
|
||||
export const getAnnouncements = (params?: any) => api.get('/announcements/', { params });
|
||||
/**
|
||||
* @author xujl
|
||||
* Api说明: getAnnouncementDetail 接口
|
||||
*/
|
||||
export const getAnnouncementDetail = (id: string) => api.get(`/announcements/${id}/`);
|
||||
|
||||
// 系统配置接口
|
||||
/**
|
||||
* @author xujl
|
||||
* Api说明: getSystemConfigs 接口
|
||||
*/
|
||||
export const getSystemConfigs = (params?: any) => api.get('/configs/', { params });
|
||||
/**
|
||||
* @author xujl
|
||||
* Api说明: getSystemConfigByKey 接口
|
||||
*/
|
||||
export const getSystemConfigByKey = (key: string) => api.get(`/configs/${key}/`);
|
||||
|
||||
// 通知接口
|
||||
/**
|
||||
* @author xujl
|
||||
* Api说明: getNotifications 接口
|
||||
*/
|
||||
export const getNotifications = (params?: any) => api.get('/notifications/', { params });
|
||||
/**
|
||||
* @author xujl
|
||||
* Api说明: readNotification 接口
|
||||
*/
|
||||
export const readNotification = (id: string) => api.post(`/notifications/${id}/read/`);
|
||||
/**
|
||||
* @author xujl
|
||||
* Api说明: readAllNotifications 接口
|
||||
*/
|
||||
export const readAllNotifications = () => api.post('/notifications/read_all/');
|
||||
/**
|
||||
* @author xujl
|
||||
* Api说明: deleteNotification 接口
|
||||
*/
|
||||
export const deleteNotification = (id: string) => api.delete(`/notifications/${id}/`);
|
||||
|
||||
// 文件上传接口
|
||||
/**
|
||||
* @author xujl
|
||||
* Api说明: uploadFile 接口
|
||||
*/
|
||||
export const uploadFile = (data: FormData) => api.post('/upload/', data, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
});
|
||||
70
src/api/tasks.ts
Normal file
70
src/api/tasks.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import api from './index';
|
||||
|
||||
// 任务接口
|
||||
/**
|
||||
* @author xujl
|
||||
* Api说明: getTasks 接口
|
||||
*/
|
||||
export const getTasks = (params?: any) => api.get('/tasks/', { params });
|
||||
/**
|
||||
* @author xujl
|
||||
* Api说明: getTaskDetail 接口
|
||||
*/
|
||||
export const getTaskDetail = (id: string) => api.get(`/tasks/${id}/`);
|
||||
/**
|
||||
* @author xujl
|
||||
* Api说明: createTask 接口
|
||||
*/
|
||||
export const createTask = (data: any) => api.post('/tasks/', data);
|
||||
/**
|
||||
* @author xujl
|
||||
* Api说明: updateTask 接口
|
||||
*/
|
||||
export const updateTask = (id: string, data: any) => api.put(`/tasks/${id}/`, data);
|
||||
/**
|
||||
* @author xujl
|
||||
* Api说明: publishTask 接口
|
||||
*/
|
||||
export const publishTask = (id: string) => api.post(`/tasks/${id}/publish/`);
|
||||
/**
|
||||
* @author xujl
|
||||
* Api说明: deleteTask 接口
|
||||
*/
|
||||
export const deleteTask = (id: string) => api.delete(`/tasks/${id}/`);
|
||||
|
||||
// 任务申请接口
|
||||
/**
|
||||
* @author xujl
|
||||
* Api说明: getApplications 接口
|
||||
*/
|
||||
export const getApplications = (params?: any) => api.get('/applications/', { params });
|
||||
/**
|
||||
* @author xujl
|
||||
* Api说明: getApplicationDetail 接口
|
||||
*/
|
||||
export const getApplicationDetail = (id: string) => api.get(`/applications/${id}/`);
|
||||
/**
|
||||
* @author xujl
|
||||
* Api说明: applyForTask 接口
|
||||
*/
|
||||
export const applyForTask = (data: { task: string; cover_letter?: string; expected_days?: number; expected_price?: number; attachments?: any[] }) => api.post('/applications/', data);
|
||||
/**
|
||||
* @author xujl
|
||||
* Api说明: approveApplication 接口
|
||||
*/
|
||||
export const approveApplication = (id: string) => api.post(`/applications/${id}/approve/`);
|
||||
/**
|
||||
* @author xujl
|
||||
* Api说明: rejectApplication 接口
|
||||
*/
|
||||
export const rejectApplication = (id: string, reason?: string) => api.post(`/applications/${id}/reject/`, { reason });
|
||||
/**
|
||||
* @author xujl
|
||||
* Api说明: updateApplication 接口
|
||||
*/
|
||||
export const updateApplication = (id: string, data: any) => api.put(`/applications/${id}/`, data);
|
||||
/**
|
||||
* @author xujl
|
||||
* Api说明: withdrawApplication 接口
|
||||
*/
|
||||
export const withdrawApplication = (id: string) => api.delete(`/applications/${id}/`);
|
||||
50
src/api/users.ts
Normal file
50
src/api/users.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import api from './index';
|
||||
|
||||
// 用户信息接口
|
||||
/**
|
||||
* @author xujl
|
||||
* Api说明: getUsers 接口
|
||||
*/
|
||||
export const getUsers = (params?: any) => api.get('/users/', { params });
|
||||
/**
|
||||
* @author xujl
|
||||
* Api说明: getUserDetail 接口
|
||||
*/
|
||||
export const getUserDetail = (id: string) => api.get(`/users/${id}/`);
|
||||
/**
|
||||
* @author xujl
|
||||
* Api说明: updateUser 接口
|
||||
*/
|
||||
export const updateUser = (id: string, data: any) => api.put(`/users/${id}/`, data);
|
||||
/**
|
||||
* @author xujl
|
||||
* Api说明: deleteUser 接口
|
||||
*/
|
||||
export const deleteUser = (id: string) => api.delete(`/users/${id}/`);
|
||||
|
||||
// 企业接口
|
||||
/**
|
||||
* @author xujl
|
||||
* Api说明: getEnterprises 接口
|
||||
*/
|
||||
export const getEnterprises = (params?: any) => api.get('/enterprises/', { params });
|
||||
/**
|
||||
* @author xujl
|
||||
* Api说明: getEnterpriseDetail 接口
|
||||
*/
|
||||
export const getEnterpriseDetail = (id: string) => api.get(`/enterprises/${id}/`);
|
||||
/**
|
||||
* @author xujl
|
||||
* Api说明: createEnterprise 接口
|
||||
*/
|
||||
export const createEnterprise = (data: any) => api.post('/enterprises/', data);
|
||||
/**
|
||||
* @author xujl
|
||||
* Api说明: updateEnterprise 接口
|
||||
*/
|
||||
export const updateEnterprise = (id: string, data: any) => api.put(`/enterprises/${id}/`, data);
|
||||
/**
|
||||
* @author xujl
|
||||
* Api说明: deleteEnterprise 接口
|
||||
*/
|
||||
export const deleteEnterprise = (id: string) => api.delete(`/enterprises/${id}/`);
|
||||
90
src/components/common/SlideVerify.vue
Normal file
90
src/components/common/SlideVerify.vue
Normal file
@@ -0,0 +1,90 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
import { ArrowRight, Check } from 'lucide-vue-next';
|
||||
|
||||
const props = defineProps({
|
||||
successText: { type: String, default: '验证通过' },
|
||||
defaultText: { type: String, default: '请按住滑块,拖动到最右边' },
|
||||
});
|
||||
const emit = defineEmits(['success']);
|
||||
|
||||
const isSuccess = ref(false);
|
||||
const startX = ref(0);
|
||||
const currentX = ref(0);
|
||||
const isDragging = ref(false);
|
||||
const trackRef = ref<HTMLElement | null>(null);
|
||||
|
||||
const onMouseDown = (e: MouseEvent | TouchEvent) => {
|
||||
if (isSuccess.value) return;
|
||||
isDragging.value = true;
|
||||
startX.value = e instanceof MouseEvent ? e.clientX : e.touches[0].clientX;
|
||||
};
|
||||
|
||||
const onMouseMove = (e: MouseEvent | TouchEvent) => {
|
||||
if (!isDragging.value || isSuccess.value || !trackRef.value) return;
|
||||
const clientX = e instanceof MouseEvent ? e.clientX : e.touches[0].clientX;
|
||||
const maxDistance = trackRef.value.offsetWidth - 40; // 40 is thumb width
|
||||
let moveX = clientX - startX.value;
|
||||
|
||||
if (moveX < 0) moveX = 0;
|
||||
if (moveX >= maxDistance) {
|
||||
moveX = maxDistance;
|
||||
isSuccess.value = true;
|
||||
isDragging.value = false;
|
||||
emit('success');
|
||||
}
|
||||
currentX.value = moveX;
|
||||
};
|
||||
|
||||
const onMouseUp = () => {
|
||||
if (!isDragging.value) return;
|
||||
isDragging.value = false;
|
||||
if (!isSuccess.value) {
|
||||
currentX.value = 0; // reset
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('mousemove', onMouseMove);
|
||||
window.addEventListener('mouseup', onMouseUp);
|
||||
window.addEventListener('touchmove', onMouseMove);
|
||||
window.addEventListener('touchend', onMouseUp);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('mousemove', onMouseMove);
|
||||
window.removeEventListener('mouseup', onMouseUp);
|
||||
window.removeEventListener('touchmove', onMouseMove);
|
||||
window.removeEventListener('touchend', onMouseUp);
|
||||
});
|
||||
|
||||
const reset = () => {
|
||||
isSuccess.value = false;
|
||||
currentX.value = 0;
|
||||
};
|
||||
|
||||
defineExpose({ reset });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative w-full h-10 bg-gray-100 rounded-md overflow-hidden select-none border border-gray-200" ref="trackRef">
|
||||
<!-- Background progress -->
|
||||
<div class="absolute top-0 left-0 h-full bg-green-500 transition-none" :style="{ width: `${currentX + 20}px` }" v-if="currentX > 0 || isSuccess"></div>
|
||||
|
||||
<!-- Text -->
|
||||
<div class="absolute inset-0 flex items-center justify-center text-sm transition-colors duration-300 z-10 pointer-events-none" :class="isSuccess ? 'text-white font-bold' : 'text-gray-500'">
|
||||
{{ isSuccess ? successText : defaultText }}
|
||||
</div>
|
||||
|
||||
<!-- Thumb -->
|
||||
<div
|
||||
class="absolute top-0 left-0 w-10 h-full bg-white border border-gray-300 shadow-sm flex items-center justify-center cursor-grab active:cursor-grabbing z-20 rounded-md transition-none"
|
||||
:style="{ transform: `translateX(${currentX}px)` }"
|
||||
@mousedown.prevent="onMouseDown"
|
||||
@touchstart.passive="onMouseDown"
|
||||
>
|
||||
<Check v-if="isSuccess" class="w-5 h-5 text-green-500" />
|
||||
<ArrowRight v-else class="w-5 h-5 text-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
138
src/components/enterprise/EnterpriseProfileDrawer.vue
Normal file
138
src/components/enterprise/EnterpriseProfileDrawer.vue
Normal file
@@ -0,0 +1,138 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { Building2, Mail, Phone, MapPin, Briefcase, ChevronRight, ShieldCheck } from 'lucide-vue-next';
|
||||
import { getEnterpriseDetail } from '@/api/enterprise';
|
||||
import { getTasks } from '@/api/tasks';
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean;
|
||||
enterpriseId: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
const router = useRouter();
|
||||
|
||||
const visible = ref(props.modelValue);
|
||||
const loading = ref(false);
|
||||
const enterprise = ref<any>({});
|
||||
const tasks = ref<any[]>([]);
|
||||
|
||||
watch(() => props.modelValue, (val) => {
|
||||
visible.value = val;
|
||||
if (val && props.enterpriseId) {
|
||||
fetchData(props.enterpriseId);
|
||||
}
|
||||
});
|
||||
|
||||
watch(visible, (val) => {
|
||||
emit('update:modelValue', val);
|
||||
});
|
||||
|
||||
const fetchData = async (id: string) => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res: any = await getEnterpriseDetail(id);
|
||||
enterprise.value = res;
|
||||
|
||||
// Fetch OPEN tasks published by this enterprise
|
||||
const tasksRes: any = await getTasks({ enterprise: id, status: 'OPEN' });
|
||||
tasks.value = tasksRes.results || tasksRes || [];
|
||||
} catch (error) {
|
||||
console.error('Failed to load enterprise details:', error);
|
||||
ElMessage.error('无法加载企业详情');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
visible.value = false;
|
||||
};
|
||||
|
||||
const goToTask = (taskId: string) => {
|
||||
visible.value = false;
|
||||
router.push(`/user/tasks/${taskId}`);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-drawer
|
||||
v-model="visible"
|
||||
title="企业主页"
|
||||
size="500px"
|
||||
:before-close="handleClose"
|
||||
destroy-on-close
|
||||
class="enterprise-drawer"
|
||||
>
|
||||
<div v-loading="loading" class="h-full flex flex-col">
|
||||
<template v-if="enterprise.id">
|
||||
<!-- Enterprise Header -->
|
||||
<div class="px-6 pb-6 border-b border-gray-100">
|
||||
<div class="flex items-center gap-4 mb-4">
|
||||
<div class="w-16 h-16 rounded-xl bg-gradient-to-br from-blue-100 to-indigo-100 flex items-center justify-center text-blue-600 shadow-inner shrink-0 overflow-hidden">
|
||||
<el-image v-if="enterprise.logo_url" :src="enterprise.logo_url" fit="contain" class="w-full h-full" />
|
||||
<Building2 v-else class="w-8 h-8" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-gray-800 flex items-center gap-1.5">
|
||||
{{ enterprise.company_name }}
|
||||
<ShieldCheck class="w-5 h-5 text-green-500" v-if="enterprise.status === 'VERIFIED'" />
|
||||
</h2>
|
||||
<div class="text-xs text-gray-500 mt-1 flex items-center gap-1">
|
||||
统一社会信用代码: {{ enterprise.credit_code || '未提供' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 rounded-lg p-4 text-sm text-gray-600 leading-relaxed mb-4">
|
||||
{{ enterprise.description || '该企业尚未填写详细介绍。' }}
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 text-sm text-gray-600">
|
||||
<div class="flex items-center gap-2"><MapPin class="w-4 h-4 text-gray-400" /> {{ enterprise.address || '未填写地址' }}</div>
|
||||
<div class="flex items-center gap-2" v-if="enterprise.contact_name"><Briefcase class="w-4 h-4 text-gray-400" /> 联系人: {{ enterprise.contact_name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Enterprise Tasks -->
|
||||
<div class="flex-1 overflow-y-auto bg-gray-50 px-6 py-6">
|
||||
<h3 class="text-lg font-bold text-gray-800 mb-4 flex items-center gap-2">
|
||||
正在招募的任务 <span class="bg-blue-100 text-blue-600 px-2 py-0.5 rounded-full text-xs">{{ tasks.length }}</span>
|
||||
</h3>
|
||||
|
||||
<div v-if="tasks.length > 0" class="space-y-4">
|
||||
<el-card v-for="task in tasks" :key="task.id" shadow="hover" class="cursor-pointer border-transparent hover:border-blue-200 transition-all" @click="goToTask(task.id)">
|
||||
<div class="flex justify-between items-start mb-2">
|
||||
<h4 class="font-bold text-gray-800 line-clamp-1 flex-1 pr-4">{{ task.title }}</h4>
|
||||
<span class="text-blue-600 font-bold whitespace-nowrap">¥{{ task.budget_min }} - {{ task.budget_max }}</span>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 line-clamp-2 mb-3 h-8">{{ task.description }}</p>
|
||||
<div class="flex items-center justify-between mt-2 pt-3 border-t border-gray-100">
|
||||
<div class="flex gap-1">
|
||||
<el-tag size="small" type="info" effect="plain" v-for="tag in (task.skill_tags || []).slice(0,2)" :key="tag">{{ tag }}</el-tag>
|
||||
</div>
|
||||
<div class="text-blue-500 text-xs flex items-center group-hover:text-blue-700 font-medium">去承接 <ChevronRight class="w-3 h-3 ml-0.5" /></div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<el-empty v-else description="该企业暂无招募中的任务" :image-size="60" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.enterprise-drawer :deep(.el-drawer__header) {
|
||||
margin-bottom: 0;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
font-weight: bold;
|
||||
}
|
||||
.enterprise-drawer :deep(.el-drawer__body) {
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
189
src/components/expert/ExpertProfileDrawer.vue
Normal file
189
src/components/expert/ExpertProfileDrawer.vue
Normal file
@@ -0,0 +1,189 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { Star, Mail, ShieldCheck, Phone, FileText, Download, X } from 'lucide-vue-next';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import api from '@/api';
|
||||
import VueOfficeDocx from '@vue-office/docx';
|
||||
import '@vue-office/docx/lib/index.css';
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean;
|
||||
expertId: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'invite']);
|
||||
|
||||
const visible = ref(props.modelValue);
|
||||
const loading = ref(false);
|
||||
const expert = ref<any>(null);
|
||||
|
||||
const previewVisible = ref(false);
|
||||
const previewUrl = ref('');
|
||||
|
||||
watch(() => props.modelValue, (val) => {
|
||||
visible.value = val;
|
||||
if (val && props.expertId) {
|
||||
fetchData(props.expertId);
|
||||
}
|
||||
});
|
||||
|
||||
watch(visible, (val) => {
|
||||
emit('update:modelValue', val);
|
||||
});
|
||||
|
||||
const fetchData = async (id: string) => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res: any = await api.get(`/users/${id}/`);
|
||||
expert.value = res;
|
||||
} catch (error) {
|
||||
console.error('Failed to load expert details:', error);
|
||||
ElMessage.error('无法加载专家详情');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleInvite = () => {
|
||||
emit('invite', expert.value);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<el-drawer v-model="visible" size="800px" :with-header="false" destroy-on-close direction="rtl">
|
||||
<!-- Main Scrollable Container -->
|
||||
<div class="h-full overflow-y-auto bg-gray-50 relative" v-loading="loading">
|
||||
|
||||
<!-- Floating Actions -->
|
||||
<div class="absolute top-4 right-4 z-50 flex items-center gap-3">
|
||||
<el-button type="primary" round plain class="shadow-sm bg-white/90 backdrop-blur" @click="handleInvite">
|
||||
<Mail class="w-4 h-4 mr-2" /> 邀约合作
|
||||
</el-button>
|
||||
<div class="cursor-pointer text-white hover:text-blue-100 transition-colors bg-black/20 p-2 rounded-full hover:bg-black/30" @click="visible = false">
|
||||
<X class="w-5 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="expert">
|
||||
<div class="bg-gray-50 pb-8">
|
||||
<!-- Header Profile Banner -->
|
||||
<div class="bg-gradient-to-r from-blue-600 to-indigo-800 px-8 pt-12 pb-10 text-white shadow-md">
|
||||
<div class="flex gap-6 items-end">
|
||||
<el-avatar :size="100" :src="expert.avatar_url" class="border-4 border-white shadow-lg bg-white text-blue-600 text-4xl font-bold">
|
||||
{{ expert.opc_certification?.real_name?.[0] || expert.nickname?.[0] || expert.username?.[0] || 'O' }}
|
||||
</el-avatar>
|
||||
<div class="flex-1 pb-1">
|
||||
<h2 class="text-3xl font-bold flex items-center gap-2 tracking-tight">
|
||||
{{ expert.opc_certification?.real_name || expert.username }}
|
||||
<span v-if="expert.nickname && expert.nickname !== expert.opc_certification?.real_name" class="text-xl font-normal text-blue-200 ml-1">({{ expert.nickname }})</span>
|
||||
<ShieldCheck class="w-6 h-6 text-green-400" />
|
||||
</h2>
|
||||
<div class="flex items-center gap-6 mt-3 text-blue-100 text-sm font-medium">
|
||||
<span class="flex items-center gap-1.5"><Mail class="w-4 h-4" /> {{ expert.email || '未绑定邮箱' }}</span>
|
||||
<span class="flex items-center gap-1.5"><Phone class="w-4 h-4" /> {{ expert.phone || '未公开手机' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-8 mt-8 space-y-8">
|
||||
<!-- Basic Info -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
|
||||
<h3 class="text-base font-bold text-gray-800 border-b border-gray-100 px-6 py-4 bg-gray-50/50">基础档案</h3>
|
||||
<div class="p-6">
|
||||
<el-descriptions :column="2" class="custom-descriptions">
|
||||
<el-descriptions-item label="实名核验"><span class="text-green-600 font-bold flex items-center gap-1"><ShieldCheck class="w-4 h-4" /> 已通过官方认证</span></el-descriptions-item>
|
||||
<el-descriptions-item label="入驻时间"><span class="text-gray-900">{{ new Date(expert.created_at).toLocaleDateString() }}</span></el-descriptions-item>
|
||||
<el-descriptions-item label="所在地"><span class="text-gray-900">{{ expert.location || '未公开' }}</span></el-descriptions-item>
|
||||
<el-descriptions-item label="任务履历"><span class="text-gray-900">已完成 <span class="text-blue-600 font-bold px-1 text-base">{{ expert.completed_tasks || 0 }}</span> 项任务</span></el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Skills & Experience -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
|
||||
<h3 class="text-base font-bold text-gray-800 border-b border-gray-100 px-6 py-4 bg-gray-50/50">业务能力</h3>
|
||||
<div class="p-6">
|
||||
<h4 class="text-sm text-gray-500 mb-3">认证技能领域</h4>
|
||||
<div class="flex flex-wrap gap-2 mb-8">
|
||||
<el-tag v-for="skill in expert.opc_certification?.skills || []" :key="skill" effect="light" size="large" type="primary" round class="px-4 py-1 h-auto font-medium">
|
||||
{{ skill }}
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<h4 class="text-sm text-gray-500 mb-3">项目经验简述</h4>
|
||||
<div class="bg-gray-50 p-5 rounded-lg text-sm text-gray-700 whitespace-pre-wrap leading-relaxed border border-gray-200 shadow-inner">
|
||||
{{ expert.opc_certification?.experience || expert.bio || '该专家暂未填写详细的项目经验描述。' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Resume Section -->
|
||||
<div class="px-8 pb-8 bg-gray-50" v-if="expert.opc_certification?.resume_url">
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden mb-10">
|
||||
<div class="flex items-center justify-between border-b border-gray-100 px-6 py-4 bg-gray-50/50">
|
||||
<h3 class="text-base font-bold text-gray-800 flex items-center gap-2"><FileText class="w-5 h-5 text-gray-500" /> 简历附件</h3>
|
||||
<div class="flex items-center gap-3">
|
||||
<el-button type="primary" plain size="small" @click="previewUrl = expert.opc_certification.resume_url; previewVisible = true" round>
|
||||
全屏放大预览
|
||||
</el-button>
|
||||
<el-button type="success" size="small" tag="a" :href="expert.opc_certification.resume_url" target="_blank" download round>
|
||||
<Download class="w-3 h-3 mr-1" /> 下载附件
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick embedded preview -->
|
||||
<div class="h-[600px] bg-gray-100 p-2 relative">
|
||||
<template v-if="expert.opc_certification.resume_url.split('?')[0].toLowerCase().endsWith('.pdf')">
|
||||
<iframe :src="expert.opc_certification.resume_url" class="w-full h-full border border-gray-300 rounded shadow-sm bg-white"></iframe>
|
||||
</template>
|
||||
<template v-else-if="['.jpg', '.jpeg', '.png', '.webp'].some((ext: string) => expert.opc_certification.resume_url.split('?')[0].toLowerCase().endsWith(ext))">
|
||||
<el-image :src="expert.opc_certification.resume_url" class="w-full h-full rounded shadow-sm bg-white" fit="contain" />
|
||||
</template>
|
||||
<template v-else-if="['.docx', '.doc'].some((ext: string) => expert.opc_certification.resume_url.split('?')[0].toLowerCase().endsWith(ext))">
|
||||
<div class="w-full h-full border border-gray-300 rounded shadow-sm bg-white overflow-hidden">
|
||||
<VueOfficeDocx :src="expert.opc_certification.resume_url" class="w-full h-full" style="height: 100%" />
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="h-full flex flex-col items-center justify-center bg-white rounded shadow-sm border border-gray-200">
|
||||
<div class="text-5xl mb-4 opacity-50">📄</div>
|
||||
<div class="text-gray-500 mb-6 font-medium">该文件格式不支持内嵌预览</div>
|
||||
<el-button type="primary" tag="a" :href="expert.opc_certification.resume_url" target="_blank" download round size="large" class="px-8 shadow-md"><Download class="w-4 h-4 mr-2" /> 点击下载原文件</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-drawer>
|
||||
|
||||
<!-- Fullscreen Document Preview Dialog -->
|
||||
<el-dialog v-model="previewVisible" title="简历在线全屏预览" width="850px" top="3vh" :destroy-on-close="true" center>
|
||||
<div v-if="['.jpg', '.jpeg', '.png', '.webp'].some(ext => previewUrl.split('?')[0].toLowerCase().endsWith(ext))" class="flex justify-center">
|
||||
<el-image :src="previewUrl" class="max-h-[85vh]" fit="contain" />
|
||||
</div>
|
||||
<div v-else-if="previewUrl.split('?')[0].toLowerCase().endsWith('.pdf')" class="flex justify-center">
|
||||
<iframe :src="previewUrl" width="100%" style="height: 80vh" border="0"></iframe>
|
||||
</div>
|
||||
<div v-else-if="['.docx', '.doc'].some(ext => previewUrl.split('?')[0].toLowerCase().endsWith(ext))" class="flex justify-center h-[80vh] overflow-y-auto">
|
||||
<vue-office-docx :src="previewUrl" />
|
||||
</div>
|
||||
<div v-else class="text-center py-10">
|
||||
<el-result icon="warning" title="无法预览该类型文件" sub-title="该格式不支持在线预览,请下载后查看。" />
|
||||
<el-button type="primary" tag="a" :href="previewUrl" target="_blank" download>下载文件</el-button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.custom-descriptions :deep(.el-descriptions__label) {
|
||||
width: 120px;
|
||||
color: #6b7280;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
111
src/components/user/UserTasksDrawer.vue
Normal file
111
src/components/user/UserTasksDrawer.vue
Normal file
@@ -0,0 +1,111 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { Briefcase, AlertCircle, XCircle } from 'lucide-vue-next';
|
||||
import api from '@/api';
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean;
|
||||
user: any;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'task-cancelled']);
|
||||
|
||||
const visible = ref(props.modelValue);
|
||||
const loading = ref(false);
|
||||
const tasks = ref<any[]>([]);
|
||||
|
||||
watch(() => props.modelValue, (val) => {
|
||||
visible.value = val;
|
||||
if (val && props.user) {
|
||||
fetchTasks();
|
||||
}
|
||||
});
|
||||
|
||||
watch(visible, (val) => {
|
||||
emit('update:modelValue', val);
|
||||
});
|
||||
|
||||
const fetchTasks = async () => {
|
||||
if (!props.user) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const res: any = await api.get(`/users/${props.user.id}/active_tasks/`);
|
||||
tasks.value = res.results || res;
|
||||
} catch (error) {
|
||||
ElMessage.error('获取用户任务失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const forceCancelTask = (task: any) => {
|
||||
ElMessageBox.confirm(
|
||||
`您正在以管理员身份强制取消该任务:<strong>${task.title}</strong><br><br>这将中断该任务的所有流程,是否继续?`,
|
||||
'危险操作:强制取消任务',
|
||||
{
|
||||
dangerouslyUseHTMLString: true,
|
||||
confirmButtonText: '强制取消',
|
||||
cancelButtonText: '暂不操作',
|
||||
type: 'error',
|
||||
confirmButtonClass: 'el-button--danger'
|
||||
}
|
||||
).then(async () => {
|
||||
try {
|
||||
await api.post(`/tasks/${task.id}/cancel_task/`, { batch_reject: true });
|
||||
ElMessage.success('任务已成功强制取消');
|
||||
fetchTasks();
|
||||
emit('task-cancelled');
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.response?.data?.detail || '取消任务失败');
|
||||
}
|
||||
}).catch(() => {});
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-drawer
|
||||
v-model="visible"
|
||||
:title="`用户任务明细 - ${user?.nickname || user?.username}`"
|
||||
size="500px"
|
||||
destroy-on-close
|
||||
>
|
||||
<div class="p-4" v-loading="loading">
|
||||
<div class="mb-6 bg-blue-50 text-blue-800 p-4 rounded-xl flex items-start gap-3">
|
||||
<AlertCircle class="w-5 h-5 flex-shrink-0 mt-0.5 text-blue-500" />
|
||||
<div class="text-sm leading-relaxed">
|
||||
<p class="font-bold mb-1">为什么无法删除该用户?</p>
|
||||
<p>该用户当前仍有进行中的任务(作为发布方或接单专家)。为保证系统数据流转的完整性,必须先强制取消以下所有关联任务,才能安全注销该用户账号。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="tasks.length > 0" class="space-y-4">
|
||||
<div v-for="task in tasks" :key="task.id" class="border border-gray-200 rounded-xl p-4 bg-white hover:border-gray-300 transition-colors">
|
||||
<div class="flex justify-between items-start mb-3">
|
||||
<h3 class="font-bold text-gray-800 flex items-center gap-2">
|
||||
<Briefcase class="w-4 h-4 text-gray-400" />
|
||||
{{ task.title }}
|
||||
</h3>
|
||||
<el-tag size="small" :type="task.role === '专家接单' ? 'success' : 'warning'" effect="light">
|
||||
{{ task.role }}
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center mt-4">
|
||||
<div class="text-xs text-gray-500">
|
||||
任务状态: <span class="font-medium text-gray-700">{{ task.task_status }}</span>
|
||||
</div>
|
||||
<el-button type="danger" size="small" plain @click="forceCancelTask(task)">
|
||||
<XCircle class="w-3.5 h-3.5 mr-1" /> 强制取消
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-empty v-else description="该用户暂无进行中的任务,可安全删除" :image-size="100">
|
||||
<el-button type="primary" @click="visible = false">知道了</el-button>
|
||||
</el-empty>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</template>
|
||||
7
src/env.d.ts
vendored
Normal file
7
src/env.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
1
src/index.css
Normal file
1
src/index.css
Normal file
@@ -0,0 +1 @@
|
||||
@import "tailwindcss";
|
||||
358
src/layouts/AdminLayout.vue
Normal file
358
src/layouts/AdminLayout.vue
Normal file
@@ -0,0 +1,358 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
||||
import { RouterView, useRouter, useRoute } from 'vue-router';
|
||||
import {
|
||||
LayoutDashboard, Users, Building2,
|
||||
ShieldCheck, Briefcase, Bell, Monitor, Box,
|
||||
LogOut, Key, Settings, UserCircle, PlusCircle,
|
||||
Home, ArrowLeft, GripVertical, Menu,
|
||||
ChevronDown, ChevronRight
|
||||
} from 'lucide-vue-next';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const authStore = useAuthStore();
|
||||
const user = computed(() => authStore.user);
|
||||
|
||||
// Sidebar visibility from localStorage (default: hidden)
|
||||
const sidebarVisible = ref(localStorage.getItem('sidebarVisible') === 'true');
|
||||
const toggleSidebar = () => {
|
||||
sidebarVisible.value = !sidebarVisible.value;
|
||||
localStorage.setItem('sidebarVisible', String(sidebarVisible.value));
|
||||
window.dispatchEvent(new Event('sidebar-visibility-change'));
|
||||
};
|
||||
|
||||
const onVisibilityChange = () => {
|
||||
sidebarVisible.value = localStorage.getItem('sidebarVisible') === 'true';
|
||||
};
|
||||
|
||||
const navGroups = [
|
||||
{ key: 'core', label: '', items: [
|
||||
{ name: '工作台', path: '/admin/dashboard', icon: LayoutDashboard },
|
||||
]},
|
||||
{ key: 'biz', label: '业务管理', items: [
|
||||
{ name: '全平台任务', path: '/admin/tasks', icon: Briefcase, perm: 'menu:business:tasks' },
|
||||
{ name: '发布任务', path: '/admin/tasks/create', icon: PlusCircle, perm: 'menu:business:tasks' },
|
||||
{ name: '企业管理', path: '/admin/enterprises', icon: Building2, perm: 'menu:business:enterprises' },
|
||||
{ name: 'OPC专家库', path: '/admin/opc-users', icon: UserCircle, perm: 'menu:account:users' },
|
||||
]},
|
||||
{ key: 'audit', label: '审核中心', items: [
|
||||
{ name: 'OPC认证审核', path: '/admin/certifications', icon: ShieldCheck, perm: 'menu:business:certs' },
|
||||
{ name: '企业认证审核', path: '/admin/enterprise-certs', icon: Building2, perm: 'menu:business:enterprises' },
|
||||
]},
|
||||
{ key: 'account', label: '账户与权限', items: [
|
||||
{ name: '用户管理', path: '/admin/users', icon: Users, perm: 'menu:account:users' },
|
||||
{ name: '角色与权限', path: '/admin/roles', icon: Key, perm: 'menu:account:roles' },
|
||||
]},
|
||||
{ key: 'ops', label: '运营配置', items: [
|
||||
{ name: '系统公告', path: '/admin/announcements', icon: Bell, perm: 'menu:settings:announcements' },
|
||||
{ name: '技能标签', path: '/admin/skills', icon: Settings, perm: 'menu:settings:announcements' },
|
||||
{ name: '模型市场', path: '/admin/models', icon: Box, perm: 'menu:business:models' },
|
||||
{ name: '数据大屏', path: '/screen', icon: Monitor, perm: 'menu:business:screen' },
|
||||
]},
|
||||
];
|
||||
|
||||
// Collapse state per group
|
||||
const collapsedGroups = ref<Record<string, boolean>>({});
|
||||
const toggleGroup = (key: string) => {
|
||||
collapsedGroups.value[key] = !collapsedGroups.value[key];
|
||||
};
|
||||
|
||||
const allNavItems = navGroups.flatMap(g => g.items);
|
||||
|
||||
const visibleNavGroups = computed(() => {
|
||||
return navGroups.map(group => ({
|
||||
...group,
|
||||
items: group.items.filter(item => {
|
||||
if (!item.perm) return true;
|
||||
return authStore.hasPermission(item.perm);
|
||||
})
|
||||
})).filter(group => group.items.length > 0);
|
||||
});
|
||||
|
||||
const activeMenu = computed(() => {
|
||||
const path = route.path;
|
||||
const exact = allNavItems.find(item => item.path === path);
|
||||
if (exact) return exact.path;
|
||||
const prefix = allNavItems
|
||||
.filter(item => path.startsWith(item.path.replace(/\/[^/]*$/, '')))
|
||||
.sort((a, b) => b.path.length - a.path.length);
|
||||
return prefix.length > 0 ? prefix[0].path : path;
|
||||
});
|
||||
|
||||
const handleLogout = () => {
|
||||
authStore.logout();
|
||||
router.push('/login');
|
||||
};
|
||||
|
||||
const handleMenuSelect = (path: string) => {
|
||||
router.push(path);
|
||||
};
|
||||
|
||||
// Floating toolbar draggable logic
|
||||
const fabPos = ref({ x: -1, y: -1 });
|
||||
const isDragging = ref(false);
|
||||
const dragOffset = ref({ x: 0, y: 0 });
|
||||
|
||||
onMounted(() => {
|
||||
fabPos.value = { x: window.innerWidth - 80, y: window.innerHeight - 180 };
|
||||
window.addEventListener('sidebar-visibility-change', onVisibilityChange);
|
||||
});
|
||||
|
||||
const onFabDown = (e: MouseEvent | TouchEvent) => {
|
||||
isDragging.value = true;
|
||||
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
|
||||
const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY;
|
||||
dragOffset.value = { x: clientX - fabPos.value.x, y: clientY - fabPos.value.y };
|
||||
document.addEventListener('mousemove', onFabMove);
|
||||
document.addEventListener('mouseup', onFabUp);
|
||||
document.addEventListener('touchmove', onFabMove);
|
||||
document.addEventListener('touchend', onFabUp);
|
||||
};
|
||||
const onFabMove = (e: MouseEvent | TouchEvent) => {
|
||||
if (!isDragging.value) return;
|
||||
const clientX = 'touches' in e ? (e as TouchEvent).touches[0].clientX : (e as MouseEvent).clientX;
|
||||
const clientY = 'touches' in e ? (e as TouchEvent).touches[0].clientY : (e as MouseEvent).clientY;
|
||||
fabPos.value = {
|
||||
x: Math.max(0, Math.min(window.innerWidth - 56, clientX - dragOffset.value.x)),
|
||||
y: Math.max(0, Math.min(window.innerHeight - 140, clientY - dragOffset.value.y)),
|
||||
};
|
||||
};
|
||||
const onFabUp = () => {
|
||||
isDragging.value = false;
|
||||
document.removeEventListener('mousemove', onFabMove);
|
||||
document.removeEventListener('mouseup', onFabUp);
|
||||
document.removeEventListener('touchmove', onFabMove);
|
||||
document.removeEventListener('touchend', onFabUp);
|
||||
};
|
||||
onUnmounted(() => {
|
||||
onFabUp();
|
||||
window.removeEventListener('sidebar-visibility-change', onVisibilityChange);
|
||||
});
|
||||
|
||||
const goHome = () => router.push('/admin/dashboard');
|
||||
const goBack = () => router.back();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-container class="min-h-screen">
|
||||
<!-- Sidebar (visible only when enabled) -->
|
||||
<transition name="sidebar-slide">
|
||||
<div v-if="sidebarVisible" class="sidebar-rail">
|
||||
<div class="sidebar-inner">
|
||||
<div class="sidebar-header">
|
||||
<div class="logo-icon">O</div>
|
||||
<span class="logo-text">OPC 平台 <span class="logo-sub">管理后台</span></span>
|
||||
</div>
|
||||
|
||||
<nav class="sidebar-nav">
|
||||
<template v-for="group in visibleNavGroups" :key="group.key">
|
||||
<!-- Non-labeled group (core) -->
|
||||
<template v-if="!group.label">
|
||||
<div
|
||||
v-for="item in group.items" :key="item.path"
|
||||
class="nav-item" :class="{ active: activeMenu === item.path }"
|
||||
@click="handleMenuSelect(item.path)"
|
||||
>
|
||||
<component :is="item.icon" class="nav-icon" />
|
||||
<span class="nav-label">{{ item.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Labeled group with collapse -->
|
||||
<template v-else>
|
||||
<div class="nav-group-header" @click="toggleGroup(group.key)">
|
||||
<span class="nav-group-label">{{ group.label }}</span>
|
||||
<ChevronDown v-if="!collapsedGroups[group.key]" class="w-3.5 h-3.5 text-gray-400" />
|
||||
<ChevronRight v-else class="w-3.5 h-3.5 text-gray-400" />
|
||||
</div>
|
||||
<div v-show="!collapsedGroups[group.key]" class="nav-group-items">
|
||||
<div
|
||||
v-for="item in group.items" :key="item.path"
|
||||
class="nav-item" :class="{ active: activeMenu === item.path }"
|
||||
@click="handleMenuSelect(item.path)"
|
||||
>
|
||||
<component :is="item.icon" class="nav-icon" />
|
||||
<span class="nav-label">{{ item.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<el-container class="main-container" :class="{ 'sidebar-on': sidebarVisible }">
|
||||
<!-- Header -->
|
||||
<el-header class="app-header">
|
||||
<div class="header-left">
|
||||
<button class="sidebar-toggle" @click="toggleSidebar" :title="sidebarVisible ? '收起侧栏' : '展开侧栏'">
|
||||
<Menu class="w-5 h-5" />
|
||||
</button>
|
||||
<el-tag type="danger" effect="dark" size="small" round class="!font-semibold">
|
||||
<ShieldCheck class="w-3 h-3 mr-1 inline" />超级管理员
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<el-dropdown trigger="click">
|
||||
<div class="user-avatar-area">
|
||||
<div class="hidden sm:block text-right mr-3">
|
||||
<div class="text-sm font-semibold text-gray-800 leading-none mb-0.5">{{ user?.nickname || user?.username }}</div>
|
||||
<div class="text-xs text-gray-400">系统管理员</div>
|
||||
</div>
|
||||
<el-avatar :size="36" class="bg-red-500 text-white font-bold">
|
||||
{{ user?.nickname?.[0] || 'A' }}
|
||||
</el-avatar>
|
||||
</div>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item @click="handleLogout">
|
||||
<LogOut class="w-4 h-4 mr-2" />退出登录
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</el-header>
|
||||
|
||||
<el-main class="app-main">
|
||||
<RouterView />
|
||||
</el-main>
|
||||
</el-container>
|
||||
|
||||
<!-- Floating Toolbar -->
|
||||
<div
|
||||
class="fab-toolbar"
|
||||
:style="{ left: fabPos.x + 'px', top: fabPos.y + 'px' }"
|
||||
v-show="fabPos.x >= 0"
|
||||
>
|
||||
<div class="fab-handle" @mousedown.prevent="onFabDown" @touchstart.prevent="onFabDown">
|
||||
<GripVertical class="w-3.5 h-3.5" />
|
||||
</div>
|
||||
<button class="fab-btn" @click="goHome" title="回到主页"><Home class="w-4 h-4" /></button>
|
||||
<button class="fab-btn" @click="goBack" title="返回上页"><ArrowLeft class="w-4 h-4" /></button>
|
||||
</div>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Sidebar Rail */
|
||||
.sidebar-rail {
|
||||
position: fixed;
|
||||
top: 0; left: 0; bottom: 0;
|
||||
width: 220px;
|
||||
background: #fff;
|
||||
border-right: 1px solid #f0f0f0;
|
||||
z-index: 200;
|
||||
overflow: hidden;
|
||||
}
|
||||
.sidebar-inner {
|
||||
display: flex; flex-direction: column; height: 100%; width: 220px;
|
||||
}
|
||||
|
||||
/* Header / Logo */
|
||||
.sidebar-header {
|
||||
height: 64px; display: flex; align-items: center;
|
||||
padding: 0 16px; gap: 10px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
background: #111827; flex-shrink: 0;
|
||||
}
|
||||
.logo-icon {
|
||||
width: 32px; height: 32px; background: #fff; border-radius: 8px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-weight: 900; font-size: 14px; color: #ef4444; flex-shrink: 0;
|
||||
}
|
||||
.logo-text { font-weight: 800; font-size: 15px; color: #fff; white-space: nowrap; flex: 1; }
|
||||
.logo-sub { color: #f87171; font-weight: 600; }
|
||||
|
||||
/* Sidebar transition */
|
||||
.sidebar-slide-enter-active,
|
||||
.sidebar-slide-leave-active { transition: transform 0.25s cubic-bezier(0.4,0,0.2,1), opacity 0.2s; }
|
||||
.sidebar-slide-enter-from,
|
||||
.sidebar-slide-leave-to { transform: translateX(-100%); opacity: 0; }
|
||||
|
||||
/* Navigation */
|
||||
.sidebar-nav { flex: 1; padding: 8px; overflow-y: auto; overflow-x: hidden; }
|
||||
|
||||
.nav-group-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 14px 12px 6px;
|
||||
cursor: pointer; user-select: none;
|
||||
}
|
||||
.nav-group-label {
|
||||
font-size: 11px; font-weight: 700;
|
||||
color: #9ca3af; letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.nav-group-header:hover .nav-group-label { color: #6b7280; }
|
||||
|
||||
.nav-item {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
height: 40px; padding: 0 12px;
|
||||
border-radius: 10px; cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
color: #6b7280; font-size: 13px;
|
||||
white-space: nowrap; margin-bottom: 1px;
|
||||
}
|
||||
.nav-item:hover { background: #f3f4f6; color: #374151; }
|
||||
.nav-item.active { background: #fee2e2; color: #ef4444; font-weight: 600; }
|
||||
.nav-icon { width: 18px; height: 18px; flex-shrink: 0; }
|
||||
.nav-label { flex: 1; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
/* Main container */
|
||||
.main-container {
|
||||
margin-left: 0;
|
||||
transition: margin-left 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.main-container.sidebar-on { margin-left: 220px; }
|
||||
|
||||
/* Header */
|
||||
.app-header {
|
||||
height: 56px; display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 0 24px; border-bottom: 1px solid #eef0f4;
|
||||
background: #fff; position: sticky; top: 0; z-index: 50;
|
||||
}
|
||||
.header-left { display: flex; align-items: center; gap: 12px; }
|
||||
.sidebar-toggle {
|
||||
width: 36px; height: 36px;
|
||||
border-radius: 8px; border: 1px solid #e5e7eb;
|
||||
background: #fff;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
cursor: pointer; color: #6b7280;
|
||||
transition: all 0.15s; flex-shrink: 0;
|
||||
}
|
||||
.sidebar-toggle:hover { background: #f9fafb; color: #111827; border-color: #d1d5db; }
|
||||
.header-right { display: flex; align-items: center; gap: 16px; flex-shrink: 0; }
|
||||
.user-avatar-area { display: flex; align-items: center; cursor: pointer; }
|
||||
.app-main { background: #f5f7fa; min-height: calc(100vh - 56px); padding: 24px; }
|
||||
|
||||
/* Floating Toolbar */
|
||||
.fab-toolbar {
|
||||
position: fixed; z-index: 9999;
|
||||
display: flex; flex-direction: column;
|
||||
align-items: center; gap: 6px;
|
||||
background: rgba(30, 30, 32, 0.55);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border-radius: 16px; padding: 8px 6px;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.15);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
}
|
||||
.fab-handle {
|
||||
cursor: grab; color: rgba(255,255,255,0.35);
|
||||
padding: 2px 0; user-select: none; touch-action: none;
|
||||
}
|
||||
.fab-handle:active { cursor: grabbing; }
|
||||
.fab-btn {
|
||||
width: 36px; height: 36px; border-radius: 10px;
|
||||
border: none; background: rgba(255,255,255,0.08);
|
||||
color: rgba(255,255,255,0.7);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
cursor: pointer; transition: all 0.15s;
|
||||
}
|
||||
.fab-btn:hover {
|
||||
background: rgba(239,68,68,0.2);
|
||||
color: #f87171;
|
||||
}
|
||||
</style>
|
||||
403
src/layouts/EnterpriseLayout.vue
Normal file
403
src/layouts/EnterpriseLayout.vue
Normal file
@@ -0,0 +1,403 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
||||
import { RouterView, useRouter, useRoute } from 'vue-router';
|
||||
import {
|
||||
LayoutDashboard, PlusCircle, ListTodo, Users, Bell,
|
||||
LogOut, Search, Building2, Mail, ShieldCheck, Settings,
|
||||
Home, ArrowLeft, GripVertical, TrendingUp, Menu,
|
||||
ChevronDown, ChevronRight
|
||||
} from 'lucide-vue-next';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { getTasks } from '@/api/tasks';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const authStore = useAuthStore();
|
||||
const user = computed(() => authStore.user);
|
||||
|
||||
// Sidebar visibility — controlled by toggle button in header
|
||||
const sidebarVisible = ref(localStorage.getItem('sidebarVisible') === 'true');
|
||||
const toggleSidebar = () => {
|
||||
sidebarVisible.value = !sidebarVisible.value;
|
||||
localStorage.setItem('sidebarVisible', String(sidebarVisible.value));
|
||||
window.dispatchEvent(new Event('sidebar-visibility-change'));
|
||||
};
|
||||
const onVisibilityChange = () => {
|
||||
sidebarVisible.value = localStorage.getItem('sidebarVisible') === 'true';
|
||||
};
|
||||
|
||||
// Sidebar nav grouped by business domain
|
||||
const navGroups = [
|
||||
{ key: 'biz', label: '业务中心', items: [
|
||||
{ name: '企业工作台', path: '/enterprise/dashboard', icon: LayoutDashboard, perm: 'menu:ent:dashboard' },
|
||||
{ name: '需求发布', path: '/enterprise/tasks/create', icon: PlusCircle, perm: 'menu:ent:tasks:create' },
|
||||
{ name: '项目管理', path: '/enterprise/tasks', icon: ListTodo, perm: 'menu:ent:tasks' },
|
||||
]},
|
||||
{ key: 'talent', label: '人才管理', items: [
|
||||
{ name: '专家人才库', path: '/enterprise/opc-users', icon: Search, perm: 'menu:ent:opc_users' },
|
||||
{ name: '我邀请的', path: '/enterprise/invitations', icon: Mail, perm: 'menu:ent:invitations' },
|
||||
]},
|
||||
{ key: 'org', label: '企业管理', items: [
|
||||
{ name: '成员管理', path: '/enterprise/team', icon: Users, perm: 'menu:ent:team' },
|
||||
{ name: '企业档案', path: '/enterprise/profile', icon: Building2, perm: 'menu:ent:profile' },
|
||||
{ name: '企业资质', path: '/enterprise/verification', icon: ShieldCheck, perm: 'menu:ent:verification' },
|
||||
{ name: '账号设置', path: '/enterprise/settings', icon: Settings, perm: 'menu:ent:settings' },
|
||||
]},
|
||||
];
|
||||
|
||||
const allNavItems = navGroups.flatMap(g => g.items);
|
||||
|
||||
// Collapse state per group (all expanded by default)
|
||||
const collapsedGroups = ref<Record<string, boolean>>({});
|
||||
const toggleGroup = (key: string) => {
|
||||
collapsedGroups.value[key] = !collapsedGroups.value[key];
|
||||
};
|
||||
|
||||
import api from '@/api';
|
||||
|
||||
const myRole = ref('MEMBER');
|
||||
const enterprise = ref<any>(null);
|
||||
const memberCount = ref(0);
|
||||
const taskCount = ref(0);
|
||||
const completedCount = ref(0);
|
||||
|
||||
const visibleNavGroups = computed(() => {
|
||||
return navGroups.map(group => ({
|
||||
...group,
|
||||
items: group.items.filter(item => {
|
||||
if (!item.perm) return true;
|
||||
return authStore.hasPermission(item.perm);
|
||||
})
|
||||
})).filter(group => group.items.length > 0);
|
||||
});
|
||||
|
||||
const activeMenu = computed(() => {
|
||||
const path = route.path;
|
||||
const exact = allNavItems.find(item => item.path === path);
|
||||
if (exact) return exact.path;
|
||||
const prefix = allNavItems
|
||||
.filter(item => path.startsWith(item.path) && item.path !== '/enterprise/dashboard')
|
||||
.sort((a, b) => b.path.length - a.path.length);
|
||||
return prefix.length > 0 ? prefix[0].path : '/enterprise/dashboard';
|
||||
});
|
||||
|
||||
const handleLogout = () => {
|
||||
authStore.logout();
|
||||
router.push('/login');
|
||||
};
|
||||
|
||||
const handleMenuSelect = (path: string) => {
|
||||
router.push(path);
|
||||
};
|
||||
|
||||
const handleCommand = (cmd: string) => {
|
||||
if (cmd === 'profile') router.push('/enterprise/profile');
|
||||
else if (cmd === 'logout') handleLogout();
|
||||
};
|
||||
|
||||
// Floating toolbar draggable logic
|
||||
const fabPos = ref({ x: -1, y: -1 });
|
||||
const isDragging = ref(false);
|
||||
const dragOffset = ref({ x: 0, y: 0 });
|
||||
|
||||
onMounted(async () => {
|
||||
fabPos.value = { x: window.innerWidth - 80, y: window.innerHeight - 180 };
|
||||
window.addEventListener('sidebar-visibility-change', onVisibilityChange);
|
||||
try {
|
||||
const [entRes, membersRes, tasksRes]: any[] = await Promise.all([
|
||||
api.get('/enterprises/me/'),
|
||||
api.get('/enterprise-members/'),
|
||||
getTasks(),
|
||||
]);
|
||||
enterprise.value = entRes;
|
||||
if (entRes?.my_role) myRole.value = entRes.my_role;
|
||||
const mList = membersRes.results || membersRes;
|
||||
memberCount.value = Array.isArray(mList) ? mList.length : 0;
|
||||
const tList = tasksRes.results || [];
|
||||
taskCount.value = tList.length;
|
||||
completedCount.value = tList.filter((t: any) => t.status === 'COMPLETED').length;
|
||||
} catch (e) { console.error('Failed to fetch enterprise info'); }
|
||||
});
|
||||
|
||||
const onFabDown = (e: MouseEvent | TouchEvent) => {
|
||||
isDragging.value = true;
|
||||
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
|
||||
const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY;
|
||||
dragOffset.value = { x: clientX - fabPos.value.x, y: clientY - fabPos.value.y };
|
||||
document.addEventListener('mousemove', onFabMove);
|
||||
document.addEventListener('mouseup', onFabUp);
|
||||
document.addEventListener('touchmove', onFabMove);
|
||||
document.addEventListener('touchend', onFabUp);
|
||||
};
|
||||
const onFabMove = (e: MouseEvent | TouchEvent) => {
|
||||
if (!isDragging.value) return;
|
||||
const clientX = 'touches' in e ? (e as TouchEvent).touches[0].clientX : (e as MouseEvent).clientX;
|
||||
const clientY = 'touches' in e ? (e as TouchEvent).touches[0].clientY : (e as MouseEvent).clientY;
|
||||
fabPos.value = {
|
||||
x: Math.max(0, Math.min(window.innerWidth - 56, clientX - dragOffset.value.x)),
|
||||
y: Math.max(0, Math.min(window.innerHeight - 140, clientY - dragOffset.value.y)),
|
||||
};
|
||||
};
|
||||
const onFabUp = () => {
|
||||
isDragging.value = false;
|
||||
document.removeEventListener('mousemove', onFabMove);
|
||||
document.removeEventListener('mouseup', onFabUp);
|
||||
document.removeEventListener('touchmove', onFabMove);
|
||||
document.removeEventListener('touchend', onFabUp);
|
||||
};
|
||||
onUnmounted(() => {
|
||||
onFabUp();
|
||||
window.removeEventListener('sidebar-visibility-change', onVisibilityChange);
|
||||
});
|
||||
|
||||
const goHome = () => router.push('/enterprise/dashboard');
|
||||
const goBack = () => router.back();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-container class="min-h-screen">
|
||||
<!-- Sidebar -->
|
||||
<transition name="sidebar-slide">
|
||||
<div v-if="sidebarVisible" class="sidebar-rail">
|
||||
<div class="sidebar-inner">
|
||||
<!-- Logo -->
|
||||
<div class="sidebar-header">
|
||||
<div class="logo-icon">O</div>
|
||||
<span class="logo-text">OPC 平台 <span class="logo-sub">企业端</span></span>
|
||||
</div>
|
||||
|
||||
<!-- Grouped Navigation -->
|
||||
<nav class="sidebar-nav">
|
||||
<template v-for="group in visibleNavGroups" :key="group.key">
|
||||
<div class="nav-group-header" @click="toggleGroup(group.key)">
|
||||
<span class="nav-group-label">{{ group.label }}</span>
|
||||
<ChevronDown v-if="!collapsedGroups[group.key]" class="w-3.5 h-3.5 text-gray-400" />
|
||||
<ChevronRight v-else class="w-3.5 h-3.5 text-gray-400" />
|
||||
</div>
|
||||
<transition name="group-collapse">
|
||||
<div v-show="!collapsedGroups[group.key]" class="nav-group-items">
|
||||
<div
|
||||
v-for="item in group.items" :key="item.path"
|
||||
class="nav-item" :class="{ active: activeMenu === item.path }"
|
||||
@click="handleMenuSelect(item.path)"
|
||||
>
|
||||
<component :is="item.icon" class="nav-icon" />
|
||||
<span class="nav-label">{{ item.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<!-- Main -->
|
||||
<el-container class="main-container" :class="{ 'sidebar-on': sidebarVisible }">
|
||||
<!-- Header -->
|
||||
<el-header class="app-header">
|
||||
<!-- Left: toggle + badges -->
|
||||
<div class="header-left">
|
||||
<button class="sidebar-toggle" @click="toggleSidebar" :title="sidebarVisible ? '收起侧栏' : '展开侧栏'">
|
||||
<Menu class="w-5 h-5" />
|
||||
</button>
|
||||
<el-tag v-if="enterprise?.status" :type="enterprise.status === 'VERIFIED' ? 'success' : enterprise.status === 'REJECTED' ? 'danger' : 'warning'" effect="dark" size="small" round class="!font-semibold">
|
||||
<ShieldCheck class="w-3 h-3 mr-0.5 inline" />{{ enterprise?.status === 'VERIFIED' ? '已认证' : enterprise?.status === 'REJECTED' ? '被拒绝' : '待核验' }}
|
||||
</el-tag>
|
||||
<span class="hdr-sep"></span>
|
||||
<span class="hdr-pill"><ListTodo class="w-3.5 h-3.5 text-blue-500" /> {{ taskCount }} 个任务</span>
|
||||
<span class="hdr-pill"><Users class="w-3.5 h-3.5 text-green-500" /> {{ memberCount }} 名成员</span>
|
||||
<span class="hdr-pill"><TrendingUp class="w-3.5 h-3.5 text-amber-500" /> 历史完成 <strong style="color:#059669;margin:0 2px">{{ completedCount }}</strong> 项目</span>
|
||||
</div>
|
||||
|
||||
<!-- Right: actions -->
|
||||
<div class="header-right">
|
||||
<router-link to="/enterprise/dashboard">
|
||||
<el-badge :value="0" :hidden="true">
|
||||
<Bell class="w-5 h-5 text-gray-400 hover:text-amber-500 transition-colors cursor-pointer" />
|
||||
</el-badge>
|
||||
</router-link>
|
||||
<el-dropdown @command="handleCommand" trigger="click">
|
||||
<div class="user-avatar-area">
|
||||
<div class="hidden sm:block text-right mr-3">
|
||||
<div class="text-sm font-semibold text-gray-800 leading-none mb-0.5">{{ user?.nickname || user?.username }}</div>
|
||||
<div class="text-xs text-gray-400">{{ myRole === 'OWNER' ? '企业负责人' : '企业成员' }}</div>
|
||||
</div>
|
||||
<el-avatar :size="36" :src="user?.avatar_url" class="bg-amber-500 text-white font-bold">
|
||||
{{ user?.nickname?.[0] || user?.username?.[0] || 'U' }}
|
||||
</el-avatar>
|
||||
</div>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="profile">
|
||||
<Building2 class="w-4 h-4 mr-2" />企业信息
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item command="logout" divided>
|
||||
<LogOut class="w-4 h-4 mr-2" />退出登录
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</el-header>
|
||||
|
||||
<!-- Content -->
|
||||
<el-main class="app-main">
|
||||
<RouterView />
|
||||
</el-main>
|
||||
</el-container>
|
||||
|
||||
<!-- Floating Toolbar -->
|
||||
<div
|
||||
class="fab-toolbar"
|
||||
:style="{ left: fabPos.x + 'px', top: fabPos.y + 'px' }"
|
||||
v-show="fabPos.x >= 0"
|
||||
>
|
||||
<div class="fab-handle" @mousedown.prevent="onFabDown" @touchstart.prevent="onFabDown">
|
||||
<GripVertical class="w-3.5 h-3.5" />
|
||||
</div>
|
||||
<button class="fab-btn" @click="goHome" title="回到主页"><Home class="w-4 h-4" /></button>
|
||||
<button class="fab-btn" @click="goBack" title="返回上页"><ArrowLeft class="w-4 h-4" /></button>
|
||||
</div>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Sidebar Rail */
|
||||
.sidebar-rail {
|
||||
position: fixed;
|
||||
top: 0; left: 0; bottom: 0;
|
||||
width: 220px;
|
||||
background: #fff;
|
||||
border-right: 1px solid #f0f0f0;
|
||||
z-index: 200;
|
||||
overflow: hidden;
|
||||
}
|
||||
.sidebar-inner {
|
||||
display: flex; flex-direction: column; height: 100%; width: 220px;
|
||||
}
|
||||
|
||||
/* Sidebar transition */
|
||||
.sidebar-slide-enter-active,
|
||||
.sidebar-slide-leave-active { transition: transform 0.25s cubic-bezier(0.4,0,0.2,1), opacity 0.2s; }
|
||||
.sidebar-slide-enter-from,
|
||||
.sidebar-slide-leave-to { transform: translateX(-100%); opacity: 0; }
|
||||
|
||||
/* Header / Logo */
|
||||
.sidebar-header {
|
||||
height: 64px; display: flex; align-items: center;
|
||||
padding: 0 16px; gap: 10px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
background: #1d1d1f; flex-shrink: 0;
|
||||
}
|
||||
.logo-icon {
|
||||
width: 32px; height: 32px; background: #fff; border-radius: 8px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-weight: 900; font-size: 14px; color: #1d1d1f; flex-shrink: 0;
|
||||
}
|
||||
.logo-text { font-weight: 800; font-size: 15px; color: #fff; white-space: nowrap; flex: 1; }
|
||||
.logo-sub { color: #fbbf24; font-weight: 400; }
|
||||
|
||||
/* Grouped Navigation */
|
||||
.sidebar-nav { flex: 1; padding: 8px; overflow-y: auto; overflow-x: hidden; }
|
||||
|
||||
.nav-group-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 14px 12px 6px;
|
||||
cursor: pointer; user-select: none;
|
||||
}
|
||||
.nav-group-label {
|
||||
font-size: 11px; font-weight: 700;
|
||||
color: #9ca3af; letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.nav-group-header:hover .nav-group-label { color: #6b7280; }
|
||||
|
||||
.group-collapse-enter-active,
|
||||
.group-collapse-leave-active { transition: all 0.2s ease; overflow: hidden; }
|
||||
.group-collapse-enter-from,
|
||||
.group-collapse-leave-to { opacity: 0; max-height: 0; }
|
||||
|
||||
.nav-item {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
height: 40px; padding: 0 12px;
|
||||
border-radius: 10px; cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
color: #6b7280; font-size: 13px;
|
||||
white-space: nowrap; margin-bottom: 1px;
|
||||
}
|
||||
.nav-item:hover { background: #f3f4f6; color: #374151; }
|
||||
.nav-item.active { background: #fffbeb; color: #d97706; font-weight: 600; }
|
||||
.nav-icon { width: 18px; height: 18px; flex-shrink: 0; }
|
||||
.nav-label { flex: 1; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
/* Main container */
|
||||
.main-container {
|
||||
margin-left: 0;
|
||||
transition: margin-left 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.main-container.sidebar-on { margin-left: 220px; }
|
||||
|
||||
/* Header */
|
||||
.app-header {
|
||||
height: 56px;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 0 24px;
|
||||
border-bottom: 1px solid #eef0f4;
|
||||
background: #fff;
|
||||
position: sticky; top: 0; z-index: 50;
|
||||
}
|
||||
.header-left {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
}
|
||||
.sidebar-toggle {
|
||||
width: 36px; height: 36px;
|
||||
border-radius: 8px; border: 1px solid #e5e7eb;
|
||||
background: #fff;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
cursor: pointer; color: #6b7280;
|
||||
transition: all 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sidebar-toggle:hover { background: #f9fafb; color: #111827; border-color: #d1d5db; }
|
||||
.hdr-sep {
|
||||
width: 1px; height: 20px;
|
||||
background: #e5e7eb;
|
||||
}
|
||||
.hdr-pill {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
font-size: 12px; color: #6b7280; font-weight: 500;
|
||||
}
|
||||
.header-right { display: flex; align-items: center; gap: 16px; flex-shrink: 0; }
|
||||
.user-avatar-area { display: flex; align-items: center; cursor: pointer; }
|
||||
.app-main { background: #f5f7fa; min-height: calc(100vh - 56px); padding: 24px; }
|
||||
|
||||
/* Floating Toolbar */
|
||||
.fab-toolbar {
|
||||
position: fixed; z-index: 9999;
|
||||
display: flex; flex-direction: column;
|
||||
align-items: center; gap: 6px;
|
||||
background: rgba(30, 30, 32, 0.55);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border-radius: 16px; padding: 8px 6px;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.15);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
}
|
||||
.fab-handle {
|
||||
cursor: grab; color: rgba(255,255,255,0.35);
|
||||
padding: 2px 0; user-select: none; touch-action: none;
|
||||
}
|
||||
.fab-handle:active { cursor: grabbing; }
|
||||
.fab-btn {
|
||||
width: 36px; height: 36px; border-radius: 10px;
|
||||
border: none; background: rgba(255,255,255,0.08);
|
||||
color: rgba(255,255,255,0.7);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
cursor: pointer; transition: all 0.15s;
|
||||
}
|
||||
.fab-btn:hover {
|
||||
background: rgba(251,191,36,0.2);
|
||||
color: #fbbf24;
|
||||
}
|
||||
</style>
|
||||
454
src/layouts/MainLayout.vue
Normal file
454
src/layouts/MainLayout.vue
Normal file
@@ -0,0 +1,454 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue';
|
||||
import { RouterView, useRouter, useRoute } from 'vue-router';
|
||||
import {
|
||||
LayoutDashboard, ShoppingBag, Briefcase, Box, Bell, Monitor,
|
||||
LogOut, Menu, Mail, ShieldCheck, User, Calendar, Pin, PinOff, Send,
|
||||
Star, CheckCircle, Home, ArrowLeft, GripVertical
|
||||
} from 'lucide-vue-next';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const authStore = useAuthStore();
|
||||
const user = computed(() => authStore.user);
|
||||
|
||||
// Sidebar visibility from localStorage (default: hidden)
|
||||
const sidebarVisible = ref(localStorage.getItem('sidebarVisible') === 'true');
|
||||
const toggleSidebar = () => {
|
||||
sidebarVisible.value = !sidebarVisible.value;
|
||||
localStorage.setItem('sidebarVisible', String(sidebarVisible.value));
|
||||
window.dispatchEvent(new Event('sidebar-visibility-change'));
|
||||
};
|
||||
|
||||
const onVisibilityChange = () => {
|
||||
sidebarVisible.value = localStorage.getItem('sidebarVisible') === 'true';
|
||||
};
|
||||
|
||||
const allNavItems = [
|
||||
{ name: '专家工作台', path: '/user/dashboard', icon: LayoutDashboard, perm: 'menu:user:dashboard' },
|
||||
{ name: '接单大厅', path: '/user/tasks/market', icon: ShoppingBag, perm: 'menu:user:tasks' },
|
||||
{ name: '我的项目', path: '/user/tasks/my', icon: Briefcase, perm: 'menu:user:my_tasks' },
|
||||
{ name: '我的申请', path: '/user/applications', icon: Send, perm: 'menu:user:my_tasks' },
|
||||
{ name: '邀请我的', path: '/user/invitations', icon: Mail, perm: 'menu:user:invitations' },
|
||||
{ name: '平台公告', path: '/user/announcements', icon: Bell, perm: 'menu:user:announcements' },
|
||||
{ name: 'OPC 认证', path: '/user/certification', icon: ShieldCheck, perm: 'menu:user:certification' },
|
||||
{ name: '预约空间', path: '/user/reservations', icon: Calendar, perm: 'menu:user:settings' },
|
||||
];
|
||||
|
||||
const navItems = computed(() => {
|
||||
const items = allNavItems.filter(item => {
|
||||
if (!item.perm) return true;
|
||||
return authStore.hasPermission(item.perm);
|
||||
});
|
||||
if (user.value?.is_staff || user.value?.is_superuser) {
|
||||
items.push({ name: '后台管理', path: '/admin/dashboard', icon: ShieldCheck, perm: '' });
|
||||
}
|
||||
return items;
|
||||
});
|
||||
|
||||
const activeMenu = computed(() => {
|
||||
const path = route.path;
|
||||
const exact = navItems.value.find(item => item.path === path);
|
||||
if (exact) return exact.path;
|
||||
const prefix = navItems.value
|
||||
.filter(item => path.startsWith(item.path.replace(/\/[^/]*$/, '')))
|
||||
.sort((a, b) => b.path.length - a.path.length);
|
||||
return prefix.length > 0 ? prefix[0].path : path;
|
||||
});
|
||||
|
||||
const handleLogout = () => {
|
||||
authStore.logout();
|
||||
router.push('/login');
|
||||
};
|
||||
|
||||
const handleMenuSelect = (index: string) => {
|
||||
router.push(index);
|
||||
};
|
||||
|
||||
const handleCommand = (cmd: string) => {
|
||||
if (cmd === 'profile') router.push('/user/profile');
|
||||
else if (cmd === 'cert') router.push('/user/certification');
|
||||
else if (cmd === 'logout') handleLogout();
|
||||
};
|
||||
|
||||
// Floating toolbar draggable logic
|
||||
const fabPos = ref({ x: -1, y: -1 });
|
||||
const isDragging = ref(false);
|
||||
const dragOffset = ref({ x: 0, y: 0 });
|
||||
|
||||
onMounted(() => {
|
||||
fabPos.value = { x: window.innerWidth - 80, y: window.innerHeight - 180 };
|
||||
window.addEventListener('sidebar-visibility-change', onVisibilityChange);
|
||||
});
|
||||
|
||||
const onFabDown = (e: MouseEvent | TouchEvent) => {
|
||||
isDragging.value = true;
|
||||
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
|
||||
const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY;
|
||||
dragOffset.value = { x: clientX - fabPos.value.x, y: clientY - fabPos.value.y };
|
||||
document.addEventListener('mousemove', onFabMove);
|
||||
document.addEventListener('mouseup', onFabUp);
|
||||
document.addEventListener('touchmove', onFabMove);
|
||||
document.addEventListener('touchend', onFabUp);
|
||||
};
|
||||
const onFabMove = (e: MouseEvent | TouchEvent) => {
|
||||
if (!isDragging.value) return;
|
||||
const clientX = 'touches' in e ? (e as TouchEvent).touches[0].clientX : (e as MouseEvent).clientX;
|
||||
const clientY = 'touches' in e ? (e as TouchEvent).touches[0].clientY : (e as MouseEvent).clientY;
|
||||
fabPos.value = {
|
||||
x: Math.max(0, Math.min(window.innerWidth - 56, clientX - dragOffset.value.x)),
|
||||
y: Math.max(0, Math.min(window.innerHeight - 140, clientY - dragOffset.value.y)),
|
||||
};
|
||||
};
|
||||
const onFabUp = () => {
|
||||
isDragging.value = false;
|
||||
document.removeEventListener('mousemove', onFabMove);
|
||||
document.removeEventListener('mouseup', onFabUp);
|
||||
document.removeEventListener('touchmove', onFabMove);
|
||||
document.removeEventListener('touchend', onFabUp);
|
||||
};
|
||||
onUnmounted(() => {
|
||||
onFabUp();
|
||||
window.removeEventListener('sidebar-visibility-change', onVisibilityChange);
|
||||
});
|
||||
|
||||
const goHome = () => {
|
||||
const prefix = route.path.startsWith('/enterprise') ? '/enterprise/dashboard' : '/user/dashboard';
|
||||
router.push(prefix);
|
||||
};
|
||||
const goBack = () => router.back();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-container class="min-h-screen">
|
||||
<!-- Sidebar (visible only when enabled) -->
|
||||
<div v-if="sidebarVisible" class="sidebar-rail">
|
||||
<div class="sidebar-inner">
|
||||
<!-- Logo -->
|
||||
<div class="sidebar-header">
|
||||
<div class="logo-icon">O</div>
|
||||
<span class="logo-text">OPC 平台 <span class="logo-sub">专家端</span></span>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="sidebar-nav">
|
||||
<div
|
||||
v-for="item in navItems"
|
||||
:key="item.path"
|
||||
class="nav-item"
|
||||
:class="{ active: activeMenu === item.path }"
|
||||
@click="handleMenuSelect(item.path)"
|
||||
>
|
||||
<component :is="item.icon" class="nav-icon" />
|
||||
<span class="nav-label">{{ item.name }}</span>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-container class="main-container" :class="{ 'sidebar-on': sidebarVisible }">
|
||||
<!-- Header -->
|
||||
<el-header class="app-header">
|
||||
<!-- Left: toggle + profile info -->
|
||||
<div class="header-left">
|
||||
<button class="sidebar-toggle" @click="toggleSidebar" :title="sidebarVisible ? '收起侧栏' : '展开侧栏'">
|
||||
<Menu class="w-5 h-5" />
|
||||
</button>
|
||||
<p v-if="user?.bio" class="header-bio">
|
||||
<span class="bio-mark">✦</span> {{ user.bio }}
|
||||
</p>
|
||||
<span class="hdr-sep"></span>
|
||||
<div class="header-badges">
|
||||
<span v-if="user?.location" class="hdr-pill loc-pill">📍 {{ user.location }}</span>
|
||||
<span class="hdr-pill hdr-stars">
|
||||
<Star v-for="n in 5" :key="n" class="w-3.5 h-3.5"
|
||||
:style="{ fill: n <= Math.round(Number(user?.rating || 5)) ? '#facc15' : 'none', color: n <= Math.round(Number(user?.rating || 5)) ? '#facc15' : '#d1d5db', filter: n <= Math.round(Number(user?.rating || 5)) ? 'drop-shadow(0 1px 2px rgba(250,204,21,0.3))' : 'none' }" />
|
||||
</span>
|
||||
<span class="hdr-pill done-pill"><CheckCircle class="w-3.5 h-3.5 text-emerald-500" /> 历史完成 <strong>{{ user?.completed_tasks || 0 }}</strong> 项目</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: actions -->
|
||||
<div class="header-right">
|
||||
<router-link to="/user/announcements">
|
||||
<el-button :icon="Bell" circle size="small" />
|
||||
</router-link>
|
||||
|
||||
<router-link to="/user/certification">
|
||||
<el-tag v-if="!user?.roles?.includes('OPC_USER')" type="warning" effect="dark" size="small" round>
|
||||
<ShieldCheck class="w-3 h-3 mr-1 inline" />OPC 待认证
|
||||
</el-tag>
|
||||
<el-tag v-else type="success" effect="light" size="small" round>
|
||||
<ShieldCheck class="w-3 h-3 mr-1 inline" />OPC 认证专家
|
||||
</el-tag>
|
||||
</router-link>
|
||||
|
||||
<el-dropdown @command="handleCommand" trigger="click">
|
||||
<div class="user-avatar-area">
|
||||
<div class="hidden sm:block text-right mr-3">
|
||||
<div class="text-sm font-semibold text-gray-800 leading-none mb-0.5">{{ user?.nickname || user?.username || '用户' }}</div>
|
||||
<div class="text-xs text-gray-400">{{ user?.roles?.includes('OPC_USER') ? 'OPC 认证专家' : '平台用户' }}</div>
|
||||
</div>
|
||||
<el-avatar :size="36" :src="user?.avatar_url" class="bg-blue-500 text-white font-bold">
|
||||
{{ user?.nickname?.[0] || user?.username?.[0] || 'U' }}
|
||||
</el-avatar>
|
||||
</div>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="profile">
|
||||
<User class="w-4 h-4 mr-2" />个人资料
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item command="cert">
|
||||
<ShieldCheck class="w-4 h-4 mr-2" />OPC 认证
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item command="logout" divided>
|
||||
<LogOut class="w-4 h-4 mr-2" />退出登录
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</el-header>
|
||||
|
||||
<el-main class="app-main">
|
||||
<RouterView />
|
||||
</el-main>
|
||||
</el-container>
|
||||
|
||||
<!-- Floating Toolbar -->
|
||||
<div
|
||||
class="fab-toolbar"
|
||||
:style="{ left: fabPos.x + 'px', top: fabPos.y + 'px' }"
|
||||
:class="{ dragging: isDragging }"
|
||||
>
|
||||
<div class="fab-grip" @mousedown.prevent="onFabDown" @touchstart.prevent="onFabDown">
|
||||
<GripVertical class="w-3.5 h-3.5" />
|
||||
</div>
|
||||
<button class="fab-btn" @click="goHome" title="回到主页">
|
||||
<Home class="w-4 h-4" />
|
||||
</button>
|
||||
<button class="fab-btn" @click="goBack" title="返回上一页">
|
||||
<ArrowLeft class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Sidebar Rail */
|
||||
.sidebar-rail {
|
||||
position: fixed;
|
||||
top: 0; left: 0; bottom: 0;
|
||||
width: 220px;
|
||||
background: #fff;
|
||||
border-right: 1px solid #f0f0f0;
|
||||
z-index: 200;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
/* Header / Logo */
|
||||
.sidebar-header {
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 16px;
|
||||
gap: 10px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
background: #1d1d1f;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.logo-icon {
|
||||
width: 32px; height: 32px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-weight: 900; font-size: 14px; color: #1d1d1f;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.logo-text {
|
||||
font-weight: 800; font-size: 15px; color: #fff;
|
||||
white-space: nowrap; flex: 1;
|
||||
}
|
||||
.logo-sub { color: #60a5fa; font-weight: 400; }
|
||||
|
||||
/* Navigation */
|
||||
.sidebar-nav {
|
||||
flex: 1;
|
||||
padding: 8px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
height: 42px;
|
||||
padding: 0 12px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.nav-item:hover {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
}
|
||||
.nav-item.active {
|
||||
background: #eff6ff;
|
||||
color: #3b82f6;
|
||||
font-weight: 600;
|
||||
}
|
||||
.nav-icon {
|
||||
width: 20px; height: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.nav-label {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Main container */
|
||||
.main-container {
|
||||
margin-left: 0;
|
||||
transition: margin-left 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.main-container.sidebar-on {
|
||||
margin-left: 220px;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
height: 56px;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 0 24px;
|
||||
border-bottom: 1px solid #eef0f4;
|
||||
background: #fff;
|
||||
position: sticky; top: 0; z-index: 50;
|
||||
}
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.sidebar-toggle {
|
||||
width: 36px; height: 36px;
|
||||
border-radius: 8px; border: 1px solid #e5e7eb;
|
||||
background: #fff;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
cursor: pointer; color: #6b7280;
|
||||
transition: all 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sidebar-toggle:hover { background: #f9fafb; color: #111827; border-color: #d1d5db; }
|
||||
.hdr-sep {
|
||||
width: 1px; height: 20px;
|
||||
background: #e5e7eb; flex-shrink: 0;
|
||||
}
|
||||
.header-bio {
|
||||
font-size: 14px;
|
||||
background: linear-gradient(90deg, #7c3aed, #a78bfa);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
font-style: italic;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 420px;
|
||||
margin: 0;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
.bio-mark {
|
||||
-webkit-text-fill-color: #a78bfa;
|
||||
font-style: normal;
|
||||
margin-right: 2px;
|
||||
}
|
||||
.header-badges {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.hdr-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
font-weight: 500;
|
||||
}
|
||||
.loc-pill { color: #64748b; }
|
||||
.done-pill strong { color: #059669; }
|
||||
.hdr-stars { gap: 2px; }
|
||||
.header-right { display: flex; align-items: center; gap: 12px; flex-shrink: 0; }
|
||||
.user-avatar-area { display: flex; align-items: center; cursor: pointer; }
|
||||
.app-main { background: #f5f7fa; min-height: calc(100vh - 64px); padding: 24px; }
|
||||
|
||||
/* Floating Toolbar */
|
||||
.fab-toolbar {
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px;
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.55);
|
||||
backdrop-filter: blur(16px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(180%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.08), 0 1px 4px rgba(0,0,0,0.04);
|
||||
transition: box-shadow 0.2s, opacity 0.2s;
|
||||
user-select: none;
|
||||
}
|
||||
.fab-toolbar:hover {
|
||||
background: rgba(255, 255, 255, 0.75);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
.fab-toolbar.dragging {
|
||||
opacity: 0.85;
|
||||
cursor: grabbing;
|
||||
}
|
||||
.fab-grip {
|
||||
width: 32px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: grab;
|
||||
color: #c0c4cc;
|
||||
border-radius: 6px;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.fab-grip:hover { color: #909399; }
|
||||
.fab-btn {
|
||||
width: 36px; height: 36px;
|
||||
border-radius: 10px;
|
||||
border: none;
|
||||
background: rgba(255,255,255,0.6);
|
||||
color: #6b7280;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.fab-btn:hover {
|
||||
background: #3b82f6;
|
||||
color: #fff;
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 2px 8px rgba(59,130,246,0.25);
|
||||
}
|
||||
</style>
|
||||
17
src/main.ts
Normal file
17
src/main.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { createApp } from 'vue';
|
||||
import { createPinia } from 'pinia';
|
||||
import ElementPlus from 'element-plus';
|
||||
import 'element-plus/dist/index.css';
|
||||
import App from './App.vue';
|
||||
import router from './router';
|
||||
import './index.css';
|
||||
|
||||
const app = createApp(App);
|
||||
const pinia = createPinia();
|
||||
|
||||
app.use(ElementPlus);
|
||||
app.use(pinia);
|
||||
app.use(router);
|
||||
|
||||
app.mount('#app');
|
||||
|
||||
387
src/router/index.ts
Normal file
387
src/router/index.ts
Normal file
@@ -0,0 +1,387 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
import type { RouteRecordRaw } from 'vue-router';
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'Home',
|
||||
component: () => import('@/views/HomeView.vue')
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
name: 'Register',
|
||||
component: () => import('@/views/RegisterView.vue')
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('@/views/LoginView.vue'),
|
||||
meta: { requiresGuest: true }
|
||||
},
|
||||
{
|
||||
path: '/forgot-password',
|
||||
name: 'ForgotPassword',
|
||||
component: () => import('@/views/ForgotPasswordView.vue'),
|
||||
meta: { requiresGuest: true }
|
||||
},
|
||||
{
|
||||
path: '/enterprise/login',
|
||||
name: 'EnterpriseLogin',
|
||||
component: () => import('@/views/enterprise/LoginView.vue')
|
||||
},
|
||||
{
|
||||
path: '/enterprise/register',
|
||||
name: 'EnterpriseRegister',
|
||||
component: () => import('@/views/enterprise/RegisterView.vue')
|
||||
},
|
||||
{
|
||||
path: '/user',
|
||||
component: () => import('@/layouts/MainLayout.vue'),
|
||||
children: [
|
||||
{
|
||||
path: 'dashboard',
|
||||
name: 'Dashboard',
|
||||
component: () => import('@/views/user/DashboardView.vue')
|
||||
},
|
||||
{
|
||||
path: 'profile',
|
||||
name: 'Profile',
|
||||
component: () => import('@/views/user/ProfileView.vue')
|
||||
},
|
||||
{
|
||||
path: 'certification',
|
||||
redirect: '/user/certification/status'
|
||||
},
|
||||
{
|
||||
path: 'certification/apply',
|
||||
name: 'CertificationApply',
|
||||
component: () => import('@/views/user/certification/ApplyView.vue')
|
||||
},
|
||||
{
|
||||
path: 'certification/status',
|
||||
name: 'CertificationStatus',
|
||||
component: () => import('@/views/user/certification/StatusView.vue')
|
||||
},
|
||||
{
|
||||
path: 'tasks/market',
|
||||
name: 'TaskMarket',
|
||||
component: () => import('@/views/user/tasks/TaskMarketView.vue')
|
||||
},
|
||||
{
|
||||
path: 'tasks/:id',
|
||||
name: 'TaskDetail',
|
||||
component: () => import('@/views/user/tasks/TaskDetailView.vue'),
|
||||
props: true
|
||||
},
|
||||
{
|
||||
path: 'tasks/:id/apply',
|
||||
name: 'TaskApply',
|
||||
component: () => import('@/views/user/tasks/TaskApplyView.vue'),
|
||||
props: true
|
||||
},
|
||||
{
|
||||
path: 'tasks/my',
|
||||
name: 'MyTasks',
|
||||
component: () => import('@/views/user/tasks/MyTasksView.vue')
|
||||
},
|
||||
{
|
||||
path: 'applications',
|
||||
name: 'MyApplications',
|
||||
component: () => import('@/views/user/tasks/MyApplicationsView.vue')
|
||||
},
|
||||
{
|
||||
path: 'models',
|
||||
name: 'ModelMarket',
|
||||
component: () => import('@/views/user/models/ModelMarketView.vue')
|
||||
},
|
||||
{
|
||||
path: 'models/keys',
|
||||
name: 'ModelKeys',
|
||||
component: () => import('@/views/user/models/ApiKeyListView.vue')
|
||||
},
|
||||
{
|
||||
path: 'invitations',
|
||||
name: 'UserInvitations',
|
||||
component: () => import('@/views/user/InvitationsView.vue')
|
||||
},
|
||||
{
|
||||
path: 'announcements',
|
||||
name: 'Announcements',
|
||||
component: () => import('@/views/AnnouncementsView.vue')
|
||||
},
|
||||
{
|
||||
path: 'reservations',
|
||||
name: 'UserReservations',
|
||||
component: () => import('@/views/user/ReservationView.vue')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/enterprise',
|
||||
component: () => import('@/layouts/EnterpriseLayout.vue'),
|
||||
children: [
|
||||
{
|
||||
path: 'dashboard',
|
||||
name: 'EnterpriseDashboard',
|
||||
component: () => import('@/views/enterprise/DashboardView.vue')
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
name: 'EnterpriseUserSettings',
|
||||
component: () => import('@/views/enterprise/UserSettingsView.vue')
|
||||
},
|
||||
{
|
||||
path: 'profile',
|
||||
name: 'EnterpriseProfile',
|
||||
component: () => import('@/views/enterprise/ProfileView.vue')
|
||||
},
|
||||
{
|
||||
path: 'verification',
|
||||
name: 'EnterpriseVerification',
|
||||
component: () => import('@/views/enterprise/VerificationView.vue')
|
||||
},
|
||||
{
|
||||
path: 'team',
|
||||
name: 'EnterpriseTeam',
|
||||
component: () => import('@/views/enterprise/TeamView.vue')
|
||||
},
|
||||
{
|
||||
path: 'tasks',
|
||||
name: 'EnterpriseTasks',
|
||||
component: () => import('@/views/enterprise/TaskListView.vue')
|
||||
},
|
||||
{
|
||||
path: 'invitations',
|
||||
name: 'EnterpriseInvitations',
|
||||
component: () => import('@/views/enterprise/InvitationsView.vue')
|
||||
},
|
||||
{
|
||||
path: 'tasks/create',
|
||||
name: 'EnterpriseTaskCreate',
|
||||
component: () => import('@/views/enterprise/TaskCreateView.vue')
|
||||
},
|
||||
{
|
||||
path: 'tasks/:id',
|
||||
name: 'EnterpriseTaskDetail',
|
||||
component: () => import('@/views/enterprise/TaskDetailView.vue'),
|
||||
props: true
|
||||
},
|
||||
{
|
||||
path: 'tasks/:id/edit',
|
||||
name: 'EnterpriseTaskEdit',
|
||||
component: () => import('@/views/enterprise/TaskCreateView.vue'), // Using Same view for edit as POC
|
||||
props: true
|
||||
},
|
||||
{
|
||||
path: 'tasks/:id/applications',
|
||||
name: 'EnterpriseTaskApplications',
|
||||
component: () => import('@/views/enterprise/TaskApplicationsView.vue'),
|
||||
props: true
|
||||
},
|
||||
{
|
||||
path: 'opc-users',
|
||||
name: 'EnterpriseOPCUsers',
|
||||
component: () => import('@/views/enterprise/OPCUsersView.vue')
|
||||
},
|
||||
{
|
||||
path: 'models',
|
||||
name: 'EnterpriseModels',
|
||||
component: () => import('@/views/enterprise/ModelsView.vue')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/admin/login',
|
||||
name: 'AdminLogin',
|
||||
component: () => import('@/views/admin/LoginView.vue')
|
||||
},
|
||||
{
|
||||
path: '/admin',
|
||||
component: () => import('@/layouts/AdminLayout.vue'),
|
||||
redirect: '/admin/dashboard',
|
||||
children: [
|
||||
{
|
||||
path: 'dashboard',
|
||||
name: 'AdminDashboard',
|
||||
component: () => import('@/views/admin/DashboardView.vue')
|
||||
},
|
||||
{
|
||||
path: 'users',
|
||||
name: 'AdminUsers',
|
||||
component: () => import('@/views/admin/UserListView.vue')
|
||||
},
|
||||
{
|
||||
path: 'enterprises',
|
||||
name: 'AdminEnterprises',
|
||||
component: () => import('@/views/admin/EnterpriseListView.vue')
|
||||
},
|
||||
{
|
||||
path: 'roles',
|
||||
name: 'AdminRoles',
|
||||
component: () => import('@/views/admin/RoleListView.vue')
|
||||
},
|
||||
{
|
||||
path: 'permissions',
|
||||
name: 'AdminPermissions',
|
||||
component: () => import('@/views/admin/PermissionListView.vue')
|
||||
},
|
||||
{
|
||||
path: 'certifications',
|
||||
name: 'AdminCertifications',
|
||||
component: () => import('@/views/admin/CertificationListView.vue')
|
||||
},
|
||||
{
|
||||
path: 'enterprise-certs',
|
||||
name: 'AdminEnterpriseCerts',
|
||||
component: () => import('@/views/admin/EnterpriseCertListView.vue')
|
||||
},
|
||||
{
|
||||
path: 'opc-users',
|
||||
name: 'AdminOPCUsers',
|
||||
component: () => import('@/views/admin/OPCUsersListView.vue')
|
||||
},
|
||||
{
|
||||
path: 'tasks',
|
||||
name: 'AdminTasks',
|
||||
component: () => import('@/views/admin/AdminTaskListView.vue')
|
||||
},
|
||||
{
|
||||
path: 'tasks/create',
|
||||
name: 'AdminTaskCreate',
|
||||
component: () => import('@/views/enterprise/TaskCreateView.vue')
|
||||
},
|
||||
{
|
||||
path: 'tasks/:id',
|
||||
name: 'AdminTaskDetail',
|
||||
component: () => import('@/views/enterprise/TaskDetailView.vue'),
|
||||
props: true
|
||||
},
|
||||
{
|
||||
path: 'tasks/:id/edit',
|
||||
name: 'AdminTaskEdit',
|
||||
component: () => import('@/views/enterprise/TaskCreateView.vue'),
|
||||
props: true
|
||||
},
|
||||
{
|
||||
path: 'tasks/:id/applications',
|
||||
name: 'AdminTaskApplications',
|
||||
component: () => import('@/views/enterprise/TaskApplicationsView.vue'),
|
||||
props: true
|
||||
},
|
||||
{
|
||||
path: 'reservation/config',
|
||||
name: 'AdminReservationConfig',
|
||||
component: () => import('@/views/admin/ReservationConfigView.vue')
|
||||
},
|
||||
{
|
||||
path: 'reservations',
|
||||
name: 'AdminReservations',
|
||||
component: () => import('@/views/admin/AdminReservationView.vue')
|
||||
},
|
||||
{
|
||||
path: 'models',
|
||||
name: 'AdminModels',
|
||||
component: () => import('@/views/admin/AdminModelListView.vue')
|
||||
},
|
||||
{
|
||||
path: 'announcements',
|
||||
name: 'AdminAnnouncements',
|
||||
component: () => import('@/views/admin/AnnouncementsView.vue')
|
||||
},
|
||||
{
|
||||
path: 'skills',
|
||||
name: 'AdminSkills',
|
||||
component: () => import('@/views/admin/SkillManageView.vue')
|
||||
},
|
||||
{
|
||||
path: 'deleted-users',
|
||||
name: 'AdminDeletedUsers',
|
||||
component: () => import('@/views/admin/DeletedUsersView.vue')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/screen',
|
||||
|
||||
name: 'Screen',
|
||||
component: () => import('@/views/ScreenView.vue')
|
||||
}
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
});
|
||||
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
|
||||
// Navigation guard
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
const authStore = useAuthStore();
|
||||
const publicPages = ['/', '/login', '/register', '/enterprise/login', '/enterprise/register', '/admin/login', '/screen'];
|
||||
const authRequired = !publicPages.includes(to.path);
|
||||
const token = localStorage.getItem('access_token');
|
||||
|
||||
// If there's a token but no user data, try to fetch it first
|
||||
if (token && !authStore.user) {
|
||||
try {
|
||||
await authStore.fetchUser();
|
||||
} catch (error) {
|
||||
console.error('Failed to restore session');
|
||||
// Token might be invalid, clear it
|
||||
authStore.logout();
|
||||
}
|
||||
}
|
||||
|
||||
const loggedIn = !!authStore.user;
|
||||
|
||||
// 1. Check if auth is required
|
||||
if (authRequired && !loggedIn) {
|
||||
if (to.path.startsWith('/admin')) {
|
||||
return next('/admin/login');
|
||||
}
|
||||
if (to.path.startsWith('/enterprise')) {
|
||||
return next('/enterprise/login');
|
||||
}
|
||||
return next('/login');
|
||||
}
|
||||
|
||||
// 2. If logged in but trying to access login/register
|
||||
if (loggedIn && (to.path === '/login' || to.path === '/enterprise/login' || to.path === '/admin/login')) {
|
||||
if (authStore.userRoles.includes('ADMIN') || authStore.user?.is_superuser) {
|
||||
return next('/admin/dashboard');
|
||||
}
|
||||
if (authStore.userRoles.includes('ENTERPRISE')) {
|
||||
return next('/enterprise/dashboard');
|
||||
}
|
||||
return next('/user/dashboard');
|
||||
}
|
||||
|
||||
// 3. Role based protection for /enterprise routes
|
||||
if (loggedIn && to.path.startsWith('/enterprise')) {
|
||||
if (!authStore.userRoles.includes('ENTERPRISE')) {
|
||||
return next('/user/dashboard');
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Admin check
|
||||
if (loggedIn && to.path.startsWith('/admin')) {
|
||||
if (!authStore.user.is_staff && !authStore.user.is_superuser) {
|
||||
return next('/user/dashboard');
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Block enterprise-only users from accessing /user routes (e.g. personal profile)
|
||||
if (loggedIn && to.path.startsWith('/user')) {
|
||||
if (authStore.userRoles.includes('ENTERPRISE') && !authStore.userRoles.includes('USER') && !authStore.userRoles.includes('OPC_USER')) {
|
||||
return next('/enterprise/dashboard');
|
||||
}
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
export default router;
|
||||
90
src/stores/auth.ts
Normal file
90
src/stores/auth.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, computed } from 'vue';
|
||||
import api from '@/api';
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const user = ref<any>(null);
|
||||
const token = ref<string | null>(localStorage.getItem('access_token'));
|
||||
|
||||
const isAuthenticated = computed(() => !!token.value);
|
||||
const userRoles = computed(() => user.value?.roles || []);
|
||||
const userPermissions = computed(() => user.value?.permissions || []);
|
||||
|
||||
const hasPermission = (permissionCode: string) => {
|
||||
if (!user.value) return false;
|
||||
if (user.value.is_superuser || userPermissions.value.includes('*')) return true;
|
||||
return userPermissions.value.includes(permissionCode);
|
||||
};
|
||||
|
||||
async function login(credentials: any) {
|
||||
try {
|
||||
const response: any = await api.post('/auth/login/', credentials);
|
||||
const { access, refresh, user: userData } = response;
|
||||
|
||||
token.value = access;
|
||||
localStorage.setItem('access_token', access);
|
||||
localStorage.setItem('refresh_token', refresh);
|
||||
user.value = userData;
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Login failed', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function enterpriseLogin(credentials: any) {
|
||||
try {
|
||||
const response: any = await api.post('/auth/enterprise/login/', credentials);
|
||||
const { access, refresh, user: userData } = response;
|
||||
|
||||
token.value = access;
|
||||
localStorage.setItem('access_token', access);
|
||||
localStorage.setItem('refresh_token', refresh);
|
||||
user.value = userData;
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Enterprise login failed', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function register(userData: any) {
|
||||
try {
|
||||
const response: any = await api.post('/auth/register/', userData);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Registration failed', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchUser() {
|
||||
if (!token.value) return;
|
||||
try {
|
||||
const response: any = await api.get('/auth/me/');
|
||||
user.value = response;
|
||||
} catch (error) {
|
||||
logout();
|
||||
}
|
||||
}
|
||||
|
||||
function logout() {
|
||||
user.value = null;
|
||||
token.value = null;
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
}
|
||||
|
||||
return {
|
||||
user,
|
||||
token,
|
||||
isAuthenticated,
|
||||
userRoles,
|
||||
login,
|
||||
enterpriseLogin,
|
||||
register,
|
||||
fetchUser,
|
||||
logout,
|
||||
hasPermission
|
||||
};
|
||||
});
|
||||
48
src/stores/system.ts
Normal file
48
src/stores/system.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
import api from '@/api';
|
||||
|
||||
export const useSystemStore = defineStore('system', () => {
|
||||
const models = ref<any[]>([]);
|
||||
const announcements = ref<any[]>([]);
|
||||
|
||||
async function fetchModels() {
|
||||
try {
|
||||
const response: any = await api.get('/models/');
|
||||
models.value = response;
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Fetch models failed', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAnnouncements() {
|
||||
try {
|
||||
const response: any = await api.get('/announcements/');
|
||||
announcements.value = response;
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Fetch announcements failed', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function getToken(modelId: string) {
|
||||
try {
|
||||
const response: any = await api.post('/tokens/', { model: modelId });
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Get token failed', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
models,
|
||||
announcements,
|
||||
fetchModels,
|
||||
fetchAnnouncements,
|
||||
getToken
|
||||
};
|
||||
});
|
||||
85
src/stores/tasks.ts
Normal file
85
src/stores/tasks.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
import api from '@/api';
|
||||
|
||||
export const useTasksStore = defineStore('tasks', () => {
|
||||
const tasks = ref<any[]>([]);
|
||||
const invitations = ref<any[]>([]);
|
||||
|
||||
async function fetchOpenTasks() {
|
||||
try {
|
||||
const response: any = await api.get('/tasks/');
|
||||
tasks.value = response.results || response || [];
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Fetch tasks failed', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchInvitations() {
|
||||
try {
|
||||
const response: any = await api.get('/invitations/');
|
||||
invitations.value = response.results || response || [];
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Fetch invitations failed', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function acceptInvitation(id: string) {
|
||||
try {
|
||||
await api.post(`/invitations/${id}/accept/`);
|
||||
await fetchInvitations();
|
||||
} catch (error) {
|
||||
console.error('Accept invitation failed', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function rejectInvitation(id: string) {
|
||||
try {
|
||||
await api.post(`/invitations/${id}/reject/`);
|
||||
await fetchInvitations();
|
||||
} catch (error) {
|
||||
console.error('Reject invitation failed', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteInvitation(id: string) {
|
||||
try {
|
||||
await api.delete(`/invitations/${id}/`);
|
||||
await fetchInvitations();
|
||||
} catch (error) {
|
||||
console.error('Delete invitation failed', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function discussInvitation(id: string, message: string) {
|
||||
try {
|
||||
await api.post(`/invitations/${id}/discuss/`, { message });
|
||||
await fetchInvitations();
|
||||
} catch (error) {
|
||||
console.error('Discuss invitation failed', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// fetchTasks is an alias for fetchOpenTasks (used by OPCUsersView)
|
||||
const fetchTasks = fetchOpenTasks;
|
||||
|
||||
return {
|
||||
tasks,
|
||||
invitations,
|
||||
fetchOpenTasks,
|
||||
fetchTasks,
|
||||
fetchInvitations,
|
||||
acceptInvitation,
|
||||
rejectInvitation,
|
||||
deleteInvitation,
|
||||
discussInvitation
|
||||
};
|
||||
});
|
||||
57
src/types/enums.ts
Normal file
57
src/types/enums.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* 全局枚举定义
|
||||
*/
|
||||
|
||||
export enum UserStatus {
|
||||
ACTIVE = 'ACTIVE',
|
||||
INACTIVE = 'INACTIVE',
|
||||
BANNED = 'BANNED'
|
||||
}
|
||||
|
||||
export enum CertStatus {
|
||||
PENDING = 'PENDING',
|
||||
APPROVED = 'APPROVED',
|
||||
REJECTED = 'REJECTED'
|
||||
}
|
||||
|
||||
export enum TaskStatus {
|
||||
OPEN = 'OPEN',
|
||||
IN_PROGRESS = 'IN_PROGRESS',
|
||||
SUBMITTED = 'SUBMITTED',
|
||||
COMPLETED = 'COMPLETED',
|
||||
CANCELLED = 'CANCELLED'
|
||||
}
|
||||
|
||||
export enum Priority {
|
||||
LOW = 'LOW',
|
||||
MEDIUM = 'MEDIUM',
|
||||
HIGH = 'HIGH',
|
||||
URGENT = 'URGENT'
|
||||
}
|
||||
|
||||
export enum ApplyStatus {
|
||||
PENDING = 'PENDING',
|
||||
APPROVED = 'APPROVED',
|
||||
REJECTED = 'REJECTED'
|
||||
}
|
||||
|
||||
export enum Role {
|
||||
ADMIN = 'ADMIN',
|
||||
USER = 'USER',
|
||||
OPC_USER = 'OPC_USER',
|
||||
AUDITOR = 'AUDITOR'
|
||||
}
|
||||
|
||||
export enum ExecutionStatus {
|
||||
IN_PROGRESS = 'IN_PROGRESS',
|
||||
FINISHED = 'FINISHED',
|
||||
ABANDONED = 'ABANDONED'
|
||||
}
|
||||
|
||||
export enum AcceptanceStatus {
|
||||
PENDING_SUBMIT = 'PENDING_SUBMIT',
|
||||
UNDER_REVIEW = 'UNDER_REVIEW',
|
||||
PENDING_CONFIRM = 'PENDING_CONFIRM',
|
||||
ACCEPTED = 'ACCEPTED',
|
||||
REJECTED = 'REJECTED'
|
||||
}
|
||||
105
src/views/AnnouncementsView.vue
Normal file
105
src/views/AnnouncementsView.vue
Normal file
@@ -0,0 +1,105 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { Megaphone, Calendar, ChevronRight } from 'lucide-vue-next';
|
||||
import { getAnnouncements } from '@/api/system';
|
||||
import { MdPreview } from 'md-editor-v3';
|
||||
import 'md-editor-v3/lib/preview.css';
|
||||
|
||||
const news = ref<any[]>([]);
|
||||
const isLoading = ref(true);
|
||||
|
||||
// Detail dialog
|
||||
const detailVisible = ref(false);
|
||||
const selectedAnn = ref<any>(null);
|
||||
const openDetail = (item: any) => {
|
||||
selectedAnn.value = item;
|
||||
detailVisible.value = true;
|
||||
};
|
||||
|
||||
const audienceLabel = (audience: string) => {
|
||||
const map: Record<string, string> = { ALL: '全平台', USER: '用户专属', ENTERPRISE: '企业专属' };
|
||||
return map[audience] || '综合公告';
|
||||
};
|
||||
|
||||
const fetchAnnouncements = async () => {
|
||||
try {
|
||||
const res: any = await getAnnouncements({ audience: 'USER' });
|
||||
news.value = res.results || [];
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch announcements:', e);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchAnnouncements();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">系统公告</h1>
|
||||
<p class="text-gray-500 mt-1">第一时间掌握平台动态、政策解读与功能上新。</p>
|
||||
</div>
|
||||
|
||||
<el-card shadow="never" v-loading="isLoading" class="!border-gray-100">
|
||||
<div v-if="news.length > 0" class="divide-y divide-gray-100">
|
||||
<div
|
||||
v-for="item in news"
|
||||
:key="item.id"
|
||||
class="py-5 first:pt-0 last:pb-0 hover:bg-gray-50/50 transition-colors group cursor-pointer"
|
||||
@click="openDetail(item)"
|
||||
>
|
||||
<div class="flex items-start gap-4">
|
||||
<!-- Cover image or icon -->
|
||||
<div v-if="item.cover_url" class="w-28 h-20 rounded-lg overflow-hidden flex-shrink-0 border border-gray-100">
|
||||
<img :src="item.cover_url" class="w-full h-full object-cover" />
|
||||
</div>
|
||||
<div v-else class="p-2 bg-blue-50 rounded-lg group-hover:bg-blue-500 transition-colors shrink-0 mt-1">
|
||||
<Megaphone class="w-5 h-5 text-blue-500 group-hover:text-white" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<div class="flex items-center space-x-2">
|
||||
<h3 class="text-base font-bold text-gray-900 group-hover:text-blue-600 transition-colors truncate">{{ item.title }}</h3>
|
||||
</div>
|
||||
<div class="flex items-center text-gray-400 text-xs flex-shrink-0 ml-3">
|
||||
<Calendar class="w-3.5 h-3.5 mr-1" />
|
||||
{{ new Date(item.created_at).toLocaleDateString() }}
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 leading-relaxed line-clamp-2">{{ item.content?.replace(/[#*`_~>\-\[\]()!]/g, '').substring(0, 150) }}</p>
|
||||
<div class="mt-3 flex items-center justify-between">
|
||||
<el-tag size="small">{{ audienceLabel(item.target_audience) }}</el-tag>
|
||||
<el-button text type="primary" size="small">
|
||||
查看详情 <ChevronRight class="ml-1 w-3 h-3" />
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else-if="!isLoading" description="暂无公告" />
|
||||
</el-card>
|
||||
|
||||
<!-- Announcement Detail Dialog with MD Preview -->
|
||||
<el-dialog v-model="detailVisible" :title="selectedAnn?.title" width="700px" top="5vh">
|
||||
<div v-if="selectedAnn" class="space-y-4">
|
||||
<div class="flex items-center gap-3 text-sm text-gray-400">
|
||||
<el-tag size="small" type="primary">{{ audienceLabel(selectedAnn.target_audience) }}</el-tag>
|
||||
<span class="flex items-center gap-1">
|
||||
<Calendar class="w-3 h-3" />{{ new Date(selectedAnn.created_at).toLocaleDateString() }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="selectedAnn.cover_url" class="rounded-xl overflow-hidden border border-gray-100">
|
||||
<img :src="selectedAnn.cover_url" class="w-full max-h-64 object-cover" />
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded-xl p-6 border border-gray-100 min-h-[200px]">
|
||||
<MdPreview :modelValue="selectedAnn.content" />
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
160
src/views/ForgotPasswordView.vue
Normal file
160
src/views/ForgotPasswordView.vue
Normal file
@@ -0,0 +1,160 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import api from '@/api';
|
||||
import { Mail, KeyRound, Lock, ArrowLeft, CheckCircle } from 'lucide-vue-next';
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const step = ref(1); // 1: input email, 2: reset password, 3: success
|
||||
|
||||
const requestForm = ref({
|
||||
email: ''
|
||||
});
|
||||
|
||||
const resetForm = ref({
|
||||
verification_code: '',
|
||||
new_password: '',
|
||||
confirm_password: ''
|
||||
});
|
||||
|
||||
const isRequesting = ref(false);
|
||||
const isResetting = ref(false);
|
||||
|
||||
const requestReset = async () => {
|
||||
if (!requestForm.value.email) {
|
||||
ElMessage.warning('请输入注册邮箱');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
isRequesting.value = true;
|
||||
const res: any = await api.post('/users/request_password_reset/', { email: requestForm.value.email });
|
||||
ElMessage.success(res.status || '验证码已发送,请查收');
|
||||
step.value = 2;
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.response?.data?.email?.[0] || '发送验证码失败');
|
||||
} finally {
|
||||
isRequesting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const confirmReset = async () => {
|
||||
if (!resetForm.value.verification_code) {
|
||||
ElMessage.warning('请输入验证码');
|
||||
return;
|
||||
}
|
||||
if (!resetForm.value.new_password || resetForm.value.new_password.length < 6) {
|
||||
ElMessage.warning('新密码不能少于6个字符');
|
||||
return;
|
||||
}
|
||||
if (resetForm.value.new_password !== resetForm.value.confirm_password) {
|
||||
ElMessage.warning('两次输入的密码不一致');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
isResetting.value = true;
|
||||
const payload = {
|
||||
email: requestForm.value.email,
|
||||
verification_code: resetForm.value.verification_code,
|
||||
new_password: resetForm.value.new_password
|
||||
};
|
||||
await api.post('/users/confirm_password_reset/', payload);
|
||||
step.value = 3;
|
||||
} catch (error: any) {
|
||||
const data = error.response?.data;
|
||||
if (data?.verification_code) {
|
||||
ElMessage.error(data.verification_code[0]);
|
||||
} else {
|
||||
ElMessage.error(data?.detail || '密码重置失败,请重试');
|
||||
}
|
||||
} finally {
|
||||
isResetting.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-50 flex items-center justify-center px-4">
|
||||
<div class="max-w-md w-full">
|
||||
<el-button text @click="router.push('/login')" class="mb-6">
|
||||
<ArrowLeft class="w-4 h-4 mr-1" />返回登录页
|
||||
</el-button>
|
||||
|
||||
<el-card shadow="always" class="!rounded-2xl !p-2">
|
||||
<div class="text-center mb-8 pt-4">
|
||||
<div class="mx-auto h-16 w-16 rounded-2xl flex items-center justify-center mb-4 transition-colors duration-300"
|
||||
:class="step === 3 ? 'bg-green-100' : 'bg-blue-50'">
|
||||
<KeyRound v-if="step !== 3" class="w-8 h-8 text-blue-600" />
|
||||
<CheckCircle v-else class="w-8 h-8 text-green-600" />
|
||||
</div>
|
||||
<h2 class="text-2xl font-bold text-gray-800">找回密码</h2>
|
||||
<p class="text-xs text-gray-400 mt-1 tracking-widest uppercase">Password Recovery</p>
|
||||
</div>
|
||||
|
||||
<!-- Step 1: Request Code -->
|
||||
<div v-if="step === 1" class="animate-fade-in">
|
||||
<p class="text-sm text-gray-500 mb-6 text-center">请输入您注册时使用的邮箱,我们将向您发送重置验证码。</p>
|
||||
<el-form @submit.prevent="requestReset" label-position="top">
|
||||
<el-form-item label="注册邮箱">
|
||||
<el-input v-model="requestForm.email" placeholder="example@domain.com" size="large">
|
||||
<template #prefix><Mail class="w-4 h-4 text-gray-400" /></template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-button type="primary" native-type="submit" :loading="isRequesting" class="w-full mt-2" size="large">
|
||||
发送验证码
|
||||
</el-button>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Reset Password -->
|
||||
<div v-else-if="step === 2" class="animate-fade-in">
|
||||
<div class="bg-blue-50 text-blue-800 text-sm p-3 rounded-lg mb-6 border border-blue-100 text-center">
|
||||
验证码已发送至 <strong>{{ requestForm.email }}</strong>
|
||||
</div>
|
||||
<el-form @submit.prevent="confirmReset" label-position="top">
|
||||
<el-form-item label="6位验证码">
|
||||
<el-input v-model="resetForm.verification_code" placeholder="输入收到的验证码" size="large" maxlength="6">
|
||||
<template #prefix><KeyRound class="w-4 h-4 text-gray-400" /></template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="设置新密码">
|
||||
<el-input v-model="resetForm.new_password" type="password" placeholder="至少 6 个字符" size="large" show-password>
|
||||
<template #prefix><Lock class="w-4 h-4 text-gray-400" /></template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="确认新密码">
|
||||
<el-input v-model="resetForm.confirm_password" type="password" placeholder="再次输入新密码" size="large" show-password>
|
||||
<template #prefix><Lock class="w-4 h-4 text-gray-400" /></template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-button type="primary" native-type="submit" :loading="isResetting" class="w-full mt-2" size="large">
|
||||
确认重置密码
|
||||
</el-button>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Success -->
|
||||
<div v-else class="animate-fade-in text-center py-4">
|
||||
<h3 class="text-xl font-bold text-gray-800 mb-2">密码重置成功!</h3>
|
||||
<p class="text-sm text-gray-500 mb-8">您现在可以使用新密码登录系统了。</p>
|
||||
<el-button type="primary" @click="router.push('/login')" class="w-full" size="large">
|
||||
立即去登录
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
</style>
|
||||
327
src/views/HomeView.vue
Normal file
327
src/views/HomeView.vue
Normal file
@@ -0,0 +1,327 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Rocket,
|
||||
ShieldCheck,
|
||||
Zap,
|
||||
Globe,
|
||||
ArrowRight,
|
||||
Target,
|
||||
BarChart3,
|
||||
Users
|
||||
} from 'lucide-vue-next';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
const authStore = useAuthStore();
|
||||
|
||||
|
||||
const features = [
|
||||
{
|
||||
title: '数字化基建',
|
||||
description: '为一人公司提供全方位的云端办公环境与算力资源支持。',
|
||||
icon: Zap,
|
||||
color: 'text-blue-500',
|
||||
bg: 'bg-blue-50'
|
||||
},
|
||||
{
|
||||
title: '全球任务广场',
|
||||
description: '接入全球顶级任务需求,涵盖数据标注、系统测试与创意设计等领域。',
|
||||
icon: Globe,
|
||||
color: 'text-purple-500',
|
||||
bg: 'bg-purple-50'
|
||||
},
|
||||
{
|
||||
title: '合规保障体系',
|
||||
description: '行业领先的 OPC 技术认证与法律合规框架,保护您的每一份产出。',
|
||||
icon: ShieldCheck,
|
||||
color: 'text-green-500',
|
||||
bg: 'bg-green-50'
|
||||
}
|
||||
];
|
||||
|
||||
const steps = [
|
||||
{ label: '01', title: '完成 OPC 认证', description: '提交个人资质,通过平台专业技能审核。' },
|
||||
{ label: '02', title: '申领数字资产', description: '获取工作站、API 密钥以及专属协作模型。' },
|
||||
{ label: '03', title: '即刻开启收益', description: '在全球任务广场中领取并完成高报酬任务。' }
|
||||
];
|
||||
|
||||
import { onMounted, ref } from 'vue';
|
||||
import api from '@/api';
|
||||
|
||||
const announcements = ref<any[]>([
|
||||
{ id: 1, title: 'CorpScale 2.0 品牌升级:助力一人公司跨越式增长', created_at: '2024-04-24', tag: '品牌公告' },
|
||||
{ id: 2, title: '关于新增 4D 点云标注、多模态意图识别等高端任务类型的通知', created_at: '2024-04-22', tag: '任务上新' },
|
||||
{ id: 3, title: '开发者生态支持计划:首批 OPC 认证专家将获得算力补贴', created_at: '2024-04-20', tag: '生态扶持' }
|
||||
]);
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res: any = await api.get('/system/announcements/');
|
||||
if (res.results && res.results.length > 0) {
|
||||
announcements.value = res.results.slice(0, 3);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch announcements', e);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-white selection:bg-blue-100 selection:text-blue-900 font-sans antialiased">
|
||||
<!-- Navbar -->
|
||||
<nav class="fixed top-0 w-full z-50 bg-white/80 backdrop-blur-xl border-b border-gray-100">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between h-20 items-center">
|
||||
<div class="flex items-center space-x-3 cursor-pointer" @click="router.push('/')">
|
||||
<div class="w-10 h-10 bg-gray-900 rounded-xl flex items-center justify-center text-white shadow-xl shadow-gray-200">
|
||||
<Rocket class="w-6 h-6" />
|
||||
</div>
|
||||
<span class="text-xl font-black tracking-tighter text-gray-900 italic">CorpScale <span class="text-blue-600">OPC</span></span>
|
||||
</div>
|
||||
|
||||
<div class="hidden md:flex items-center space-x-8">
|
||||
<a href="#features" class="text-sm font-bold text-gray-500 hover:text-gray-900 transition-colors">平台愿景</a>
|
||||
<a href="#announcements" class="text-sm font-bold text-gray-500 hover:text-gray-900 transition-colors">社区动态</a>
|
||||
<button
|
||||
@click="router.push('/login')"
|
||||
class="px-6 py-2.5 bg-gray-900 text-white rounded-xl text-sm font-black hover:bg-blue-600 hover:shadow-lg hover:shadow-blue-200 transition-all active:scale-95"
|
||||
>
|
||||
登录控制台
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Hero Section -->
|
||||
<section class="relative pt-40 pb-20 overflow-hidden">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
|
||||
<div class="max-w-3xl">
|
||||
<div class="inline-flex items-center space-x-2 px-3 py-1 bg-blue-50 text-blue-600 rounded-full mb-6">
|
||||
<Zap class="w-3.5 h-3.5" />
|
||||
<span class="text-[10px] font-black uppercase tracking-[0.2em]">Scale Your Individual Potentials</span>
|
||||
</div>
|
||||
<h1 class="text-6xl md:text-8xl font-black text-gray-900 tracking-tighter leading-[0.9] mb-8">
|
||||
一人即 <span class="text-transparent bg-clip-text bg-gradient-to-r from-blue-600 to-purple-600">生产力。</span>
|
||||
</h1>
|
||||
<p class="text-xl text-gray-500 font-medium leading-relaxed mb-10 max-w-xl">
|
||||
CorpScale 为数字化时代的专业个人提供从基建、合规到全球任务对接的一站式服务,让您以“一人公司”模式高效运作。
|
||||
</p>
|
||||
<div class="flex flex-col sm:flex-row gap-4">
|
||||
<button
|
||||
v-if="!authStore.isAuthenticated"
|
||||
@click="router.push('/login')"
|
||||
class="px-10 py-5 bg-gray-900 text-white rounded-[2rem] text-lg font-black hover:bg-blue-600 transition-all flex items-center justify-center space-x-3 group shadow-2xl shadow-blue-100"
|
||||
>
|
||||
<span>个人 / OPC 入驻</span>
|
||||
<ArrowRight class="w-5 h-5 group-hover:translate-x-1 transition-transform" />
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
@click="router.push(authStore.userRoles.includes('ENTERPRISE') ? '/enterprise/dashboard' : '/user/dashboard')"
|
||||
class="px-10 py-5 bg-blue-600 text-white rounded-[2rem] text-lg font-black hover:bg-gray-900 transition-all flex items-center justify-center space-x-3 group shadow-2xl shadow-blue-100"
|
||||
>
|
||||
<span>进入控制台</span>
|
||||
<ArrowRight class="w-5 h-5 group-hover:translate-x-1 transition-transform" />
|
||||
</button>
|
||||
<button
|
||||
v-if="!authStore.isAuthenticated"
|
||||
@click="router.push('/enterprise/login')"
|
||||
class="px-10 py-5 bg-white border-2 border-gray-900 text-gray-900 rounded-[2rem] text-lg font-black hover:border-blue-600 hover:text-blue-600 transition-all flex items-center justify-center space-x-3 group"
|
||||
>
|
||||
<Users class="w-5 h-5" />
|
||||
<span>企业级入口</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="!authStore.isAuthenticated"
|
||||
@click="router.push('/admin/login')"
|
||||
class="px-10 py-5 bg-white border-2 border-gray-200 text-gray-500 rounded-[2rem] text-lg font-black hover:border-gray-900 hover:text-gray-900 transition-all flex items-center justify-center space-x-3 group"
|
||||
>
|
||||
<ShieldCheck class="w-5 h-5" />
|
||||
<span>管理端入口</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="mt-16 flex items-center space-x-12">
|
||||
<div v-for="stat in ['10k+ 注册伙伴', '80w+ 任务处理', '$2.5m+ 年度结算']" :key="stat" class="flex flex-col">
|
||||
<span class="text-xs font-black text-gray-400 uppercase tracking-widest mb-1">{{ stat.split(' ')[1] }}</span>
|
||||
<span class="text-2xl font-black text-gray-900 italic tracking-tighter">{{ stat.split(' ')[0] }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Decorative Elements -->
|
||||
<div class="absolute top-1/2 right-0 -translate-y-1/2 w-1/2 h-full opacity-10 pointer-events-none select-none">
|
||||
<div class="absolute inset-0 bg-gradient-to-l from-blue-500/20 to-transparent blur-3xl"></div>
|
||||
<div class="relative w-full h-full">
|
||||
<Rocket class="absolute top-1/4 right-0 w-[500px] h-[500px] text-gray-900 -rotate-12" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Features Section -->
|
||||
<section id="features" class="py-32 bg-gray-50">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="text-center mb-20">
|
||||
<h2 class="text-[10px] font-black text-blue-600 uppercase tracking-[0.5em] mb-4">Core Ecosystem</h2>
|
||||
<p class="text-4xl font-black text-gray-900 italic tracking-tighter">驱动您的个体增长引擎</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-12">
|
||||
<div
|
||||
v-for="feature in features"
|
||||
:key="feature.title"
|
||||
class="bg-white p-10 rounded-[3rem] shadow-sm hover:shadow-2xl hover:shadow-gray-200 transition-all duration-500 group"
|
||||
>
|
||||
<div :class="['w-16 h-16 rounded-[1.5rem] flex items-center justify-center mb-8 group-hover:scale-110 transition-transform duration-500 shadow-sm', feature.bg, feature.color]">
|
||||
<component :is="feature.icon" class="w-8 h-8" />
|
||||
</div>
|
||||
<h3 class="text-2xl font-black text-gray-900 mb-4">{{ feature.title }}</h3>
|
||||
<p class="text-gray-500 font-medium leading-relaxed">{{ feature.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Workflow Section -->
|
||||
<section id="workflow" class="py-32 bg-white relative overflow-hidden">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex flex-col lg:flex-row items-center gap-16">
|
||||
<div class="lg:w-1/2">
|
||||
<h2 class="text-[10px] font-black text-blue-600 uppercase tracking-[0.5em] mb-4">Onboarding</h2>
|
||||
<p class="text-5xl font-black text-gray-900 italic tracking-tighter leading-tight mb-8">
|
||||
从专家到实体 <br/> 只需三个简单的步伐
|
||||
</p>
|
||||
<p class="text-lg text-gray-500 font-medium mb-10">
|
||||
我们简化的入驻流程确保您能以最快速度获得专业工具包并开始创造价值。
|
||||
</p>
|
||||
<div class="space-y-4">
|
||||
<div v-for="benefit in ['技能导向而非背景导向', '灵活的任务结算周期', '全数字化税筹合规支持']" :key="benefit" class="flex items-center space-x-3">
|
||||
<div class="w-6 h-6 bg-green-50 text-green-600 rounded-full flex items-center justify-center">
|
||||
<Target class="w-3.5 h-3.5" />
|
||||
</div>
|
||||
<span class="text-gray-900 font-bold">{{ benefit }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lg:w-1/2 grid grid-cols-1 gap-6">
|
||||
<div
|
||||
v-for="step in steps"
|
||||
:key="step.label"
|
||||
class="flex items-start space-x-6 p-8 bg-gray-50 rounded-[2.5rem] hover:bg-blue-600 hover:text-white transition-all duration-500 group"
|
||||
>
|
||||
<div class="text-4xl font-black italic text-blue-600 group-hover:text-white/40 transition-colors">
|
||||
{{ step.label }}
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="text-xl font-black mb-2">{{ step.title }}</h4>
|
||||
<p class="text-gray-500 font-medium group-hover:text-white/80 transition-colors">
|
||||
{{ step.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Announcements Section -->
|
||||
<section id="announcements" class="py-32 bg-white">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex flex-col lg:flex-row items-start gap-12">
|
||||
<div class="lg:w-1/3">
|
||||
<h2 class="text-[10px] font-black text-blue-600 uppercase tracking-[0.5em] mb-4">News Feed</h2>
|
||||
<p class="text-4xl font-black text-gray-900 italic tracking-tighter leading-tight mb-8">社区公告与 <br/> 品牌中心</p>
|
||||
<button class="flex items-center space-x-3 text-xs font-black text-gray-400 uppercase tracking-widest hover:text-blue-600 transition-colors">
|
||||
<span>查看全部动态</span>
|
||||
<ArrowRight class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="lg:w-2/2 grid grid-cols-1 gap-4 w-full">
|
||||
<div
|
||||
v-for="item in announcements"
|
||||
:key="item.id"
|
||||
class="p-6 bg-gray-50 border border-gray-100 rounded-3xl flex items-center justify-between hover:bg-white hover:shadow-xl hover:shadow-gray-100 transition-all group"
|
||||
>
|
||||
<div class="flex items-center space-x-6">
|
||||
<div class="px-3 py-1 bg-white text-[9px] font-black text-blue-600 rounded-lg border border-gray-100 uppercase tracking-widest">
|
||||
{{ item.tag }}
|
||||
</div>
|
||||
<h4 class="text-sm font-bold text-gray-900 group-hover:text-blue-700 transition-colors">{{ item.title }}</h4>
|
||||
</div>
|
||||
<div class="text-[10px] font-black text-gray-300 font-mono italic">
|
||||
{{ item.date }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Final CTA -->
|
||||
<section class="py-32">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
||||
<div class="bg-gray-900 rounded-[4rem] p-16 sm:p-24 text-white relative overflow-hidden">
|
||||
<div class="relative z-10">
|
||||
<h2 class="text-5xl md:text-7xl font-black italic tracking-tighter mb-8 italic">
|
||||
准备好定义您的 <br/> 下一阶段了吗?
|
||||
</h2>
|
||||
<div class="flex flex-col sm:flex-row items-center justify-center gap-6">
|
||||
<button
|
||||
@click="router.push('/login')"
|
||||
class="px-12 py-6 bg-blue-600 hover:bg-white hover:text-gray-900 rounded-[2rem] text-xl font-black transition-all shadow-2xl shadow-blue-500/20 active:scale-95"
|
||||
>
|
||||
个人 / OPC 入驻
|
||||
</button>
|
||||
<button
|
||||
@click="router.push('/enterprise/register')"
|
||||
class="px-12 py-6 bg-white text-gray-900 hover:bg-blue-600 hover:text-white rounded-[2rem] text-xl font-black transition-all active:scale-95"
|
||||
>
|
||||
企业资质核验
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Zap class="absolute -right-20 -bottom-20 w-80 h-80 text-white/5 rotate-12" />
|
||||
<Globe class="absolute -left-20 -top-20 w-80 h-80 text-white/5 -rotate-12" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="py-20 border-t border-gray-100 bg-gray-50/30">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
||||
<div class="flex items-center justify-center space-x-3 mb-8">
|
||||
<div class="w-8 h-8 bg-gray-900 rounded-lg flex items-center justify-center text-white">
|
||||
<Rocket class="w-5 h-5" />
|
||||
</div>
|
||||
<span class="text-lg font-black tracking-tighter text-gray-900 italic">CorpScale <span class="text-blue-600">OPC</span></span>
|
||||
</div>
|
||||
<p class="text-gray-400 text-sm font-medium mb-8">
|
||||
© 2024 CorpScale Digital Infrastructure. All rights reserved. <br/>
|
||||
专为数字化专业个人打造的一人公司生态系统。
|
||||
</p>
|
||||
<div class="flex justify-center space-x-12 items-center">
|
||||
<a href="#" class="text-xs font-black text-gray-400 hover:text-gray-900 uppercase tracking-widest transition-colors">Privacy</a>
|
||||
<a href="#" class="text-xs font-black text-gray-400 hover:text-gray-900 uppercase tracking-widest transition-colors">Terms</a>
|
||||
<a href="#" class="text-xs font-black text-gray-400 hover:text-gray-900 uppercase tracking-widest transition-colors">Contact</a>
|
||||
<a href="#" @click.prevent="router.push('/admin/login')" class="text-xs font-black text-gray-400 hover:text-blue-600 uppercase tracking-widest transition-colors flex items-center gap-1">
|
||||
<ShieldCheck class="w-3.5 h-3.5" />
|
||||
<span>Admin Control</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
</style>
|
||||
84
src/views/LoginView.vue
Normal file
84
src/views/LoginView.vue
Normal file
@@ -0,0 +1,84 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { User, ArrowLeft } from 'lucide-vue-next';
|
||||
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
const isLoading = ref(false);
|
||||
const errorMsg = ref('');
|
||||
const loginForm = ref({ username: '', password: '' });
|
||||
|
||||
const handleLogin = async () => {
|
||||
try {
|
||||
isLoading.value = true;
|
||||
errorMsg.value = '';
|
||||
await authStore.login(loginForm.value);
|
||||
if (authStore.userRoles.includes('ENTERPRISE')) {
|
||||
router.push('/enterprise/dashboard');
|
||||
} else {
|
||||
router.push('/user/dashboard');
|
||||
}
|
||||
} catch (e: any) {
|
||||
const detail = e.response?.data?.detail || '';
|
||||
if (detail.includes('No active account') || detail.includes('no_active_account') || detail.includes('禁用')) {
|
||||
errorMsg.value = '您的账号已被禁用,可能因为违规操作或安全风险。如有疑问请联系平台管理员。';
|
||||
} else {
|
||||
errorMsg.value = detail || '登录失败,请检查账号密码';
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-50 flex items-center justify-center px-4">
|
||||
<div class="max-w-md w-full">
|
||||
<el-button text @click="router.push('/')" class="mb-6">
|
||||
<ArrowLeft class="w-4 h-4 mr-1" />返回首页
|
||||
</el-button>
|
||||
|
||||
<el-card shadow="always" class="!rounded-2xl !p-2">
|
||||
<div class="text-center mb-8 pt-4">
|
||||
<div class="mx-auto h-16 w-16 bg-blue-600 rounded-2xl flex items-center justify-center mb-4">
|
||||
<User class="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h2 class="text-2xl font-bold text-gray-800">个人开发者登录</h2>
|
||||
<p class="text-xs text-gray-400 mt-1 uppercase tracking-widest">OPC Talent Hub Access</p>
|
||||
</div>
|
||||
|
||||
<el-form @submit.prevent="handleLogin" label-position="top">
|
||||
<el-alert v-if="errorMsg" type="error" :title="errorMsg" :closable="false" class="mb-4" />
|
||||
|
||||
<el-form-item label="开发者账号">
|
||||
<el-input v-model="loginForm.username" placeholder="用户名 / 手机号" size="large" prefix-icon="User" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<div class="flex justify-between items-center w-full">
|
||||
<span class="text-sm text-gray-700">登录密码</span>
|
||||
<el-button text type="primary" size="small" @click="router.push('/forgot-password')">忘记密码?</el-button>
|
||||
</div>
|
||||
<el-input v-model="loginForm.password" type="password" placeholder="请输入您的密码" size="large" prefix-icon="Lock" show-password />
|
||||
</el-form-item>
|
||||
|
||||
<el-button type="primary" native-type="submit" :loading="isLoading" class="w-full" size="large">
|
||||
{{ isLoading ? '正在验证...' : '进入开发者中心' }}
|
||||
</el-button>
|
||||
|
||||
<el-divider>
|
||||
<span class="text-xs text-gray-400">还没有数字身份?</span>
|
||||
</el-divider>
|
||||
|
||||
<el-button class="w-full" @click="router.push('/register')">立即注册</el-button>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<div class="mt-6 text-center">
|
||||
<el-button text type="info" @click="router.push('/enterprise/login')">切换到企业合作伙伴入口</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
172
src/views/RegisterView.vue
Normal file
172
src/views/RegisterView.vue
Normal file
@@ -0,0 +1,172 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { Rocket, Plus } from 'lucide-vue-next';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { uploadFileToMinIO } from '@/api';
|
||||
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
const errorMsg = ref('');
|
||||
const isLoading = ref(false);
|
||||
|
||||
const form = ref({
|
||||
username: '', phone: '', email: '',
|
||||
nickname: '', password: '', confirmPassword: '',
|
||||
avatar_url: ''
|
||||
});
|
||||
|
||||
const handleAvatarUpload = async (options: any) => {
|
||||
try {
|
||||
const url = await uploadFileToMinIO(options.file, 'avatars');
|
||||
form.value.avatar_url = url;
|
||||
ElMessage.success('头像上传成功');
|
||||
} catch (error) {
|
||||
ElMessage.error('头像上传失败,请重试');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegister = async () => {
|
||||
if (!form.value.username.trim()) { errorMsg.value = '用户名不能为空'; return; }
|
||||
if (form.value.password.length < 6) { errorMsg.value = '密码长度至少为 6 位'; return; }
|
||||
if (form.value.password !== form.value.confirmPassword) { errorMsg.value = '两次输入的密码不一致'; return; }
|
||||
if (form.value.phone && !/^1[3-9]\d{9}$/.test(form.value.phone)) { errorMsg.value = '请输入有效的11位手机号'; return; }
|
||||
if (form.value.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.value.email)) { errorMsg.value = '请输入有效的邮箱地址'; return; }
|
||||
|
||||
try {
|
||||
isLoading.value = true;
|
||||
errorMsg.value = '';
|
||||
await authStore.register({
|
||||
username: form.value.username,
|
||||
phone: form.value.phone || undefined,
|
||||
email: form.value.email || undefined,
|
||||
password: form.value.password,
|
||||
nickname: form.value.nickname || form.value.username,
|
||||
avatar_url: form.value.avatar_url || undefined
|
||||
});
|
||||
await authStore.login({ username: form.value.username, password: form.value.password });
|
||||
router.push('/user/certification/apply');
|
||||
} catch (e: any) {
|
||||
if (e.response?.data) {
|
||||
const data = e.response.data;
|
||||
if (typeof data === 'object') {
|
||||
const errors = Object.entries(data).map(([key, val]) => {
|
||||
const fn: any = { username: '用户名', phone: '手机号', email: '邮箱', password: '密码', nickname: '昵称' }[key] || key;
|
||||
return `${fn}: ${Array.isArray(val) ? val[0] : val}`;
|
||||
});
|
||||
errorMsg.value = errors.join('; ');
|
||||
} else {
|
||||
errorMsg.value = data.detail || '注册失败';
|
||||
}
|
||||
} else {
|
||||
errorMsg.value = '网络错误,请检查后端服务是否运行';
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-50 flex items-center justify-center px-4 py-12">
|
||||
<div class="max-w-lg w-full">
|
||||
<el-card shadow="always" class="!rounded-2xl !p-2">
|
||||
<div class="text-center mb-6 pt-4">
|
||||
<div class="mx-auto h-14 w-14 bg-gray-900 rounded-2xl flex items-center justify-center mb-4">
|
||||
<Rocket class="w-7 h-7 text-blue-400" />
|
||||
</div>
|
||||
<h2 class="text-2xl font-bold text-gray-800">加入 <span class="text-blue-600">CorpScale</span></h2>
|
||||
<p class="text-sm text-gray-400 mt-1">开启您的一人公司智能之旅</p>
|
||||
</div>
|
||||
|
||||
<el-form @submit.prevent="handleRegister" label-position="top">
|
||||
<el-alert v-if="errorMsg" type="error" :title="errorMsg" :closable="false" class="mb-4" />
|
||||
|
||||
<!-- Avatar Upload -->
|
||||
<div class="flex justify-center mb-6">
|
||||
<el-upload
|
||||
action=""
|
||||
class="avatar-uploader"
|
||||
:show-file-list="false"
|
||||
:http-request="handleAvatarUpload"
|
||||
accept="image/*"
|
||||
>
|
||||
<img v-if="form.avatar_url" :src="form.avatar_url" class="w-full h-full object-cover" />
|
||||
<div v-else class="text-gray-400 flex flex-col items-center">
|
||||
<Plus class="w-6 h-6 mb-1" />
|
||||
<span class="text-[10px]">上传头像</span>
|
||||
</div>
|
||||
</el-upload>
|
||||
</div>
|
||||
|
||||
<el-form-item label="用户名" required>
|
||||
<el-input v-model="form.username" placeholder="请输入用户名" size="large" />
|
||||
</el-form-item>
|
||||
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="手机号(可选)">
|
||||
<el-input v-model="form.phone" placeholder="11位手机号" size="large" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="邮箱(可选)">
|
||||
<el-input v-model="form.email" type="email" placeholder="email@example.com" size="large" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form-item label="显示昵称">
|
||||
<el-input v-model="form.nickname" placeholder="显示昵称(不填则使用用户名)" size="large" />
|
||||
</el-form-item>
|
||||
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="设置密码" required>
|
||||
<el-input v-model="form.password" type="password" placeholder="至少6位" size="large" show-password />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="确认密码" required>
|
||||
<el-input v-model="form.confirmPassword" type="password" placeholder="再次输入" size="large" show-password />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-button type="primary" native-type="submit" :loading="isLoading" class="w-full" size="large">
|
||||
{{ isLoading ? '正在处理...' : '立即注册账户' }}
|
||||
</el-button>
|
||||
|
||||
<el-divider>
|
||||
<span class="text-xs text-gray-400">已有账户?</span>
|
||||
</el-divider>
|
||||
|
||||
<el-button class="w-full" @click="router.push('/login')">去登录</el-button>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.avatar-uploader {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
.avatar-uploader :deep(.el-upload) {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
border-radius: 50%;
|
||||
border: 2px dashed #e5e7eb;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
.avatar-uploader :deep(.el-upload:hover) {
|
||||
border-color: #60a5fa;
|
||||
}
|
||||
</style>
|
||||
190
src/views/ScreenView.vue
Normal file
190
src/views/ScreenView.vue
Normal file
@@ -0,0 +1,190 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, computed } from 'vue';
|
||||
import {
|
||||
Globe,
|
||||
Zap,
|
||||
ArrowUpRight,
|
||||
} from 'lucide-vue-next';
|
||||
import { useRouter } from 'vue-router';
|
||||
import api from '@/api';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const activeIndex = ref(0);
|
||||
const tasks = ref<any[]>([
|
||||
{ id: 'loading', title: 'Loading...', company: '...', budget: '...', type: '...', deadline: '...', icon: 'https://api.dicebear.com/7.x/icons/svg?seed=loading' }
|
||||
]);
|
||||
|
||||
const fetchTasks = async () => {
|
||||
try {
|
||||
const res: any = await api.get('/tasks/?status=OPEN');
|
||||
const data = res.results || res;
|
||||
if (data && data.length > 0) {
|
||||
tasks.value = data.map((t: any) => ({
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
company: t.publisher_name || 'CorpScale Platform',
|
||||
budget: `¥${t.budget}`,
|
||||
type: t.task_type || 'GENERAL',
|
||||
deadline: dayjs(t.deadline).diff(dayjs(), 'day') > 0 ? `${dayjs(t.deadline).diff(dayjs(), 'day')}天后截止` : '即将截止',
|
||||
icon: `https://api.dicebear.com/7.x/icons/svg?seed=${t.id}`
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch screen tasks', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Auto Carousel Logic
|
||||
let timer: any = null;
|
||||
onMounted(() => {
|
||||
fetchTasks();
|
||||
timer = setInterval(() => {
|
||||
if (tasks.value.length > 0) {
|
||||
activeIndex.value = (activeIndex.value + 1) % tasks.value.length;
|
||||
}
|
||||
}, 5000);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer) clearInterval(timer);
|
||||
});
|
||||
|
||||
const getVisibleTasks = computed(() => {
|
||||
if (tasks.value.length === 0) return [];
|
||||
const result = [];
|
||||
const count = Math.min(3, tasks.value.length);
|
||||
for (let i = 0; i < count; i++) {
|
||||
result.push(tasks.value[(activeIndex.value + i) % tasks.value.length]);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-950 text-white p-12 flex flex-col space-y-12 overflow-hidden relative">
|
||||
<!-- Futuristic Background Elements -->
|
||||
<div class="absolute top-0 right-0 w-[800px] h-[800px] bg-blue-600/10 blur-[200px] -z-10 rounded-full"></div>
|
||||
<div class="absolute bottom-0 left-0 w-[600px] h-[600px] bg-purple-600/10 blur-[150px] -z-10 rounded-full"></div>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-6">
|
||||
<div class="w-16 h-16 bg-blue-600 rounded-[1.5rem] flex items-center justify-center shadow-2xl shadow-blue-500/20">
|
||||
<Globe class="w-8 h-8 text-white animate-spin-slow" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-5xl font-black italic tracking-tighter">CORPSCALE <span class="text-blue-500">LIVE</span></h1>
|
||||
<p class="text-sm font-black text-gray-500 uppercase tracking-[0.5em] mt-1">Global OPC Opportunity Stream</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-8">
|
||||
<div class="text-right">
|
||||
<p class="text-3xl font-black font-mono text-blue-400">REAL-TIME</p>
|
||||
<p class="text-[10px] font-black text-gray-600 uppercase tracking-widest mt-1">Live Global Task Stream</p>
|
||||
</div>
|
||||
<button @click="router.back()" class="p-5 bg-white/5 border border-white/10 rounded-3xl hover:bg-white/10 transition-all backdrop-blur-xl">
|
||||
<Zap class="w-6 h-6 text-blue-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Live Carousel Grid -->
|
||||
<div class="flex-1 grid grid-cols-1 lg:grid-cols-3 gap-8 relative items-center">
|
||||
<TransitionGroup name="carousel" tag="div" class="contents">
|
||||
<div
|
||||
v-for="(task, idx) in getVisibleTasks"
|
||||
:key="task.id"
|
||||
class="bg-white/5 border border-white/10 rounded-[4rem] p-12 backdrop-blur-2xl flex flex-col justify-between hover:border-blue-500/50 transition-all duration-700 group relative overflow-hidden h-[500px] cursor-pointer"
|
||||
:class="idx === 1 ? 'scale-110 z-10 shadow-[0_0_100px_rgba(37,99,235,0.1)] opacity-100' : 'scale-90 opacity-40 blur-[2px]'"
|
||||
@click="router.push(`/user/tasks/${task.id}`)"
|
||||
>
|
||||
<!-- Grid Lines Background -->
|
||||
<div class="absolute inset-0 opacity-10 pointer-events-none" style="background-image: radial-gradient(#fff 1px, transparent 1px); background-size: 30px 30px;"></div>
|
||||
|
||||
<div class="space-y-8 relative">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="w-24 h-24 bg-gradient-to-br from-blue-500 to-purple-600 rounded-[2.5rem] p-6 shadow-2xl flex items-center justify-center">
|
||||
<img :src="task.icon" class="w-full h-full invert brightness-0" />
|
||||
</div>
|
||||
<div class="px-6 py-2 bg-blue-500/20 border border-blue-500/30 rounded-2xl">
|
||||
<span class="text-xs font-black text-blue-400 uppercase tracking-widest">{{ task.deadline }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<p class="text-xs font-black text-gray-500 uppercase tracking-[0.3em]">{{ task.company }}</p>
|
||||
<h2 class="text-4xl font-black italic first-letter:text-blue-500 leading-tight group-hover:text-blue-400 transition-colors">{{ task.title }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span class="px-3 py-1 bg-white/5 rounded-lg text-[10px] font-black uppercase tracking-widest border border-white/10">{{ task.type }}</span>
|
||||
<span class="px-3 py-1 bg-white/5 rounded-lg text-[10px] font-black uppercase tracking-widest border border-white/10">CERTIFIED</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-12 pt-8 border-t border-white/10 flex items-end justify-between relative">
|
||||
<div>
|
||||
<p class="text-[9px] font-black text-gray-500 uppercase tracking-widest mb-1">Estimate Budget</p>
|
||||
<p class="text-5xl font-black text-blue-500 tracking-tighter">{{ task.budget }}</p>
|
||||
</div>
|
||||
<div class="flex flex-col items-center space-y-2 opacity-50">
|
||||
<div class="w-12 h-12 rounded-full border border-white/20 flex items-center justify-center group-hover:bg-blue-600 group-hover:border-blue-600 transition-all">
|
||||
<ArrowUpRight class="w-6 h-6" />
|
||||
</div>
|
||||
<span class="text-[8px] font-black uppercase tracking-widest">APPLY NOW</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
|
||||
<!-- Navigation Progress -->
|
||||
<div class="flex justify-center space-x-4">
|
||||
<div
|
||||
v-for="(task, i) in tasks"
|
||||
:key="i"
|
||||
class="h-1 rounded-full transition-all duration-500"
|
||||
:class="i === activeIndex ? 'w-24 bg-blue-600 shadow-[0_0_15px_rgba(37,99,235,0.8)]' : 'w-8 bg-white/10'"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Floating Stats Footer -->
|
||||
<div class="grid grid-cols-4 gap-12 pt-12 border-t border-white/5">
|
||||
<div v-for="stat in ['GLOBAL NETWORK', 'SMART MATCHING', 'SECURE TRANSACTIONS', 'OPC POWERED']" :key="stat" class="text-center group cursor-crosshair">
|
||||
<p class="text-xs font-black text-gray-500 uppercase tracking-[0.4em] group-hover:text-blue-400 transition-colors">{{ stat }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.carousel-enter-active,
|
||||
.carousel-leave-active {
|
||||
transition: all 0.8s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.carousel-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(100px) scale(0.8);
|
||||
}
|
||||
|
||||
.carousel-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-100px) scale(0.8);
|
||||
}
|
||||
|
||||
.animate-spin-slow {
|
||||
animation: spin 8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
156
src/views/admin/AdminModelListView.vue
Normal file
156
src/views/admin/AdminModelListView.vue
Normal file
@@ -0,0 +1,156 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { Plus, Edit, Trash2, Box } from 'lucide-vue-next';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import api from '@/api';
|
||||
|
||||
const models = ref<any[]>([]);
|
||||
const isLoading = ref(true);
|
||||
const dialogVisible = ref(false);
|
||||
const isSubmitting = ref(false);
|
||||
|
||||
const form = ref({
|
||||
id: '',
|
||||
name: '',
|
||||
provider: 'CorpScale',
|
||||
description: '',
|
||||
price_per_token: 0.001,
|
||||
is_active: true
|
||||
});
|
||||
|
||||
const isEdit = ref(false);
|
||||
|
||||
const fetchModels = async () => {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const res: any = await api.get('/system/models/');
|
||||
models.value = res.results || res || [];
|
||||
} catch (error) {
|
||||
ElMessage.error('获取模型列表失败');
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchModels();
|
||||
});
|
||||
|
||||
const handleAdd = () => {
|
||||
isEdit.value = false;
|
||||
form.value = {
|
||||
id: '', name: '', provider: 'CorpScale', description: '',
|
||||
price_per_token: 0.001, is_active: true
|
||||
};
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
const handleEdit = (row: any) => {
|
||||
isEdit.value = true;
|
||||
form.value = { ...row };
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除该模型吗?', '提示', { type: 'warning' });
|
||||
// In actual implementation we might just set is_active=False or delete if allowed
|
||||
ElMessage.info('暂不支持物理删除模型');
|
||||
} catch (e: any) {
|
||||
// cancel
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!form.value.name) {
|
||||
ElMessage.warning('请输入模型名称');
|
||||
return;
|
||||
}
|
||||
isSubmitting.value = true;
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
await api.put(`/system/models/${form.value.id}/`, form.value);
|
||||
ElMessage.success('更新成功');
|
||||
} else {
|
||||
await api.post('/system/models/', form.value);
|
||||
ElMessage.success('创建成功');
|
||||
}
|
||||
dialogVisible.value = false;
|
||||
fetchModels();
|
||||
} catch (error) {
|
||||
ElMessage.error('操作失败');
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-800">平台模型管理</h1>
|
||||
<p class="text-gray-500 mt-1 text-sm">新增、编辑或配置平台内可用的组件服务模型</p>
|
||||
</div>
|
||||
<!-- Note: the backend ReadOnlyModelViewSet needs to be upgraded for POST/PUT if we enable it -->
|
||||
<el-button type="primary" @click="handleAdd" class="shadow-sm">
|
||||
<Plus class="w-4 h-4 mr-2" />新增模型
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-card shadow="never" class="border-none" v-loading="isLoading">
|
||||
<el-table :data="models" style="width: 100%">
|
||||
<el-table-column prop="name" label="模型名称" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<div class="flex items-center gap-2">
|
||||
<Box class="w-4 h-4 text-purple-500" />
|
||||
<span class="font-bold text-gray-800">{{ row.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="provider" label="供应商" width="120" />
|
||||
<el-table-column label="单价/Token" width="120">
|
||||
<template #default="{ row }">
|
||||
<span class="font-bold text-orange-500">¥{{ row.price_per_token }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="description" label="描述" min-width="250" show-overflow-tooltip />
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-switch v-model="row.is_active" disabled />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="handleEdit(row)"><Edit class="w-4 h-4 mr-1" />编辑</el-button>
|
||||
<el-button link type="danger" @click="handleDelete(row.id)"><Trash2 class="w-4 h-4 mr-1" />删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑模型' : '新增模型'" width="600px">
|
||||
<el-form label-width="100px" label-position="left">
|
||||
<el-form-item label="模型名称" required>
|
||||
<el-input v-model="form.name" placeholder="如:CS-Llama-Instruct-7B" />
|
||||
</el-form-item>
|
||||
<el-form-item label="供应商" required>
|
||||
<el-input v-model="form.provider" />
|
||||
</el-form-item>
|
||||
<el-form-item label="单价/Token">
|
||||
<el-input-number v-model="form.price_per_token" :min="0" :precision="6" :step="0.0001" />
|
||||
</el-form-item>
|
||||
<el-form-item label="模型描述">
|
||||
<el-input v-model="form.description" type="textarea" rows="4" />
|
||||
</el-form-item>
|
||||
<el-form-item label="是否上架">
|
||||
<el-switch v-model="form.is_active" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="isSubmitting" @click="handleSubmit">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
172
src/views/admin/AdminReservationView.vue
Normal file
172
src/views/admin/AdminReservationView.vue
Normal file
@@ -0,0 +1,172 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { Plus, Edit, Trash2 } from 'lucide-vue-next';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import api from '@/api';
|
||||
|
||||
const resources = ref<any[]>([]);
|
||||
const isLoading = ref(true);
|
||||
const dialogVisible = ref(false);
|
||||
const isSubmitting = ref(false);
|
||||
|
||||
const form = ref({
|
||||
id: '',
|
||||
name: '',
|
||||
type: 'MEETING_ROOM',
|
||||
description: '',
|
||||
capacity: 10,
|
||||
location: '',
|
||||
price_per_unit: 0,
|
||||
price_unit: '小时',
|
||||
is_active: true
|
||||
});
|
||||
|
||||
const isEdit = ref(false);
|
||||
|
||||
const fetchResources = async () => {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const res: any = await api.get('/reservations/resources/');
|
||||
resources.value = res.results || res || [];
|
||||
} catch (error) {
|
||||
ElMessage.error('获取资源列表失败');
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchResources();
|
||||
});
|
||||
|
||||
const handleAdd = () => {
|
||||
isEdit.value = false;
|
||||
form.value = {
|
||||
id: '', name: '', type: 'MEETING_ROOM', description: '',
|
||||
capacity: 10, location: '', price_per_unit: 0, price_unit: '小时', is_active: true
|
||||
};
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
const handleEdit = (row: any) => {
|
||||
isEdit.value = true;
|
||||
form.value = { ...row };
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除该资源吗?', '提示', { type: 'warning' });
|
||||
await api.delete(`/reservations/resources/${id}/`);
|
||||
ElMessage.success('删除成功');
|
||||
fetchResources();
|
||||
} catch (e: any) {
|
||||
if (e !== 'cancel') ElMessage.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!form.value.name) {
|
||||
ElMessage.warning('请输入资源名称');
|
||||
return;
|
||||
}
|
||||
isSubmitting.value = true;
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
await api.put(`/reservations/resources/${form.value.id}/`, form.value);
|
||||
ElMessage.success('更新成功');
|
||||
} else {
|
||||
await api.post('/reservations/resources/', form.value);
|
||||
ElMessage.success('创建成功');
|
||||
}
|
||||
dialogVisible.value = false;
|
||||
fetchResources();
|
||||
} catch (error) {
|
||||
ElMessage.error('操作失败');
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-800">空间与资源管理</h1>
|
||||
<p class="text-gray-500 mt-1 text-sm">管理会议室、工位、门禁等可预约资源</p>
|
||||
</div>
|
||||
<el-button type="primary" @click="handleAdd" class="shadow-sm">
|
||||
<Plus class="w-4 h-4 mr-2" />新增资源
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-card shadow="never" class="border-none" v-loading="isLoading">
|
||||
<el-table :data="resources" style="width: 100%">
|
||||
<el-table-column prop="name" label="资源名称" min-width="150" />
|
||||
<el-table-column prop="type" label="类型" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.type === 'MEETING_ROOM' ? 'primary' : 'success'">
|
||||
{{ row.type === 'MEETING_ROOM' ? '会议室' : '门禁通行' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="单价" width="120">
|
||||
<template #default="{ row }">
|
||||
<span class="font-bold text-orange-500">¥{{ row.price_per_unit }}</span> / {{ row.price_unit }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="location" label="位置" min-width="150" />
|
||||
<el-table-column prop="capacity" label="容量" width="80" />
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-switch v-model="row.is_active" disabled />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="handleEdit(row)"><Edit class="w-4 h-4 mr-1" />编辑</el-button>
|
||||
<el-button link type="danger" @click="handleDelete(row.id)"><Trash2 class="w-4 h-4 mr-1" />删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑资源' : '新增资源'" width="600px">
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="资源名称" required>
|
||||
<el-input v-model="form.name" />
|
||||
</el-form-item>
|
||||
<el-form-item label="类型">
|
||||
<el-select v-model="form.type" class="w-full">
|
||||
<el-option label="会议室" value="MEETING_ROOM" />
|
||||
<el-option label="门禁通行" value="ACCESS_CONTROL" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="位置">
|
||||
<el-input v-model="form.location" />
|
||||
</el-form-item>
|
||||
<el-form-item label="容量">
|
||||
<el-input-number v-model="form.capacity" :min="1" />
|
||||
</el-form-item>
|
||||
<el-form-item label="计费标准">
|
||||
<div class="flex gap-2 w-full">
|
||||
<el-input-number v-model="form.price_per_unit" :min="0" :step="10" placeholder="价格" class="flex-1" />
|
||||
<span class="text-gray-500 mt-1">元 /</span>
|
||||
<el-input v-model="form.price_unit" placeholder="单位(如: 小时/次)" class="w-24" />
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="描述">
|
||||
<el-input v-model="form.description" type="textarea" rows="3" />
|
||||
</el-form-item>
|
||||
<el-form-item label="是否启用">
|
||||
<el-switch v-model="form.is_active" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="isSubmitting" @click="handleSubmit">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
332
src/views/admin/AdminTaskListView.vue
Normal file
332
src/views/admin/AdminTaskListView.vue
Normal file
@@ -0,0 +1,332 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { PlusCircle, Calendar, ClipboardList, Building2, Search, ShieldAlert, Trash2, Star, Eye } from 'lucide-vue-next';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { getTasks } from '@/api/tasks';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import api from '@/api';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
const searchQuery = ref('');
|
||||
const tasks = ref<any[]>([]);
|
||||
const isLoading = ref(true);
|
||||
const selectedIds = ref<string[]>([]);
|
||||
|
||||
const tabs = computed(() => {
|
||||
return [
|
||||
{ name: 'ACTIVE', label: '活跃任务', count: tasks.value.filter(t => ['OPEN', 'IN_PROGRESS', 'IN_REVIEW'].includes(t.status)).length },
|
||||
{ name: 'OPEN', label: '招募中', count: tasks.value.filter(t => t.status === 'OPEN').length },
|
||||
{ name: 'IN_PROGRESS', label: '执行中', count: tasks.value.filter(t => t.status === 'IN_PROGRESS').length },
|
||||
{ name: 'IN_REVIEW', label: '验收中', count: tasks.value.filter(t => t.status === 'IN_REVIEW').length },
|
||||
{ name: 'COMPLETED', label: '已结案', count: tasks.value.filter(t => t.status === 'COMPLETED').length },
|
||||
{ name: 'CANCELLED', label: '已下架', count: tasks.value.filter(t => t.status === 'CANCELLED').length },
|
||||
];
|
||||
});
|
||||
|
||||
// Read query param to pre-select tab
|
||||
const initialTab = typeof route.query.status === 'string' && ['ACTIVE', 'OPEN', 'IN_PROGRESS', 'IN_REVIEW', 'COMPLETED', 'CANCELLED'].includes(route.query.status)
|
||||
? route.query.status : 'ACTIVE';
|
||||
const activeTab = ref(initialTab);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
isLoading.value = true;
|
||||
const tasksRes: any = await getTasks();
|
||||
tasks.value = tasksRes?.results || tasksRes || [];
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(fetchData);
|
||||
|
||||
const activeTasks = computed(() => {
|
||||
return tasks.value.filter(task => {
|
||||
if (task.status === 'DRAFT') return false;
|
||||
|
||||
const matchesSearch = task.title.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
|
||||
(task.publisher_name || '').toLowerCase().includes(searchQuery.value.toLowerCase()) ||
|
||||
(task.enterprise_name || '').toLowerCase().includes(searchQuery.value.toLowerCase());
|
||||
if (!matchesSearch) return false;
|
||||
|
||||
if (activeTab.value === 'ACTIVE') return ['OPEN', 'IN_PROGRESS', 'IN_REVIEW'].includes(task.status);
|
||||
return task.status === activeTab.value;
|
||||
});
|
||||
});
|
||||
|
||||
const getStatusType = (status: string) => {
|
||||
switch (status) {
|
||||
case 'OPEN': return 'primary';
|
||||
case 'IN_PROGRESS': return 'warning';
|
||||
case 'IN_REVIEW': return 'primary';
|
||||
case 'COMPLETED': return 'success';
|
||||
case 'CANCELLED': return 'danger';
|
||||
default: return 'info';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
switch (status) {
|
||||
case 'OPEN': return '招募中';
|
||||
case 'DRAFT': return '草稿';
|
||||
case 'IN_PROGRESS': return '进行中';
|
||||
case 'IN_REVIEW': return '待验收';
|
||||
case 'COMPLETED': return '已结案';
|
||||
case 'CANCELLED': return '已下架';
|
||||
default: return status;
|
||||
}
|
||||
};
|
||||
|
||||
// Cancel reason dialog
|
||||
const cancelDialogVisible = ref(false);
|
||||
const cancelTargetId = ref('');
|
||||
const cancelReason = ref('');
|
||||
const cancelType = ref<'违规下架' | '内容不合规' | '企业申请下架' | '其他'>('违规下架');
|
||||
const cancelTypes = ['违规下架', '内容不合规', '企业申请下架', '其他'];
|
||||
|
||||
const openCancelDialog = (taskId: string) => {
|
||||
cancelTargetId.value = taskId;
|
||||
cancelReason.value = '';
|
||||
cancelType.value = '违规下架';
|
||||
cancelDialogVisible.value = true;
|
||||
};
|
||||
|
||||
const confirmCancel = async () => {
|
||||
if (!cancelReason.value.trim()) {
|
||||
ElMessage.warning('请填写具体的下架原因');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const fullReason = `【${cancelType.value}】${cancelReason.value}`;
|
||||
try {
|
||||
await api.post(`/tasks/${cancelTargetId.value}/cancel_task/`, { cancel_reason: fullReason });
|
||||
} catch (err: any) {
|
||||
if (err.response?.data?.requires_batch_reject) {
|
||||
await api.post(`/tasks/${cancelTargetId.value}/cancel_task/`, { batch_reject: true, cancel_reason: fullReason });
|
||||
} else { throw err; }
|
||||
}
|
||||
ElMessage.success('已下架任务');
|
||||
cancelDialogVisible.value = false;
|
||||
fetchData();
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e.response?.data?.detail || '下架失败,请重试');
|
||||
}
|
||||
};
|
||||
|
||||
const deleteTask = async (taskId: string) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要永久删除该任务吗?删除后无法恢复,相关的申请记录也会一并清除。', '确认删除', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '确认删除',
|
||||
cancelButtonText: '取消',
|
||||
confirmButtonClass: 'el-button--danger'
|
||||
});
|
||||
await api.delete(`/tasks/${taskId}/`);
|
||||
ElMessage.success('任务已永久删除');
|
||||
fetchData();
|
||||
} catch (e: any) {
|
||||
if (e !== 'cancel') {
|
||||
ElMessage.error(e.response?.data?.detail || '删除失败,请重试');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const toggleRecommend = async (task: any) => {
|
||||
try {
|
||||
const newVal = !task.is_recommended;
|
||||
await api.post(`/tasks/${task.id}/toggle_recommend/`, { is_recommended: newVal });
|
||||
task.is_recommended = newVal;
|
||||
ElMessage.success(newVal ? '已设为推荐置顶' : '已取消推荐');
|
||||
} catch (e) {
|
||||
ElMessage.error('操作失败,请重试');
|
||||
}
|
||||
};
|
||||
|
||||
// Select all
|
||||
const selectableTasks = computed(() => activeTasks.value.filter(t => ['OPEN', 'IN_PROGRESS', 'IN_REVIEW'].includes(t.status)));
|
||||
|
||||
const isAllSelected = computed(() => {
|
||||
return selectableTasks.value.length > 0 && selectedIds.value.length === selectableTasks.value.length;
|
||||
});
|
||||
const toggleSelectAll = (val: boolean) => {
|
||||
if (val) {
|
||||
selectedIds.value = selectableTasks.value.map(t => t.id);
|
||||
} else {
|
||||
selectedIds.value = [];
|
||||
}
|
||||
};
|
||||
const toggleSelect = (id: string) => {
|
||||
const idx = selectedIds.value.indexOf(id);
|
||||
if (idx >= 0) selectedIds.value.splice(idx, 1);
|
||||
else selectedIds.value.push(id);
|
||||
};
|
||||
|
||||
// Batch cancel
|
||||
const batchCancel = async () => {
|
||||
if (selectedIds.value.length === 0) { ElMessage.warning('请先选择要下架的任务'); return; }
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定要批量下架选中的 ${selectedIds.value.length} 个任务吗?`, '批量下架', { type: 'warning' });
|
||||
for (const id of selectedIds.value) {
|
||||
try {
|
||||
await api.post(`/tasks/${id}/cancel_task/`, { batch_reject: true, cancel_reason: '【批量管理操作】管理员批量下架' });
|
||||
} catch(e) {}
|
||||
}
|
||||
ElMessage.success(`已批量下架 ${selectedIds.value.length} 个任务`);
|
||||
selectedIds.value = [];
|
||||
fetchData();
|
||||
} catch (e) {}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-800">全平台任务监管</h1>
|
||||
<p class="text-sm text-gray-400 mt-1">管理所有企业与管理员发布的任务,可进行推荐、下架和删除操作</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<el-button type="primary" @click="router.push('/admin/tasks/create')" class="shadow-sm">
|
||||
<PlusCircle class="w-4 h-4 mr-1" />发布任务
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white p-2 rounded-xl shadow-sm border border-gray-100 flex gap-2">
|
||||
<button
|
||||
v-for="tab in tabs" :key="tab.name"
|
||||
@click="activeTab = tab.name"
|
||||
class="flex-1 py-2 text-sm font-medium rounded-lg transition-all flex items-center justify-center gap-2"
|
||||
:class="activeTab === tab.name ? 'bg-blue-50 text-blue-600' : 'text-gray-500 hover:bg-gray-50'"
|
||||
>
|
||||
{{ tab.label }}
|
||||
<span class="px-1.5 py-0.5 rounded-md text-xs font-bold transition-colors"
|
||||
:class="activeTab === tab.name ? 'bg-blue-200/50 text-blue-700' : 'bg-gray-100 text-gray-500'">
|
||||
{{ tab.count }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="flex justify-between items-center bg-white p-4 rounded-xl shadow-sm border border-gray-100">
|
||||
<div class="flex items-center gap-4">
|
||||
<el-checkbox
|
||||
:model-value="isAllSelected"
|
||||
@change="toggleSelectAll"
|
||||
:indeterminate="selectedIds.length > 0 && selectedIds.length < selectableTasks.length"
|
||||
>
|
||||
全选
|
||||
</el-checkbox>
|
||||
<el-button v-if="selectedIds.length > 0" type="danger" plain size="small" @click="batchCancel">
|
||||
批量下架 ({{ selectedIds.length }})
|
||||
</el-button>
|
||||
</div>
|
||||
<el-input
|
||||
v-model="searchQuery"
|
||||
placeholder="搜索任务标题 / 企业名称..."
|
||||
class="!w-80"
|
||||
clearable
|
||||
>
|
||||
<template #prefix><Search class="w-4 h-4 text-gray-400" /></template>
|
||||
</el-input>
|
||||
</div>
|
||||
|
||||
<!-- Task List -->
|
||||
<div v-loading="isLoading" class="space-y-4">
|
||||
<el-empty v-if="activeTasks.length === 0" description="暂无符合条件的任务" />
|
||||
|
||||
<el-card v-for="task in activeTasks" :key="task.id" shadow="hover" class="rounded-xl border-gray-100 group">
|
||||
<div class="flex gap-6 items-start">
|
||||
<div class="flex items-center gap-3 shrink-0">
|
||||
<el-checkbox
|
||||
v-if="['OPEN', 'IN_PROGRESS', 'IN_REVIEW'].includes(task.status)"
|
||||
:model-value="selectedIds.includes(task.id)"
|
||||
@change="toggleSelect(task.id)"
|
||||
@click.stop
|
||||
/>
|
||||
<div class="w-12 h-12 rounded-xl bg-blue-50 text-blue-600 flex items-center justify-center ml-1">
|
||||
<ClipboardList class="w-6 h-6" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-start justify-between mb-2">
|
||||
<div>
|
||||
<div class="flex items-center gap-3 mb-1">
|
||||
<h3 class="text-lg font-bold text-gray-800 truncate max-w-md cursor-pointer hover:text-blue-600" @click="router.push(`/admin/tasks/${task.id}`)">{{ task.title }}</h3>
|
||||
<el-tag :type="getStatusType(task.status)" size="small" effect="light" round>
|
||||
{{ getStatusLabel(task.status) }}
|
||||
</el-tag>
|
||||
<el-tag v-if="task.is_recommended" type="warning" size="small" effect="dark" round>⭐ 推荐</el-tag>
|
||||
</div>
|
||||
<div class="flex items-center gap-4 text-sm text-gray-500">
|
||||
<span class="flex items-center gap-1.5">
|
||||
<Building2 class="w-4 h-4" />
|
||||
<template v-if="task.enterprise_name">{{ task.enterprise_name }}</template>
|
||||
<el-tag v-else type="danger" size="small" effect="dark" round>管理员发布</el-tag>
|
||||
</span>
|
||||
<span class="flex items-center gap-1.5"><Calendar class="w-4 h-4" /> 截止: {{ task.deadline ? new Date(task.deadline).toLocaleDateString() : '未设置' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-lg font-bold text-orange-500">¥{{ task.budget_max || task.budget }}</div>
|
||||
<div class="text-xs text-gray-400">总预算</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-gray-600 text-sm line-clamp-2 mt-3">{{ task.description }}</p>
|
||||
<p v-if="task.status === 'CANCELLED' && task.cancel_reason" class="text-xs text-red-500 mt-3 bg-red-50 p-2.5 rounded-lg border border-red-100 flex items-center gap-1">
|
||||
<ShieldAlert class="w-3.5 h-3.5" /> {{ task.cancel_reason }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Admin Actions -->
|
||||
<div class="flex flex-col gap-2 shrink-0 border-l border-gray-100 pl-6 ml-2 h-full justify-center">
|
||||
<el-button type="primary" plain @click="router.push(`/admin/tasks/${task.id}`)"><Eye class="w-4 h-4 mr-1" />查看详情</el-button>
|
||||
|
||||
<template v-if="['OPEN', 'IN_PROGRESS', 'IN_REVIEW'].includes(task.status)">
|
||||
<el-button v-if="!task.enterprise_name" type="primary" plain @click="router.push(`/admin/tasks/${task.id}/applications`)">查看申请</el-button>
|
||||
<el-button :type="task.is_recommended ? 'warning' : 'default'" plain @click.stop="toggleRecommend(task)">
|
||||
<Star class="w-4 h-4 mr-1" :class="task.is_recommended ? 'fill-orange-400' : ''" />{{ task.is_recommended ? '取消推荐' : '设为推荐' }}
|
||||
</el-button>
|
||||
<el-button type="danger" @click="openCancelDialog(task.id)"><ShieldAlert class="w-4 h-4 mr-1"/>下架任务</el-button>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<el-button type="danger" plain @click="deleteTask(task.id)"><Trash2 class="w-4 h-4 mr-1" />永久删除</el-button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<!-- Cancel Reason Dialog -->
|
||||
<el-dialog v-model="cancelDialogVisible" title="下架任务" width="500px">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<div class="text-sm font-medium text-gray-700 mb-2">下架原因分类</div>
|
||||
<el-radio-group v-model="cancelType" class="flex flex-wrap gap-2">
|
||||
<el-radio-button v-for="t in cancelTypes" :key="t" :value="t">{{ t }}</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-medium text-gray-700 mb-2">具体说明(必填)</div>
|
||||
<el-input v-model="cancelReason" type="textarea" :rows="4" placeholder="请详细描述下架原因,此信息将通知发布方..." />
|
||||
</div>
|
||||
<el-alert type="warning" :closable="false" show-icon>
|
||||
<template #title>下架后该任务将从所有用户的任务广场中移除,已有的申请也将被批量拒绝。</template>
|
||||
</el-alert>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="cancelDialogVisible = false">取消</el-button>
|
||||
<el-button type="danger" @click="confirmCancel">确认下架</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
264
src/views/admin/AnnouncementsView.vue
Normal file
264
src/views/admin/AnnouncementsView.vue
Normal file
@@ -0,0 +1,264 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { Bell, Edit, Trash2, Image as ImageIcon, Star } from 'lucide-vue-next';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import dayjs from 'dayjs';
|
||||
import api, { uploadFileToMinIO } from '@/api';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { MdEditor, MdPreview } from 'md-editor-v3';
|
||||
import 'md-editor-v3/lib/style.css';
|
||||
|
||||
const announcements = ref<any[]>([]);
|
||||
const loading = ref(true);
|
||||
|
||||
const dialogVisible = ref(false);
|
||||
const form = ref<any>({ id: '', title: '', content: '', cover_url: '', is_published: true, target_audience: 'ALL' });
|
||||
const isEditing = ref(false);
|
||||
const saveLoading = ref(false);
|
||||
|
||||
const authStore = useAuthStore();
|
||||
|
||||
// Preview dialog
|
||||
const previewVisible = ref(false);
|
||||
const previewAnn = ref<any>(null);
|
||||
|
||||
const audienceOptions = [
|
||||
{ label: '全平台', value: 'ALL' },
|
||||
{ label: '仅用户端', value: 'USER' },
|
||||
{ label: '仅企业端', value: 'ENTERPRISE' },
|
||||
];
|
||||
const audienceMap: Record<string, { text: string, type: string }> = {
|
||||
ALL: { text: '全平台', type: 'primary' },
|
||||
USER: { text: '用户端', type: 'success' },
|
||||
ENTERPRISE: { text: '企业端', type: 'warning' },
|
||||
};
|
||||
|
||||
const fetchAnnouncements = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res: any = await api.get('/announcements/');
|
||||
announcements.value = res.results || res;
|
||||
} catch (error) {
|
||||
ElMessage.error('无法获取公告列表');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const openDialog = (row: any = null) => {
|
||||
if (row) {
|
||||
isEditing.value = true;
|
||||
form.value = { ...row };
|
||||
} else {
|
||||
isEditing.value = false;
|
||||
form.value = { id: '', title: '', content: '', cover_url: '', is_published: true, target_audience: 'ALL' };
|
||||
}
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
const handleCoverUpload = async (options: any) => {
|
||||
try {
|
||||
const url = await uploadFileToMinIO(options.file, 'announcements');
|
||||
form.value.cover_url = url;
|
||||
ElMessage.success('封面图上传成功');
|
||||
} catch (error) {
|
||||
ElMessage.error('图片上传失败');
|
||||
}
|
||||
};
|
||||
|
||||
// MD editor image upload handler
|
||||
const handleEditorUpload = async (files: File[], callback: (urls: string[]) => void) => {
|
||||
const urls: string[] = [];
|
||||
for (const file of files) {
|
||||
try {
|
||||
const url = await uploadFileToMinIO(file, 'announcements');
|
||||
urls.push(url);
|
||||
} catch (e) {
|
||||
ElMessage.error('图片上传失败');
|
||||
}
|
||||
}
|
||||
callback(urls);
|
||||
};
|
||||
|
||||
const saveAnnouncement = async () => {
|
||||
saveLoading.value = true;
|
||||
try {
|
||||
const payload = { ...form.value, publisher: authStore.user?.id };
|
||||
if (isEditing.value) {
|
||||
await api.put(`/announcements/${form.value.id}/`, payload);
|
||||
ElMessage.success('更新成功');
|
||||
} else {
|
||||
await api.post('/announcements/', payload);
|
||||
ElMessage.success('发布成功');
|
||||
}
|
||||
dialogVisible.value = false;
|
||||
fetchAnnouncements();
|
||||
} catch (error) {
|
||||
ElMessage.error('操作失败');
|
||||
} finally {
|
||||
saveLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const deleteAnnouncement = async (id: string) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除此公告吗?', '确认删除', { type: 'warning' });
|
||||
await api.delete(`/announcements/${id}/`);
|
||||
ElMessage.success('删除成功');
|
||||
fetchAnnouncements();
|
||||
} catch (error) {}
|
||||
};
|
||||
|
||||
const openPreview = (ann: any) => {
|
||||
previewAnn.value = ann;
|
||||
previewVisible.value = true;
|
||||
};
|
||||
|
||||
onMounted(() => fetchAnnouncements());
|
||||
|
||||
const toggleRecommend = async (row: any) => {
|
||||
try {
|
||||
const newVal = !row.is_recommended;
|
||||
await api.post(`/announcements/${row.id}/toggle_recommend/`, { is_recommended: newVal });
|
||||
row.is_recommended = newVal;
|
||||
ElMessage.success(newVal ? '已置顶推荐' : '已取消推荐');
|
||||
} catch (e) {
|
||||
ElMessage.error('操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (date: string) => dayjs(date).format('YYYY-MM-DD HH:mm');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-card shadow="never" class="!border-none" v-loading="loading">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-xl font-bold text-gray-800">公告管理</h2>
|
||||
<el-button type="primary" @click="openDialog()">发布新公告</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="announcements" style="width: 100%" class="custom-table">
|
||||
<el-table-column label="公告内容" min-width="300">
|
||||
<template #default="{ row }">
|
||||
<div class="flex items-start gap-3 py-1 cursor-pointer" @click="openPreview(row)">
|
||||
<img v-if="row.cover_url" :src="row.cover_url" class="w-16 h-10 object-cover rounded flex-shrink-0" />
|
||||
<div class="mt-1" v-else><Bell class="w-4 h-4 text-gray-400" /></div>
|
||||
<div>
|
||||
<div class="font-semibold text-gray-800">{{ row.title }}</div>
|
||||
<div class="text-xs text-gray-500 mt-1 line-clamp-1">{{ row.content?.replace(/<[^>]+>/g, '').replace(/[#*`_~>\-]/g, '').substring(0, 60) || '无预览' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="受众" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="(audienceMap[row.target_audience] || audienceMap.ALL).type as any" size="small" effect="plain">
|
||||
{{ (audienceMap[row.target_audience] || audienceMap.ALL).text }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="发布状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.is_published ? 'success' : 'info'" size="small">
|
||||
{{ row.is_published ? '已发布' : '草稿' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="创建时间" width="170">
|
||||
<template #default="{ row }">
|
||||
<span class="text-sm text-gray-500">{{ formatDate(row.created_at) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="推荐" width="70">
|
||||
<template #default="{ row }">
|
||||
<el-button text :type="row.is_recommended ? 'warning' : 'info'" size="small" @click="toggleRecommend(row)">
|
||||
<Star class="w-4 h-4" :class="row.is_recommended ? 'fill-orange-400 text-orange-500' : 'text-gray-400'" />
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="操作" width="140" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button text type="primary" size="small" @click="openDialog(row)"><Edit class="w-4 h-4 mr-1" />编辑</el-button>
|
||||
<el-button text type="danger" size="small" @click="deleteAnnouncement(row.id)"><Trash2 class="w-4 h-4" /></el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- Create/Edit Dialog with MD Editor -->
|
||||
<el-dialog v-model="dialogVisible" :title="isEditing ? '编辑公告' : '发布新公告'" width="900px" top="3vh">
|
||||
<el-form :model="form" label-width="80px">
|
||||
<el-form-item label="公告标题">
|
||||
<el-input v-model="form.title" placeholder="输入公告标题" size="large" />
|
||||
</el-form-item>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<el-form-item label="公告受众">
|
||||
<el-select v-model="form.target_audience" class="w-full">
|
||||
<el-option v-for="opt in audienceOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="是否发布">
|
||||
<el-switch v-model="form.is_published" active-text="发布" inactive-text="草稿" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<el-form-item label="封面图片">
|
||||
<div class="w-full">
|
||||
<div v-if="form.cover_url" class="relative mb-3 inline-block">
|
||||
<img :src="form.cover_url" class="max-h-32 rounded-lg border border-gray-200 shadow-sm" />
|
||||
<el-button type="danger" circle size="small" class="!absolute -top-2 -right-2" @click="form.cover_url = ''">
|
||||
<Trash2 class="w-3 h-3" />
|
||||
</el-button>
|
||||
</div>
|
||||
<el-upload
|
||||
:show-file-list="false"
|
||||
:http-request="handleCoverUpload"
|
||||
accept="image/*"
|
||||
>
|
||||
<el-button type="primary" plain size="small"><ImageIcon class="w-3.5 h-3.5 mr-1" />{{ form.cover_url ? '更换封面' : '上传封面图' }}</el-button>
|
||||
</el-upload>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="公告内容">
|
||||
<div class="w-full border border-gray-200 rounded-lg overflow-hidden">
|
||||
<MdEditor
|
||||
v-model="form.content"
|
||||
:style="{ height: '400px' }"
|
||||
language="zh-CN"
|
||||
:preview="false"
|
||||
:toolbarsExclude="['github', 'htmlPreview']"
|
||||
@onUploadImg="handleEditorUpload"
|
||||
/>
|
||||
</div>
|
||||
<div class="text-xs text-gray-400 mt-2">提示:支持 Markdown 格式,可直接粘贴图片或使用工具栏上传。</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveAnnouncement" :loading="saveLoading">确认发布</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- Large Preview Panel with MD Preview -->
|
||||
<el-dialog v-model="previewVisible" :title="previewAnn?.title" width="750px" top="3vh">
|
||||
<div v-if="previewAnn" class="space-y-4">
|
||||
<div class="flex items-center gap-3 text-sm text-gray-400">
|
||||
<el-tag :type="(audienceMap[previewAnn.target_audience] || audienceMap.ALL).type as any" size="small">
|
||||
{{ (audienceMap[previewAnn.target_audience] || audienceMap.ALL).text }}
|
||||
</el-tag>
|
||||
<el-tag size="small" :type="previewAnn.is_published ? 'success' : 'info'">{{ previewAnn.is_published ? '已发布' : '草稿' }}</el-tag>
|
||||
<span>{{ formatDate(previewAnn.created_at) }}</span>
|
||||
</div>
|
||||
<div v-if="previewAnn.cover_url" class="rounded-xl overflow-hidden border border-gray-100">
|
||||
<img :src="previewAnn.cover_url" class="w-full max-h-72 object-cover" />
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded-xl p-6 border border-gray-100 min-h-[200px]">
|
||||
<MdPreview :modelValue="previewAnn.content" />
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</el-card>
|
||||
</template>
|
||||
346
src/views/admin/CertificationListView.vue
Normal file
346
src/views/admin/CertificationListView.vue
Normal file
@@ -0,0 +1,346 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { getCertifications, approveCertification, rejectCertification } from '@/api/certifications';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import VueOfficeDocx from '@vue-office/docx';
|
||||
import '@vue-office/docx/lib/index.css';
|
||||
import {
|
||||
ShieldCheck,
|
||||
Search,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Eye
|
||||
} from 'lucide-vue-next';
|
||||
|
||||
const certifications = ref<any[]>([]);
|
||||
const isLoading = ref(true);
|
||||
const filterStatus = ref('PENDING');
|
||||
const searchQuery = ref('');
|
||||
|
||||
const detailVisible = ref(false);
|
||||
const currentCert = ref<any>(null);
|
||||
|
||||
const previewVisible = ref(false);
|
||||
const previewUrl = ref('');
|
||||
|
||||
const handleView = (row: any) => {
|
||||
currentCert.value = row;
|
||||
detailVisible.value = true;
|
||||
};
|
||||
|
||||
const fetchCerts = async () => {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
// 始终获取全量数据,以保证顶部统计卡片的数据准确
|
||||
const res: any = await getCertifications({});
|
||||
certifications.value = res.data || res.results || res;
|
||||
} catch (e) {
|
||||
ElMessage.error('获取认证列表失败');
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const filteredCerts = computed(() => {
|
||||
if (!Array.isArray(certifications.value)) return [];
|
||||
|
||||
return certifications.value.filter(c => {
|
||||
// 1. 过滤状态: 此页面为审核专用页面,不展示 APPROVED
|
||||
if (filterStatus.value === 'PENDING' && c.status !== 'PENDING') return false;
|
||||
if (filterStatus.value === 'REJECTED' && c.status !== 'REJECTED') return false;
|
||||
if (filterStatus.value === 'ALL_UNAPPROVED' && c.status === 'APPROVED') return false;
|
||||
|
||||
// 2. 关键词搜索
|
||||
if (searchQuery.value) {
|
||||
const q = searchQuery.value.toLowerCase();
|
||||
const matchName = (c.real_name || '').toLowerCase().includes(q);
|
||||
const matchUID = (c.id || '').toLowerCase().includes(q);
|
||||
if (!matchName && !matchUID) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
const handleApprove = async (id: string) => {
|
||||
try {
|
||||
await approveCertification(id);
|
||||
ElMessage.success('审核已通过');
|
||||
fetchCerts();
|
||||
} catch (e) {
|
||||
ElMessage.error('操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleReject = async (id: string) => {
|
||||
try {
|
||||
const { value: reason } = await ElMessageBox.prompt('请输入驳回原因', '驳回确认', {
|
||||
confirmButtonText: '确认驳回',
|
||||
cancelButtonText: '取消',
|
||||
inputPlaceholder: '资质不全 / 信息有误...'
|
||||
});
|
||||
|
||||
if (reason) {
|
||||
await rejectCertification(id, reason);
|
||||
ElMessage.success('已驳回');
|
||||
fetchCerts();
|
||||
}
|
||||
} catch (e) {
|
||||
// Cancelled
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusType = (status: string) => {
|
||||
if (status === 'APPROVED') return 'success';
|
||||
if (status === 'REJECTED') return 'danger';
|
||||
return 'warning';
|
||||
};
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
if (status === 'APPROVED') return '已过审';
|
||||
if (status === 'REJECTED') return '已驳回';
|
||||
if (status === 'PENDING') return '待审核';
|
||||
return status;
|
||||
};
|
||||
|
||||
onMounted(fetchCerts);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||
<div class="min-w-[200px]">
|
||||
<h1 class="text-2xl font-bold text-gray-800">OPC 专家审核后台</h1>
|
||||
<p class="text-sm text-gray-400">处理待审核的专家入驻请求</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center justify-between gap-4 mt-4 md:mt-0">
|
||||
<div class="overflow-x-auto max-w-full">
|
||||
<el-radio-group v-model="filterStatus" size="large" style="flex-wrap: nowrap !important; display: inline-flex;">
|
||||
<el-radio-button value="PENDING" label="PENDING">待审核</el-radio-button>
|
||||
<el-radio-button value="REJECTED" label="REJECTED">已驳回</el-radio-button>
|
||||
<el-radio-button value="ALL_UNAPPROVED" label="ALL_UNAPPROVED">全部待处理</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
<div class="w-full sm:w-auto flex-1 sm:flex-none min-w-[200px]">
|
||||
<el-input v-model="searchQuery" placeholder="搜索申请人..." prefix-icon="Search" class="w-full sm:w-64" clearable />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="8" v-for="stat in [
|
||||
{ label: '待处理申请', count: Array.isArray(certifications) ? certifications.filter(c => c.status === 'PENDING').length : 0, color: '#f56c6c' },
|
||||
{ label: '已过审数量', count: Array.isArray(certifications) ? certifications.filter(c => c.status === 'APPROVED').length : 0, color: '#67c23a' },
|
||||
{ label: '累计收到申请', count: Array.isArray(certifications) ? certifications.length : 0, color: '#409eff' }
|
||||
]" :key="stat.label">
|
||||
<el-card shadow="never">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-sm text-gray-400 mb-1">{{ stat.label }}</div>
|
||||
<div class="text-3xl font-bold" :style="{ color: stat.color }">{{ stat.count }}</div>
|
||||
</div>
|
||||
<div class="w-12 h-12 rounded-xl flex items-center justify-center" :style="{ background: stat.color + '15', color: stat.color }">
|
||||
<ShieldCheck class="w-6 h-6" />
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- Table -->
|
||||
<el-card shadow="never" class="!border-gray-100 mt-6">
|
||||
<el-table :data="filteredCerts" stripe v-loading="isLoading">
|
||||
<el-table-column label="申请人" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<div class="flex items-center gap-3">
|
||||
<el-avatar :size="36" :src="row.user_detail?.avatar_url" class="bg-blue-100 text-blue-700 font-bold">
|
||||
{{ (row.user_detail?.nickname || row.user_detail?.username || 'U')[0] }}
|
||||
</el-avatar>
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-gray-900">{{ row.real_name }}</div>
|
||||
<div class="text-xs text-gray-400">UID: {{ row.id?.split('-')[0] || row.id }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="实名信息" width="160">
|
||||
<template #default="{ row }">
|
||||
<span class="text-sm text-gray-600">{{ row.id_card || '隐藏信息' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="技能标签" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<el-tag v-for="skill in row.skills" :key="skill" size="small" type="info">{{ skill }}</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="提交时间" width="160">
|
||||
<template #default="{ row }">
|
||||
{{ new Date(row.created_at).toLocaleString() }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="操作" width="150" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div v-if="row.status === 'PENDING'" class="flex items-center justify-center gap-2">
|
||||
<el-tooltip content="通过" placement="top">
|
||||
<el-button type="success" circle size="small" plain @click="handleApprove(row.id)">
|
||||
<el-icon><CheckCircle class="w-3 h-3" /></el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="驳回" placement="top">
|
||||
<el-button type="danger" circle size="small" plain @click="handleReject(row.id)">
|
||||
<el-icon><XCircle class="w-3 h-3" /></el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="查看" placement="top">
|
||||
<el-button type="info" circle size="small" plain @click="handleView(row)">
|
||||
<el-icon><Eye class="w-3 h-3" /></el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
<div v-else class="flex items-center justify-center gap-2">
|
||||
<el-tag :type="getStatusType(row.status)" effect="plain">
|
||||
{{ getStatusLabel(row.status) }}
|
||||
</el-tag>
|
||||
<el-button text type="primary" size="small" @click="handleView(row)">详情</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<template #empty>
|
||||
<el-empty description="暂无匹配的认证申请" />
|
||||
</template>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<!-- Detail Dialog -->
|
||||
<el-dialog v-model="detailVisible" title="认证申请详情" width="700px" top="5vh">
|
||||
<div v-if="currentCert" class="space-y-6">
|
||||
<!-- User Profile Header -->
|
||||
<div class="flex items-center gap-5 p-5 bg-gradient-to-r from-blue-50 to-indigo-50 rounded-xl border border-blue-100">
|
||||
<el-avatar :size="72" :src="currentCert.user_detail?.avatar_url" class="bg-blue-100 text-blue-700 text-2xl font-bold border-2 border-white shadow-md">
|
||||
{{ (currentCert.user_detail?.nickname || currentCert.user_detail?.username || 'U')[0] }}
|
||||
</el-avatar>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-xl font-bold text-gray-900 flex items-center gap-2">
|
||||
{{ currentCert.real_name }}
|
||||
<el-tag :type="getStatusType(currentCert.status)" size="small" effect="light" class="font-medium">
|
||||
{{ getStatusLabel(currentCert.status) }}
|
||||
</el-tag>
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500 mt-1">
|
||||
账号: {{ currentCert.user_detail?.username || '—' }}
|
||||
<span v-if="currentCert.user_detail?.nickname" class="ml-2">昵称: {{ currentCert.user_detail.nickname }}</span>
|
||||
</p>
|
||||
<div class="flex items-center gap-4 mt-2 text-sm text-gray-500">
|
||||
<span v-if="currentCert.user_detail?.phone" class="flex items-center gap-1">📱 {{ currentCert.user_detail.phone }}</span>
|
||||
<span v-if="currentCert.user_detail?.email" class="flex items-center gap-1">📧 {{ currentCert.user_detail.email }}</span>
|
||||
<span v-if="!currentCert.user_detail?.phone && !currentCert.user_detail?.email" class="text-gray-400 italic">未绑定联系方式</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detailed Info -->
|
||||
<el-descriptions :column="2" border class="custom-desc">
|
||||
<el-descriptions-item label="身份证号" :span="2">
|
||||
<span class="font-mono text-gray-800">{{ currentCert.id_card || '未提供' }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="核心技能" :span="2">
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<el-tag v-for="skill in currentCert.skills" :key="skill" size="small" type="primary" effect="light" round>{{ skill }}</el-tag>
|
||||
<span v-if="!currentCert.skills?.length" class="text-gray-400 text-sm">未填写</span>
|
||||
</div>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="提交时间">
|
||||
{{ new Date(currentCert.created_at).toLocaleString() }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="审核编号">
|
||||
<span class="text-xs font-mono text-gray-500">{{ currentCert.id }}</span>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<!-- Experience -->
|
||||
<div v-if="currentCert.experience">
|
||||
<h4 class="text-sm font-bold text-gray-600 mb-2">项目经验描述</h4>
|
||||
<div class="bg-gray-50 rounded-lg border border-gray-200 p-4 text-sm text-gray-700 whitespace-pre-wrap leading-relaxed max-h-48 overflow-y-auto">
|
||||
{{ currentCert.experience }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Attachments (ID card photos, etc.) -->
|
||||
<div v-if="currentCert.attachments && currentCert.attachments.length > 0">
|
||||
<h4 class="text-sm font-bold text-gray-600 mb-2">身份证照片 / 附件</h4>
|
||||
<div class="flex gap-3 overflow-x-auto pb-2">
|
||||
<div v-for="(file, index) in currentCert.attachments" :key="index" class="flex-shrink-0 w-48 h-32 border border-gray-200 rounded-lg overflow-hidden">
|
||||
<el-image :src="file.url || file" class="w-full h-full" fit="cover" :preview-src-list="currentCert.attachments.map((f: any) => f.url || f)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Resume -->
|
||||
<div v-if="currentCert.resume_url">
|
||||
<h4 class="text-sm font-bold text-gray-600 mb-2">简历附件</h4>
|
||||
<div class="flex items-center gap-3">
|
||||
<el-button type="primary" size="small" plain @click="previewUrl = currentCert.resume_url; previewVisible = true">📄 在线预览简历</el-button>
|
||||
<el-button size="small" tag="a" :href="currentCert.resume_url" target="_blank" download>下载简历</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reject Reason (if rejected) -->
|
||||
<div v-if="currentCert.status === 'REJECTED' && currentCert.reject_reason" class="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<h4 class="text-sm font-bold text-red-700 mb-1">驳回原因</h4>
|
||||
<p class="text-sm text-red-600">{{ currentCert.reject_reason }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer v-if="currentCert?.status === 'PENDING'">
|
||||
<div class="flex items-center justify-end gap-3 pt-2">
|
||||
<el-button @click="detailVisible = false">关闭</el-button>
|
||||
<el-button type="danger" plain @click="handleReject(currentCert.id)">
|
||||
<XCircle class="w-4 h-4 mr-1" /> 驳回申请
|
||||
</el-button>
|
||||
<el-button type="success" @click="handleApprove(currentCert.id)">
|
||||
<CheckCircle class="w-4 h-4 mr-1" /> 通过审核
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- Document Preview Dialog -->
|
||||
<el-dialog v-model="previewVisible" title="文档在线预览" width="80%" top="5vh">
|
||||
<div class="h-[75vh] w-full bg-gray-50 rounded border border-gray-200 overflow-hidden relative flex flex-col items-center justify-center">
|
||||
<template v-if="previewUrl.split('?')[0].toLowerCase().endsWith('.pdf')">
|
||||
<iframe :src="previewUrl" class="w-full h-full border-none"></iframe>
|
||||
</template>
|
||||
<template v-else-if="['.jpg', '.jpeg', '.png', '.webp'].some(ext => previewUrl.split('?')[0].toLowerCase().endsWith(ext))">
|
||||
<el-image :src="previewUrl" class="w-full h-full" fit="contain" />
|
||||
</template>
|
||||
<template v-else-if="['.docx', '.doc'].some(ext => previewUrl.split('?')[0].toLowerCase().endsWith(ext))">
|
||||
<VueOfficeDocx :src="previewUrl" class="w-full h-full" style="height: 100%" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="text-center">
|
||||
<div class="text-4xl mb-4">📄</div>
|
||||
<div class="text-gray-500 mb-4">该文件格式不支持在线直接预览</div>
|
||||
<el-button type="primary" tag="a" :href="previewUrl" target="_blank" download>点击下载文件</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-400">如果预览为空白,可能是浏览器拦截或格式不支持</span>
|
||||
<div class="space-x-2">
|
||||
<el-button @click="previewVisible = false">关闭预览</el-button>
|
||||
<el-button type="primary" tag="a" :href="previewUrl" target="_blank" download>下载到本地</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
365
src/views/admin/DashboardView.vue
Normal file
365
src/views/admin/DashboardView.vue
Normal file
@@ -0,0 +1,365 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { Users, Briefcase, ShieldCheck, Activity, Trophy, ArrowRight, Bell, Building2, UserPlus, ClipboardList, TrendingUp, CheckSquare, ListTodo, AlertCircle } from 'lucide-vue-next';
|
||||
import api from '@/api';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
const user = computed(() => authStore.user);
|
||||
|
||||
const stats = ref({
|
||||
totalUsers: 0,
|
||||
pendingCerts: 0,
|
||||
pendingEntCerts: 0,
|
||||
totalTasks: 0,
|
||||
openTasks: 0,
|
||||
inProgressTasks: 0,
|
||||
totalEnterprises: 0,
|
||||
opcExperts: 0,
|
||||
});
|
||||
|
||||
const isLoading = ref(true);
|
||||
const recentTasks = ref<any[]>([]);
|
||||
const recentUsers = ref<any[]>([]);
|
||||
const announcements = ref<any[]>([]);
|
||||
const opcUsers = ref<any[]>([]);
|
||||
|
||||
const topExperts = computed(() => {
|
||||
return [...opcUsers.value]
|
||||
.sort((a, b) => (b.completed_tasks || 0) - (a.completed_tasks || 0))
|
||||
.slice(0, 5);
|
||||
});
|
||||
|
||||
const getTimeAgo = (dateStr: string) => {
|
||||
const diff = Date.now() - new Date(dateStr).getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 60) return `${mins} 分钟前`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return `${hours} 小时前`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days} 天前`;
|
||||
};
|
||||
|
||||
const taskStatusMap: Record<string, { label: string; type: string }> = {
|
||||
OPEN: { label: '招募中', type: 'success' },
|
||||
IN_PROGRESS: { label: '进行中', type: 'primary' },
|
||||
COMPLETED: { label: '已完成', type: 'info' },
|
||||
CANCELLED: { label: '已下架', type: 'danger' },
|
||||
CLOSED: { label: '已结案', type: 'info' },
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const [usersRes, certsRes, tasksRes, enterprisesRes, opcUsersRes, annRes] = await Promise.all([
|
||||
api.get('/users/'),
|
||||
api.get('/certifications/'),
|
||||
api.get('/tasks/'),
|
||||
api.get('/enterprises/'),
|
||||
api.get('/users/', { params: { role: 'OPC_USER' } }),
|
||||
api.get('/announcements/'),
|
||||
]);
|
||||
const allUsers = usersRes.results || usersRes || [];
|
||||
const allCerts = certsRes.results || certsRes || [];
|
||||
const allTasks = tasksRes.results || tasksRes || [];
|
||||
const allEnts = enterprisesRes.results || enterprisesRes || [];
|
||||
const allOpc = opcUsersRes.results || opcUsersRes || [];
|
||||
const allAnn = annRes.results || annRes || [];
|
||||
|
||||
stats.value = {
|
||||
totalUsers: allUsers.length,
|
||||
pendingCerts: allCerts.filter((c: any) => c.status === 'PENDING').length,
|
||||
pendingEntCerts: allEnts.filter((e: any) => e.status === 'PENDING').length,
|
||||
totalTasks: allTasks.length,
|
||||
openTasks: allTasks.filter((t: any) => t.status === 'OPEN').length,
|
||||
inProgressTasks: allTasks.filter((t: any) => t.status === 'IN_PROGRESS').length,
|
||||
totalEnterprises: allEnts.filter((e: any) => e.status === 'VERIFIED').length, // Count only verified enterprises
|
||||
opcExperts: allOpc.length,
|
||||
};
|
||||
opcUsers.value = allOpc;
|
||||
recentTasks.value = allTasks.slice(0, 6);
|
||||
recentUsers.value = allUsers.slice(0, 5);
|
||||
announcements.value = allAnn.slice(0, 4);
|
||||
} catch (error) {
|
||||
console.error('Failed to load stats', error);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6" v-loading="isLoading">
|
||||
<!-- Welcome Banner -->
|
||||
<div class="bg-gradient-to-r from-slate-800 via-slate-700 to-blue-800 rounded-2xl p-6 sm:p-8 text-white relative overflow-hidden shadow-lg">
|
||||
<div class="absolute top-0 right-0 w-72 h-72 bg-blue-500/10 rounded-full -translate-y-1/2 translate-x-1/3"></div>
|
||||
<div class="absolute bottom-0 left-1/2 w-40 h-40 bg-indigo-400/10 rounded-full translate-y-1/2"></div>
|
||||
<div class="relative z-10">
|
||||
<h1 class="text-2xl sm:text-3xl font-bold tracking-tight">OPC 管理平台</h1>
|
||||
<p class="text-blue-200 mt-2 text-sm sm:text-base">欢迎回来,{{ user?.nickname || user?.username || '管理员' }}。以下是平台运营数据概览。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Core Data Grid -->
|
||||
<div>
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div @click="router.push('/admin/users')" class="stat-card bg-white rounded-xl p-5 border border-gray-100 cursor-pointer hover:shadow-xl hover:-translate-y-1 hover:border-blue-200 transition-all group">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="p-3 rounded-xl bg-blue-50 text-blue-600 group-hover:bg-blue-600 group-hover:text-white transition-colors">
|
||||
<Users class="w-6 h-6" />
|
||||
</div>
|
||||
<ArrowRight class="w-4 h-4 text-gray-300 group-hover:text-blue-400 transition-colors" />
|
||||
</div>
|
||||
<div class="text-3xl font-black text-gray-800 tracking-tight">{{ stats.totalUsers }}</div>
|
||||
<div class="text-sm text-gray-400 mt-1 font-medium">注册用户</div>
|
||||
</div>
|
||||
|
||||
<div @click="router.push('/admin/opc-users')" class="stat-card bg-white rounded-xl p-5 border border-gray-100 cursor-pointer hover:shadow-xl hover:-translate-y-1 hover:border-cyan-200 transition-all group">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="p-3 rounded-xl bg-cyan-50 text-cyan-600 group-hover:bg-cyan-600 group-hover:text-white transition-colors">
|
||||
<ShieldCheck class="w-6 h-6" />
|
||||
</div>
|
||||
<ArrowRight class="w-4 h-4 text-gray-300 group-hover:text-cyan-400 transition-colors" />
|
||||
</div>
|
||||
<div class="text-3xl font-black text-gray-800 tracking-tight">{{ stats.opcExperts }}</div>
|
||||
<div class="text-sm text-gray-400 mt-1 font-medium">认证专家</div>
|
||||
</div>
|
||||
|
||||
<div @click="router.push('/admin/enterprises')" class="stat-card bg-white rounded-xl p-5 border border-gray-100 cursor-pointer hover:shadow-xl hover:-translate-y-1 hover:border-green-200 transition-all group">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="p-3 rounded-xl bg-green-50 text-green-600 group-hover:bg-green-600 group-hover:text-white transition-colors">
|
||||
<Building2 class="w-6 h-6" />
|
||||
</div>
|
||||
<ArrowRight class="w-4 h-4 text-gray-300 group-hover:text-green-400 transition-colors" />
|
||||
</div>
|
||||
<div class="text-3xl font-black text-gray-800 tracking-tight">{{ stats.totalEnterprises }}</div>
|
||||
<div class="text-sm text-gray-400 mt-1 font-medium">已认证企业</div>
|
||||
</div>
|
||||
|
||||
<div @click="router.push('/admin/tasks')" class="stat-card bg-white rounded-xl p-5 border border-gray-100 cursor-pointer hover:shadow-xl hover:-translate-y-1 hover:border-indigo-200 transition-all group relative overflow-hidden">
|
||||
<div class="absolute right-0 top-0 w-24 h-24 bg-gradient-to-br from-indigo-50 to-transparent rounded-bl-full -z-10"></div>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="p-3 rounded-xl bg-indigo-50 text-indigo-600 group-hover:bg-indigo-600 group-hover:text-white transition-colors">
|
||||
<ListTodo class="w-6 h-6" />
|
||||
</div>
|
||||
<ArrowRight class="w-4 h-4 text-gray-300 group-hover:text-indigo-400 transition-colors" />
|
||||
</div>
|
||||
<div class="text-3xl font-black text-gray-800 tracking-tight">{{ stats.totalTasks }}</div>
|
||||
<div class="text-sm text-gray-400 mt-1 font-medium">平台全部任务</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Management Groups -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
|
||||
<!-- 待审核分组 -->
|
||||
<div class="bg-white rounded-xl border border-gray-100 shadow-sm overflow-hidden flex flex-col">
|
||||
<div class="px-6 py-4 border-b border-gray-50 flex items-center justify-between bg-orange-50/30">
|
||||
<h3 class="font-bold text-gray-800 flex items-center gap-2">
|
||||
<AlertCircle class="w-5 h-5 text-orange-500" /> 待处理审核
|
||||
</h3>
|
||||
<span class="px-2.5 py-0.5 rounded-full bg-orange-100 text-orange-600 text-xs font-bold">{{ stats.pendingCerts + stats.pendingEntCerts }} 项待办</span>
|
||||
</div>
|
||||
<div class="p-6 grid grid-cols-2 gap-6 flex-1">
|
||||
<div @click="router.push('/admin/certifications')" class="group cursor-pointer rounded-xl p-5 border-2 border-orange-50 bg-white hover:border-orange-200 hover:bg-orange-50/50 transition-all relative overflow-hidden">
|
||||
<div class="absolute right-0 top-0 w-16 h-16 bg-orange-50 rounded-bl-full -z-10 group-hover:scale-110 transition-transform"></div>
|
||||
<div class="text-gray-500 text-sm font-medium mb-2 flex items-center gap-1.5">
|
||||
<ShieldCheck class="w-4 h-4 text-orange-400" /> OPC认证审核
|
||||
</div>
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span class="text-4xl font-black text-orange-600">{{ stats.pendingCerts }}</span>
|
||||
<span class="text-xs text-orange-400 font-medium">个申请</span>
|
||||
</div>
|
||||
</div>
|
||||
<div @click="router.push('/admin/enterprise-certs')" class="group cursor-pointer rounded-xl p-5 border-2 border-red-50 bg-white hover:border-red-200 hover:bg-red-50/50 transition-all relative overflow-hidden">
|
||||
<div class="absolute right-0 top-0 w-16 h-16 bg-red-50 rounded-bl-full -z-10 group-hover:scale-110 transition-transform"></div>
|
||||
<div class="text-gray-500 text-sm font-medium mb-2 flex items-center gap-1.5">
|
||||
<Building2 class="w-4 h-4 text-red-400" /> 企业入驻审核
|
||||
</div>
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span class="text-4xl font-black text-red-600">{{ stats.pendingEntCerts }}</span>
|
||||
<span class="text-xs text-red-400 font-medium">个申请</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 任务进行状态 -->
|
||||
<div class="bg-white rounded-xl border border-gray-100 shadow-sm overflow-hidden flex flex-col">
|
||||
<div class="px-6 py-4 border-b border-gray-50 flex items-center justify-between bg-indigo-50/30">
|
||||
<h3 class="font-bold text-gray-800 flex items-center gap-2">
|
||||
<Briefcase class="w-5 h-5 text-indigo-500" /> 活跃任务概览
|
||||
</h3>
|
||||
<el-button text type="primary" size="small" @click="router.push('/admin/tasks')">
|
||||
查看全部 <ArrowRight class="w-3 h-3 ml-1" />
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="p-6 grid grid-cols-2 gap-6 flex-1">
|
||||
<div @click="router.push({ path: '/admin/tasks', query: { status: 'OPEN' } })" class="group cursor-pointer rounded-xl p-5 border-2 border-blue-50 bg-white hover:border-blue-200 hover:bg-blue-50/50 transition-all relative overflow-hidden">
|
||||
<div class="absolute right-0 top-0 w-16 h-16 bg-blue-50 rounded-bl-full -z-10 group-hover:scale-110 transition-transform"></div>
|
||||
<div class="text-gray-500 text-sm font-medium mb-2 flex items-center gap-1.5">
|
||||
<CheckSquare class="w-4 h-4 text-blue-400" /> 招募中任务
|
||||
</div>
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span class="text-4xl font-black text-blue-600">{{ stats.openTasks }}</span>
|
||||
<span class="text-xs text-blue-400 font-medium">个项目</span>
|
||||
</div>
|
||||
</div>
|
||||
<div @click="router.push({ path: '/admin/tasks', query: { status: 'IN_PROGRESS' } })" class="group cursor-pointer rounded-xl p-5 border-2 border-purple-50 bg-white hover:border-purple-200 hover:bg-purple-50/50 transition-all relative overflow-hidden">
|
||||
<div class="absolute right-0 top-0 w-16 h-16 bg-purple-50 rounded-bl-full -z-10 group-hover:scale-110 transition-transform"></div>
|
||||
<div class="text-gray-500 text-sm font-medium mb-2 flex items-center gap-1.5">
|
||||
<Activity class="w-4 h-4 text-purple-400" /> 进行中任务
|
||||
</div>
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span class="text-4xl font-black text-purple-600">{{ stats.inProgressTasks }}</span>
|
||||
<span class="text-xs text-purple-400 font-medium">个项目</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Main Grid -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<!-- Recent Tasks -->
|
||||
<div class="lg:col-span-2 bg-white rounded-xl border border-gray-100 overflow-hidden shadow-sm">
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-50">
|
||||
<h3 class="font-bold text-gray-800 flex items-center gap-2">
|
||||
<Briefcase class="w-4 h-4 text-indigo-500" />最近任务动态
|
||||
</h3>
|
||||
<el-button text type="primary" size="small" @click="router.push('/admin/tasks')">
|
||||
查看全部 <ArrowRight class="w-3 h-3 ml-1" />
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-50">
|
||||
<div v-for="task in recentTasks" :key="task.id" class="px-6 py-4 hover:bg-gray-50/50 transition-colors cursor-pointer flex items-center justify-between gap-4 group" @click="router.push(`/admin/tasks/${task.id}`)">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="font-medium text-gray-800 text-sm truncate group-hover:text-blue-600 transition-colors">{{ task.title }}</div>
|
||||
<div class="text-xs text-gray-400 mt-1 flex items-center gap-3">
|
||||
<span class="flex items-center gap-1"><Building2 class="w-3 h-3"/> {{ task.publisher_name || '未知企业' }}</span>
|
||||
<span class="font-mono text-gray-500">¥{{ task.budget_min?.toLocaleString() || 0 }} - {{ task.budget_max?.toLocaleString() || 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col items-end gap-1.5 flex-shrink-0">
|
||||
<el-tag :type="(taskStatusMap[task.status]?.type as any) || 'info'" size="small" effect="light" round>
|
||||
{{ taskStatusMap[task.status]?.label || task.status }}
|
||||
</el-tag>
|
||||
<span class="text-xs text-gray-400">{{ getTimeAgo(task.created_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="recentTasks.length === 0" class="px-6 py-12 text-center text-gray-400 text-sm flex flex-col items-center">
|
||||
<Briefcase class="w-8 h-8 text-gray-200 mb-2" />
|
||||
暂无任务数据
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column -->
|
||||
<div class="space-y-6">
|
||||
<!-- Top Experts -->
|
||||
<div class="bg-white rounded-xl border border-gray-100 overflow-hidden shadow-sm">
|
||||
<div class="flex items-center justify-between px-5 py-4 border-b border-gray-50">
|
||||
<h3 class="font-bold text-gray-800 flex items-center gap-2">
|
||||
<Trophy class="w-4 h-4 text-yellow-500" />专家排行榜
|
||||
</h3>
|
||||
<el-button text type="primary" size="small" @click="router.push('/admin/opc-users')">
|
||||
更多 <ArrowRight class="w-3 h-3 ml-1" />
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="p-4 space-y-2">
|
||||
<div v-for="(expert, idx) in topExperts" :key="expert.id" class="flex items-center gap-3 p-2 rounded-lg hover:bg-gray-50 transition-colors cursor-pointer group" @click="router.push('/admin/opc-users')">
|
||||
<div class="w-6 h-6 rounded-full flex items-center justify-center text-xs font-black flex-shrink-0"
|
||||
:class="idx === 0 ? 'bg-yellow-100 text-yellow-600 shadow-sm' : idx === 1 ? 'bg-gray-100 text-gray-500 shadow-sm' : idx === 2 ? 'bg-orange-50 text-orange-500 shadow-sm' : 'bg-gray-50 text-gray-400'">
|
||||
{{ idx + 1 }}
|
||||
</div>
|
||||
<el-avatar :size="32" :src="expert.avatar_url" class="bg-blue-100 text-blue-600 text-xs font-bold flex-shrink-0 border border-blue-200">
|
||||
{{ expert.opc_certification?.real_name?.[0] || expert.nickname?.[0] || 'O' }}
|
||||
</el-avatar>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-medium text-gray-800 truncate group-hover:text-blue-600 transition-colors">{{ expert.opc_certification?.real_name || expert.nickname || expert.username }}</div>
|
||||
<div class="text-xs text-gray-400 mt-0.5">{{ expert.completed_tasks || 0 }} 项目 · <span class="text-yellow-500">★{{ Number(expert.rating || 5).toFixed(1) }}</span></div>
|
||||
</div>
|
||||
<el-tag v-if="expert.is_recommended" size="small" type="warning" effect="light" round>推荐</el-tag>
|
||||
</div>
|
||||
<div v-if="topExperts.length === 0" class="text-center text-gray-400 text-sm py-8">暂无专家数据</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="bg-white rounded-xl border border-gray-100 overflow-hidden shadow-sm">
|
||||
<div class="px-5 py-4 border-b border-gray-50 bg-gray-50/50">
|
||||
<h3 class="font-bold text-gray-800 flex items-center gap-2">
|
||||
<TrendingUp class="w-4 h-4 text-blue-500" />快捷操作
|
||||
</h3>
|
||||
</div>
|
||||
<div class="p-5 grid grid-cols-2 gap-3">
|
||||
<div @click="router.push('/admin/enterprise-certs')" class="quick-action-btn">
|
||||
<Building2 class="w-5 h-5 text-red-500 mb-1" />
|
||||
<span>企业审核</span>
|
||||
</div>
|
||||
<div @click="router.push('/admin/certifications')" class="quick-action-btn">
|
||||
<ShieldCheck class="w-5 h-5 text-orange-500 mb-1" />
|
||||
<span>OPC审核</span>
|
||||
</div>
|
||||
<div @click="router.push('/admin/tasks/create')" class="quick-action-btn">
|
||||
<Briefcase class="w-5 h-5 text-indigo-500 mb-1" />
|
||||
<span>发布任务</span>
|
||||
</div>
|
||||
<div @click="router.push('/admin/announcements')" class="quick-action-btn">
|
||||
<Bell class="w-5 h-5 text-blue-500 mb-1" />
|
||||
<span>发布公告</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Announcements -->
|
||||
<div v-if="announcements.length > 0" class="bg-white rounded-xl border border-gray-100 overflow-hidden shadow-sm">
|
||||
<div class="flex items-center justify-between px-5 py-4 border-b border-gray-50 bg-blue-50/30">
|
||||
<h3 class="font-bold text-gray-800 flex items-center gap-2">
|
||||
<Bell class="w-4 h-4 text-blue-500" />最新公告
|
||||
</h3>
|
||||
<el-button text type="primary" size="small" @click="router.push('/admin/announcements')">
|
||||
管理 <ArrowRight class="w-3 h-3 ml-1" />
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-50">
|
||||
<div v-for="ann in announcements" :key="ann.id" class="px-5 py-3 hover:bg-gray-50/50 transition-colors cursor-pointer group" @click="router.push('/admin/announcements')">
|
||||
<div class="text-sm font-medium text-gray-700 truncate group-hover:text-blue-600 transition-colors">{{ ann.title }}</div>
|
||||
<div class="text-xs text-gray-400 mt-1">{{ getTimeAgo(ann.created_at) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.quick-action-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 16px 8px;
|
||||
border-radius: 12px;
|
||||
background: #f9fafb;
|
||||
border: 1px solid #f3f4f6;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #4b5563;
|
||||
}
|
||||
.quick-action-btn:hover {
|
||||
background: #eff6ff;
|
||||
border-color: #bfdbfe;
|
||||
color: #1d4ed8;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.1);
|
||||
}
|
||||
</style>
|
||||
|
||||
110
src/views/admin/DeletedUsersView.vue
Normal file
110
src/views/admin/DeletedUsersView.vue
Normal file
@@ -0,0 +1,110 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { RotateCcw, Trash2, UserX, Clock } from 'lucide-vue-next';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import api from '@/api';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const users = ref<any[]>([]);
|
||||
const loading = ref(true);
|
||||
|
||||
const fetchDeletedUsers = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res: any = await api.get('/users/deleted_users/');
|
||||
users.value = res.results || res || [];
|
||||
} catch (error) {
|
||||
ElMessage.error('无法获取已删除用户列表');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const restoreUser = async (user: any) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要恢复用户 <strong>${getOriginalName(user.username)}</strong> 吗?恢复后该用户将重新激活。`,
|
||||
'恢复用户',
|
||||
{ dangerouslyUseHTMLString: true, type: 'info', confirmButtonText: '确认恢复' }
|
||||
);
|
||||
await api.post(`/users/${user.id}/restore/`);
|
||||
ElMessage.success('用户已恢复');
|
||||
fetchDeletedUsers();
|
||||
} catch (error: any) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error(error?.response?.data?.detail || '恢复失败');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getOriginalName = (username: string) => {
|
||||
const idx = username.indexOf('__deleted_');
|
||||
return idx > 0 ? username.substring(0, idx) : username;
|
||||
};
|
||||
|
||||
const formatDate = (date: string) => dayjs(date).format('YYYY-MM-DD HH:mm');
|
||||
|
||||
onMounted(fetchDeletedUsers);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<UserX class="w-6 h-6 text-gray-400" />
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-gray-800">用户回收站</h1>
|
||||
<p class="text-xs text-gray-400 mt-0.5">已删除的用户数据保留在此,支持恢复操作</p>
|
||||
</div>
|
||||
</div>
|
||||
<el-button @click="$router.push('/admin/users')" plain>
|
||||
← 返回用户管理
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-card shadow="never" class="!border-none rounded-2xl" v-loading="loading">
|
||||
<el-table :data="users" stripe :header-cell-style="{ background:'#f8fafc', color:'#475569', fontWeight: '600' }">
|
||||
<el-table-column label="用户" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<div class="flex items-center gap-3">
|
||||
<el-avatar :size="36" :src="row.avatar_url" class="bg-gray-200 text-gray-600">
|
||||
{{ getOriginalName(row.username)?.[0]?.toUpperCase() }}
|
||||
</el-avatar>
|
||||
<div>
|
||||
<div class="font-medium text-gray-800">{{ getOriginalName(row.username) }}</div>
|
||||
<div class="text-xs text-gray-400">{{ row.nickname || '无昵称' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="原手机号" width="150">
|
||||
<template #default="{ row }">
|
||||
<span class="text-sm text-gray-600 font-mono">{{ row.phone ? getOriginalName(row.phone) : '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="原邮箱" width="200">
|
||||
<template #default="{ row }">
|
||||
<span class="text-sm text-gray-600">{{ row.email ? getOriginalName(row.email) : '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="删除时间" width="180">
|
||||
<template #default="{ row }">
|
||||
<div class="flex items-center gap-1 text-sm text-gray-500">
|
||||
<Clock class="w-3.5 h-3.5" />
|
||||
{{ formatDate(row.updated_at) }}
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button text type="primary" size="small" @click="restoreUser(row)">
|
||||
<RotateCcw class="w-3.5 h-3.5 mr-1" /> 恢复
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-empty v-if="!loading && users.length === 0" description="回收站为空" :image-size="60" />
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
276
src/views/admin/EnterpriseCertListView.vue
Normal file
276
src/views/admin/EnterpriseCertListView.vue
Normal file
@@ -0,0 +1,276 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import api from '@/api';
|
||||
import dayjs from 'dayjs';
|
||||
import { Building2, CheckCircle, XCircle, Eye, ShieldCheck, Search } from 'lucide-vue-next';
|
||||
|
||||
const enterprises = ref<any[]>([]);
|
||||
const isLoading = ref(true);
|
||||
const filterStatus = ref('PENDING');
|
||||
const searchQuery = ref('');
|
||||
|
||||
const detailVisible = ref(false);
|
||||
const currentEnterprise = ref<any>(null);
|
||||
|
||||
const fetchEnterprises = async () => {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const res: any = await api.get('/enterprises/');
|
||||
enterprises.value = res.results || res;
|
||||
} catch (e) {
|
||||
ElMessage.error('获取企业列表失败');
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const filteredEnterprises = computed(() => {
|
||||
if (!Array.isArray(enterprises.value)) return [];
|
||||
return enterprises.value.filter(e => {
|
||||
if (filterStatus.value === 'PENDING' && e.status !== 'PENDING') return false;
|
||||
if (filterStatus.value === 'REJECTED' && e.status !== 'REJECTED') return false;
|
||||
if (filterStatus.value === 'ALL_UNVERIFIED' && e.status === 'VERIFIED') return false;
|
||||
if (searchQuery.value) {
|
||||
const q = searchQuery.value.toLowerCase();
|
||||
const matchName = (e.company_name || '').toLowerCase().includes(q);
|
||||
const matchCode = (e.credit_code || '').toLowerCase().includes(q);
|
||||
const matchContact = (e.contact_name || '').toLowerCase().includes(q);
|
||||
if (!matchName && !matchCode && !matchContact) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
const handleView = (row: any) => {
|
||||
currentEnterprise.value = row;
|
||||
detailVisible.value = true;
|
||||
};
|
||||
|
||||
const handleApprove = async (id: string) => {
|
||||
try {
|
||||
await api.post(`/enterprises/${id}/verify_status/`, { status: 'VERIFIED' });
|
||||
ElMessage.success('企业认证已通过');
|
||||
detailVisible.value = false;
|
||||
fetchEnterprises();
|
||||
} catch (e) {
|
||||
ElMessage.error('操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleReject = async (id: string) => {
|
||||
try {
|
||||
const { value: reason } = await ElMessageBox.prompt('请输入驳回原因', '驳回确认', {
|
||||
confirmButtonText: '确认驳回',
|
||||
cancelButtonText: '取消',
|
||||
inputPlaceholder: '资质不全 / 信息有误...'
|
||||
});
|
||||
if (reason) {
|
||||
await api.post(`/enterprises/${id}/verify_status/`, { status: 'REJECTED', reason });
|
||||
ElMessage.success('已驳回');
|
||||
detailVisible.value = false;
|
||||
fetchEnterprises();
|
||||
}
|
||||
} catch (e) {
|
||||
// Cancelled
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusType = (status: string) => {
|
||||
if (status === 'VERIFIED') return 'success';
|
||||
if (status === 'REJECTED') return 'danger';
|
||||
return 'warning';
|
||||
};
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
if (status === 'VERIFIED') return '已认证';
|
||||
if (status === 'REJECTED') return '已驳回';
|
||||
if (status === 'PENDING') return '待审核';
|
||||
return status;
|
||||
};
|
||||
|
||||
const formatDate = (date: string) => dayjs(date).format('YYYY-MM-DD HH:mm');
|
||||
|
||||
onMounted(fetchEnterprises);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||
<div class="min-w-[200px]">
|
||||
<h1 class="text-2xl font-bold text-gray-800">企业认证审核</h1>
|
||||
<p class="text-sm text-gray-400">处理待审核的企业入驻认证申请</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center justify-between gap-4 mt-4 md:mt-0">
|
||||
<div class="overflow-x-auto max-w-full">
|
||||
<el-radio-group v-model="filterStatus" size="large" style="flex-wrap: nowrap !important; display: inline-flex;">
|
||||
<el-radio-button value="PENDING" label="PENDING">待审核</el-radio-button>
|
||||
<el-radio-button value="REJECTED" label="REJECTED">已驳回</el-radio-button>
|
||||
<el-radio-button value="ALL_UNVERIFIED" label="ALL_UNVERIFIED">全部待处理</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
<div class="w-full sm:w-auto flex-1 sm:flex-none min-w-[200px]">
|
||||
<el-input v-model="searchQuery" placeholder="搜索企业名称/信用代码..." class="w-full sm:w-64" clearable>
|
||||
<template #prefix><Search class="w-4 h-4 text-gray-400" /></template>
|
||||
</el-input>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="8" v-for="stat in [
|
||||
{ label: '待处理申请', count: Array.isArray(enterprises) ? enterprises.filter(e => e.status === 'PENDING').length : 0, color: '#f56c6c' },
|
||||
{ label: '已认证企业', count: Array.isArray(enterprises) ? enterprises.filter(e => e.status === 'VERIFIED').length : 0, color: '#67c23a' },
|
||||
{ label: '累计入驻申请', count: Array.isArray(enterprises) ? enterprises.length : 0, color: '#409eff' }
|
||||
]" :key="stat.label">
|
||||
<el-card shadow="never">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-sm text-gray-400 mb-1">{{ stat.label }}</div>
|
||||
<div class="text-3xl font-bold" :style="{ color: stat.color }">{{ stat.count }}</div>
|
||||
</div>
|
||||
<div class="w-12 h-12 rounded-xl flex items-center justify-center" :style="{ background: stat.color + '15', color: stat.color }">
|
||||
<Building2 class="w-6 h-6" />
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- Table -->
|
||||
<el-card shadow="never" class="!border-gray-100 mt-6">
|
||||
<el-table :data="filteredEnterprises" stripe v-loading="isLoading">
|
||||
<el-table-column label="企业信息" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-lg bg-blue-50 flex items-center justify-center border border-blue-100 flex-shrink-0 overflow-hidden">
|
||||
<img v-if="row.logo_url" :src="row.logo_url" class="w-full h-full object-cover" />
|
||||
<Building2 v-else class="w-5 h-5 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-gray-900">{{ row.company_name }}</div>
|
||||
<div class="text-xs text-gray-400 font-mono">{{ row.credit_code || '无信用代码' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="联系人" width="140">
|
||||
<template #default="{ row }">
|
||||
<div class="text-sm text-gray-700">{{ row.contact_name || '—' }}</div>
|
||||
<div class="text-xs text-gray-400">{{ row.contact_phone || '—' }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="注册账号" width="140">
|
||||
<template #default="{ row }">
|
||||
<div class="flex items-center gap-2">
|
||||
<el-avatar :size="24" :src="row.user_details?.avatar_url" class="bg-blue-100 text-blue-700 font-bold text-xs">
|
||||
{{ (row.user_details?.nickname || row.user_details?.username || 'U')[0] }}
|
||||
</el-avatar>
|
||||
<span class="text-sm text-gray-600">{{ row.user_details?.nickname || row.user_details?.username || '—' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="申请时间" width="160">
|
||||
<template #default="{ row }">
|
||||
{{ formatDate(row.created_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="操作" width="150" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div v-if="row.status === 'PENDING'" class="flex items-center justify-center gap-2">
|
||||
<el-tooltip content="通过" placement="top">
|
||||
<el-button type="success" circle size="small" plain @click="handleApprove(row.id)">
|
||||
<el-icon><CheckCircle class="w-3 h-3" /></el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="驳回" placement="top">
|
||||
<el-button type="danger" circle size="small" plain @click="handleReject(row.id)">
|
||||
<el-icon><XCircle class="w-3 h-3" /></el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="查看详情" placement="top">
|
||||
<el-button type="info" circle size="small" plain @click="handleView(row)">
|
||||
<el-icon><Eye class="w-3 h-3" /></el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
<div v-else class="flex items-center justify-center gap-2">
|
||||
<el-tag :type="getStatusType(row.status)" effect="plain">
|
||||
{{ getStatusLabel(row.status) }}
|
||||
</el-tag>
|
||||
<el-button text type="primary" size="small" @click="handleView(row)">详情</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<template #empty>
|
||||
<el-empty description="暂无匹配的企业认证申请" />
|
||||
</template>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<!-- Detail Dialog -->
|
||||
<el-dialog v-model="detailVisible" title="企业认证详情" width="700px" top="5vh">
|
||||
<div v-if="currentEnterprise" class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-5 p-5 bg-gradient-to-r from-blue-50 to-indigo-50 rounded-xl border border-blue-100">
|
||||
<div class="w-16 h-16 rounded-xl bg-white flex items-center justify-center border border-blue-100 shadow-sm overflow-hidden">
|
||||
<img v-if="currentEnterprise.logo_url" :src="currentEnterprise.logo_url" class="w-full h-full object-cover" />
|
||||
<Building2 v-else class="w-8 h-8 text-blue-600" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-xl font-bold text-gray-900 flex items-center gap-2">
|
||||
{{ currentEnterprise.company_name }}
|
||||
<el-tag :type="getStatusType(currentEnterprise.status)" size="small" effect="light" class="font-medium">
|
||||
{{ getStatusLabel(currentEnterprise.status) }}
|
||||
</el-tag>
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500 mt-1 font-mono">{{ currentEnterprise.credit_code || '无统一社会信用代码' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="联系人">{{ currentEnterprise.contact_name || '未提供' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="联系电话">{{ currentEnterprise.contact_phone || '未提供' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="联系邮箱">{{ currentEnterprise.contact_email || '未提供' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="企业地址">{{ currentEnterprise.address || '未提供' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="注册账号">{{ currentEnterprise.user_details?.username || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="申请时间">{{ formatDate(currentEnterprise.created_at) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<div v-if="currentEnterprise.description">
|
||||
<h4 class="text-sm font-bold text-gray-600 mb-2">企业介绍</h4>
|
||||
<div class="bg-gray-50 rounded-lg border border-gray-200 p-4 text-sm text-gray-700 whitespace-pre-wrap leading-relaxed max-h-48 overflow-y-auto">
|
||||
{{ currentEnterprise.description }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Business License -->
|
||||
<div v-if="currentEnterprise.business_license">
|
||||
<h4 class="text-sm font-bold text-gray-600 mb-2">营业执照</h4>
|
||||
<div class="w-64 h-40 border border-gray-200 rounded-lg overflow-hidden">
|
||||
<el-image :src="currentEnterprise.business_license" class="w-full h-full" fit="cover" :preview-src-list="[currentEnterprise.business_license]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer v-if="currentEnterprise?.status === 'PENDING'">
|
||||
<div class="flex items-center justify-end gap-3 pt-2">
|
||||
<el-button @click="detailVisible = false">关闭</el-button>
|
||||
<el-button type="danger" plain @click="handleReject(currentEnterprise.id)">
|
||||
<XCircle class="w-4 h-4 mr-1" /> 驳回申请
|
||||
</el-button>
|
||||
<el-button type="success" @click="handleApprove(currentEnterprise.id)">
|
||||
<CheckCircle class="w-4 h-4 mr-1" /> 通过认证
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
721
src/views/admin/EnterpriseListView.vue
Normal file
721
src/views/admin/EnterpriseListView.vue
Normal file
@@ -0,0 +1,721 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue';
|
||||
import { Search, Building2, MoreHorizontal, CheckCircle, XCircle, Trash2, ShieldCheck, Upload, UserPlus, RotateCcw } from 'lucide-vue-next';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import api, { uploadFileToMinIO } from '@/api';
|
||||
import dayjs from 'dayjs';
|
||||
import SlideVerify from '@/components/common/SlideVerify.vue';
|
||||
|
||||
const enterprises = ref<any[]>([]);
|
||||
const loading = ref(true);
|
||||
const selectedEnterprises = ref<any[]>([]);
|
||||
|
||||
const fetchEnterprises = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res: any = await api.get('/enterprises/');
|
||||
enterprises.value = res.results || res;
|
||||
} catch (error) {
|
||||
ElMessage.error('无法获取企业列表');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleEntSelectionChange = (val: any[]) => {
|
||||
selectedEnterprises.value = val;
|
||||
};
|
||||
|
||||
const handleStatusChange = async (row: any, status: string) => {
|
||||
try {
|
||||
await api.post(`/enterprises/${row.id}/verify_status/`, { status });
|
||||
row.status = status;
|
||||
ElMessage.success('审核状态已更新');
|
||||
} catch (error) {
|
||||
ElMessage.error('更新审核状态失败');
|
||||
}
|
||||
};
|
||||
|
||||
const enterpriseDialogVisible = ref(false);
|
||||
const isEnterpriseEditing = ref(false);
|
||||
const enterpriseSaveLoading = ref(false);
|
||||
const enterpriseForm = ref({
|
||||
id: '', email: '', password: '', company_name: '', credit_code: '',
|
||||
contact_name: '', contact_phone: '', landline: '', contact_email: '',
|
||||
address: '', description: '', logo_url: '', business_license: ''
|
||||
});
|
||||
|
||||
const enterpriseDrawerVisible = ref(false);
|
||||
const selectedEnterprise = ref<any>(null);
|
||||
const enterpriseMembers = ref<any[]>([]);
|
||||
const enterpriseTasks = ref<any[]>([]);
|
||||
const membersLoading = ref(false);
|
||||
|
||||
const memberDialogVisible = ref(false);
|
||||
const memberSaveLoading = ref(false);
|
||||
const memberForm = ref({ username: '', password: '', nickname: '', role: 'MEMBER' });
|
||||
|
||||
const addEnterpriseMember = async () => {
|
||||
memberSaveLoading.value = true;
|
||||
try {
|
||||
await api.post('/enterprise-members/add_member/', {
|
||||
...memberForm.value,
|
||||
enterprise_id: selectedEnterprise.value.id
|
||||
});
|
||||
ElMessage.success('成员添加成功');
|
||||
memberDialogVisible.value = false;
|
||||
memberForm.value = { username: '', password: '', nickname: '', role: 'MEMBER' };
|
||||
openEnterpriseDrawer(selectedEnterprise.value); // refresh list
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.response?.data?.detail || error.response?.data?.username?.[0] || '添加失败,请检查输入');
|
||||
} finally {
|
||||
memberSaveLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogoUpload = async (options: any) => {
|
||||
try {
|
||||
const url = await uploadFileToMinIO(options.file, 'avatars');
|
||||
enterpriseForm.value.logo_url = url;
|
||||
ElMessage.success('企业 Logo 上传成功');
|
||||
} catch (error) {
|
||||
ElMessage.error('企业 Logo 上传失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleLicenseUpload = async (options: any) => {
|
||||
try {
|
||||
const url = await uploadFileToMinIO(options.file, 'certifications');
|
||||
enterpriseForm.value.business_license = url;
|
||||
ElMessage.success('营业执照上传成功');
|
||||
} catch (error) {
|
||||
ElMessage.error('营业执照上传失败');
|
||||
}
|
||||
};
|
||||
|
||||
const openEnterpriseDrawer = async (row: any) => {
|
||||
selectedEnterprise.value = row;
|
||||
enterpriseDrawerVisible.value = true;
|
||||
membersLoading.value = true;
|
||||
try {
|
||||
const [membersRes, tasksRes]: any[] = await Promise.all([
|
||||
api.get('/enterprise-members/', { params: { enterprise: row.id } }),
|
||||
api.get('/tasks/', { params: { enterprise: row.id } }),
|
||||
]);
|
||||
enterpriseMembers.value = membersRes.results || membersRes;
|
||||
enterpriseTasks.value = tasksRes.results || tasksRes || [];
|
||||
} catch (error) {
|
||||
ElMessage.error('获取企业详情失败');
|
||||
} finally {
|
||||
membersLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const openCreateEnterpriseDialog = () => {
|
||||
isEnterpriseEditing.value = false;
|
||||
enterpriseForm.value = {
|
||||
id: '', email: '', password: '', company_name: '', credit_code: '',
|
||||
contact_name: '', contact_phone: '', landline: '', contact_email: '',
|
||||
address: '', description: '', logo_url: '', business_license: ''
|
||||
};
|
||||
enterpriseDialogVisible.value = true;
|
||||
};
|
||||
|
||||
const openEditEnterpriseDialog = (row: any) => {
|
||||
isEnterpriseEditing.value = true;
|
||||
enterpriseForm.value = {
|
||||
id: row.id, email: '', password: '', company_name: row.company_name,
|
||||
credit_code: row.credit_code, contact_name: row.contact_name,
|
||||
contact_phone: row.contact_phone, landline: row.landline || '', contact_email: row.contact_email || '',
|
||||
address: row.address || '', description: row.description || '',
|
||||
logo_url: row.logo_url || '', business_license: row.business_license || ''
|
||||
};
|
||||
enterpriseDialogVisible.value = true;
|
||||
};
|
||||
|
||||
const saveEnterprise = async () => {
|
||||
enterpriseSaveLoading.value = true;
|
||||
try {
|
||||
if (isEnterpriseEditing.value) {
|
||||
await api.put(`/enterprises/${enterpriseForm.value.id}/`, enterpriseForm.value);
|
||||
ElMessage.success('企业信息已更新');
|
||||
} else {
|
||||
await api.post('/enterprises/', enterpriseForm.value);
|
||||
ElMessage.success('企业创建成功');
|
||||
}
|
||||
enterpriseDialogVisible.value = false;
|
||||
fetchEnterprises();
|
||||
} catch (error: any) {
|
||||
ElMessage.error('操作失败,请检查填写信息');
|
||||
} finally {
|
||||
enterpriseSaveLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const deleteConfirmVisible = ref(false);
|
||||
const batchDeleteVisible = ref(false);
|
||||
const batchEntRemoveVisible = ref(false);
|
||||
const enterpriseToDelete = ref<any>(null);
|
||||
const selectedMembers = ref<any[]>([]);
|
||||
const isDeleteVerified = ref(false);
|
||||
const isBatchDeleteVerified = ref(false);
|
||||
const isBatchEntRemoveVerified = ref(false);
|
||||
const deleteLoading = ref(false);
|
||||
const slideVerifyRef = ref<any>(null);
|
||||
const batchSlideVerifyRef = ref<any>(null);
|
||||
const batchEntSlideVerifyRef = ref<any>(null);
|
||||
|
||||
const handleMemberSelectionChange = (val: any[]) => {
|
||||
selectedMembers.value = val;
|
||||
};
|
||||
|
||||
const deleteEnterprise = async (row: any) => {
|
||||
enterpriseToDelete.value = row;
|
||||
isDeleteVerified.value = false;
|
||||
if (slideVerifyRef.value) slideVerifyRef.value.reset();
|
||||
|
||||
try {
|
||||
const res: any = await api.get('/enterprise-members/', { params: { enterprise: row.id } });
|
||||
const members = res.results || res;
|
||||
if (members.length > 0) {
|
||||
await ElMessageBox.confirm(
|
||||
`企业 <strong>${row.company_name}</strong> 当前仍有 <strong>${members.length}</strong> 名员工。强制清退将自动移除所有成员,并取消进行中的任务。<br><br>是否继续?`,
|
||||
'强制清退企业',
|
||||
{ dangerouslyUseHTMLString: true, type: 'warning', confirmButtonText: '强制清退', cancelButtonText: '取消', confirmButtonClass: 'el-button--danger' }
|
||||
);
|
||||
}
|
||||
deleteConfirmVisible.value = true;
|
||||
} catch (error) {
|
||||
// User cancelled
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDeleteEnterprise = async () => {
|
||||
if (!enterpriseToDelete.value) return;
|
||||
deleteLoading.value = true;
|
||||
try {
|
||||
await api.delete(`/enterprises/${enterpriseToDelete.value.id}/`);
|
||||
ElMessage.success('企业已成功清退');
|
||||
deleteConfirmVisible.value = false;
|
||||
fetchEnterprises();
|
||||
} catch (error) {
|
||||
ElMessage.error('清退失败');
|
||||
} finally {
|
||||
deleteLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const batchRemoveEnterprises = async () => {
|
||||
if (selectedEnterprises.value.length === 0) return;
|
||||
deleteLoading.value = true;
|
||||
try {
|
||||
for (const ent of selectedEnterprises.value) {
|
||||
await api.delete(`/enterprises/${ent.id}/`);
|
||||
}
|
||||
ElMessage.success(`已批量清退 ${selectedEnterprises.value.length} 家企业`);
|
||||
batchEntRemoveVisible.value = false;
|
||||
selectedEnterprises.value = [];
|
||||
fetchEnterprises();
|
||||
} catch (error) {
|
||||
ElMessage.error('批量清退失败');
|
||||
} finally {
|
||||
deleteLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const confirmBatchDeleteMembers = async () => {
|
||||
if (selectedMembers.value.length === 0) return;
|
||||
deleteLoading.value = true;
|
||||
try {
|
||||
const memberIds = selectedMembers.value.map(m => m.id);
|
||||
await api.post('/enterprise-members/batch_delete/', { member_ids: memberIds });
|
||||
ElMessage.success(`已成功批量移除 ${selectedMembers.value.length} 名员工`);
|
||||
batchDeleteVisible.value = false;
|
||||
const res: any = await api.get('/enterprise-members/', { params: { enterprise: selectedEnterprise.value.id } });
|
||||
enterpriseMembers.value = res.results || res;
|
||||
selectedMembers.value = [];
|
||||
} catch (error) {
|
||||
ElMessage.error('批量移除失败,请检查网络或重试');
|
||||
} finally {
|
||||
deleteLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const removeMember = (member: any) => {
|
||||
ElMessageBox.confirm(`确定要移除员工 ${member.user_details?.nickname || member.user_details?.username} 吗?`, '移除员工', {
|
||||
confirmButtonText: '确定移除',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
confirmButtonClass: 'el-button--danger'
|
||||
}).then(async () => {
|
||||
try {
|
||||
await api.delete(`/enterprise-members/${member.id}/`);
|
||||
ElMessage.success('已成功移除');
|
||||
const res: any = await api.get('/enterprise-members/', { params: { enterprise: selectedEnterprise.value.id } });
|
||||
enterpriseMembers.value = res.results || res;
|
||||
} catch (e) {
|
||||
ElMessage.error('移除失败');
|
||||
}
|
||||
}).catch(() => {});
|
||||
};
|
||||
|
||||
const taskStatusMap: Record<string, { type: string, text: string }> = {
|
||||
'DRAFT': { type: 'info', text: '草稿' },
|
||||
'OPEN': { type: 'primary', text: '招募中' },
|
||||
'IN_PROGRESS': { type: 'warning', text: '进行中' },
|
||||
'IN_REVIEW': { type: '', text: '待验收' },
|
||||
'COMPLETED': { type: 'success', text: '已完成' },
|
||||
'CANCELLED': { type: 'danger', text: '已取消' },
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchEnterprises();
|
||||
});
|
||||
|
||||
const formatDate = (date: string) => dayjs(date).format('YYYY-MM-DD');
|
||||
|
||||
const statusConfig: Record<string, { type: string, text: string }> = {
|
||||
'PENDING': { type: 'warning', text: '待审核' },
|
||||
'VERIFIED': { type: 'success', text: '已认证' },
|
||||
'REJECTED': { type: 'danger', text: '已拒绝' }
|
||||
};
|
||||
|
||||
// ─── Enterprise Recycle Bin ───
|
||||
const activeTab = ref('active');
|
||||
const deletedEnterprises = ref<any[]>([]);
|
||||
const deletedLoading = ref(false);
|
||||
|
||||
const fetchDeletedEnterprises = async () => {
|
||||
deletedLoading.value = true;
|
||||
try {
|
||||
const res: any = await api.get('/enterprises/deleted_enterprises/');
|
||||
deletedEnterprises.value = res.results || res || [];
|
||||
} catch (error) {
|
||||
ElMessage.error('无法获取已删除企业列表');
|
||||
} finally {
|
||||
deletedLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const restoreEnterprise = async (ent: any) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要恢复企业 <strong>${ent.company_name}</strong> 吗?`,
|
||||
'恢复企业',
|
||||
{ dangerouslyUseHTMLString: true, type: 'info', confirmButtonText: '确认恢复' }
|
||||
);
|
||||
await api.post(`/enterprises/${ent.id}/restore/`);
|
||||
ElMessage.success('企业已恢复');
|
||||
fetchDeletedEnterprises();
|
||||
fetchEnterprises();
|
||||
} catch (error: any) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error(error?.response?.data?.detail || '恢复失败');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
watch(activeTab, (val) => {
|
||||
if (val === 'deleted') fetchDeletedEnterprises();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<el-tabs v-model="activeTab" class="!-mb-2">
|
||||
<el-tab-pane name="active">
|
||||
<template #label><span class="flex items-center gap-1.5"><Building2 class="w-4 h-4" />企业管理</span></template>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane name="deleted">
|
||||
<template #label><span class="flex items-center gap-1.5"><Trash2 class="w-4 h-4" />回收站</span></template>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<el-card v-show="activeTab === 'active'" shadow="never" class="!border-none">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-xl font-bold text-gray-800">企业管理</h2>
|
||||
<div class="flex gap-2">
|
||||
<el-button v-if="selectedEnterprises.length > 0" type="danger" plain @click="() => { batchEntRemoveVisible = true; isBatchEntRemoveVerified = false; }">
|
||||
<Trash2 class="w-4 h-4 mr-1" /> 批量清退 ({{ selectedEnterprises.length }})
|
||||
</el-button>
|
||||
<el-button type="primary" @click="openCreateEnterpriseDialog">新增企业</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Grid Layout -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6" v-loading="loading">
|
||||
<el-card v-for="row in enterprises" :key="row.id" shadow="hover"
|
||||
class="border-transparent hover:border-blue-200 hover:shadow-xl transition-all duration-300 rounded-xl relative"
|
||||
:class="{ '!border-blue-400 ring-2 ring-blue-200': selectedEnterprises.some(e => e.id === row.id) }"
|
||||
:body-style="{ padding: '0px', display: 'flex', flexDirection: 'column', height: '100%' }">
|
||||
|
||||
<!-- Selection checkbox -->
|
||||
<el-checkbox
|
||||
class="!absolute top-3 left-3 z-10"
|
||||
:model-value="selectedEnterprises.some(e => e.id === row.id)"
|
||||
@change="(v: any) => { if (v) selectedEnterprises.push(row); else selectedEnterprises = selectedEnterprises.filter(e => e.id !== row.id); }"
|
||||
/>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex items-start justify-between p-6 pb-4 cursor-pointer pl-10" @click="openEnterpriseDrawer(row)">
|
||||
<div class="w-12 h-12 rounded-xl bg-gradient-to-br from-blue-50 to-indigo-50 flex items-center justify-center flex-shrink-0 shadow-sm border border-blue-100/50 overflow-hidden">
|
||||
<img v-if="row.logo_url" :src="row.logo_url" class="w-full h-full object-cover" />
|
||||
<Building2 v-else class="w-6 h-6 text-blue-600" />
|
||||
</div>
|
||||
<el-dropdown trigger="click">
|
||||
<div class="cursor-pointer p-1 text-gray-400 hover:text-blue-500 transition-colors" @click.stop>
|
||||
<MoreHorizontal class="w-5 h-5" />
|
||||
</div>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item @click="openEditEnterpriseDialog(row)">编辑信息</el-dropdown-item>
|
||||
<el-dropdown-item v-if="row.status !== 'VERIFIED'" @click="handleStatusChange(row, 'VERIFIED')"><CheckCircle class="w-4 h-4 mr-2 text-green-500" />通过认证</el-dropdown-item>
|
||||
<el-dropdown-item v-if="row.status === 'PENDING'" @click="handleStatusChange(row, 'REJECTED')"><XCircle class="w-4 h-4 mr-2 text-red-500" />驳回申请</el-dropdown-item>
|
||||
<el-dropdown-item divided class="text-red-500" @click="deleteEnterprise(row)"><Trash2 class="w-4 h-4 mr-2" />强制清退</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="px-6 pb-6 flex-1 cursor-pointer" @click="openEnterpriseDrawer(row)">
|
||||
<div class="font-bold text-lg text-gray-800 line-clamp-1 mb-1">{{ row.company_name }}</div>
|
||||
<div class="text-xs text-gray-400 mb-5 font-mono bg-gray-50 inline-block px-2 py-1 rounded">{{ row.credit_code || '无统一信用代码' }}</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center text-sm">
|
||||
<span class="text-gray-400 w-16 text-xs tracking-widest">负责人</span>
|
||||
<span class="font-medium text-gray-700 flex items-center gap-1.5">
|
||||
<el-avatar :size="18" :src="row.user_details?.avatar_url" class="bg-gray-100">{{ row.user_details?.nickname?.[0] || '管' }}</el-avatar>
|
||||
{{ row.user_details?.nickname || row.user_details?.username || '未绑定' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center text-sm">
|
||||
<span class="text-gray-400 w-16 text-xs tracking-widest">联系人</span>
|
||||
<span class="font-medium text-gray-700">{{ row.contact_name || '未提供' }}</span>
|
||||
</div>
|
||||
<div class="flex items-center text-sm">
|
||||
<span class="text-gray-400 w-16 text-xs tracking-widest">电话</span>
|
||||
<span class="font-medium text-gray-700">{{ row.contact_phone || '未提供' }}</span>
|
||||
</div>
|
||||
<div class="flex items-center text-sm">
|
||||
<span class="text-gray-400 w-16 text-xs tracking-widest">入驻日</span>
|
||||
<span class="font-medium text-gray-700">{{ formatDate(row.created_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="bg-gray-50 px-6 py-3 border-t border-gray-100 flex justify-between items-center mt-auto">
|
||||
<span class="text-xs text-gray-500 font-medium">认证状态</span>
|
||||
<el-tag :type="statusConfig[row.status]?.type || 'info'" size="small" effect="dark" class="!border-none shadow-sm rounded-md tracking-wider px-3">
|
||||
{{ statusConfig[row.status]?.text || row.status }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<div v-if="enterprises.length === 0 && !loading" class="col-span-full py-20 text-center text-gray-400">
|
||||
<el-empty description="暂无企业数据" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="enterpriseDialogVisible" :title="isEnterpriseEditing ? '企业详情/编辑' : '代注册新企业'" width="650px">
|
||||
<el-form :model="enterpriseForm" label-width="100px" class="pr-6">
|
||||
<template v-if="!isEnterpriseEditing">
|
||||
<div class="mb-4 text-sm text-gray-500 bg-blue-50 p-3 rounded-lg flex items-center gap-2">
|
||||
<ShieldCheck class="w-4 h-4 text-blue-500" /> 作为管理员代注册,将自动为其生成系统账号并免审直接生效。
|
||||
</div>
|
||||
<el-divider content-position="left">账号凭证</el-divider>
|
||||
<el-form-item label="主账号邮箱" required>
|
||||
<el-input v-model="enterpriseForm.email" placeholder="将作为企业主账号登录凭证" />
|
||||
</el-form-item>
|
||||
<el-form-item label="初始密码" required>
|
||||
<el-input v-model="enterpriseForm.password" type="password" show-password placeholder="不少于6位" />
|
||||
</el-form-item>
|
||||
<el-divider content-position="left">资质资料</el-divider>
|
||||
</template>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="企业全称" required>
|
||||
<el-input v-model="enterpriseForm.company_name" placeholder="营业执照全称" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="信用代码" required>
|
||||
<el-input v-model="enterpriseForm.credit_code" placeholder="18位社会信用代码" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="企业Logo">
|
||||
<el-upload
|
||||
class="border border-gray-200 border-dashed rounded-lg cursor-pointer hover:border-blue-500 w-24 h-24 flex items-center justify-center overflow-hidden bg-gray-50 group"
|
||||
action=""
|
||||
:http-request="handleLogoUpload"
|
||||
:show-file-list="false"
|
||||
>
|
||||
<img v-if="enterpriseForm.logo_url" :src="enterpriseForm.logo_url" class="w-full h-full object-cover" />
|
||||
<div v-else class="text-gray-400 group-hover:text-blue-500 flex flex-col items-center">
|
||||
<Upload class="w-5 h-5 mb-1" />
|
||||
<span class="text-xs">点击上传</span>
|
||||
</div>
|
||||
</el-upload>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="营业执照">
|
||||
<el-upload
|
||||
class="border border-gray-200 border-dashed rounded-lg cursor-pointer hover:border-blue-500 w-32 h-24 flex items-center justify-center overflow-hidden bg-gray-50 group"
|
||||
action=""
|
||||
:http-request="handleLicenseUpload"
|
||||
:show-file-list="false"
|
||||
>
|
||||
<img v-if="enterpriseForm.business_license" :src="enterpriseForm.business_license" class="w-full h-full object-cover" />
|
||||
<div v-else class="text-gray-400 group-hover:text-blue-500 flex flex-col items-center">
|
||||
<Upload class="w-5 h-5 mb-1" />
|
||||
<span class="text-xs">上传资质</span>
|
||||
</div>
|
||||
</el-upload>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="业务联系人" required>
|
||||
<el-input v-model="enterpriseForm.contact_name" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="手机号(可选)">
|
||||
<el-input v-model="enterpriseForm.contact_phone" placeholder="手机号" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="座机号码(可选)">
|
||||
<el-input v-model="enterpriseForm.landline" placeholder="如 0592-5555555" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="联系邮箱">
|
||||
<el-input v-model="enterpriseForm.contact_email" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form-item label="联系邮箱">
|
||||
<el-input v-model="enterpriseForm.contact_email" />
|
||||
</el-form-item>
|
||||
<el-form-item label="企业地址">
|
||||
<el-input v-model="enterpriseForm.address" />
|
||||
</el-form-item>
|
||||
<el-form-item label="企业介绍">
|
||||
<el-input type="textarea" v-model="enterpriseForm.description" :rows="3" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="enterpriseDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveEnterprise" :loading="enterpriseSaveLoading">{{ isEnterpriseEditing ? '保存修改' : '确认并创建账号' }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- Enterprise Details Drawer -->
|
||||
<el-drawer v-model="enterpriseDrawerVisible" size="600px" title="企业详情与团队">
|
||||
<div v-if="selectedEnterprise" class="space-y-8 pb-10">
|
||||
<!-- Header Info -->
|
||||
<div class="flex items-center gap-6">
|
||||
<el-avatar :size="80" shape="square" class="rounded-2xl shadow-sm border border-gray-100" :src="selectedEnterprise.logo_url">
|
||||
<Building2 v-if="!selectedEnterprise.logo_url" class="w-10 h-10 text-gray-400" />
|
||||
</el-avatar>
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-gray-800 mb-2">{{ selectedEnterprise.company_name }}</h2>
|
||||
<el-tag :type="statusConfig[selectedEnterprise.status]?.type || 'info'" size="small" effect="dark" class="!border-none shadow-sm rounded-md tracking-wider">
|
||||
{{ statusConfig[selectedEnterprise.status]?.text || selectedEnterprise.status }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-descriptions title="基础档案" :column="1" border class="mt-6">
|
||||
<el-descriptions-item label="统一信用代码">{{ selectedEnterprise.credit_code || '未提供' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="联系人">{{ selectedEnterprise.contact_name || '未提供' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="联系电话">{{ selectedEnterprise.contact_phone || '未提供' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="座机号码">{{ selectedEnterprise.landline || '未提供' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="联系邮箱">{{ selectedEnterprise.contact_email || '未提供' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="企业地址">{{ selectedEnterprise.address || '未提供' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="入驻时间">{{ formatDate(selectedEnterprise.created_at) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<div>
|
||||
<h3 class="text-lg font-bold text-gray-800 mb-4 flex justify-between items-center">
|
||||
<span>团队成员 ({{ enterpriseMembers.length }})</span>
|
||||
<div class="space-x-2 flex">
|
||||
<el-button v-if="selectedMembers.length > 0" type="danger" size="small" @click="() => { batchDeleteVisible = true; if (batchSlideVerifyRef) batchSlideVerifyRef.reset(); isBatchDeleteVerified = false; }">
|
||||
<Trash2 class="w-4 h-4 mr-1" /> 批量移除 ({{ selectedMembers.length }})
|
||||
</el-button>
|
||||
<el-button type="primary" size="small" plain @click="memberDialogVisible = true">
|
||||
<UserPlus class="w-4 h-4 mr-1" /> 添加成员
|
||||
</el-button>
|
||||
</div>
|
||||
</h3>
|
||||
<el-table :data="enterpriseMembers" v-loading="membersLoading" border @selection-change="handleMemberSelectionChange">
|
||||
<el-table-column type="selection" width="50" align="center" />
|
||||
<el-table-column label="成员" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<div class="flex items-center gap-2">
|
||||
<el-avatar :size="24" :src="row.user_details?.avatar_url">{{ row.user_details?.nickname?.[0] || 'U' }}</el-avatar>
|
||||
<span class="font-medium">{{ row.user_details?.nickname || row.user_details?.username }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="role" label="角色" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.role === 'ADMIN' ? 'danger' : 'info'" size="small">{{ row.role === 'ADMIN' ? '管理员' : '普通成员' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="加入时间" width="120">
|
||||
<template #default="{ row }">
|
||||
{{ formatDate(row.joined_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button text type="danger" size="small" @click="removeMember(row)">移除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
</el-drawer>
|
||||
|
||||
<!-- Add Member Dialog -->
|
||||
<el-dialog v-model="memberDialogVisible" title="添加团队成员" width="400px" append-to-body>
|
||||
<el-form :model="memberForm" label-width="80px">
|
||||
<el-form-item label="登录账号" required>
|
||||
<el-input v-model="memberForm.username" placeholder="英文或数字" />
|
||||
</el-form-item>
|
||||
<el-form-item label="初始密码" required>
|
||||
<el-input v-model="memberForm.password" type="password" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item label="成员姓名">
|
||||
<el-input v-model="memberForm.nickname" placeholder="真实姓名或昵称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="系统角色">
|
||||
<el-select v-model="memberForm.role" class="w-full">
|
||||
<el-option label="普通成员" value="MEMBER" />
|
||||
<el-option label="管理员" value="ADMIN" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="memberDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="addEnterpriseMember" :loading="memberSaveLoading">确认添加</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- Enterprise Deletion Dialogs -->
|
||||
<el-dialog v-model="deleteConfirmVisible" title="确认删除企业" width="400px" append-to-body>
|
||||
<div class="mb-4 text-gray-600 text-sm">
|
||||
您即将删除企业 <strong>{{ enterpriseToDelete?.company_name }}</strong>。该操作不可撤销。
|
||||
</div>
|
||||
<SlideVerify ref="slideVerifyRef" @success="isDeleteVerified = true" />
|
||||
<template #footer>
|
||||
<el-button @click="deleteConfirmVisible = false">取消</el-button>
|
||||
<el-button type="danger" :disabled="!isDeleteVerified" @click="confirmDeleteEnterprise" :loading="deleteLoading">确认删除</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="batchDeleteVisible" title="危险操作:批量移除员工" width="450px" append-to-body>
|
||||
<div class="mb-4">
|
||||
<el-alert type="warning" :closable="false" show-icon>
|
||||
<template #title>
|
||||
您即将移除选中的 <strong>{{ selectedMembers.length }}</strong> 名员工。
|
||||
</template>
|
||||
</el-alert>
|
||||
</div>
|
||||
<div class="mb-6 text-sm text-gray-600 leading-relaxed">
|
||||
批量移除员工后,这些用户将立刻失去企业的访问权限。为了防止误操作,请滑动下方滑块以验证您的操作。
|
||||
</div>
|
||||
<SlideVerify ref="batchSlideVerifyRef" @success="isBatchDeleteVerified = true" />
|
||||
<template #footer>
|
||||
<el-button @click="batchDeleteVisible = false">取消</el-button>
|
||||
<el-button type="danger" :disabled="!isBatchDeleteVerified" @click="confirmBatchDeleteMembers" :loading="deleteLoading">确认批量移除</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- Batch Enterprise Removal Dialog -->
|
||||
<el-dialog v-model="batchEntRemoveVisible" title="批量清退企业" width="450px" append-to-body>
|
||||
<div class="mb-4">
|
||||
<el-alert type="error" :closable="false" show-icon>
|
||||
<template #title>
|
||||
即将强制清退 <strong>{{ selectedEnterprises.length }}</strong> 家企业,关联任务将被自动取消。
|
||||
</template>
|
||||
</el-alert>
|
||||
</div>
|
||||
<div class="space-y-2 mb-4 max-h-40 overflow-y-auto">
|
||||
<div v-for="ent in selectedEnterprises" :key="ent.id" class="flex items-center gap-2 text-sm text-gray-600">
|
||||
<el-avatar :size="20" :src="ent.logo_url" shape="square" class="bg-gray-100">{{ ent.company_name?.[0] }}</el-avatar>
|
||||
{{ ent.company_name }}
|
||||
</div>
|
||||
</div>
|
||||
<SlideVerify ref="batchEntSlideVerifyRef" @success="isBatchEntRemoveVerified = true" />
|
||||
<template #footer>
|
||||
<el-button @click="batchEntRemoveVisible = false">取消</el-button>
|
||||
<el-button type="danger" :disabled="!isBatchEntRemoveVerified" @click="batchRemoveEnterprises" :loading="deleteLoading">确认批量清退</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</el-card>
|
||||
|
||||
<!-- Deleted Enterprises (Recycle Bin) -->
|
||||
<el-card v-show="activeTab === 'deleted'" shadow="never" class="!border-none" v-loading="deletedLoading">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-2 text-gray-500">
|
||||
<Trash2 class="w-5 h-5" />
|
||||
<span class="text-sm">已清退的企业保留在此,支持恢复操作</span>
|
||||
</div>
|
||||
</div>
|
||||
<el-table :data="deletedEnterprises" stripe :header-cell-style="{ background:'#f8fafc', color:'#475569', fontWeight: '600' }">
|
||||
<el-table-column label="企业" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<div class="flex items-center gap-3">
|
||||
<el-avatar :size="36" :src="row.logo_url" shape="square" class="bg-gray-100 text-gray-600 rounded-lg">
|
||||
{{ row.company_name?.[0] || 'E' }}
|
||||
</el-avatar>
|
||||
<div>
|
||||
<div class="font-medium text-gray-800">{{ row.company_name }}</div>
|
||||
<div class="text-xs text-gray-400">{{ row.credit_code?.split('__deleted_')[0] || '-' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="联系人" width="120">
|
||||
<template #default="{ row }">
|
||||
<span class="text-sm text-gray-600">{{ row.contact_name || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="联系电话" width="150">
|
||||
<template #default="{ row }">
|
||||
<span class="text-sm text-gray-600 font-mono">{{ row.contact_phone || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="清退时间" width="150">
|
||||
<template #default="{ row }">
|
||||
<span class="text-sm text-gray-500">{{ formatDate(row.updated_at) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button text type="primary" size="small" @click="restoreEnterprise(row)">
|
||||
<RotateCcw class="w-3.5 h-3.5 mr-1" /> 恢复
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-if="!deletedLoading && deletedEnterprises.length === 0" description="回收站为空" :image-size="60" />
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
120
src/views/admin/LoginView.vue
Normal file
120
src/views/admin/LoginView.vue
Normal file
@@ -0,0 +1,120 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { ShieldCheck, Lock, User } from 'lucide-vue-next';
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
const isLoading = ref(false);
|
||||
const errorMsg = ref('');
|
||||
const loginForm = ref({ username: 'admin', password: 'admin123' });
|
||||
|
||||
const handleLogin = async () => {
|
||||
try {
|
||||
isLoading.value = true;
|
||||
errorMsg.value = '';
|
||||
await authStore.login(loginForm.value);
|
||||
|
||||
// 检查是否为管理员
|
||||
if (authStore.userRoles.includes('ADMIN') || authStore.user?.is_superuser || authStore.user?.is_staff) {
|
||||
router.push('/admin/dashboard');
|
||||
ElMessage.success('欢迎回来,管理员');
|
||||
} else {
|
||||
// 如果不是管理员,强制退出
|
||||
authStore.logout();
|
||||
errorMsg.value = '您没有后台管理系统的访问权限';
|
||||
}
|
||||
} catch (e: any) {
|
||||
errorMsg.value = e.response?.data?.detail || '登录失败,请检查账号密码';
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-900 flex items-center justify-center px-4 relative overflow-hidden">
|
||||
<!-- Admin Background Decoration -->
|
||||
<div class="absolute top-[-10%] left-[-10%] w-96 h-96 bg-blue-600 rounded-full mix-blend-multiply filter blur-3xl opacity-20 animate-blob"></div>
|
||||
<div class="absolute top-[20%] right-[-10%] w-96 h-96 bg-purple-600 rounded-full mix-blend-multiply filter blur-3xl opacity-20 animate-blob animation-delay-2000"></div>
|
||||
<div class="absolute bottom-[-20%] left-[20%] w-96 h-96 bg-pink-600 rounded-full mix-blend-multiply filter blur-3xl opacity-20 animate-blob animation-delay-4000"></div>
|
||||
|
||||
<div class="max-w-md w-full relative z-10">
|
||||
<el-card shadow="always" class="!rounded-2xl !p-2 !bg-gray-800 !border-gray-700">
|
||||
<div class="text-center mb-8 pt-4">
|
||||
<div class="mx-auto h-16 w-16 bg-gradient-to-br from-blue-600 to-indigo-700 rounded-2xl flex items-center justify-center mb-4 shadow-lg shadow-blue-900/50">
|
||||
<ShieldCheck class="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h2 class="text-2xl font-bold text-white">系统管理后台</h2>
|
||||
<p class="text-xs text-gray-400 mt-1 uppercase tracking-widest">Admin Control Center</p>
|
||||
</div>
|
||||
|
||||
<el-form @submit.prevent="handleLogin" label-position="top">
|
||||
<el-alert v-if="errorMsg" type="error" :title="errorMsg" :closable="false" class="mb-4" />
|
||||
|
||||
<el-form-item>
|
||||
<el-input
|
||||
v-model="loginForm.username"
|
||||
placeholder="管理员账号"
|
||||
size="large"
|
||||
class="admin-input"
|
||||
>
|
||||
<template #prefix>
|
||||
<User class="w-5 h-5 text-gray-400" />
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item class="mt-6">
|
||||
<el-input
|
||||
v-model="loginForm.password"
|
||||
type="password"
|
||||
placeholder="管理员密码"
|
||||
size="large"
|
||||
show-password
|
||||
class="admin-input"
|
||||
>
|
||||
<template #prefix>
|
||||
<Lock class="w-5 h-5 text-gray-400" />
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-button
|
||||
type="primary"
|
||||
native-type="submit"
|
||||
:loading="isLoading"
|
||||
class="w-full mt-6 !h-12 !text-base font-semibold shadow-lg shadow-blue-900/50"
|
||||
size="large"
|
||||
>
|
||||
{{ isLoading ? '身份验证中...' : '安全登录' }}
|
||||
</el-button>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<div class="mt-8 text-center">
|
||||
<el-button text class="!text-gray-500 hover:!text-gray-300" @click="router.push('/')">
|
||||
返回平台首页
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.admin-input .el-input__wrapper) {
|
||||
background-color: #374151 !important;
|
||||
box-shadow: 0 0 0 1px #4B5563 inset !important;
|
||||
}
|
||||
:deep(.admin-input .el-input__wrapper.is-focus) {
|
||||
box-shadow: 0 0 0 1px #3B82F6 inset !important;
|
||||
}
|
||||
:deep(.admin-input .el-input__inner) {
|
||||
color: #F3F4F6 !important;
|
||||
}
|
||||
:deep(.el-form-item__label) {
|
||||
color: #9CA3AF !important;
|
||||
}
|
||||
</style>
|
||||
369
src/views/admin/OPCUsersListView.vue
Normal file
369
src/views/admin/OPCUsersListView.vue
Normal file
@@ -0,0 +1,369 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { ShieldCheck, Search, Mail, Phone, FileText, X, Download, Image as ImageIcon, ChevronRight, Star } from 'lucide-vue-next';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import api from '@/api';
|
||||
import VueOfficeDocx from '@vue-office/docx';
|
||||
import '@vue-office/docx/lib/index.css';
|
||||
import { toPng } from 'html-to-image';
|
||||
|
||||
const opcUsers = ref<any[]>([]);
|
||||
const loading = ref(true);
|
||||
const searchQuery = ref('');
|
||||
|
||||
const filteredUsers = computed(() => {
|
||||
if (!searchQuery.value) return opcUsers.value;
|
||||
const q = searchQuery.value.toLowerCase();
|
||||
return opcUsers.value.filter((u) => {
|
||||
const name = u.opc_certification?.real_name || u.nickname || u.username || '';
|
||||
const skills = (u.opc_certification?.skills || []).join(' ');
|
||||
const email = u.email || '';
|
||||
return name.toLowerCase().includes(q) || skills.toLowerCase().includes(q) || email.toLowerCase().includes(q);
|
||||
});
|
||||
});
|
||||
|
||||
const detailVisible = ref(false);
|
||||
const currentExpert = ref<any>(null);
|
||||
|
||||
const previewVisible = ref(false);
|
||||
const previewUrl = ref('');
|
||||
|
||||
const cardRef = ref<HTMLElement | null>(null);
|
||||
const isGenerating = ref(false);
|
||||
|
||||
const fetchOpcUsers = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res: any = await api.get('/users/', { params: { role: 'OPC_USER' } });
|
||||
opcUsers.value = res.results || res;
|
||||
} catch (error) {
|
||||
ElMessage.error('无法获取认证专家列表');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const openExpertDetail = (user: any) => {
|
||||
currentExpert.value = JSON.parse(JSON.stringify(user));
|
||||
detailVisible.value = true;
|
||||
};
|
||||
|
||||
const isSavingStats = ref(false);
|
||||
const saveOpcStats = async () => {
|
||||
isSavingStats.value = true;
|
||||
try {
|
||||
await api.put(`/users/${currentExpert.value.id}/update_opc_stats/`, {
|
||||
rating: currentExpert.value.rating,
|
||||
completed_tasks: currentExpert.value.completed_tasks
|
||||
});
|
||||
ElMessage.success('专家业务数据更新成功');
|
||||
fetchOpcUsers();
|
||||
} catch (error) {
|
||||
ElMessage.error('更新专家数据失败');
|
||||
} finally {
|
||||
isSavingStats.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const downloadCard = async () => {
|
||||
if (!cardRef.value || !currentExpert.value) return;
|
||||
isGenerating.value = true;
|
||||
try {
|
||||
const dataUrl = await toPng(cardRef.value, {
|
||||
pixelRatio: 2,
|
||||
backgroundColor: '#f9fafb',
|
||||
});
|
||||
const link = document.createElement('a');
|
||||
link.download = `OPC专家档案_${currentExpert.value.opc_certification?.real_name || currentExpert.value.nickname || '未命名'}.png`;
|
||||
link.href = dataUrl;
|
||||
link.click();
|
||||
ElMessage.success('专家名片已成功保存为图片');
|
||||
} catch (error) {
|
||||
console.error('Image generation error:', error);
|
||||
ElMessage.error('图片生成失败,请重试(可能受跨域图片影响)');
|
||||
} finally {
|
||||
isGenerating.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const toggleRecommend = async (user: any, e: Event) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
const newVal = !user.is_recommended;
|
||||
await api.post(`/users/${user.id}/toggle_recommend/`, { is_recommended: newVal, recommend_priority: newVal ? 10 : 0 });
|
||||
user.is_recommended = newVal;
|
||||
user.recommend_priority = newVal ? 10 : 0;
|
||||
ElMessage.success(newVal ? '已设为推荐专家(置顶)' : '已取消推荐');
|
||||
} catch (e) {
|
||||
ElMessage.error('操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const updatePriority = async (user: any) => {
|
||||
try {
|
||||
await api.post(`/users/${user.id}/toggle_recommend/`, {
|
||||
is_recommended: user.is_recommended,
|
||||
recommend_priority: user.recommend_priority
|
||||
});
|
||||
ElMessage.success('推荐优先级已更新');
|
||||
} catch (e) {
|
||||
ElMessage.error('更新失败');
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => fetchOpcUsers());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6" v-loading="loading">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-800">OPC 专家库</h1>
|
||||
<p class="text-sm text-gray-400 mt-1">管理平台已认证的高质量专家资产</p>
|
||||
</div>
|
||||
<el-input v-model="searchQuery" placeholder="搜索专家姓名、技能或邮箱..." class="!w-80" clearable size="large">
|
||||
<template #prefix><Search class="w-4 h-4 text-gray-400" /></template>
|
||||
</el-input>
|
||||
</div>
|
||||
|
||||
<!-- Grid Layout -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
<el-card v-for="user in filteredUsers" :key="user.id" shadow="hover"
|
||||
class="cursor-pointer group border-transparent hover:border-blue-200 hover:shadow-xl transition-all duration-300 rounded-xl"
|
||||
:body-style="{ padding: '0px' }"
|
||||
@click="openExpertDetail(user)"
|
||||
>
|
||||
<div class="flex flex-col items-center p-6 pb-4">
|
||||
<el-avatar :size="80" :src="user.avatar_url" class="bg-gradient-to-br from-blue-100 to-indigo-100 text-blue-700 text-2xl font-bold shadow-sm mb-4 border-2 border-white ring-2 ring-blue-50">
|
||||
{{ user.opc_certification?.real_name?.[0] || user.nickname?.[0] || user.username?.[0] || 'O' }}
|
||||
</el-avatar>
|
||||
<div class="text-lg font-bold text-gray-800 flex items-center justify-center gap-1 w-full truncate px-2">
|
||||
<span class="truncate">{{ user.nickname || user.username }}</span>
|
||||
<ShieldCheck class="w-4 h-4 text-green-500 flex-shrink-0" />
|
||||
<el-tag v-if="user.is_recommended" size="small" type="warning" effect="dark" round class="border-none shadow-sm ml-1">⭐ 推荐</el-tag>
|
||||
</div>
|
||||
<div v-if="user.opc_certification?.real_name" class="text-sm text-gray-500 mt-1 truncate px-2">
|
||||
{{ user.opc_certification.real_name }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-400 mt-1 mb-4 truncate px-2">{{ user.email || user.phone || '未绑定联系方式' }}</div>
|
||||
|
||||
<div class="flex flex-wrap justify-center gap-1.5 mt-3 h-[46px] overflow-hidden w-full">
|
||||
<el-tag v-for="skill in (user.opc_certification?.skills || []).slice(0, 3)" :key="skill" size="small" effect="plain" type="info" round class="!border-gray-200">
|
||||
{{ skill }}
|
||||
</el-tag>
|
||||
<el-tag v-if="(user.opc_certification?.skills || []).length > 3" size="small" type="info" round effect="plain" class="!border-gray-200">
|
||||
+{{ user.opc_certification.skills.length - 3 }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<!-- Bio Snippet -->
|
||||
<div v-if="user.bio" class="text-xs text-gray-500 mt-2 line-clamp-2 px-4 text-center leading-relaxed">
|
||||
{{ user.bio }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 w-full px-5 py-3 border-t border-gray-100 group-hover:bg-blue-50 transition-colors relative mt-auto">
|
||||
<div class="flex justify-between items-center w-full group-hover:opacity-0 transition-opacity duration-300">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-gray-400 text-[10px] mb-0.5 tracking-wider">专家评级</span>
|
||||
<el-rate :model-value="Number(user.rating) || 5.0" disabled show-score text-color="#ff9900" class="-ml-1 scale-90 origin-left" />
|
||||
</div>
|
||||
<div class="text-right flex flex-col items-end">
|
||||
<span class="text-gray-400 text-[10px] tracking-wider mb-0.5">累计完成</span>
|
||||
<div class="font-bold text-gray-700 text-sm">{{ user.completed_tasks || 0 }} <span class="font-normal text-xs text-gray-500">项</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Hover State -->
|
||||
<div class="absolute inset-0 flex items-center justify-center gap-3 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||
<span class="text-sm font-bold text-blue-600 flex items-center gap-1">查看主页 <ChevronRight class="w-4 h-4"/></span>
|
||||
<el-button :type="user.is_recommended ? 'warning' : 'default'" size="small" round @click="toggleRecommend(user, $event)">
|
||||
<Star class="w-3.5 h-3.5 mr-0.5" :class="user.is_recommended ? 'fill-orange-400' : ''" />{{ user.is_recommended ? '取消' : '推荐' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<div v-if="filteredUsers.length === 0" class="col-span-full py-20 text-center text-gray-400">
|
||||
<el-empty description="没有找到匹配的专家" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Expert Detail Drawer -->
|
||||
<el-drawer v-model="detailVisible" size="800px" :with-header="false" destroy-on-close direction="rtl">
|
||||
<!-- Main Scrollable Container -->
|
||||
<div class="h-full overflow-y-auto bg-gray-50 relative">
|
||||
|
||||
<!-- Floating Actions -->
|
||||
<div class="absolute top-4 right-4 z-50 flex items-center gap-3">
|
||||
<el-button type="primary" round plain :loading="isGenerating" @click="downloadCard" class="shadow-sm bg-white/90 backdrop-blur">
|
||||
<ImageIcon class="w-4 h-4 mr-2" /> 导出专家名片
|
||||
</el-button>
|
||||
<div class="cursor-pointer text-white hover:text-blue-100 transition-colors bg-black/20 p-2 rounded-full hover:bg-black/30" @click="detailVisible = false">
|
||||
<X class="w-5 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="currentExpert">
|
||||
<!-- Content to be screenshotted -->
|
||||
<div ref="cardRef" class="bg-gray-50 pb-8">
|
||||
<!-- Header Profile Banner -->
|
||||
<div class="bg-gradient-to-r from-blue-600 to-indigo-800 px-8 pt-12 pb-10 text-white shadow-md">
|
||||
<div class="flex gap-6 items-end">
|
||||
<el-avatar :size="100" :src="currentExpert.avatar_url" class="border-4 border-white shadow-lg bg-white text-blue-600 text-4xl font-bold">
|
||||
{{ currentExpert.opc_certification?.real_name?.[0] || currentExpert.nickname?.[0] || currentExpert.username?.[0] || 'O' }}
|
||||
</el-avatar>
|
||||
<div class="flex-1 pb-1">
|
||||
<h2 class="text-3xl font-bold flex items-center gap-2 tracking-tight">
|
||||
{{ currentExpert.opc_certification?.real_name || currentExpert.username }}
|
||||
<span v-if="currentExpert.nickname && currentExpert.nickname !== currentExpert.opc_certification?.real_name" class="text-xl font-normal text-blue-200 ml-1">({{ currentExpert.nickname }})</span>
|
||||
<ShieldCheck class="w-6 h-6 text-green-400" />
|
||||
</h2>
|
||||
<div class="flex items-center gap-6 mt-3 text-blue-100 text-sm font-medium">
|
||||
<span class="flex items-center gap-1.5"><Mail class="w-4 h-4" /> {{ currentExpert.email || '未绑定邮箱' }}</span>
|
||||
<span class="flex items-center gap-1.5"><Phone class="w-4 h-4" /> {{ currentExpert.phone || '未绑定手机' }}</span>
|
||||
<el-rate :model-value="Number(currentExpert.rating) || 5.0" disabled text-color="#ff9900" class="ml-2" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-8 mt-8 space-y-8">
|
||||
<!-- Basic Info -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
|
||||
<h3 class="text-base font-bold text-gray-800 border-b border-gray-100 px-6 py-4 bg-gray-50/50">基础档案</h3>
|
||||
<div class="p-6">
|
||||
<el-descriptions :column="2" class="custom-descriptions">
|
||||
<el-descriptions-item label="实名核验"><span class="text-green-600 font-bold flex items-center gap-1"><ShieldCheck class="w-4 h-4" /> 已通过官方认证</span></el-descriptions-item>
|
||||
<el-descriptions-item label="入驻时间"><span class="text-gray-900">{{ new Date(currentExpert.created_at).toLocaleDateString() }}</span></el-descriptions-item>
|
||||
<el-descriptions-item label="所在地"><span class="text-gray-900">{{ currentExpert.location || '保密' }}</span></el-descriptions-item>
|
||||
<el-descriptions-item label="任务履历"><span class="text-gray-900">已完成 <span class="text-blue-600 font-bold px-1 text-base">{{ currentExpert.completed_tasks || 0 }}</span> 项任务</span></el-descriptions-item>
|
||||
<el-descriptions-item label="综合评分"><span class="text-orange-500 font-bold px-1 text-base">★ {{ Number(currentExpert.rating).toFixed(1) || '5.0' }}</span></el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Admin Maintenance -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden" data-html2canvas-ignore>
|
||||
<div class="flex items-center justify-between border-b border-gray-100 px-6 py-4 bg-red-50/50">
|
||||
<h3 class="text-base font-bold text-red-800 flex items-center gap-2">管理员维护</h3>
|
||||
<el-button type="danger" plain size="small" :loading="isSavingStats" @click="saveOpcStats">保存数据调整</el-button>
|
||||
</div>
|
||||
<div class="p-6 space-y-4">
|
||||
<!-- Recommend Control -->
|
||||
<div class="bg-orange-50 rounded-lg p-4 border border-orange-100">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<span class="text-sm font-bold text-orange-800">⭐ 推荐置顶</span>
|
||||
<el-switch v-model="currentExpert.is_recommended" active-text="推荐中" inactive-text="未推荐"
|
||||
@change="(v: any) => { api.post(`/users/${currentExpert.id}/toggle_recommend/`, { is_recommended: v, recommend_priority: currentExpert.recommend_priority || 10 }); ElMessage.success(v ? '已设为推荐' : '已取消推荐'); fetchOpcUsers(); }" />
|
||||
</div>
|
||||
<div v-if="currentExpert.is_recommended" class="flex items-center gap-3">
|
||||
<span class="text-xs text-orange-600 whitespace-nowrap">优先级(数字越大越靠前):</span>
|
||||
<el-input-number v-model="currentExpert.recommend_priority" :min="0" :max="100" :step="1" size="small"
|
||||
@change="() => updatePriority(currentExpert)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-form label-position="left" label-width="120px">
|
||||
<el-form-item label="完单数">
|
||||
<el-input-number v-model="currentExpert.completed_tasks" :min="0" :step="1" />
|
||||
</el-form-item>
|
||||
<el-form-item label="综合评分">
|
||||
<el-input-number v-model="currentExpert.rating" :min="1" :max="5" :step="0.1" :precision="1" />
|
||||
</el-form-item>
|
||||
<div class="text-xs text-gray-400 mt-2">提示:通常评级和分数应由系统通过实际订单评价自动计算,仅在特殊情况或初始化时手动修正。此部分不会导出至名片。</div>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Skills & Experience -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
|
||||
<h3 class="text-base font-bold text-gray-800 border-b border-gray-100 px-6 py-4 bg-gray-50/50">业务能力</h3>
|
||||
<div class="p-6">
|
||||
<h4 class="text-sm text-gray-500 mb-3">认证技能领域</h4>
|
||||
<div class="flex flex-wrap gap-2 mb-8">
|
||||
<el-tag v-for="skill in currentExpert.opc_certification?.skills || []" :key="skill" effect="light" size="large" type="primary" round class="px-4 py-1 h-auto font-medium">
|
||||
{{ skill }}
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<h4 class="text-sm text-gray-500 mb-3">项目经验简述</h4>
|
||||
<div class="bg-gray-50 p-5 rounded-lg text-sm text-gray-700 whitespace-pre-wrap leading-relaxed border border-gray-200 shadow-inner">
|
||||
{{ currentExpert.opc_certification?.experience || currentExpert.bio || '该专家暂未填写详细的项目经验描述。' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Logo Footer for Image export -->
|
||||
<div class="py-4 text-center text-gray-300 flex items-center justify-center gap-2">
|
||||
<ShieldCheck class="w-4 h-4" /> <span class="text-sm tracking-widest font-bold">CORPSCALE OPC EXPERT PLATFORM</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Resume Section (Outside cardRef, but inside the main scroll container) -->
|
||||
<div class="px-8 pb-8 bg-gray-50" v-if="currentExpert.opc_certification?.resume_url">
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden mb-10">
|
||||
<div class="flex items-center justify-between border-b border-gray-100 px-6 py-4 bg-gray-50/50">
|
||||
<h3 class="text-base font-bold text-gray-800 flex items-center gap-2"><FileText class="w-5 h-5 text-gray-500" /> 简历附件</h3>
|
||||
<div class="flex items-center gap-3">
|
||||
<el-button type="primary" plain size="small" @click="previewUrl = currentExpert.opc_certification.resume_url; previewVisible = true" round>
|
||||
全屏放大预览
|
||||
</el-button>
|
||||
<el-button type="success" size="small" tag="a" :href="currentExpert.opc_certification.resume_url" target="_blank" download round>
|
||||
<Download class="w-3 h-3 mr-1" /> 下载附件
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick embedded preview for PDF/Image or fallback -->
|
||||
<div class="h-[600px] bg-gray-100 p-2 relative">
|
||||
<template v-if="currentExpert.opc_certification.resume_url.split('?')[0].toLowerCase().endsWith('.pdf')">
|
||||
<iframe :src="currentExpert.opc_certification.resume_url" class="w-full h-full border border-gray-300 rounded shadow-sm bg-white"></iframe>
|
||||
</template>
|
||||
<template v-else-if="['.jpg', '.jpeg', '.png', '.webp'].some(ext => currentExpert.opc_certification.resume_url.split('?')[0].toLowerCase().endsWith(ext))">
|
||||
<el-image :src="currentExpert.opc_certification.resume_url" class="w-full h-full rounded shadow-sm bg-white" fit="contain" />
|
||||
</template>
|
||||
<template v-else-if="['.docx', '.doc'].some(ext => currentExpert.opc_certification.resume_url.split('?')[0].toLowerCase().endsWith(ext))">
|
||||
<div class="w-full h-full border border-gray-300 rounded shadow-sm bg-white overflow-hidden">
|
||||
<VueOfficeDocx :src="currentExpert.opc_certification.resume_url" class="w-full h-full" style="height: 100%" />
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="h-full flex flex-col items-center justify-center bg-white rounded shadow-sm border border-gray-200">
|
||||
<div class="text-5xl mb-4 opacity-50">📄</div>
|
||||
<div class="text-gray-500 mb-6 font-medium">该文件格式不支持内嵌预览</div>
|
||||
<el-button type="primary" tag="a" :href="currentExpert.opc_certification.resume_url" target="_blank" download round size="large" class="px-8 shadow-md"><Download class="w-4 h-4 mr-2" /> 点击下载原文件</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</el-drawer>
|
||||
|
||||
<!-- Fullscreen Document Preview Dialog -->
|
||||
<el-dialog v-model="previewVisible" title="简历在线全屏预览" width="850px" top="3vh" :destroy-on-close="true" center>
|
||||
<div v-if="['.jpg', '.jpeg', '.png', '.webp'].some(ext => previewUrl.split('?')[0].toLowerCase().endsWith(ext))" class="flex justify-center">
|
||||
<el-image :src="previewUrl" class="max-h-[85vh]" fit="contain" />
|
||||
</div>
|
||||
<div v-else-if="previewUrl.split('?')[0].toLowerCase().endsWith('.pdf')" class="flex justify-center">
|
||||
<iframe :src="previewUrl" width="100%" style="height: 80vh" border="0"></iframe>
|
||||
</div>
|
||||
<div v-else-if="['.docx', '.doc'].some(ext => previewUrl.split('?')[0].toLowerCase().endsWith(ext))" class="flex justify-center h-[80vh] overflow-y-auto">
|
||||
<vue-office-docx :src="previewUrl" />
|
||||
</div>
|
||||
<div v-else class="text-center py-10">
|
||||
<el-result icon="warning" title="无法预览该类型文件" sub-title="该格式不支持在线预览,请下载后查看。" />
|
||||
<el-button type="primary" tag="a" :href="previewUrl" target="_blank" download>下载文件</el-button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.custom-descriptions :deep(.el-descriptions__label) {
|
||||
width: 120px;
|
||||
color: #6b7280;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
167
src/views/admin/PermissionListView.vue
Normal file
167
src/views/admin/PermissionListView.vue
Normal file
@@ -0,0 +1,167 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import api from '@/api';
|
||||
|
||||
const permissions = ref<any[]>([]);
|
||||
const flatPermissions = ref<any[]>([]);
|
||||
const loading = ref(true);
|
||||
|
||||
const dialogVisible = ref(false);
|
||||
const isEditing = ref(false);
|
||||
const form = ref({ id: '', name: '', code: '', type: 'MENU', path: '', method: '', parent: null });
|
||||
|
||||
const buildTree = (data: any[], parentId: string | null = null): any[] => {
|
||||
return data
|
||||
.filter(item => item.parent === parentId)
|
||||
.map(item => ({
|
||||
...item,
|
||||
children: buildTree(data, item.id)
|
||||
}));
|
||||
};
|
||||
|
||||
const fetchPermissions = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res: any = await api.get('/permissions/');
|
||||
flatPermissions.value = res.results || res;
|
||||
permissions.value = buildTree(flatPermissions.value);
|
||||
} catch (error) {
|
||||
ElMessage.error('无法获取权限列表');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => fetchPermissions());
|
||||
|
||||
const openDialog = (row: any = null) => {
|
||||
if (row) {
|
||||
isEditing.value = true;
|
||||
form.value = { ...row };
|
||||
} else {
|
||||
isEditing.value = false;
|
||||
form.value = { id: '', name: '', code: '', type: 'MENU', path: '', method: '', parent: null };
|
||||
}
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
const savePermission = async () => {
|
||||
try {
|
||||
if (isEditing.value) {
|
||||
await api.put(`/permissions/${form.value.id}/`, form.value);
|
||||
ElMessage.success('更新成功');
|
||||
} else {
|
||||
await api.post('/permissions/', form.value);
|
||||
ElMessage.success('创建成功');
|
||||
}
|
||||
dialogVisible.value = false;
|
||||
fetchPermissions();
|
||||
} catch (error) {
|
||||
ElMessage.error('操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const deletePermission = async (id: string) => {
|
||||
try {
|
||||
await api.delete(`/permissions/${id}/`);
|
||||
ElMessage.success('删除成功');
|
||||
fetchPermissions();
|
||||
} catch (error) {
|
||||
ElMessage.error('删除失败');
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-800">权限树管理</h1>
|
||||
<p class="text-sm text-gray-400 mt-1">管理系统所有的菜单、页面按钮与底层接口权限</p>
|
||||
</div>
|
||||
<el-button type="primary" size="large" @click="openDialog()" class="shadow-sm">
|
||||
新增权限节点
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-card shadow="never" class="border-none bg-white rounded-2xl shadow-sm !p-2" v-loading="loading">
|
||||
|
||||
<el-table :data="permissions" style="width: 100%" class="custom-table" row-key="id" default-expand-all stripe :header-cell-style="{background:'#f8fafc', color:'#475569', fontWeight:'600'}">
|
||||
<el-table-column prop="name" label="权限名称" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<span class="font-medium text-gray-800">{{ row.name }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="code" label="权限标识" width="220">
|
||||
<template #default="{ row }">
|
||||
<span class="font-mono text-sm text-blue-600 bg-blue-50 px-2 py-1 rounded">{{ row.code }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="type" label="节点类型" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.type === 'MENU'" type="primary" effect="light" round size="small">菜单导航</el-tag>
|
||||
<el-tag v-else-if="row.type === 'BUTTON'" type="success" effect="light" round size="small">界面按钮</el-tag>
|
||||
<el-tag v-else type="warning" effect="light" round size="small">后端接口</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="path" label="路径/路由/API" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<span class="text-gray-500 text-sm">{{ row.path || '--' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="method" label="请求方法" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.method" type="info" size="small" effect="plain">{{ row.method }}</el-tag>
|
||||
<span v-else class="text-gray-300">--</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button text type="primary" size="small" @click="openDialog(row)" class="!px-2">编辑</el-button>
|
||||
<el-button text type="danger" size="small" @click="deletePermission(row.id)" class="!px-2">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="isEditing ? '编辑权限节点' : '新增权限节点'" width="550px" top="5vh">
|
||||
<el-form :model="form" label-width="100px" class="pt-4 pr-4">
|
||||
<el-form-item label="上级节点">
|
||||
<el-select v-model="form.parent" clearable placeholder="留空则作为顶级节点" class="w-full">
|
||||
<el-option v-for="p in flatPermissions" :key="p.id" :label="p.name + ' (' + p.code + ')'" :value="p.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="权限名称" required>
|
||||
<el-input v-model="form.name" placeholder="例如: 用户管理 / 审核通过" />
|
||||
</el-form-item>
|
||||
<el-form-item label="权限标识" required>
|
||||
<el-input v-model="form.code" placeholder="例如: user:manage 或 api:user:delete" />
|
||||
</el-form-item>
|
||||
<el-form-item label="类型" required>
|
||||
<el-radio-group v-model="form.type">
|
||||
<el-radio label="MENU">菜单导航</el-radio>
|
||||
<el-radio label="BUTTON">界面按钮</el-radio>
|
||||
<el-radio label="API">后端接口</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="路径/路由" v-if="form.type !== 'BUTTON'">
|
||||
<el-input v-model="form.path" placeholder="例如: /admin/users 或 /api/v1/users/" />
|
||||
</el-form-item>
|
||||
<el-form-item label="请求方法" v-if="form.type === 'API'">
|
||||
<el-select v-model="form.method" clearable placeholder="RESTful 请求方法" class="w-full">
|
||||
<el-option label="GET" value="GET" />
|
||||
<el-option label="POST" value="POST" />
|
||||
<el-option label="PUT" value="PUT" />
|
||||
<el-option label="DELETE" value="DELETE" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false" size="large">取消</el-button>
|
||||
<el-button type="primary" @click="savePermission" size="large">确认保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
109
src/views/admin/ReservationConfigView.vue
Normal file
109
src/views/admin/ReservationConfigView.vue
Normal file
@@ -0,0 +1,109 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { Settings } from 'lucide-vue-next';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import api from '@/api';
|
||||
|
||||
const config = ref({
|
||||
access_control_price: 20,
|
||||
access_control_unit: '次',
|
||||
meeting_room_price: 150,
|
||||
meeting_room_unit: '小时'
|
||||
});
|
||||
|
||||
const isSaving = ref(false);
|
||||
const configExists = ref(false);
|
||||
|
||||
const fetchConfig = async () => {
|
||||
try {
|
||||
const res: any = await api.get('/system/configs/reservation_fee/');
|
||||
if (res && res.config_value) {
|
||||
config.value = res.config_value;
|
||||
configExists.value = true;
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.response?.status !== 404) {
|
||||
ElMessage.error('无法加载预约配置');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => fetchConfig());
|
||||
|
||||
const saveConfig = async () => {
|
||||
isSaving.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
config_key: 'reservation_fee',
|
||||
config_value: config.value,
|
||||
description: '门禁入场与会议室收费标准'
|
||||
};
|
||||
if (configExists.value) {
|
||||
await api.put('/system/configs/reservation_fee/', payload);
|
||||
} else {
|
||||
await api.post('/system/configs/', payload);
|
||||
configExists.value = true;
|
||||
}
|
||||
ElMessage.success('预约费用配置已保存');
|
||||
} catch (error) {
|
||||
ElMessage.error('保存配置失败');
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-card shadow="never" class="!border-none max-w-2xl">
|
||||
<div class="flex items-center mb-6">
|
||||
<div class="w-10 h-10 rounded-lg bg-gray-100 flex items-center justify-center mr-3">
|
||||
<Settings class="w-5 h-5 text-gray-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-gray-800">平台预约费用配置</h2>
|
||||
<p class="text-xs text-gray-500">设置物理空间资源的使用费率</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-form label-position="top">
|
||||
<h3 class="text-sm font-semibold text-gray-700 border-b pb-2 mb-4">门禁入场配置</h3>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="单次/单日价格 (¥)">
|
||||
<el-input-number v-model="config.access_control_price" :min="0" :precision="2" :step="1" class="w-full" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="计费单位">
|
||||
<el-select v-model="config.access_control_unit" class="w-full">
|
||||
<el-option label="次" value="次" />
|
||||
<el-option label="天" value="天" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<h3 class="text-sm font-semibold text-gray-700 border-b pb-2 mt-6 mb-4">会议室配置</h3>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="基础价格 (¥)">
|
||||
<el-input-number v-model="config.meeting_room_price" :min="0" :precision="2" :step="10" class="w-full" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="计费单位">
|
||||
<el-select v-model="config.meeting_room_unit" class="w-full">
|
||||
<el-option label="小时" value="小时" />
|
||||
<el-option label="半天" value="半天" />
|
||||
<el-option label="全天" value="全天" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<div class="mt-8 flex justify-end">
|
||||
<el-button type="primary" :loading="isSaving" @click="saveConfig">保存全局配置</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</template>
|
||||
318
src/views/admin/RoleListView.vue
Normal file
318
src/views/admin/RoleListView.vue
Normal file
@@ -0,0 +1,318 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { Shield, Key, Settings, Trash2, Edit3 } from 'lucide-vue-next';
|
||||
import api from '@/api';
|
||||
|
||||
const roles = ref<any[]>([]);
|
||||
const permissions = ref<any[]>([]);
|
||||
const loading = ref(true);
|
||||
|
||||
const roleDialogVisible = ref(false);
|
||||
const roleForm = ref({ id: '', code: '', name: '', description: '' });
|
||||
const isEditing = ref(false);
|
||||
|
||||
const permDialogVisible = ref(false);
|
||||
const currentRole = ref<any>(null);
|
||||
const permLoading = ref(false);
|
||||
const checkedMenus = ref<string[]>([]);
|
||||
|
||||
// All configurable pages grouped by section
|
||||
const menuPages = computed(() => {
|
||||
if (!currentRole.value) return [];
|
||||
const code = currentRole.value.code;
|
||||
|
||||
// Admin pages
|
||||
const adminPages = [
|
||||
{ group: '管理后台 — 业务管理', items: [
|
||||
{ code: 'menu:business:tasks', label: '全平台任务' },
|
||||
{ code: 'menu:business:tasks:publish', label: '发布任务(管理员发布)' },
|
||||
{ code: 'menu:business:enterprises', label: '企业管理' },
|
||||
{ code: 'menu:account:users', label: 'OPC 专家库' },
|
||||
]},
|
||||
{ group: '管理后台 — 审核中心', items: [
|
||||
{ code: 'menu:business:certs', label: 'OPC 认证审核' },
|
||||
{ code: 'menu:business:enterprise_certs', label: '企业认证审核' },
|
||||
]},
|
||||
{ group: '管理后台 — 账户与权限', items: [
|
||||
{ code: 'menu:account:user_manage', label: '用户管理' },
|
||||
{ code: 'menu:account:roles', label: '角色与权限' },
|
||||
]},
|
||||
{ group: '管理后台 — 运营配置', items: [
|
||||
{ code: 'menu:settings:announcements', label: '系统公告' },
|
||||
{ code: 'menu:settings:skills', label: '技能标签管理' },
|
||||
{ code: 'menu:business:models', label: '模型市场' },
|
||||
{ code: 'menu:business:screen', label: '数据大屏' },
|
||||
]},
|
||||
];
|
||||
|
||||
// Enterprise pages
|
||||
const enterprisePages = [
|
||||
{ group: '企业端 — 功能页面', items: [
|
||||
{ code: 'menu:ent:dashboard', label: '企业工作台' },
|
||||
{ code: 'menu:ent:tasks', label: '项目管理' },
|
||||
{ code: 'menu:ent:tasks:create', label: '需求发布' },
|
||||
{ code: 'menu:ent:team', label: '成员管理' },
|
||||
{ code: 'menu:ent:opc_users', label: '搜索专家' },
|
||||
{ code: 'menu:ent:invitations', label: '定向邀约' },
|
||||
{ code: 'menu:ent:verification', label: '企业资质' },
|
||||
{ code: 'menu:ent:profile', label: '企业档案' },
|
||||
{ code: 'menu:ent:settings', label: '账号设置' },
|
||||
]},
|
||||
];
|
||||
|
||||
// User/OPC pages
|
||||
const userPages = [
|
||||
{ group: '用户端 — 功能页面', items: [
|
||||
{ code: 'menu:user:dashboard', label: '专家工作台' },
|
||||
{ code: 'menu:user:tasks', label: '接单大厅' },
|
||||
{ code: 'menu:user:my_tasks', label: '我的项目' },
|
||||
{ code: 'menu:user:invitations', label: '定向邀约' },
|
||||
{ code: 'menu:user:announcements', label: '平台公告' },
|
||||
{ code: 'menu:user:certification', label: 'OPC 认证' },
|
||||
{ code: 'menu:user:profile', label: '个人资料' },
|
||||
{ code: 'menu:user:settings', label: '账号设置' },
|
||||
]},
|
||||
];
|
||||
|
||||
// Show relevant sections based on role type
|
||||
if (code === 'ADMIN') return adminPages;
|
||||
if (code === 'ENTERPRISE') return enterprisePages;
|
||||
if (code === 'OPC_USER' || code === 'USER') return userPages;
|
||||
// For custom roles, show all
|
||||
return [...adminPages, ...enterprisePages, ...userPages];
|
||||
});
|
||||
|
||||
const fetchRoles = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res: any = await api.get('/roles/');
|
||||
roles.value = res.results || res;
|
||||
} catch (error) {
|
||||
ElMessage.error('无法获取角色列表');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchPermissions = async () => {
|
||||
try {
|
||||
const res: any = await api.get('/permissions/');
|
||||
permissions.value = res.results || res;
|
||||
} catch (error) {
|
||||
ElMessage.error('无法获取权限列表');
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchRoles();
|
||||
fetchPermissions();
|
||||
});
|
||||
|
||||
const openCreateDialog = () => {
|
||||
isEditing.value = false;
|
||||
roleForm.value = { id: '', code: '', name: '', description: '' };
|
||||
roleDialogVisible.value = true;
|
||||
};
|
||||
|
||||
const saveRole = async () => {
|
||||
try {
|
||||
if (isEditing.value) {
|
||||
await api.put(`/roles/${roleForm.value.id}/`, roleForm.value);
|
||||
ElMessage.success('角色更新成功');
|
||||
} else {
|
||||
await api.post('/roles/', roleForm.value);
|
||||
ElMessage.success('角色创建成功');
|
||||
}
|
||||
roleDialogVisible.value = false;
|
||||
fetchRoles();
|
||||
} catch (error) {
|
||||
ElMessage.error('操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const deleteRole = async (role: any) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定要删除角色「${role.name}」吗?已分配该角色的用户将失去对应权限。`, '确认删除', { type: 'warning', confirmButtonText: '确认删除', cancelButtonText: '取消' });
|
||||
await api.delete(`/roles/${role.id}/`);
|
||||
ElMessage.success('角色已删除');
|
||||
fetchRoles();
|
||||
} catch (error) {}
|
||||
};
|
||||
|
||||
const openPermDialog = (role: any) => {
|
||||
currentRole.value = role;
|
||||
const rolePerm = role.permission_ids || [];
|
||||
const allPermCodes = permissions.value.filter((p: any) => rolePerm.includes(p.id)).map((p: any) => p.code);
|
||||
checkedMenus.value = [...allPermCodes];
|
||||
permDialogVisible.value = true;
|
||||
};
|
||||
|
||||
const saveMenuPermissions = async () => {
|
||||
permLoading.value = true;
|
||||
try {
|
||||
// Collect all checked codes + their parent group codes
|
||||
const allCodes = new Set<string>([...checkedMenus.value]);
|
||||
for (const code of checkedMenus.value) {
|
||||
const parts = code.split(':');
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
allCodes.add(parts.slice(0, i).join(':'));
|
||||
}
|
||||
}
|
||||
|
||||
// Map codes to IDs where possible
|
||||
const permIds = permissions.value
|
||||
.filter((p: any) => allCodes.has(p.code))
|
||||
.map((p: any) => p.id);
|
||||
|
||||
// Also send the codes that might not yet have IDs in the DB
|
||||
const knownCodes = new Set(permissions.value.map((p: any) => p.code));
|
||||
const unknownCodes = [...allCodes].filter(c => !knownCodes.has(c));
|
||||
|
||||
await api.post(`/roles/${currentRole.value.id}/assign_permissions/`, {
|
||||
permissions: permIds,
|
||||
permission_codes: unknownCodes
|
||||
});
|
||||
ElMessage.success('页面权限配置已保存');
|
||||
permDialogVisible.value = false;
|
||||
// Refresh permissions list to get any newly-created ones
|
||||
await fetchPermissions();
|
||||
fetchRoles();
|
||||
} catch (error) {
|
||||
ElMessage.error('权限配置失败');
|
||||
} finally {
|
||||
permLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const getRoleMenuCount = (role: any) => {
|
||||
const rolePerm = role.permission_ids || [];
|
||||
const codes = permissions.value.filter((p: any) => rolePerm.includes(p.id)).map((p: any) => p.code);
|
||||
return codes.filter((c: string) => c.startsWith('menu:')).length;
|
||||
};
|
||||
|
||||
const toggleGroupAll = (group: any, checked: boolean) => {
|
||||
for (const item of group.items) {
|
||||
if (checked) {
|
||||
if (!checkedMenus.value.includes(item.code)) checkedMenus.value.push(item.code);
|
||||
} else {
|
||||
checkedMenus.value = checkedMenus.value.filter(c => c !== item.code);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const isGroupAllChecked = (group: any) => {
|
||||
return group.items.every((item: any) => checkedMenus.value.includes(item.code));
|
||||
};
|
||||
|
||||
const isGroupIndeterminate = (group: any) => {
|
||||
const checked = group.items.filter((item: any) => checkedMenus.value.includes(item.code)).length;
|
||||
return checked > 0 && checked < group.items.length;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-800">角色与页面权限</h1>
|
||||
<p class="text-sm text-gray-400 mt-1">管理系统角色,配置不同角色可访问的功能页面</p>
|
||||
</div>
|
||||
<el-button type="primary" size="large" @click="openCreateDialog" class="shadow-sm">
|
||||
新增角色
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- Role Cards -->
|
||||
<div v-loading="loading" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<el-card v-for="role in roles" :key="role.id" shadow="hover" class="rounded-xl border-gray-100 group">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="w-12 h-12 rounded-xl flex items-center justify-center text-white font-bold"
|
||||
:class="role.is_system ? 'bg-gradient-to-br from-red-500 to-orange-500' : 'bg-gradient-to-br from-blue-500 to-cyan-500'">
|
||||
<Shield class="w-6 h-6" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<h3 class="text-base font-bold text-gray-800">{{ role.name }}</h3>
|
||||
<el-tag v-if="role.is_system" type="danger" size="small" effect="dark" round>系统内置</el-tag>
|
||||
</div>
|
||||
<div class="text-xs text-gray-400 font-mono mb-1">{{ role.code }}</div>
|
||||
<p class="text-sm text-gray-500 line-clamp-2">{{ role.description || '暂无描述' }}</p>
|
||||
<div class="mt-3 flex items-center justify-between">
|
||||
<span class="text-xs text-gray-400">
|
||||
<Key class="w-3 h-3 inline mr-1" />{{ getRoleMenuCount(role) }} 个页面权限
|
||||
</span>
|
||||
<div class="flex gap-1">
|
||||
<el-button text type="primary" size="small" @click="openPermDialog(role)">
|
||||
<Settings class="w-3.5 h-3.5 mr-1" />配置页面
|
||||
</el-button>
|
||||
<el-button text type="primary" size="small" @click="() => { isEditing = true; roleForm = { ...role }; roleDialogVisible = true; }" :disabled="role.is_system">
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button text type="danger" size="small" @click="deleteRole(role)" :disabled="role.is_system">
|
||||
删除
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<!-- 新增/编辑角色弹窗 -->
|
||||
<el-dialog v-model="roleDialogVisible" :title="isEditing ? '编辑角色' : '新增角色'" width="500px" top="5vh">
|
||||
<el-form :model="roleForm" label-width="80px" class="pt-4 pr-4">
|
||||
<el-form-item label="角色名称" required>
|
||||
<el-input v-model="roleForm.name" placeholder="例如: 运营管理员" />
|
||||
</el-form-item>
|
||||
<el-form-item label="角色标识" required>
|
||||
<el-input v-model="roleForm.code" :disabled="isEditing" placeholder="例如: OP_ADMIN" />
|
||||
<div class="text-xs text-gray-400 mt-1">角色标识用于系统内部识别,创建后不可修改</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="角色描述">
|
||||
<el-input v-model="roleForm.description" type="textarea" :rows="3" placeholder="描述该角色的主要职责和权限范围..." />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="roleDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveRole">确认保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 配置页面权限弹窗 -->
|
||||
<el-dialog v-model="permDialogVisible" :title="`配置「${currentRole?.name}」可见页面`" width="580px" top="5vh">
|
||||
<div class="space-y-4 max-h-[55vh] overflow-y-auto pr-2">
|
||||
<el-alert type="info" :closable="false" show-icon class="!mb-4">
|
||||
<template #title>勾选的页面将在对应角色的管理菜单中显示,未勾选的页面将隐藏。</template>
|
||||
</el-alert>
|
||||
<div v-for="group in menuPages" :key="group.group" class="bg-gray-50 rounded-xl p-4 border border-gray-100">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<el-checkbox
|
||||
:model-value="isGroupAllChecked(group)"
|
||||
:indeterminate="isGroupIndeterminate(group)"
|
||||
@change="(v: any) => toggleGroupAll(group, v)"
|
||||
class="!font-bold"
|
||||
>
|
||||
<span class="text-sm font-bold text-gray-700">{{ group.group }}</span>
|
||||
</el-checkbox>
|
||||
<span class="text-xs text-gray-400">{{ group.items.filter((i: any) => checkedMenus.includes(i.code)).length }}/{{ group.items.length }}</span>
|
||||
</div>
|
||||
<div class="pl-6 space-y-2">
|
||||
<el-checkbox
|
||||
v-for="item in group.items"
|
||||
:key="item.code"
|
||||
:label="item.label"
|
||||
:model-value="checkedMenus.includes(item.code)"
|
||||
@change="(v: any) => { if (v) checkedMenus.push(item.code); else checkedMenus = checkedMenus.filter(c => c !== item.code); }"
|
||||
class="!block !ml-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="permDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveMenuPermissions" :loading="permLoading">保存页面配置</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
140
src/views/admin/SkillManageView.vue
Normal file
140
src/views/admin/SkillManageView.vue
Normal file
@@ -0,0 +1,140 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { Plus, Pencil, Trash2 } from 'lucide-vue-next';
|
||||
import api from '@/api';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
|
||||
const skills = ref<any[]>([]);
|
||||
const isLoading = ref(true);
|
||||
const dialogVisible = ref(false);
|
||||
const saveLoading = ref(false);
|
||||
const form = ref({
|
||||
id: '',
|
||||
name: '',
|
||||
category: '',
|
||||
sort_order: 0,
|
||||
is_active: true,
|
||||
});
|
||||
|
||||
const categories = ['技术', '设计', '内容', '数据', '运营', '专业服务', '其他'];
|
||||
|
||||
const fetchSkills = async () => {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const res: any = await api.get('/skills/');
|
||||
skills.value = res.results || res || [];
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
form.value = { id: '', name: '', category: '', sort_order: 0, is_active: true };
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
const openEdit = (skill: any) => {
|
||||
form.value = { ...skill };
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
const saveSkill = async () => {
|
||||
if (!form.value.name.trim()) {
|
||||
ElMessage.warning('请输入技能名称');
|
||||
return;
|
||||
}
|
||||
saveLoading.value = true;
|
||||
try {
|
||||
if (form.value.id) {
|
||||
await api.put(`/skills/${form.value.id}/`, form.value);
|
||||
} else {
|
||||
await api.post('/skills/', form.value);
|
||||
}
|
||||
ElMessage.success(form.value.id ? '已更新' : '已添加');
|
||||
dialogVisible.value = false;
|
||||
fetchSkills();
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e.response?.data?.name?.[0] || '操作失败');
|
||||
} finally {
|
||||
saveLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const deleteSkill = async (id: string) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除该技能标签吗?', '确认删除', { type: 'warning' });
|
||||
await api.delete(`/skills/${id}/`);
|
||||
ElMessage.success('已删除');
|
||||
fetchSkills();
|
||||
} catch (e: any) {
|
||||
if (e !== 'cancel') ElMessage.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => fetchSkills());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-xl font-bold text-gray-800">技能标签管理</h1>
|
||||
<el-button type="primary" @click="openCreate">
|
||||
<Plus class="w-4 h-4 mr-1" />新增技能
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-card shadow="never">
|
||||
<el-table :data="skills" v-loading="isLoading" stripe>
|
||||
<el-table-column prop="name" label="技能名称" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<span class="font-semibold">{{ row.name }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="category" label="分类" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.category" size="small" type="info" effect="plain">{{ row.category }}</el-tag>
|
||||
<span v-else class="text-gray-400">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="sort_order" label="排序" width="80" align="center" />
|
||||
<el-table-column prop="is_active" label="状态" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.is_active ? 'success' : 'info'" size="small">{{ row.is_active ? '启用' : '禁用' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button text type="primary" size="small" @click="openEdit(row)"><Pencil class="w-4 h-4" /></el-button>
|
||||
<el-button text type="danger" size="small" @click="deleteSkill(row.id)"><Trash2 class="w-4 h-4" /></el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="form.id ? '编辑技能' : '新增技能'" width="420px">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="技能名称" required>
|
||||
<el-input v-model="form.name" placeholder="例如:Python开发" />
|
||||
</el-form-item>
|
||||
<el-form-item label="分类">
|
||||
<el-select v-model="form.category" placeholder="选择分类" clearable allow-create filterable class="w-full">
|
||||
<el-option v-for="c in categories" :key="c" :label="c" :value="c" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="排序权重">
|
||||
<el-input-number v-model="form.sort_order" :min="0" :max="999" />
|
||||
<span class="text-xs text-gray-400 ml-2">越小越靠前</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-switch v-model="form.is_active" active-text="启用" inactive-text="禁用" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveSkill" :loading="saveLoading">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
951
src/views/admin/UserListView.vue
Normal file
951
src/views/admin/UserListView.vue
Normal file
@@ -0,0 +1,951 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue';
|
||||
import { Search, MoreHorizontal, ShieldCheck, Ban, Edit, Trash2, KeyRound, Plus, Calendar, Mail, Phone, User as UserIcon, AlertTriangle, Briefcase, Building, UserCheck, Camera, Upload, FileText, RotateCcw } from 'lucide-vue-next';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import api, { uploadFileToMinIO } from '@/api';
|
||||
import dayjs from 'dayjs';
|
||||
import VueOfficeDocx from '@vue-office/docx';
|
||||
import '@vue-office/docx/lib/index.css';
|
||||
import UserTasksDrawer from '@/components/user/UserTasksDrawer.vue';
|
||||
|
||||
const users = ref<any[]>([]);
|
||||
const allRoles = ref<any[]>([]);
|
||||
const loading = ref(true);
|
||||
const searchForm = ref({
|
||||
username: '',
|
||||
nickname: '',
|
||||
real_name: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
role: '',
|
||||
status: '',
|
||||
idle: false
|
||||
});
|
||||
|
||||
const resetSearch = () => {
|
||||
searchForm.value = {
|
||||
username: '',
|
||||
nickname: '',
|
||||
real_name: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
role: '',
|
||||
status: '',
|
||||
idle: false
|
||||
};
|
||||
fetchUsers();
|
||||
};
|
||||
const selectedUsers = ref<string[]>([]);
|
||||
|
||||
const tasksDrawerVisible = ref(false);
|
||||
const selectedUserForTasks = ref<any>(null);
|
||||
|
||||
const roleDialogVisible = ref(false);
|
||||
const roleForm = ref({ userId: '', roleCodes: [] as string[] });
|
||||
const roleLoading = ref(false);
|
||||
|
||||
// ─── Select All ───
|
||||
const selectableUsers = computed(() => users.value.filter((u: any) => !u.roles?.includes('ADMIN')));
|
||||
|
||||
const isAllSelected = computed(() => {
|
||||
return selectableUsers.value.length > 0 && selectableUsers.value.every((u: any) => selectedUsers.value.includes(u.id));
|
||||
});
|
||||
const isIndeterminate = computed(() => {
|
||||
const selectedCount = selectedUsers.value.filter(id => selectableUsers.value.some((u: any) => u.id === id)).length;
|
||||
return selectedCount > 0 && selectedCount < selectableUsers.value.length;
|
||||
});
|
||||
const handleSelectAll = (val: boolean) => {
|
||||
selectedUsers.value = val ? selectableUsers.value.map((u: any) => u.id) : [];
|
||||
};
|
||||
|
||||
const userDetailDrawerVisible = ref(false);
|
||||
const selectedUserDetail = ref<any>(null);
|
||||
|
||||
const openUserDetail = (user: any) => {
|
||||
selectedUserDetail.value = user;
|
||||
userDetailDrawerVisible.value = true;
|
||||
};
|
||||
|
||||
const fetchRoles = async () => {
|
||||
try {
|
||||
const res: any = await api.get('/roles/');
|
||||
allRoles.value = res.results || res;
|
||||
} catch (error) {
|
||||
console.error('Failed to load roles');
|
||||
}
|
||||
};
|
||||
|
||||
const fetchUsers = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res: any = await api.get('/users/', {
|
||||
params: {
|
||||
...searchForm.value
|
||||
}
|
||||
});
|
||||
users.value = res.results || res;
|
||||
// Clean up selected users that no longer exist in the list
|
||||
const currentIds = new Set(users.value.map((u: any) => u.id));
|
||||
selectedUsers.value = selectedUsers.value.filter(id => currentIds.has(id));
|
||||
} catch (error) {
|
||||
ElMessage.error('无法获取用户列表');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchRoles();
|
||||
fetchUsers();
|
||||
});
|
||||
|
||||
const handleStatusChange = async (user: any) => {
|
||||
try {
|
||||
await api.post(`/users/${user.id}/toggle_status/`);
|
||||
user.is_active = !user.is_active;
|
||||
ElMessage.success(`用户 ${user.username} 已${user.is_active ? '启用' : '禁用'}`);
|
||||
} catch (error) {
|
||||
ElMessage.error('更新状态失败');
|
||||
}
|
||||
};
|
||||
|
||||
const userDialogVisible = ref(false);
|
||||
const isUserEditing = ref(false);
|
||||
const userSaveLoading = ref(false);
|
||||
const activeUserTab = ref('basic');
|
||||
const previewVisible = ref(false);
|
||||
const previewUrl = ref('');
|
||||
const userForm = ref({
|
||||
id: '', username: '', nickname: '', phone: '', email: '', password: '', roles: [] as string[],
|
||||
avatar_url: '', face_url: '', bio: '', location: '', status: 'ACTIVE', is_active: true,
|
||||
opc_certification: { real_name: '', id_card: '', experience: '', resume_url: '', attachments: [] as any[], skills: [] as string[], status: 'PENDING', rating: 5.0 }
|
||||
});
|
||||
|
||||
const openCreateUserDialog = () => {
|
||||
isUserEditing.value = false;
|
||||
activeUserTab.value = 'basic';
|
||||
userForm.value = {
|
||||
id: '', username: '', nickname: '', phone: '', email: '', password: '', roles: [],
|
||||
avatar_url: '', face_url: '', bio: '', location: '', status: 'ACTIVE', is_active: true,
|
||||
opc_certification: { real_name: '', id_card: '', experience: '', resume_url: '', attachments: [], skills: [], status: 'PENDING', rating: 5.0 }
|
||||
};
|
||||
userDialogVisible.value = true;
|
||||
};
|
||||
|
||||
const openEditUserDialog = (user: any) => {
|
||||
isUserEditing.value = true;
|
||||
activeUserTab.value = 'basic';
|
||||
userForm.value = {
|
||||
id: user.id, username: user.username, nickname: user.nickname, phone: user.phone, email: user.email, password: '',
|
||||
roles: user.roles || [],
|
||||
avatar_url: user.avatar_url || '',
|
||||
face_url: user.face_url || '',
|
||||
bio: user.bio || '',
|
||||
location: user.location || '',
|
||||
status: user.status || 'ACTIVE',
|
||||
is_active: user.is_active,
|
||||
opc_certification: {
|
||||
real_name: user.opc_certification?.real_name || '',
|
||||
id_card: user.opc_certification?.id_card || '',
|
||||
experience: user.opc_certification?.experience || '',
|
||||
resume_url: user.opc_certification?.resume_url || '',
|
||||
attachments: user.opc_certification?.attachments || [],
|
||||
skills: user.opc_certification?.skills || [],
|
||||
status: user.opc_certification?.status || 'PENDING',
|
||||
rating: user.opc_certification?.rating || user.rating || 5.0
|
||||
}
|
||||
};
|
||||
userDialogVisible.value = true;
|
||||
};
|
||||
|
||||
const saveUser = async () => {
|
||||
userSaveLoading.value = true;
|
||||
try {
|
||||
if (isUserEditing.value) {
|
||||
await api.put(`/users/${userForm.value.id}/`, userForm.value);
|
||||
ElMessage.success('更新成功');
|
||||
} else {
|
||||
await api.post('/users/admin_create/', userForm.value);
|
||||
ElMessage.success('创建成功');
|
||||
}
|
||||
userDialogVisible.value = false;
|
||||
fetchUsers();
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.response?.data?.username?.[0] || '操作失败');
|
||||
} finally {
|
||||
userSaveLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleAvatarUpload = async (options: any) => {
|
||||
try {
|
||||
const url = await uploadFileToMinIO(options.file, 'avatars');
|
||||
userForm.value.avatar_url = url;
|
||||
ElMessage.success('头像上传成功');
|
||||
} catch (error) {
|
||||
ElMessage.error('上传失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleFaceUpload = async (options: any) => {
|
||||
try {
|
||||
const url = await uploadFileToMinIO(options.file, 'faces');
|
||||
userForm.value.face_url = url;
|
||||
ElMessage.success('人脸照片上传成功');
|
||||
} catch (error) {
|
||||
ElMessage.error('上传失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleIDCardUpload = async (options: any) => {
|
||||
try {
|
||||
const url = await uploadFileToMinIO(options.file, 'id_cards');
|
||||
if (!userForm.value.opc_certification.attachments) {
|
||||
userForm.value.opc_certification.attachments = [];
|
||||
}
|
||||
userForm.value.opc_certification.attachments.push({ url, name: options.file.name });
|
||||
ElMessage.success('身份证照片上传成功');
|
||||
} catch (error) {
|
||||
ElMessage.error('上传失败');
|
||||
}
|
||||
};
|
||||
|
||||
const removeIDCardPhoto = (index: number) => {
|
||||
userForm.value.opc_certification.attachments.splice(index, 1);
|
||||
};
|
||||
|
||||
const handleResumeUpload = async (options: any) => {
|
||||
try {
|
||||
const url = await uploadFileToMinIO(options.file, 'expert_resumes');
|
||||
userForm.value.opc_certification.resume_url = url;
|
||||
ElMessage.success('简历上传成功');
|
||||
} catch (error) {
|
||||
ElMessage.error('上传失败');
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Single Delete with force-stop support ───
|
||||
const deleteUser = (user: any) => {
|
||||
ElMessageBox.confirm(`确定要删除用户 ${user.username} 吗?此操作不可恢复!`, '警告', {
|
||||
confirmButtonText: '确定删除',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
confirmButtonClass: 'el-button--danger'
|
||||
}).then(async () => {
|
||||
try {
|
||||
await api.delete(`/users/${user.id}/`);
|
||||
ElMessage.success('删除成功');
|
||||
selectedUsers.value = selectedUsers.value.filter(id => id !== user.id);
|
||||
fetchUsers();
|
||||
} catch (error: any) {
|
||||
let errorMsg = '删除失败';
|
||||
if (typeof error.response?.data === 'string') {
|
||||
errorMsg = `服务器异常: ${error.response?.status || '500'}`;
|
||||
} else if (error.response?.data?.[0]) {
|
||||
errorMsg = error.response.data[0];
|
||||
} else if (error.response?.data?.detail) {
|
||||
errorMsg = error.response.data.detail;
|
||||
}
|
||||
|
||||
if (errorMsg && typeof errorMsg === 'string' && errorMsg.includes('任务')) {
|
||||
// Show a triple-action dialog for task blocker
|
||||
ElMessageBox({
|
||||
title: '⚠️ 注销拦截 — 该用户有进行中的任务',
|
||||
message: `<div style="line-height:1.8">
|
||||
<p style="color:#606266">${errorMsg}</p>
|
||||
<div style="background:#FEF0F0;border-radius:8px;padding:12px 16px;margin-top:12px;border-left:3px solid #F56C6C">
|
||||
<p style="font-weight:600;color:#F56C6C;margin-bottom:4px">⚡ 强制删除</p>
|
||||
<p style="font-size:12px;color:#909399">系统将自动取消该用户所有进行中的任务和接单,并立即删除账号。</p>
|
||||
</div>
|
||||
</div>`,
|
||||
dangerouslyUseHTMLString: true,
|
||||
distinguishCancelAndClose: true,
|
||||
confirmButtonText: '🔴 强制停止任务并删除',
|
||||
cancelButtonText: '📋 查看任务明细',
|
||||
confirmButtonClass: 'el-button--danger',
|
||||
type: 'warning'
|
||||
}).then(async () => {
|
||||
// Force delete
|
||||
try {
|
||||
await api.delete(`/users/${user.id}/?force=true`);
|
||||
ElMessage.success(`用户 ${user.username} 已强制删除,相关任务已自动取消`);
|
||||
selectedUsers.value = selectedUsers.value.filter(id => id !== user.id);
|
||||
fetchUsers();
|
||||
} catch (forceErr: any) {
|
||||
ElMessage.error(forceErr.response?.data?.detail || '强制删除失败');
|
||||
}
|
||||
}).catch((action: string) => {
|
||||
if (action === 'cancel') {
|
||||
// Open task drawer
|
||||
selectedUserForTasks.value = user;
|
||||
tasksDrawerVisible.value = true;
|
||||
}
|
||||
// 'close' (clicking X) does nothing
|
||||
});
|
||||
} else {
|
||||
ElMessage.error(errorMsg);
|
||||
}
|
||||
}
|
||||
}).catch(() => {});
|
||||
};
|
||||
|
||||
// ─── Batch Delete with force-stop support ───
|
||||
const batchDeleteUsers = () => {
|
||||
if (selectedUsers.value.length === 0) return;
|
||||
ElMessageBox.confirm(
|
||||
`确定要删除选中的 ${selectedUsers.value.length} 个用户吗?此操作不可恢复!`,
|
||||
'批量删除确认',
|
||||
{
|
||||
confirmButtonText: '确认删除',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
confirmButtonClass: 'el-button--danger'
|
||||
}
|
||||
).then(async () => {
|
||||
try {
|
||||
await api.post('/users/batch_delete/', { user_ids: selectedUsers.value });
|
||||
ElMessage.success('批量删除成功');
|
||||
selectedUsers.value = [];
|
||||
fetchUsers();
|
||||
} catch (error: any) {
|
||||
const resData = error.response?.data;
|
||||
if (resData?.has_blockers && resData?.blocked_users) {
|
||||
// Build a human-readable list of blocked users
|
||||
const blockedList = resData.blocked_users
|
||||
.map((u: any) => {
|
||||
const parts = [];
|
||||
if (u.task_count > 0) parts.push(`发布任务 ${u.task_count} 项`);
|
||||
if (u.app_count > 0) parts.push(`专家接单 ${u.app_count} 项`);
|
||||
return `<li style="margin-bottom:4px"><strong>${u.nickname || u.username}</strong> — ${parts.join('、')}</li>`;
|
||||
})
|
||||
.join('');
|
||||
ElMessageBox({
|
||||
title: '⚠️ 部分用户有进行中的任务',
|
||||
message: `<div style="line-height:1.8">
|
||||
<p style="color:#606266;margin-bottom:8px">${resData.detail}</p>
|
||||
<ul style="padding-left:18px;color:#606266;font-size:13px;max-height:200px;overflow-y:auto">${blockedList}</ul>
|
||||
<div style="background:#FEF0F0;border-radius:8px;padding:12px 16px;margin-top:12px;border-left:3px solid #F56C6C">
|
||||
<p style="font-weight:600;color:#F56C6C;margin-bottom:4px">⚡ 强制删除</p>
|
||||
<p style="font-size:12px;color:#909399">系统将自动取消上述用户所有进行中的任务和接单,并立即删除全部选中账号。</p>
|
||||
</div>
|
||||
</div>`,
|
||||
dangerouslyUseHTMLString: true,
|
||||
confirmButtonText: '🔴 强制停止任务并删除',
|
||||
cancelButtonText: '取消',
|
||||
confirmButtonClass: 'el-button--danger',
|
||||
type: 'warning'
|
||||
}).then(async () => {
|
||||
try {
|
||||
await api.post('/users/batch_delete/', { user_ids: selectedUsers.value, force: true });
|
||||
ElMessage.success('批量强制删除成功,相关任务已自动取消');
|
||||
selectedUsers.value = [];
|
||||
fetchUsers();
|
||||
} catch (forceErr: any) {
|
||||
ElMessage.error(forceErr.response?.data?.detail || '强制删除失败');
|
||||
}
|
||||
}).catch(() => {});
|
||||
} else {
|
||||
let errorMsg = '部分或全部删除失败';
|
||||
if (typeof resData === 'string') {
|
||||
errorMsg = `服务器异常: ${error.response?.status || '500'}`;
|
||||
} else if (resData?.detail) {
|
||||
errorMsg = resData.detail;
|
||||
} else if (Array.isArray(resData) && resData.length > 0) {
|
||||
errorMsg = resData[0];
|
||||
}
|
||||
ElMessage.error(errorMsg);
|
||||
fetchUsers();
|
||||
}
|
||||
}
|
||||
}).catch(() => {});
|
||||
};
|
||||
|
||||
const resetPassword = (user: any) => {
|
||||
ElMessageBox.confirm(`确定将用户 ${user.username} 的密码重置为 123456 吗?`, '重置密码', {
|
||||
confirmButtonText: '确定重置',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}).then(async () => {
|
||||
try {
|
||||
await api.post(`/users/${user.id}/reset_password/`, { password: '123456' });
|
||||
ElMessage.success('密码已重置为 123456');
|
||||
} catch (error) {
|
||||
ElMessage.error('重置密码失败');
|
||||
}
|
||||
}).catch(() => {});
|
||||
};
|
||||
|
||||
const formatDate = (date: string) => dayjs(date).format('YYYY-MM-DD');
|
||||
|
||||
// ─── Deleted Users (Recycle Bin) Tab ───
|
||||
const activeTab = ref('active');
|
||||
const deletedUsers = ref<any[]>([]);
|
||||
const deletedLoading = ref(false);
|
||||
|
||||
const fetchDeletedUsers = async () => {
|
||||
deletedLoading.value = true;
|
||||
try {
|
||||
const res: any = await api.get('/users/deleted_users/');
|
||||
deletedUsers.value = res.results || res || [];
|
||||
} catch (error) {
|
||||
ElMessage.error('无法获取已删除用户列表');
|
||||
} finally {
|
||||
deletedLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const getOriginalName = (name: string) => {
|
||||
const idx = name?.indexOf('__deleted_');
|
||||
return idx > 0 ? name.substring(0, idx) : name || '-';
|
||||
};
|
||||
|
||||
const restoreUser = async (user: any) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要恢复用户 <strong>${getOriginalName(user.username)}</strong> 吗?恢复后该用户将重新激活。`,
|
||||
'恢复用户',
|
||||
{ dangerouslyUseHTMLString: true, type: 'info', confirmButtonText: '确认恢复' }
|
||||
);
|
||||
await api.post(`/users/${user.id}/restore/`);
|
||||
ElMessage.success('用户已恢复');
|
||||
fetchDeletedUsers();
|
||||
fetchUsers();
|
||||
} catch (error: any) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error(error?.response?.data?.detail || '恢复失败');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
watch(activeTab, (val) => {
|
||||
if (val === 'deleted') fetchDeletedUsers();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Tab switch: Active / Recycle Bin -->
|
||||
<el-tabs v-model="activeTab" class="!-mb-2">
|
||||
<el-tab-pane name="active">
|
||||
<template #label><span class="flex items-center gap-1.5"><UserIcon class="w-4 h-4" />用户管理</span></template>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane name="deleted">
|
||||
<template #label><span class="flex items-center gap-1.5"><Trash2 class="w-4 h-4" />回收站</span></template>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<!-- Active Users Card -->
|
||||
<el-card v-show="activeTab === 'active'" shadow="never" class="border-none bg-white rounded-2xl shadow-sm !p-2">
|
||||
<!-- Toolbar -->
|
||||
<div class="bg-gray-50/50 p-4 rounded-xl border border-gray-100 mb-6">
|
||||
<el-form :model="searchForm" label-position="left" class="flex flex-wrap items-center gap-4">
|
||||
<el-input v-model="searchForm.username" placeholder="搜索账号..." class="!w-40" clearable @change="fetchUsers" @keyup.enter="fetchUsers" />
|
||||
<el-input v-model="searchForm.nickname" placeholder="搜索昵称..." class="!w-32" clearable @change="fetchUsers" @keyup.enter="fetchUsers" />
|
||||
<el-input v-model="searchForm.real_name" placeholder="真实姓名..." class="!w-32" clearable @change="fetchUsers" @keyup.enter="fetchUsers" />
|
||||
<el-input v-model="searchForm.phone" placeholder="搜索手机号..." class="!w-40" clearable @change="fetchUsers" @keyup.enter="fetchUsers" />
|
||||
|
||||
<el-select v-model="searchForm.role" placeholder="全部角色" clearable class="!w-32" @change="fetchUsers">
|
||||
<el-option label="普通用户" value="USER" />
|
||||
<el-option label="认证专家" value="OPC_USER" />
|
||||
<el-option label="企业用户" value="ENTERPRISE" />
|
||||
<el-option label="管理员" value="ADMIN" />
|
||||
</el-select>
|
||||
|
||||
<el-select v-model="searchForm.status" placeholder="全部状态" clearable class="!w-32" @change="fetchUsers">
|
||||
<el-option label="正常活跃" value="active" />
|
||||
<el-option label="已停用" value="disabled" />
|
||||
</el-select>
|
||||
|
||||
<el-checkbox v-model="searchForm.idle" @change="fetchUsers" class="!mr-2">
|
||||
<span class="text-sm font-medium text-gray-700">仅显示闲置账号</span>
|
||||
</el-checkbox>
|
||||
|
||||
<div class="flex items-center gap-2 ml-auto">
|
||||
<el-button @click="resetSearch">重置条件</el-button>
|
||||
<el-button type="primary" @click="fetchUsers">
|
||||
<Search class="w-4 h-4 mr-1" /> 立即搜索
|
||||
</el-button>
|
||||
<el-button v-if="selectedUsers.length > 0" type="danger" @click="batchDeleteUsers">
|
||||
<Trash2 class="w-4 h-4 mr-1" /> 批量删除 ({{ selectedUsers.length }})
|
||||
</el-button>
|
||||
<el-button type="primary" @click="openCreateUserDialog" class="shadow-sm">
|
||||
<Plus class="w-4 h-4 mr-1" /> 新增用户
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- Cards List -->
|
||||
<div v-loading="loading" class="min-h-[400px]">
|
||||
<!-- Select All Bar -->
|
||||
<div v-if="users.length > 0" class="flex items-center justify-between px-4 py-3 mb-4 bg-gray-50/80 rounded-xl border border-gray-100">
|
||||
<div class="flex items-center gap-3">
|
||||
<el-checkbox
|
||||
:model-value="isAllSelected"
|
||||
:indeterminate="isIndeterminate"
|
||||
@change="handleSelectAll"
|
||||
size="large"
|
||||
>
|
||||
<span class="text-sm font-medium text-gray-700">全选</span>
|
||||
</el-checkbox>
|
||||
<span class="text-xs text-gray-400">
|
||||
共 {{ users.length }} 个用户<template v-if="selectedUsers.length > 0">,已选 {{ selectedUsers.length }} 个</template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-checkbox-group v-model="selectedUsers" v-if="users.length > 0" class="flex flex-col gap-4">
|
||||
<div v-for="user in users" :key="user.id"
|
||||
@click="openUserDetail(user)"
|
||||
class="bg-white rounded-xl p-4 border border-gray-100 hover:shadow-md hover:border-blue-200 cursor-pointer transition-all duration-300 group relative flex items-center gap-6"
|
||||
:class="{ 'border-blue-400 shadow-sm bg-blue-50/20': selectedUsers.includes(user.id), 'opacity-70': user.roles?.includes('ADMIN') }">
|
||||
|
||||
<!-- Checkbox -->
|
||||
<div @click.stop class="flex-shrink-0">
|
||||
<el-checkbox :label="user.id" size="large" class="!mr-0" :disabled="user.roles?.includes('ADMIN')">
|
||||
<span class="hidden"></span>
|
||||
</el-checkbox>
|
||||
</div>
|
||||
|
||||
<!-- Avatar & Basic Info -->
|
||||
<div class="flex items-center gap-4 w-64 flex-shrink-0">
|
||||
<div class="relative">
|
||||
<el-avatar :size="50" :src="user.avatar_url" class="bg-gradient-to-br from-blue-50 to-indigo-100 text-blue-700 font-bold shadow-inner border border-blue-100">
|
||||
{{ user.nickname?.[0] || user.username?.[0] || 'U' }}
|
||||
</el-avatar>
|
||||
<span class="absolute bottom-0 right-0 w-3 h-3 border-2 border-white rounded-full shadow-sm" :class="user.is_active ? 'bg-green-500' : 'bg-red-500'"></span>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<h3 class="font-bold text-gray-900 text-base truncate">
|
||||
{{ user.nickname || user.username }}
|
||||
<span v-if="user.opc_certification?.real_name" class="text-xs text-gray-500 font-normal ml-1">({{ user.opc_certification.real_name }})</span>
|
||||
</h3>
|
||||
<p class="text-xs text-gray-500 truncate flex items-center gap-2 mt-0.5">
|
||||
<span class="flex items-center gap-1" v-if="user.phone"><Phone class="w-3 h-3" /> {{ user.phone }}</span>
|
||||
<span class="flex items-center gap-1" v-if="user.email"><Mail class="w-3 h-3" /> {{ user.email }}</span>
|
||||
<span class="flex items-center gap-1" v-if="!user.phone && !user.email"><UserIcon class="w-3 h-3" /> {{ user.username }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Roles -->
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<div class="flex flex-wrap items-center gap-1.5" v-if="user.roles && user.roles.length > 0">
|
||||
<el-tag v-if="user.roles.includes('ADMIN')" type="danger" effect="light" round size="small" class="font-medium border-none bg-red-50 text-red-600">管理员</el-tag>
|
||||
<el-tag v-if="user.roles.includes('OPC_USER')" type="success" effect="light" round size="small" class="font-medium border-none bg-green-50 text-green-600">专家</el-tag>
|
||||
<el-tag v-if="user.roles.includes('ENTERPRISE')" type="warning" effect="light" round size="small" class="font-medium border-none bg-orange-50 text-orange-600">企业</el-tag>
|
||||
<span v-if="user.enterprise_info" class="text-xs font-medium text-gray-500 bg-gray-100 px-1.5 py-0.5 rounded">
|
||||
@{{ user.enterprise_info.company_name }}
|
||||
</span>
|
||||
<el-tag v-if="user.roles.includes('USER')" type="info" effect="plain" round size="small" class="font-medium border-none bg-gray-50 text-gray-600">普通用户</el-tag>
|
||||
</div>
|
||||
<div v-else class="text-xs text-gray-400 italic bg-gray-50 inline-block px-2 py-1 rounded">暂无分配角色</div>
|
||||
</div>
|
||||
|
||||
<!-- Registration Date -->
|
||||
<div class="w-40 flex items-center gap-1.5 text-xs text-gray-500 font-mono flex-shrink-0">
|
||||
<Calendar class="w-3.5 h-3.5 text-gray-400" />
|
||||
{{ formatDate(user.created_at) }}
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div class="w-24 flex items-center gap-1.5 text-xs font-medium flex-shrink-0" :class="user.is_active ? 'text-green-600' : 'text-red-500'">
|
||||
<span class="w-1.5 h-1.5 rounded-full" :class="user.is_active ? 'bg-green-500' : 'bg-red-500'"></span>
|
||||
{{ user.is_active ? '正常活跃' : '已停用' }}
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex-shrink-0 ml-4 flex items-center gap-1" @click.stop>
|
||||
<el-button text type="primary" bg class="!px-2.5 hover:!bg-blue-50" @click="openEditUserDialog(user)">
|
||||
<Edit class="w-4 h-4 mr-1" /> 编辑
|
||||
</el-button>
|
||||
|
||||
<el-button text type="danger" bg class="!px-2.5 hover:!bg-red-50" :disabled="user.roles?.includes('ADMIN')" @click="deleteUser(user)">
|
||||
<Trash2 class="w-4 h-4 mr-1" /> 删除
|
||||
</el-button>
|
||||
|
||||
<el-dropdown trigger="click">
|
||||
<el-button text type="info" bg class="!px-2 hover:!bg-gray-100">
|
||||
更多 <MoreHorizontal class="w-4 h-4 ml-1" />
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item @click="() => { selectedUserForTasks = user; tasksDrawerVisible = true; }"><Briefcase class="w-4 h-4 mr-2" />任务明细</el-dropdown-item>
|
||||
<el-dropdown-item @click="resetPassword(user)"><KeyRound class="w-4 h-4 mr-2" />重置密码</el-dropdown-item>
|
||||
<el-dropdown-item @click="handleStatusChange(user)" :disabled="user.roles?.includes('ADMIN')">
|
||||
<template v-if="user.is_active">
|
||||
<Ban class="w-4 h-4 mr-2 text-orange-500" />
|
||||
<span class="text-orange-500">禁用账号</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<ShieldCheck class="w-4 h-4 mr-2 text-green-500" />
|
||||
<span class="text-green-500">启用账号</span>
|
||||
</template>
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</el-checkbox-group>
|
||||
|
||||
<el-empty v-else description="暂无符合条件的用户" :image-size="120" />
|
||||
</div>
|
||||
|
||||
|
||||
<el-dialog v-model="userDialogVisible" :title="isUserEditing ? '编辑用户资料' : '新增用户'" width="900px" top="5vh">
|
||||
<div class="h-[65vh] overflow-y-auto px-2 py-2">
|
||||
<el-form :model="userForm" label-width="90px">
|
||||
|
||||
<!-- Account Info Card -->
|
||||
<el-card shadow="never" class="mb-6 border-gray-100">
|
||||
<template #header>
|
||||
<div class="font-medium text-gray-800 flex items-center">
|
||||
<UserIcon class="w-5 h-5 mr-2" /> 基础账号信息
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex gap-8">
|
||||
<!-- Avatar Upload -->
|
||||
<el-upload
|
||||
class="flex-shrink-0 w-28 h-28 rounded-full border-2 border-dashed border-gray-200 hover:border-primary flex items-center justify-center overflow-hidden cursor-pointer"
|
||||
:show-file-list="false"
|
||||
:http-request="handleAvatarUpload"
|
||||
accept="image/*"
|
||||
>
|
||||
<img v-if="userForm.avatar_url" :src="userForm.avatar_url" class="w-full h-full object-cover" />
|
||||
<div v-else class="text-gray-400 flex flex-col items-center">
|
||||
<Plus class="w-6 h-6 mb-1" />
|
||||
<span class="text-xs">上传头像</span>
|
||||
</div>
|
||||
</el-upload>
|
||||
|
||||
<div class="flex-1 space-y-4">
|
||||
<div class="grid grid-cols-2 gap-x-6">
|
||||
<el-form-item label="登录账号" required class="!mb-0">
|
||||
<el-input v-model="userForm.username" :disabled="isUserEditing" placeholder="输入唯一的登录账号" />
|
||||
</el-form-item>
|
||||
<el-form-item label="显示昵称" class="!mb-0">
|
||||
<el-input v-model="userForm.nickname" placeholder="输入用户对外显示的昵称" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-x-6">
|
||||
<el-form-item label="手机号码" class="!mb-0">
|
||||
<el-input v-model="userForm.phone" placeholder="输入 11 位手机号" />
|
||||
</el-form-item>
|
||||
<el-form-item label="电子邮箱" class="!mb-0">
|
||||
<el-input v-model="userForm.email" placeholder="输入常用电子邮箱" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-x-6">
|
||||
<el-form-item label="所在城市" class="!mb-0">
|
||||
<el-input v-model="userForm.location" placeholder="例如:上海市 浦东新区" />
|
||||
</el-form-item>
|
||||
<el-form-item label="初始密码" v-if="!isUserEditing" required class="!mb-0">
|
||||
<el-input v-model="userForm.password" type="password" show-password placeholder="设置高强度密码" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-form-item label="个人简介" class="mt-4 !mb-0">
|
||||
<el-input v-model="userForm.bio" type="textarea" :rows="2" placeholder="填写用户的简短介绍..." />
|
||||
</el-form-item>
|
||||
</el-card>
|
||||
|
||||
<!-- Permissions Card -->
|
||||
<el-card shadow="never" class="mb-6 border-gray-100 bg-gray-50/50">
|
||||
<template #header>
|
||||
<div class="font-medium text-gray-800 flex items-center">
|
||||
<ShieldCheck class="w-5 h-5 mr-2" /> 账户权限与状态
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-form-item label="系统角色">
|
||||
<el-checkbox-group v-model="userForm.roles" class="flex flex-wrap gap-3">
|
||||
<el-checkbox v-for="role in allRoles" :key="role.code" :label="role.code" :value="role.code" border class="!mr-0 bg-white">
|
||||
{{ role.name }}
|
||||
</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</el-form-item>
|
||||
|
||||
<div class="grid grid-cols-2 gap-x-6 mt-4">
|
||||
<el-form-item label="允许登录" class="!mb-0">
|
||||
<el-switch v-model="userForm.is_active" active-text="启用" inactive-text="禁用" />
|
||||
</el-form-item>
|
||||
<el-form-item label="账户状态" class="!mb-0">
|
||||
<el-select v-model="userForm.status" class="w-full">
|
||||
<el-option label="正常" value="ACTIVE" />
|
||||
<el-option label="未激活" value="INACTIVE" />
|
||||
<el-option label="封禁" value="BANNED" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- Expert Info Card -->
|
||||
<el-card shadow="never" class="border-blue-100" v-if="userForm.roles.includes('OPC_USER')">
|
||||
<template #header>
|
||||
<div class="font-medium text-blue-800 flex items-center">
|
||||
<ShieldCheck class="w-5 h-5 mr-2" /> 专家实名核验与履历
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex gap-8 mb-6">
|
||||
<!-- Face Photo -->
|
||||
<el-upload
|
||||
class="flex-shrink-0 w-32 h-32 rounded-xl border-2 border-dashed border-gray-200 hover:border-primary flex items-center justify-center overflow-hidden cursor-pointer bg-gray-50"
|
||||
:show-file-list="false"
|
||||
:http-request="handleFaceUpload"
|
||||
accept="image/*"
|
||||
>
|
||||
<img v-if="userForm.face_url" :src="userForm.face_url" class="w-full h-full object-cover" />
|
||||
<div v-else class="text-gray-400 flex flex-col items-center">
|
||||
<Camera class="w-8 h-8 mb-1 text-gray-300" />
|
||||
<span class="text-xs">人脸正面照</span>
|
||||
</div>
|
||||
</el-upload>
|
||||
|
||||
<div class="flex-1 space-y-4">
|
||||
<div class="grid grid-cols-2 gap-x-6">
|
||||
<el-form-item label="真实姓名" required class="!mb-0">
|
||||
<el-input v-model="userForm.opc_certification.real_name" placeholder="输入专家真实姓名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="身份证号" class="!mb-0">
|
||||
<el-input v-model="userForm.opc_certification.id_card" placeholder="输入 18 位身份证号" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-x-6">
|
||||
<el-form-item label="认证状态" class="!mb-0">
|
||||
<el-select v-model="userForm.opc_certification.status" class="w-full">
|
||||
<el-option label="待审核" value="PENDING" />
|
||||
<el-option label="已通过" value="APPROVED" />
|
||||
<el-option label="已驳回" value="REJECTED" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="专家评分" class="!mb-0">
|
||||
<el-input-number v-model="userForm.opc_certification.rating" :min="1" :max="5" :step="0.1" class="!w-full" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-form-item label="身份证照片" class="mb-6">
|
||||
<div class="w-full flex gap-4 overflow-x-auto pb-2">
|
||||
<div v-for="(file, index) in userForm.opc_certification.attachments" :key="index" class="relative flex-shrink-0 w-40 h-28 border border-gray-200 rounded-lg overflow-hidden group">
|
||||
<el-image :src="file.url || file" class="w-full h-full object-cover" :preview-src-list="[file.url || file]" />
|
||||
<div class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 flex items-center justify-center transition-opacity">
|
||||
<el-button type="danger" circle @click="removeIDCardPhoto(index)"><Trash2 class="w-4 h-4" /></el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-upload
|
||||
v-if="(userForm.opc_certification.attachments?.length || 0) < 2"
|
||||
class="flex-shrink-0 w-40 h-28 border-2 border-dashed border-gray-200 rounded-lg hover:border-primary flex items-center justify-center cursor-pointer bg-gray-50"
|
||||
:show-file-list="false"
|
||||
:http-request="handleIDCardUpload"
|
||||
accept="image/*"
|
||||
>
|
||||
<div class="text-gray-400 flex flex-col items-center">
|
||||
<Plus class="w-6 h-6 mb-1 text-gray-300" />
|
||||
<span class="text-xs">上传身份证(正/反面)</span>
|
||||
</div>
|
||||
</el-upload>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-divider border-style="dashed" />
|
||||
|
||||
<el-form-item label="专家技能" class="mt-4">
|
||||
<el-select v-model="userForm.opc_certification.skills" multiple filterable allow-create default-first-option placeholder="输入技能并按回车添加..." class="w-full">
|
||||
<el-option v-for="skill in userForm.opc_certification.skills" :key="skill" :label="skill" :value="skill" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="专家经验">
|
||||
<el-input v-model="userForm.opc_certification.experience" type="textarea" :rows="3" placeholder="简述专家的相关经验和优势..." />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="简历附件" class="!mb-0">
|
||||
<div class="w-full flex items-center gap-4">
|
||||
<el-upload
|
||||
class="flex-shrink-0"
|
||||
:show-file-list="false"
|
||||
:http-request="handleResumeUpload"
|
||||
accept=".pdf,.doc,.docx"
|
||||
>
|
||||
<el-button type="primary" plain size="small"><Upload class="w-3 h-3 mr-1" />上传/替换简历</el-button>
|
||||
</el-upload>
|
||||
<el-button v-if="userForm.opc_certification.resume_url" type="primary" size="small" plain @click="previewUrl = userForm.opc_certification.resume_url; previewVisible = true">
|
||||
<FileText class="w-3 h-3 mr-1"/>预览当前简历
|
||||
</el-button>
|
||||
<span v-else class="text-gray-400 text-sm">未上传简历</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-card>
|
||||
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="pt-2">
|
||||
<el-button @click="userDialogVisible = false" size="large">取消</el-button>
|
||||
<el-button type="primary" @click="saveUser" :loading="userSaveLoading" size="large">
|
||||
{{ isUserEditing ? '确认保存' : '创建用户' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<UserTasksDrawer v-model="tasksDrawerVisible" :user="selectedUserForTasks" @task-cancelled="fetchUsers" />
|
||||
|
||||
<!-- User Detail Drawer -->
|
||||
<el-drawer v-model="userDetailDrawerVisible" :title="`${selectedUserDetail?.nickname || selectedUserDetail?.username} 的详细资料`" size="400px">
|
||||
<div v-if="selectedUserDetail" class="space-y-6">
|
||||
<div class="text-center">
|
||||
<el-avatar :size="80" :src="selectedUserDetail.avatar_url" class="bg-gradient-to-br from-blue-50 to-indigo-100 text-blue-700 font-bold shadow-inner border border-blue-100 text-2xl">
|
||||
{{ selectedUserDetail.nickname?.[0] || selectedUserDetail.username?.[0] || 'U' }}
|
||||
</el-avatar>
|
||||
<h2 class="text-xl font-bold mt-3">{{ selectedUserDetail.nickname || selectedUserDetail.username }}</h2>
|
||||
<p class="text-gray-500 text-sm mt-1">@{{ selectedUserDetail.username }}</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 rounded-xl p-4 space-y-3">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-gray-500">联系电话</span>
|
||||
<span class="font-medium text-gray-900">{{ selectedUserDetail.phone || '未绑定' }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-gray-500">联系邮箱</span>
|
||||
<span class="font-medium text-gray-900">{{ selectedUserDetail.email || '未绑定' }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-gray-500">注册时间</span>
|
||||
<span class="font-medium text-gray-900">{{ formatDate(selectedUserDetail.created_at) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-sm" v-if="selectedUserDetail.opc_certification?.real_name">
|
||||
<span class="text-gray-500">真实姓名</span>
|
||||
<span class="font-medium text-gray-900">{{ selectedUserDetail.opc_certification.real_name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedUserDetail.bio" class="bg-gray-50 rounded-xl p-4 text-sm">
|
||||
<span class="text-gray-500 block mb-1">个人简介</span>
|
||||
<p class="text-gray-800 leading-relaxed">{{ selectedUserDetail.bio }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedUserDetail.roles?.includes('ENTERPRISE') && selectedUserDetail.enterprise_info" class="bg-orange-50/50 rounded-xl p-4 border border-orange-100">
|
||||
<h3 class="text-orange-800 font-bold mb-3 flex items-center"><Building class="w-4 h-4 mr-1.5" />企业信息</h3>
|
||||
<div class="text-sm space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-orange-600/70">所属企业</span>
|
||||
<span class="font-medium text-orange-900">{{ selectedUserDetail.enterprise_info.company_name }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-orange-600/70">企业角色</span>
|
||||
<span class="font-medium text-orange-900">{{ selectedUserDetail.enterprise_info.role === 'OWNER' ? '超级管理员' : '成员' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedUserDetail.roles?.includes('OPC_USER')" class="bg-green-50/50 rounded-xl p-4 border border-green-100">
|
||||
<h3 class="text-green-800 font-bold mb-3 flex items-center"><UserCheck class="w-4 h-4 mr-1.5" />专家信息</h3>
|
||||
<div class="text-sm space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-green-600/70">专家评分</span>
|
||||
<span class="font-medium text-green-900">{{ selectedUserDetail.rating || '0' }} / 5.0</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-green-600/70">已完成任务</span>
|
||||
<span class="font-medium text-green-900">{{ selectedUserDetail.completed_tasks || 0 }} 个</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-drawer>
|
||||
|
||||
<!-- Document Preview Dialog -->
|
||||
<el-dialog v-model="previewVisible" title="文档在线预览" width="80%" top="5vh" :append-to-body="true" align-center>
|
||||
<div class="h-[75vh] w-full bg-gray-50 rounded border border-gray-200 overflow-hidden relative flex flex-col items-center justify-center">
|
||||
<template v-if="previewUrl.split('?')[0].toLowerCase().endsWith('.pdf')">
|
||||
<iframe :src="previewUrl" class="w-full h-full border-none"></iframe>
|
||||
</template>
|
||||
<template v-else-if="['.jpg', '.jpeg', '.png', '.webp'].some(ext => previewUrl.split('?')[0].toLowerCase().endsWith(ext))">
|
||||
<el-image :src="previewUrl" class="w-full h-full" fit="contain" />
|
||||
</template>
|
||||
<template v-else-if="['.docx', '.doc'].some(ext => previewUrl.split('?')[0].toLowerCase().endsWith(ext))">
|
||||
<VueOfficeDocx :src="previewUrl" class="w-full h-full" style="height: 100%" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="text-center">
|
||||
<div class="text-4xl mb-4">📄</div>
|
||||
<div class="text-gray-500 mb-4">该文件格式不支持在线直接预览</div>
|
||||
<el-button type="primary" tag="a" :href="previewUrl" target="_blank" download>点击下载文件</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-400">如果预览为空白,可能是浏览器拦截或格式不支持</span>
|
||||
<div class="space-x-2">
|
||||
<el-button @click="previewVisible = false">关闭预览</el-button>
|
||||
<el-button type="primary" tag="a" :href="previewUrl" target="_blank" download>下载到本地</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</el-card>
|
||||
|
||||
<!-- Deleted Users (Recycle Bin) Card -->
|
||||
<el-card v-show="activeTab === 'deleted'" shadow="never" class="border-none bg-white rounded-2xl shadow-sm !p-2" v-loading="deletedLoading">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-2 text-gray-500">
|
||||
<Trash2 class="w-5 h-5" />
|
||||
<span class="text-sm">已删除的用户数据保留在此,支持恢复操作</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table :data="deletedUsers" stripe :header-cell-style="{ background:'#f8fafc', color:'#475569', fontWeight: '600' }">
|
||||
<el-table-column label="用户" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<div class="flex items-center gap-3">
|
||||
<el-avatar :size="36" :src="row.avatar_url" class="bg-gray-200 text-gray-600">
|
||||
{{ getOriginalName(row.username)?.[0]?.toUpperCase() }}
|
||||
</el-avatar>
|
||||
<div>
|
||||
<div class="font-medium text-gray-800">{{ getOriginalName(row.username) }}</div>
|
||||
<div class="text-xs text-gray-400">{{ row.nickname || '无昵称' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="原手机号" width="150">
|
||||
<template #default="{ row }">
|
||||
<span class="text-sm text-gray-600 font-mono">{{ row.phone ? getOriginalName(row.phone) : '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="原邮箱" width="200">
|
||||
<template #default="{ row }">
|
||||
<span class="text-sm text-gray-600">{{ row.email ? getOriginalName(row.email) : '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="删除时间" width="180">
|
||||
<template #default="{ row }">
|
||||
<span class="text-sm text-gray-500">{{ formatDate(row.updated_at) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button text type="primary" size="small" @click="restoreUser(row)">
|
||||
<RotateCcw class="w-3.5 h-3.5 mr-1" /> 恢复
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-empty v-if="!deletedLoading && deletedUsers.length === 0" description="回收站为空" :image-size="60" />
|
||||
</el-card>
|
||||
|
||||
<el-backtop :right="40" :bottom="40" />
|
||||
</div>
|
||||
</template>
|
||||
334
src/views/enterprise/DashboardView.vue
Normal file
334
src/views/enterprise/DashboardView.vue
Normal file
@@ -0,0 +1,334 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, computed } from 'vue';
|
||||
import {
|
||||
Users, ListTodo, ShieldCheck, ArrowRight, TrendingUp, Newspaper,
|
||||
Calendar, PlusCircle, Search, Building2, Mail, Briefcase, FileText, Clock,
|
||||
Star, MapPin, ChevronRight, Ban, CheckCircle, Eye
|
||||
} from 'lucide-vue-next';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import api from '@/api';
|
||||
import { getTasks } from '@/api/tasks';
|
||||
import { getAnnouncements } from '@/api/system';
|
||||
import { MdPreview } from 'md-editor-v3';
|
||||
import 'md-editor-v3/lib/preview.css';
|
||||
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
const user = computed(() => authStore.user);
|
||||
|
||||
const tasks = ref<any[]>([]);
|
||||
const members = ref<any[]>([]);
|
||||
const enterprise = ref<any>(null);
|
||||
const opcExperts = ref<any[]>([]);
|
||||
|
||||
// All 6 task status stats
|
||||
const statusCards = computed(() => [
|
||||
{ key: 'OPEN', label: '招募中', count: tasks.value.filter((t: any) => t.status === 'OPEN').length, color: '#3b82f6', bg: '#eff6ff' },
|
||||
{ key: 'IN_PROGRESS', label: '进行中', count: tasks.value.filter((t: any) => t.status === 'IN_PROGRESS').length, color: '#f59e0b', bg: '#fffbeb' },
|
||||
{ key: 'IN_REVIEW', label: '待验收', count: tasks.value.filter((t: any) => t.status === 'IN_REVIEW').length, color: '#8b5cf6', bg: '#f5f3ff' },
|
||||
{ key: 'COMPLETED', label: '已完成', count: tasks.value.filter((t: any) => t.status === 'COMPLETED').length, color: '#10b981', bg: '#ecfdf5' },
|
||||
{ key: 'CANCELLED', label: '已取消', count: tasks.value.filter((t: any) => t.status === 'CANCELLED').length, color: '#ef4444', bg: '#fef2f2' },
|
||||
{ key: 'DRAFT', label: '草稿', count: tasks.value.filter((t: any) => t.status === 'DRAFT').length, color: '#9ca3af', bg: '#f9fafb' },
|
||||
]);
|
||||
|
||||
const isLoading = ref(true);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
isLoading.value = true;
|
||||
const [tasksRes, membersRes, entRes, expertsRes]: any[] = await Promise.all([
|
||||
api.get('/tasks/'),
|
||||
api.get('/enterprise-members/'),
|
||||
api.get('/enterprises/me/'),
|
||||
api.get('/users/', { params: { role: 'OPC_USER' } }).catch(() => ({ results: [] })),
|
||||
]);
|
||||
tasks.value = tasksRes.results || [];
|
||||
members.value = membersRes.results || membersRes;
|
||||
enterprise.value = entRes as any;
|
||||
opcExperts.value = (expertsRes.results || []).slice(0, 6);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchData();
|
||||
});
|
||||
|
||||
const recentTasks = computed(() => tasks.value.slice(0, 4));
|
||||
|
||||
const getStatusType = (status: string) => {
|
||||
switch (status) {
|
||||
case 'OPEN': return 'primary';
|
||||
case 'IN_PROGRESS': return 'warning';
|
||||
case 'IN_REVIEW': return '';
|
||||
case 'COMPLETED': return 'success';
|
||||
case 'CANCELLED': return 'danger';
|
||||
case 'DRAFT': return 'info';
|
||||
default: return 'info';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
switch (status) {
|
||||
case 'OPEN': return '招募中';
|
||||
case 'IN_PROGRESS': return '进行中';
|
||||
case 'IN_REVIEW': return '待验收';
|
||||
case 'COMPLETED': return '已结案';
|
||||
case 'CANCELLED': return '已取消';
|
||||
case 'DRAFT': return '草稿';
|
||||
default: return status;
|
||||
}
|
||||
};
|
||||
|
||||
const goToStatus = (status: string) => {
|
||||
router.push(`/enterprise/tasks?status=${status}`);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dashboard-page" v-loading="isLoading">
|
||||
<!-- Task Status Overview — Full Width -->
|
||||
<div class="section-card mb-6">
|
||||
<div class="flex flex-col lg:flex-row gap-6">
|
||||
<!-- Left: Stats -->
|
||||
<div class="flex-1">
|
||||
<div class="section-header">
|
||||
<div class="section-badge" style="background: #eff6ff; color: #3b82f6;">
|
||||
<ListTodo class="w-4 h-4" />
|
||||
</div>
|
||||
<h2 class="section-title">任务概览</h2>
|
||||
<el-button text type="primary" size="small" @click="router.push('/enterprise/tasks')">全部任务 →</el-button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-6 gap-3">
|
||||
<div v-for="card in statusCards" :key="card.key"
|
||||
class="stat-card" :style="{ '--accent': card.color, '--bg': card.bg }"
|
||||
@click="goToStatus(card.key)"
|
||||
>
|
||||
<div class="flex items-center gap-1.5 mb-2">
|
||||
<div class="w-2 h-2 rounded-full" :style="{ background: card.color }"></div>
|
||||
<div class="text-xs font-medium text-gray-500">{{ card.label }}</div>
|
||||
</div>
|
||||
<div class="text-2xl font-black tracking-tight" :style="{ color: card.color }">{{ card.count }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Quick Actions -->
|
||||
<div class="w-full lg:w-64 lg:border-l lg:border-gray-100 lg:pl-6">
|
||||
<div class="section-header mb-3">
|
||||
<div class="section-badge" style="background: #fef3c7; color: #d97706; width: 28px; height: 28px;">
|
||||
<Briefcase class="w-3.5 h-3.5" />
|
||||
</div>
|
||||
<h2 class="section-title text-[14px]">快捷操作</h2>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="action-row !p-2" @click="enterprise?.status === 'VERIFIED' && router.push('/enterprise/tasks/create')">
|
||||
<div class="action-icon !w-8 !h-8" style="background: #eff6ff; color: #3b82f6;"><PlusCircle class="w-3.5 h-3.5" /></div>
|
||||
<div class="action-text">
|
||||
<span class="action-name text-[13px]">发布新任务</span>
|
||||
</div>
|
||||
<ChevronRight class="w-3.5 h-3.5 text-gray-300 ml-auto" />
|
||||
</div>
|
||||
<div class="action-row !p-2" @click="router.push('/enterprise/invitations')">
|
||||
<div class="action-icon !w-8 !h-8" style="background: #fce7f3; color: #db2777;"><Mail class="w-3.5 h-3.5" /></div>
|
||||
<div class="action-text">
|
||||
<span class="action-name text-[13px]">定向邀约记录</span>
|
||||
</div>
|
||||
<ChevronRight class="w-3.5 h-3.5 text-gray-300 ml-auto" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Row 2: Three Columns -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
|
||||
<!-- Column 1: Recent Tasks -->
|
||||
<div class="section-card flex flex-col">
|
||||
<div class="section-header">
|
||||
<div class="section-badge" style="background: #ede9fe; color: #7c3aed;">
|
||||
<Clock class="w-4 h-4" />
|
||||
</div>
|
||||
<h2 class="section-title">最近任务</h2>
|
||||
<el-button text type="primary" size="small" @click="router.push('/enterprise/tasks')">全部 →</el-button>
|
||||
</div>
|
||||
<div v-if="recentTasks.length > 0" class="flex flex-col gap-3">
|
||||
<div v-for="task in recentTasks" :key="task.id"
|
||||
class="group bg-white border border-gray-100 hover:border-blue-200 rounded-xl p-4 cursor-pointer transition-all hover:shadow-lg hover:-translate-y-0.5 hover:shadow-blue-50 flex flex-col justify-between"
|
||||
@click="router.push(`/enterprise/tasks/${task.id}`)">
|
||||
|
||||
<div>
|
||||
<div class="flex justify-between items-start mb-2 gap-2">
|
||||
<div class="text-[15px] font-bold text-gray-800 line-clamp-2 group-hover:text-blue-600 transition-colors">{{ task.title }}</div>
|
||||
<el-tag :type="getStatusType(task.status)" size="small" effect="light" round class="flex-shrink-0">{{ getStatusLabel(task.status) }}</el-tag>
|
||||
</div>
|
||||
<div class="text-[13px] text-gray-500 flex items-center gap-1.5 font-medium mb-1">
|
||||
<span class="text-blue-600 bg-blue-50 px-2 py-0.5 rounded-md">¥{{ task.budget_min?.toLocaleString() || 0 }} - {{ task.budget_max?.toLocaleString() || '不限' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex items-center justify-between text-xs text-gray-400 border-t border-gray-50 pt-3">
|
||||
<div class="flex items-center gap-1">
|
||||
<Clock class="w-3 h-3" /> {{ new Date(task.created_at).toLocaleDateString() }}
|
||||
</div>
|
||||
<div class="flex items-center gap-1" :class="task.task_applications?.length ? 'text-blue-500 font-bold' : ''">
|
||||
<Users class="w-3 h-3" /> {{ task.task_applications?.length || 0 }} 人申请
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else description="暂无任务" :image-size="60" class="flex-1 flex flex-col justify-center">
|
||||
<el-button type="primary" @click="router.push('/enterprise/tasks/create')">
|
||||
<PlusCircle class="w-4 h-4 mr-2" />发布首个任务
|
||||
</el-button>
|
||||
</el-empty>
|
||||
</div>
|
||||
|
||||
<!-- Column 2: Team Members -->
|
||||
<div class="section-card flex flex-col">
|
||||
<div class="section-header">
|
||||
<div class="section-badge" style="background: #fdf2f8; color: #db2777;">
|
||||
<Users class="w-4 h-4" />
|
||||
</div>
|
||||
<h2 class="section-title">团队成员</h2>
|
||||
<el-button text type="primary" size="small" @click="router.push('/enterprise/team')">管理 →</el-button>
|
||||
</div>
|
||||
<div v-if="members.length > 0" class="space-y-2">
|
||||
<div v-for="member in members.slice(0, 5)" :key="member.id"
|
||||
class="expert-row group" @click="router.push('/enterprise/team')">
|
||||
<el-avatar :size="38" :src="member.user_detail?.avatar_url" class="bg-pink-100 text-pink-600 font-bold text-[13px] flex-shrink-0 group-hover:ring-2 ring-pink-100 transition-all">
|
||||
{{ member.user_detail?.nickname?.[0] || member.user_detail?.username?.[0] || 'U' }}
|
||||
</el-avatar>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-bold text-gray-800 truncate">{{ member.user_detail?.nickname || member.user_detail?.username || '未知成员' }}</div>
|
||||
<div class="text-[11px] text-gray-400 truncate mt-0.5">
|
||||
{{ member.roles?.map((r: any) => r.name).join(', ') || '成员' }}
|
||||
</div>
|
||||
</div>
|
||||
<el-tag size="small" :type="member.is_active ? 'success' : 'info'" effect="light" round class="!border-none">{{ member.is_active ? '正常' : '禁用' }}</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else description="暂无成员" :image-size="40" class="flex-1 flex flex-col justify-center" />
|
||||
</div>
|
||||
|
||||
<!-- Column 3: Quick Actions & Recommended Talent -->
|
||||
<div class="flex flex-col gap-6">
|
||||
|
||||
<!-- OPC Talent Recommendations -->
|
||||
<div class="section-card flex-1">
|
||||
<div class="section-header">
|
||||
<div class="section-badge" style="background: #ecfdf5; color: #059669;">
|
||||
<Star class="w-4 h-4" />
|
||||
</div>
|
||||
<h2 class="section-title">推荐人才</h2>
|
||||
<el-button text type="primary" size="small" @click="router.push('/enterprise/opc-users')">更多 →</el-button>
|
||||
</div>
|
||||
<div v-if="opcExperts.length > 0" class="space-y-2">
|
||||
<div v-for="expert in opcExperts.slice(0, 4)" :key="expert.id"
|
||||
class="expert-row group" @click="router.push(`/enterprise/opc-users?open_drawer=${expert.id}`)">
|
||||
<el-avatar :size="38" :src="expert.avatar_url" class="bg-gradient-to-br from-emerald-400 to-cyan-500 text-white font-bold text-[13px] flex-shrink-0 group-hover:ring-2 ring-emerald-100 transition-all shadow-sm">
|
||||
{{ expert.nickname?.[0] || expert.username?.[0] || 'U' }}
|
||||
</el-avatar>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-bold text-gray-800 truncate group-hover:text-emerald-600 transition-colors">{{ expert.nickname || expert.username }}</div>
|
||||
<div class="text-[11px] text-gray-400 truncate mt-0.5">
|
||||
{{ expert.bio || '暂无简介' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 flex-shrink-0 bg-yellow-50 px-2 py-0.5 rounded-md">
|
||||
<Star class="w-3 h-3 text-yellow-500 fill-yellow-500" />
|
||||
<span class="text-[11px] font-bold text-yellow-600">{{ Number(expert.rating || 5).toFixed(1) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else description="暂无推荐" :image-size="40" class="flex-1 flex flex-col justify-center" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dashboard-page { max-width: 100%; }
|
||||
|
||||
.section-card {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
padding: 20px 24px;
|
||||
border: 1px solid #f0f0f0;
|
||||
}
|
||||
.section-header {
|
||||
display: flex; align-items: center; gap: 10px; margin-bottom: 16px;
|
||||
}
|
||||
.section-badge {
|
||||
width: 32px; height: 32px; border-radius: 10px;
|
||||
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
|
||||
}
|
||||
.section-title {
|
||||
font-size: 15px; font-weight: 700; color: #1f2937; flex: 1;
|
||||
}
|
||||
|
||||
/* Status stat cards */
|
||||
.stat-card {
|
||||
display: flex; flex-direction: column;
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
background: var(--bg);
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
text-align: left;
|
||||
}
|
||||
.stat-card:hover {
|
||||
border-color: var(--accent);
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 20px rgba(0,0,0,0.04);
|
||||
}
|
||||
|
||||
/* Action rows */
|
||||
.action-row {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 10px 12px; border-radius: 10px;
|
||||
cursor: pointer; transition: all 0.15s;
|
||||
}
|
||||
.action-row:hover { background: #f9fafb; }
|
||||
.action-icon {
|
||||
width: 36px; height: 36px; border-radius: 10px;
|
||||
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
|
||||
}
|
||||
.action-text { flex: 1; min-width: 0; }
|
||||
.action-name { font-size: 13px; font-weight: 600; color: #374151; display: block; }
|
||||
.action-desc { font-size: 11px; color: #9ca3af; }
|
||||
|
||||
/* Task rows */
|
||||
.task-row {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 10px 12px; border-radius: 10px;
|
||||
cursor: pointer; transition: background 0.15s;
|
||||
}
|
||||
.task-row:hover { background: #f9fafb; }
|
||||
|
||||
/* Expert rows */
|
||||
.expert-row {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 8px 10px; border-radius: 10px;
|
||||
cursor: pointer; transition: background 0.15s;
|
||||
}
|
||||
.expert-row:hover { background: #f0fdf4; }
|
||||
|
||||
/* Announcement nav */
|
||||
.ann-nav-btn {
|
||||
width: 24px; height: 24px; border-radius: 6px;
|
||||
border: 1px solid #e5e7eb; background: #fff; color: #6b7280;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 14px; cursor: pointer; transition: all 0.15s;
|
||||
}
|
||||
.ann-nav-btn:hover { background: #f3f4f6; border-color: #d1d5db; }
|
||||
</style>
|
||||
197
src/views/enterprise/InvitationsView.vue
Normal file
197
src/views/enterprise/InvitationsView.vue
Normal file
@@ -0,0 +1,197 @@
|
||||
<script setup lang="ts">
|
||||
import { ElMessage } from "element-plus";
|
||||
import { onMounted, ref, computed } from 'vue';
|
||||
import { useTasksStore } from '@/stores/tasks';
|
||||
import { Building2, Phone, Star } from 'lucide-vue-next';
|
||||
|
||||
const tasksStore = useTasksStore();
|
||||
const invitations = computed(() => tasksStore.invitations);
|
||||
|
||||
const activeTab = ref('ALL');
|
||||
const searchQuery = ref('');
|
||||
const showContactModal = ref(false);
|
||||
const showChatDialog = ref(false);
|
||||
const responseMessage = ref('');
|
||||
const selectedInv = ref<any>(null);
|
||||
|
||||
onMounted(async () => {
|
||||
await tasksStore.fetchInvitations();
|
||||
});
|
||||
|
||||
const openContact = (inv: any) => {
|
||||
selectedInv.value = inv;
|
||||
showContactModal.value = true;
|
||||
};
|
||||
|
||||
const openChat = (inv: any) => {
|
||||
selectedInv.value = inv;
|
||||
responseMessage.value = '';
|
||||
showChatDialog.value = true;
|
||||
};
|
||||
|
||||
const handleSendChat = async () => {
|
||||
if (!selectedInv.value || !responseMessage.value.trim()) return;
|
||||
try {
|
||||
await tasksStore.discussInvitation(selectedInv.value.id, responseMessage.value);
|
||||
responseMessage.value = '';
|
||||
selectedInv.value = invitations.value.find(i => i.id === selectedInv.value.id);
|
||||
} catch (error) {
|
||||
ElMessage.error('发送失败');
|
||||
}
|
||||
};
|
||||
|
||||
const cancelInvite = async (id: string) => {
|
||||
try {
|
||||
await tasksStore.deleteInvitation(id);
|
||||
ElMessage.success('已撤销邀约');
|
||||
} catch (error) {
|
||||
ElMessage.error('撤销失败');
|
||||
}
|
||||
};
|
||||
|
||||
const filteredInvites = computed(() => {
|
||||
let list = invitations.value;
|
||||
if (activeTab.value === 'PENDING') list = list.filter((i: any) => i.status === 'DISCUSSING' || i.status === 'PENDING');
|
||||
if (activeTab.value === 'ACCEPTED') list = list.filter((i: any) => i.status === 'ACCEPTED');
|
||||
if (activeTab.value === 'REJECTED') list = list.filter((i: any) => i.status === 'REJECTED');
|
||||
return list.filter((inv: any) =>
|
||||
inv.task_title?.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
|
||||
inv.expert_name?.toLowerCase().includes(searchQuery.value.toLowerCase())
|
||||
);
|
||||
});
|
||||
|
||||
const getStatusType = (status: string) => {
|
||||
switch (status) {
|
||||
case 'ACCEPTED': return 'success';
|
||||
case 'REJECTED': return 'info';
|
||||
case 'DISCUSSING': return 'primary';
|
||||
case 'PENDING': return 'warning';
|
||||
default: return 'info';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
switch (status) {
|
||||
case 'ACCEPTED': return '已接受';
|
||||
case 'REJECTED': return '已婉拒';
|
||||
case 'DISCUSSING': return '商讨中';
|
||||
case 'PENDING': return '待专家确认';
|
||||
default: return status;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-800">发出的邀请</h1>
|
||||
<p class="text-sm text-gray-400 mt-1">管理您发送给 OPC 专家的定向邀约</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs & Search -->
|
||||
<el-card shadow="never">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<el-tabs v-model="activeTab" class="flex-1 -mb-4">
|
||||
<el-tab-pane label="全部" name="ALL" />
|
||||
<el-tab-pane label="待确认" name="PENDING" />
|
||||
<el-tab-pane label="已接受" name="ACCEPTED" />
|
||||
<el-tab-pane label="已婉拒" name="REJECTED" />
|
||||
</el-tabs>
|
||||
<el-input v-model="searchQuery" placeholder="搜索任务或专家..." clearable style="width: 220px" />
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- Invitations List -->
|
||||
<div v-if="filteredInvites.length > 0" class="space-y-3">
|
||||
<el-card v-for="inv in filteredInvites" :key="inv.id" shadow="hover">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4 flex-1 min-w-0">
|
||||
<el-avatar :size="42" :src="inv.expert_avatar">{{ (inv.expert_name || 'U')[0] }}</el-avatar>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="font-semibold truncate">{{ inv.expert_name }}</span>
|
||||
<el-tag :type="getStatusType(inv.status)" size="small">{{ getStatusLabel(inv.status) }}</el-tag>
|
||||
</div>
|
||||
<div class="text-sm text-gray-500 truncate">任务:{{ inv.task_title }}</div>
|
||||
<div class="text-xs text-gray-400 mt-0.5">{{ new Date(inv.created_at).toLocaleDateString() }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 ml-4">
|
||||
<el-button size="small" type="primary" plain v-if="inv.status === 'DISCUSSING' || inv.status === 'PENDING'" @click="openChat(inv)">
|
||||
<span v-if="inv.messages && inv.messages.length > 0">洽谈记录 ({{inv.messages.length}})</span>
|
||||
<span v-else>发起商讨</span>
|
||||
</el-button>
|
||||
<el-button size="small" @click="openContact(inv)">联络</el-button>
|
||||
<el-popconfirm title="确定要撤销此邀约吗?" @confirm="cancelInvite(inv.id)" v-if="inv.status !== 'ACCEPTED'">
|
||||
<template #reference>
|
||||
<el-button size="small" type="danger" plain>撤销</el-button>
|
||||
</template>
|
||||
</el-popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
<el-empty v-else description="暂无邀约记录" />
|
||||
|
||||
<!-- Contact Dialog -->
|
||||
<el-dialog v-model="showContactModal" title="联系专家" width="380px" top="5vh">
|
||||
<div class="flex flex-col items-center gap-4" v-if="selectedInv">
|
||||
<el-avatar :size="64" :src="selectedInv.expert_avatar">{{ (selectedInv.expert_name || 'U')[0] }}</el-avatar>
|
||||
<div class="text-lg font-bold">{{ selectedInv.expert_name }}</div>
|
||||
<el-descriptions :column="1" border class="w-full">
|
||||
<el-descriptions-item label="联系电话">
|
||||
<div class="flex items-center gap-2">
|
||||
<Phone class="w-4 h-4 text-blue-500" />
|
||||
<span class="font-mono">{{ selectedInv.expert_phone || '未提供' }}</span>
|
||||
</div>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<p class="text-xs text-gray-400 text-center">请在工作时间内联系,并确认已查阅任务细节。</p>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="showContactModal = false" class="w-full">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- Chat Drawer -->
|
||||
<el-drawer v-model="showChatDialog" title="专家洽谈中心" size="400px" destroy-on-close>
|
||||
<div class="flex flex-col h-full -m-5">
|
||||
<div class="flex-1 overflow-y-auto p-5 space-y-4 bg-gray-50">
|
||||
<div class="text-center text-xs text-gray-400 my-2">{{ new Date(selectedInv?.created_at).toLocaleString() }} 发起邀约</div>
|
||||
|
||||
<!-- Initial Message -->
|
||||
<div class="flex gap-3 flex-row-reverse">
|
||||
<el-avatar :size="32" :src="selectedInv?.enterprise_logo">{{ selectedInv?.enterprise_name?.charAt(0) }}</el-avatar>
|
||||
<div class="bg-blue-500 text-white p-3 rounded-2xl rounded-tr-sm border border-transparent shadow-sm max-w-[80%]">
|
||||
<p class="text-sm">{{ selectedInv?.message || '邀请您参与我们的任务。' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Discussion Messages -->
|
||||
<template v-if="selectedInv?.messages && selectedInv.messages.length > 0">
|
||||
<div v-for="(msg, idx) in selectedInv.messages" :key="idx" class="flex gap-3" :class="{'flex-row-reverse': msg.sender_role === 'ENTERPRISE'}">
|
||||
<el-avatar :size="32" :src="msg.sender_avatar">{{ msg.sender_name?.charAt(0) }}</el-avatar>
|
||||
<div class="p-3 rounded-2xl shadow-sm max-w-[80%]"
|
||||
:class="msg.sender_role === 'ENTERPRISE' ? 'bg-blue-500 text-white rounded-tr-sm' : 'bg-white border border-gray-100 rounded-tl-sm text-gray-800'">
|
||||
<p class="text-sm">{{ msg.content }}</p>
|
||||
<div class="text-[10px] mt-1 opacity-70" :class="{'text-right': msg.sender_role === 'ENTERPRISE'}">
|
||||
{{ new Date(msg.created_at).toLocaleTimeString() }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="p-4 bg-white border-t border-gray-100 flex gap-2" v-if="selectedInv?.status === 'DISCUSSING' || selectedInv?.status === 'PENDING'">
|
||||
<el-input v-model="responseMessage" placeholder="输入回复内容..." @keyup.enter="handleSendChat" />
|
||||
<el-button type="primary" @click="handleSendChat">发送</el-button>
|
||||
</div>
|
||||
<div class="p-4 bg-gray-100 text-center text-gray-400 text-sm" v-else>
|
||||
该邀约已结案,无法继续留言
|
||||
</div>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
114
src/views/enterprise/LoginView.vue
Normal file
114
src/views/enterprise/LoginView.vue
Normal file
@@ -0,0 +1,114 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { Building2, Lock, ShieldCheck, ArrowLeft, User, Message } from 'lucide-vue-next';
|
||||
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
const isLoading = ref(false);
|
||||
const errorMsg = ref('');
|
||||
|
||||
const loginType = ref('ADMIN');
|
||||
|
||||
const loginForm = ref({
|
||||
enterpriseEmail: '',
|
||||
username: '',
|
||||
password: ''
|
||||
});
|
||||
|
||||
const handleLogin = async () => {
|
||||
try {
|
||||
isLoading.value = true;
|
||||
errorMsg.value = '';
|
||||
await authStore.enterpriseLogin({
|
||||
login_type: loginType.value,
|
||||
enterprise_email: loginForm.value.enterpriseEmail,
|
||||
username: loginType.value === 'EMPLOYEE' ? loginForm.value.username : '',
|
||||
password: loginForm.value.password
|
||||
});
|
||||
if (authStore.userRoles.includes('ENTERPRISE')) {
|
||||
router.push('/enterprise/dashboard');
|
||||
} else {
|
||||
router.push('/user/dashboard');
|
||||
}
|
||||
} catch (e: any) {
|
||||
errorMsg.value = e.response?.data?.detail || '';
|
||||
if (errorMsg.value.includes('No active account') || errorMsg.value.includes('no_active_account') || errorMsg.value.includes('禁用')) {
|
||||
errorMsg.value = '您的账号已被禁用,可能因为违规操作或安全风险。如有疑问请联系平台管理员。';
|
||||
} else if (!errorMsg.value) {
|
||||
errorMsg.value = '登录失败,请检查账号密码';
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-50 flex items-center justify-center px-4">
|
||||
<div class="max-w-md w-full">
|
||||
<el-button text @click="router.push('/')" class="mb-6">
|
||||
<ArrowLeft class="w-4 h-4 mr-1" />返回首页
|
||||
</el-button>
|
||||
|
||||
<el-card shadow="always" class="!rounded-2xl !p-2">
|
||||
<div class="text-center mb-6 pt-4">
|
||||
<div class="mx-auto h-16 w-16 bg-gray-900 rounded-2xl flex items-center justify-center mb-4">
|
||||
<Building2 class="w-8 h-8 text-blue-400" />
|
||||
</div>
|
||||
<h2 class="text-2xl font-bold text-gray-800">企业伙伴登录</h2>
|
||||
<p class="text-xs text-gray-400 mt-1 uppercase tracking-widest">Corporate Partner Access Portal</p>
|
||||
</div>
|
||||
|
||||
<el-alert type="info" :closable="false" show-icon class="mb-6">
|
||||
<template #title>
|
||||
<ShieldCheck class="w-3.5 h-3.5 mr-1 inline" />安全审计已开启 — 本页仅供企业用户访问
|
||||
</template>
|
||||
</el-alert>
|
||||
|
||||
<div class="flex justify-center mb-6">
|
||||
<el-radio-group v-model="loginType" size="large">
|
||||
<el-radio-button value="ADMIN">企业管理员登录</el-radio-button>
|
||||
<el-radio-button value="EMPLOYEE">企业员工登录</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
|
||||
<el-form @submit.prevent="handleLogin" label-position="top">
|
||||
<el-alert v-if="errorMsg" type="error" :title="errorMsg" :closable="false" class="mb-4" />
|
||||
|
||||
<el-form-item label="企业电子邮箱 (Company ID)">
|
||||
<el-input v-model="loginForm.enterpriseEmail" type="email" placeholder="admin@company.com" size="large" prefix-icon="Message" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="loginType === 'EMPLOYEE'" label="员工账号 (Username)">
|
||||
<el-input v-model="loginForm.username" type="text" placeholder="请输入您的员工账号" size="large" prefix-icon="User" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="安全访问密钥 (Password)">
|
||||
<el-input v-model="loginForm.password" type="password" placeholder="请输入您的密码" size="large" prefix-icon="Lock" show-password />
|
||||
</el-form-item>
|
||||
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<el-checkbox label="保持 7 天登录" />
|
||||
<el-link type="primary" :underline="false" class="text-xs">忘记密码?</el-link>
|
||||
</div>
|
||||
|
||||
<el-button type="primary" native-type="submit" :loading="isLoading" class="w-full" size="large">
|
||||
{{ isLoading ? '正在验证...' : '进入数智大屏' }}
|
||||
</el-button>
|
||||
|
||||
<el-divider>
|
||||
<span class="text-xs text-gray-400">您的企业还未入驻?</span>
|
||||
</el-divider>
|
||||
|
||||
<el-button class="w-full" @click="router.push('/enterprise/register')">申请企业认证</el-button>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<div class="mt-6 text-center">
|
||||
<el-button text type="info" @click="router.push('/login')">我是个人 / OPC 开发者</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
212
src/views/enterprise/ModelsView.vue
Normal file
212
src/views/enterprise/ModelsView.vue
Normal file
@@ -0,0 +1,212 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { Box, Star, Zap, Key, Check, Copy, Info, ArrowRight } from 'lucide-vue-next';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import api from '@/api';
|
||||
|
||||
interface Model {
|
||||
id: string;
|
||||
name: string;
|
||||
desc: string;
|
||||
price: number;
|
||||
rating: number;
|
||||
category: string;
|
||||
tag: string;
|
||||
}
|
||||
|
||||
const searchQuery = ref('');
|
||||
|
||||
const models = ref<any[]>([]);
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res: any = await api.get('/system/models/');
|
||||
models.value = res.results || res || [];
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch models', e);
|
||||
}
|
||||
});
|
||||
|
||||
const showModal = ref(false);
|
||||
const selectedModel = ref<Model | null>(null);
|
||||
const keyPurpose = ref('');
|
||||
const generatedKey = ref('');
|
||||
const isGenerating = ref(false);
|
||||
const showKeyResult = ref(false);
|
||||
const copied = ref(false);
|
||||
|
||||
const openApplyModal = (model: Model) => {
|
||||
selectedModel.value = model;
|
||||
keyPurpose.value = '';
|
||||
generatedKey.value = '';
|
||||
showKeyResult.value = false;
|
||||
showModal.value = true;
|
||||
};
|
||||
|
||||
const generateApiKey = async () => {
|
||||
if (!keyPurpose.value) return;
|
||||
isGenerating.value = true;
|
||||
try {
|
||||
const res: any = await api.post('/system/model-tokens/', {
|
||||
model_id: selectedModel.value?.id,
|
||||
model_name: selectedModel.value?.name,
|
||||
token_value: 'placeholder'
|
||||
});
|
||||
generatedKey.value = res.token_value;
|
||||
showKeyResult.value = true;
|
||||
} catch (e) {
|
||||
ElMessage.error('获取 API Key 失败');
|
||||
} finally {
|
||||
isGenerating.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const copyKey = () => {
|
||||
navigator.clipboard.writeText(generatedKey.value);
|
||||
copied.value = true;
|
||||
setTimeout(() => { copied.value = false; }, 2000);
|
||||
};
|
||||
|
||||
const getTagType = (tag: string) => {
|
||||
switch (tag) {
|
||||
case '热门': return 'danger';
|
||||
case '新发布': return 'success';
|
||||
case '旗舰款': return 'warning';
|
||||
default: return 'info';
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-5">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-800">模型市场</h1>
|
||||
<p class="text-sm text-gray-400 mt-1">{{ models.length }} 款商用模型在线</p>
|
||||
</div>
|
||||
<el-input v-model="searchQuery" placeholder="搜索模型..." prefix-icon="Search" clearable style="width: 260px" />
|
||||
</div>
|
||||
|
||||
<!-- Models Grid -->
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="8" v-for="model in models" :key="model.id" class="mb-4">
|
||||
<el-card shadow="hover" class="model-card h-full flex flex-col">
|
||||
<div class="flex-1 space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="w-10 h-10 bg-blue-600 rounded-xl flex items-center justify-center text-white">
|
||||
<Box class="w-5 h-5" />
|
||||
</div>
|
||||
<el-tag :type="getTagType(model.tag)" size="small" effect="dark" round>{{ model.tag }}</el-tag>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="text-base font-bold text-gray-800 mb-1">{{ model.name }}</h3>
|
||||
<p class="text-sm text-gray-500 line-clamp-2">{{ model.desc }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-1 text-yellow-500">
|
||||
<Star class="w-3.5 h-3.5 fill-current" />
|
||||
<span class="text-sm font-bold">{{ model.rating }}</span>
|
||||
</div>
|
||||
<el-tag size="small" type="info" effect="plain">{{ model.category }}</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-divider class="!my-3" />
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-xs text-gray-400">起步价</div>
|
||||
<div class="text-lg font-bold text-gray-800">¥{{ model.price.toLocaleString() }}<span class="text-xs text-gray-400 font-normal">/月</span></div>
|
||||
</div>
|
||||
<el-button type="primary" round plain class="w-full font-bold tracking-wide" @click="openApplyModal(model)">
|
||||
申请 API Key
|
||||
</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<!-- Custom Card -->
|
||||
<el-col :span="8" class="mb-4">
|
||||
<el-card shadow="hover" class="model-card h-full flex flex-col items-center justify-center !bg-gradient-to-br !from-blue-600 !to-indigo-700 !border-0 text-white cursor-pointer">
|
||||
<div class="w-14 h-14 bg-white/10 rounded-full flex items-center justify-center mb-4">
|
||||
<Zap class="w-7 h-7 text-blue-200" />
|
||||
</div>
|
||||
<h3 class="text-xl font-bold mb-1">定制专属模型</h3>
|
||||
<p class="text-xs text-blue-200 text-center mb-4">联系我们团队获取量身定做的 AI 解决方案</p>
|
||||
<ArrowRight class="w-6 h-6 text-white/50" />
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- API Key Dialog -->
|
||||
<el-dialog v-model="showModal" :title="showKeyResult ? '凭证生成成功' : '申请访问凭证'" width="520px" top="5vh">
|
||||
<!-- Apply Form -->
|
||||
<div v-if="!showKeyResult" class="space-y-6">
|
||||
<el-alert type="info" :closable="false" show-icon>
|
||||
<template #title>
|
||||
正在申请访问 <strong>{{ selectedModel?.name }}</strong>,
|
||||
企业凭证将关联至您的组织账户,产生的调用费用将计入企业月度账单。
|
||||
</template>
|
||||
</el-alert>
|
||||
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="凭证用途 / 项目名称">
|
||||
<el-input v-model="keyPurpose" placeholder="例如:平台业务系统测试" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- Key Result -->
|
||||
<div v-else class="space-y-6">
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="w-16 h-16 bg-green-50 text-green-500 rounded-full flex items-center justify-center mb-4">
|
||||
<Check class="w-8 h-8" />
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 text-center">请妥善保管该凭证。一旦丢失需重新申请并吊销旧凭证。</p>
|
||||
</div>
|
||||
|
||||
<div class="relative bg-gray-900 rounded-xl p-4">
|
||||
<code class="text-sm font-mono text-blue-400 break-all block pr-8">{{ generatedKey }}</code>
|
||||
<el-button
|
||||
:icon="copied ? Check : Copy"
|
||||
circle
|
||||
size="small"
|
||||
class="!absolute right-3 top-1/2 -translate-y-1/2"
|
||||
@click="copyKey"
|
||||
/>
|
||||
</div>
|
||||
<el-alert v-if="copied" type="success" title="凭证已成功复制到剪贴板" :closable="false" center />
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button v-if="!showKeyResult" @click="showModal = false">取消</el-button>
|
||||
<el-button
|
||||
v-if="!showKeyResult"
|
||||
type="primary"
|
||||
:disabled="!keyPurpose"
|
||||
:loading="isGenerating"
|
||||
@click="generateApiKey"
|
||||
>
|
||||
<Key class="w-4 h-4 mr-2" v-if="!isGenerating" />
|
||||
{{ isGenerating ? '正在生成加密凭证...' : '确认生成 API Key' }}
|
||||
</el-button>
|
||||
<el-button v-if="showKeyResult" type="primary" @click="showModal = false" class="w-full">完成并关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.model-card:hover {
|
||||
border-color: var(--el-color-primary-light-5);
|
||||
}
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
343
src/views/enterprise/OPCUsersView.vue
Normal file
343
src/views/enterprise/OPCUsersView.vue
Normal file
@@ -0,0 +1,343 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { Star, Search, User, MapPin, Target, ChevronRight, Mail, ShieldCheck, Phone, FileText, Download, X } from 'lucide-vue-next';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import api from '@/api';
|
||||
import VueOfficeDocx from '@vue-office/docx';
|
||||
import '@vue-office/docx/lib/index.css';
|
||||
|
||||
const experts = ref<any[]>([]);
|
||||
const tasks = ref<any[]>([]);
|
||||
const isLoading = ref(true);
|
||||
const searchQuery = ref('');
|
||||
|
||||
import { useRoute } from 'vue-router';
|
||||
const route = useRoute();
|
||||
|
||||
const fetchExperts = async () => {
|
||||
try {
|
||||
isLoading.value = true;
|
||||
const res: any = await api.get('/users/', { params: { role: 'OPC_USER' } });
|
||||
experts.value = res.results || [];
|
||||
|
||||
if (route.query.open_drawer) {
|
||||
const expert = experts.value.find((e: any) => e.id === route.query.open_drawer);
|
||||
if (expert) viewDetails(expert);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchTasks = async () => {
|
||||
try {
|
||||
const res: any = await api.get('/tasks/', { params: { status: 'OPEN' } });
|
||||
tasks.value = res.results || [];
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchExperts();
|
||||
fetchTasks();
|
||||
});
|
||||
|
||||
const filteredExperts = computed(() => {
|
||||
let list = experts.value;
|
||||
if (searchQuery.value) {
|
||||
const q = searchQuery.value.toLowerCase();
|
||||
list = list.filter((e: any) => {
|
||||
const name = e.opc_certification?.real_name || e.nickname || e.username || '';
|
||||
const skills = (e.opc_certification?.skills || []).join(' ');
|
||||
const email = e.email || '';
|
||||
return name.toLowerCase().includes(q) || skills.toLowerCase().includes(q) || email.toLowerCase().includes(q);
|
||||
});
|
||||
}
|
||||
// Sort: recommended first, then by recommend_priority descending
|
||||
return [...list].sort((a: any, b: any) => {
|
||||
if (a.is_recommended !== b.is_recommended) return b.is_recommended ? 1 : -1;
|
||||
return (b.recommend_priority || 0) - (a.recommend_priority || 0);
|
||||
});
|
||||
});
|
||||
|
||||
// Detail Drawer
|
||||
const showDetailDrawer = ref(false);
|
||||
const selectedExpert = ref<any>(null);
|
||||
const previewVisible = ref(false);
|
||||
const previewUrl = ref('');
|
||||
|
||||
const viewDetails = (expert: any) => {
|
||||
selectedExpert.value = expert;
|
||||
showDetailDrawer.value = true;
|
||||
};
|
||||
|
||||
// Invite Dialog
|
||||
const showInviteDialog = ref(false);
|
||||
const inviteExpert = ref<any>(null);
|
||||
const inviteForm = ref({ task_ids: [] as string[], message: '' });
|
||||
const isInviting = ref(false);
|
||||
|
||||
const openInvite = (expert: any) => {
|
||||
inviteExpert.value = expert;
|
||||
inviteForm.value = { task_ids: [], message: `您好,我是企业招募专员。我们有适合您的任务,诚邀您的参与!` };
|
||||
showInviteDialog.value = true;
|
||||
};
|
||||
|
||||
const handleInvite = async () => {
|
||||
if (!inviteForm.value.task_ids || inviteForm.value.task_ids.length === 0) {
|
||||
ElMessage.warning('请选择至少一个要邀请的任务');
|
||||
return;
|
||||
}
|
||||
isInviting.value = true;
|
||||
try {
|
||||
for (const taskId of inviteForm.value.task_ids) {
|
||||
await api.post('/invitations/', {
|
||||
expert: inviteExpert.value.id,
|
||||
task: taskId,
|
||||
message: inviteForm.value.message
|
||||
});
|
||||
}
|
||||
ElMessage.success(`已向 ${inviteExpert.value.nickname || inviteExpert.value.username} 发送任务邀请`);
|
||||
showInviteDialog.value = false;
|
||||
} catch (e: any) {
|
||||
ElMessage.error(`发送邀请失败: ${e.response?.data?.detail || '未知错误'}`);
|
||||
} finally {
|
||||
isInviting.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6" v-loading="isLoading">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-800">OPC 人才库</h1>
|
||||
<p class="text-sm text-gray-400 mt-1">发掘并邀约顶尖认证专家为您工作</p>
|
||||
</div>
|
||||
<el-input v-model="searchQuery" placeholder="搜索专家姓名、技能或邮箱..." class="!w-80" clearable size="large">
|
||||
<template #prefix><Search class="w-4 h-4 text-gray-400" /></template>
|
||||
</el-input>
|
||||
</div>
|
||||
|
||||
<!-- Grid Layout -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
<el-card v-for="user in filteredExperts" :key="user.id" shadow="hover"
|
||||
class="cursor-pointer group border-transparent hover:border-blue-200 hover:shadow-xl transition-all duration-300 rounded-xl flex flex-col"
|
||||
:body-style="{ padding: '0px', flex: 1, display: 'flex', flexDirection: 'column' }"
|
||||
@click="viewDetails(user)"
|
||||
>
|
||||
<div class="flex flex-col items-center p-6 pb-4 flex-1">
|
||||
<el-avatar :size="80" :src="user.avatar_url" class="bg-gradient-to-br from-blue-100 to-indigo-100 text-blue-700 text-2xl font-bold shadow-sm mb-4 border-2 border-white ring-2 ring-blue-50">
|
||||
{{ user.opc_certification?.real_name?.[0] || user.nickname?.[0] || user.username?.[0] || 'O' }}
|
||||
</el-avatar>
|
||||
<div class="text-lg font-bold text-gray-800 flex items-baseline gap-1">
|
||||
{{ user.opc_certification?.real_name || user.username }}
|
||||
<span v-if="user.nickname && user.nickname !== user.opc_certification?.real_name" class="text-sm font-normal text-gray-400 mx-1">({{ user.nickname }})</span>
|
||||
<ShieldCheck class="w-4 h-4 text-green-500" />
|
||||
<el-tag v-if="user.is_recommended" size="small" type="warning" effect="dark" round class="border-none shadow-sm ml-1">⭐ 推荐</el-tag>
|
||||
</div>
|
||||
<div class="text-xs text-gray-400 mt-1 mb-4">{{ user.email || user.phone || '未公开联系方式' }}</div>
|
||||
|
||||
<div class="flex flex-wrap justify-center gap-1.5 mt-3 h-[46px] overflow-hidden w-full">
|
||||
<el-tag v-for="skill in (user.opc_certification?.skills || []).slice(0, 3)" :key="skill" size="small" effect="plain" type="info" round class="!border-gray-200">
|
||||
{{ skill }}
|
||||
</el-tag>
|
||||
<el-tag v-if="(user.opc_certification?.skills || []).length > 3" size="small" type="info" round effect="plain" class="!border-gray-200">
|
||||
+{{ user.opc_certification.skills.length - 3 }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 w-full px-5 py-3 border-t border-gray-100 group-hover:bg-blue-50 transition-colors relative mt-auto">
|
||||
<div class="flex justify-between items-center w-full group-hover:opacity-0 transition-opacity duration-300">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-gray-400 text-[10px] mb-0.5 tracking-wider">专家评级</span>
|
||||
<el-rate :model-value="Number(user.rating) || 5.0" disabled show-score text-color="#ff9900" class="-ml-1 scale-90 origin-left" />
|
||||
</div>
|
||||
<div class="text-right flex flex-col items-end">
|
||||
<span class="text-gray-400 text-[10px] tracking-wider mb-0.5">累计完成</span>
|
||||
<div class="font-bold text-gray-700 text-sm">{{ user.completed_tasks || 0 }} <span class="font-normal text-xs text-gray-500">项</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Hover State -->
|
||||
<div class="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300 px-5">
|
||||
<el-button type="primary" class="w-full shadow-sm" @click.stop="openInvite(user)">发送项目邀约</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<div v-if="filteredExperts.length === 0" class="col-span-full py-20 text-center text-gray-400">
|
||||
<el-empty description="没有找到匹配的专家" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Expert Detail Drawer -->
|
||||
<el-drawer v-model="showDetailDrawer" size="800px" :with-header="false" destroy-on-close direction="rtl">
|
||||
<!-- Main Scrollable Container -->
|
||||
<div class="h-full overflow-y-auto bg-gray-50 relative">
|
||||
|
||||
<!-- Floating Actions -->
|
||||
<div class="absolute top-4 right-4 z-50 flex items-center gap-3">
|
||||
<el-button type="primary" round plain class="shadow-sm bg-white/90 backdrop-blur" @click="openInvite(selectedExpert)">
|
||||
<Mail class="w-4 h-4 mr-2" /> 邀约合作
|
||||
</el-button>
|
||||
<div class="cursor-pointer text-white hover:text-blue-100 transition-colors bg-black/20 p-2 rounded-full hover:bg-black/30" @click="showDetailDrawer = false">
|
||||
<X class="w-5 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedExpert">
|
||||
<div class="bg-gray-50 pb-8">
|
||||
<!-- Header Profile Banner -->
|
||||
<div class="bg-gradient-to-r from-blue-600 to-indigo-800 px-8 pt-12 pb-10 text-white shadow-md">
|
||||
<div class="flex gap-6 items-end">
|
||||
<el-avatar :size="100" :src="selectedExpert.avatar_url" class="border-4 border-white shadow-lg bg-white text-blue-600 text-4xl font-bold">
|
||||
{{ selectedExpert.opc_certification?.real_name?.[0] || selectedExpert.nickname?.[0] || selectedExpert.username?.[0] || 'O' }}
|
||||
</el-avatar>
|
||||
<div class="flex-1 pb-1">
|
||||
<h2 class="text-3xl font-bold flex items-center gap-2 tracking-tight">
|
||||
{{ selectedExpert.opc_certification?.real_name || selectedExpert.username }}
|
||||
<span v-if="selectedExpert.nickname && selectedExpert.nickname !== selectedExpert.opc_certification?.real_name" class="text-xl font-normal text-blue-200 ml-1">({{ selectedExpert.nickname }})</span>
|
||||
<ShieldCheck class="w-6 h-6 text-green-400" />
|
||||
</h2>
|
||||
<div class="flex items-center gap-6 mt-3 text-blue-100 text-sm font-medium">
|
||||
<span class="flex items-center gap-1.5"><Mail class="w-4 h-4" /> {{ selectedExpert.email || '未绑定邮箱' }}</span>
|
||||
<span class="flex items-center gap-1.5"><Phone class="w-4 h-4" /> {{ selectedExpert.phone || '未公开手机' }}</span>
|
||||
<el-rate :model-value="Number(selectedExpert.rating) || 5.0" disabled text-color="#ff9900" class="ml-2" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-8 mt-8 space-y-8">
|
||||
<!-- Basic Info -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
|
||||
<h3 class="text-base font-bold text-gray-800 border-b border-gray-100 px-6 py-4 bg-gray-50/50">基础档案</h3>
|
||||
<div class="p-6">
|
||||
<el-descriptions :column="2" class="custom-descriptions">
|
||||
<el-descriptions-item label="实名核验"><span class="text-green-600 font-bold flex items-center gap-1"><ShieldCheck class="w-4 h-4" /> 已通过官方认证</span></el-descriptions-item>
|
||||
<el-descriptions-item label="入驻时间"><span class="text-gray-900">{{ new Date(selectedExpert.created_at).toLocaleDateString() }}</span></el-descriptions-item>
|
||||
<el-descriptions-item label="所在地"><span class="text-gray-900">{{ selectedExpert.location || '未公开' }}</span></el-descriptions-item>
|
||||
<el-descriptions-item label="任务履历"><span class="text-gray-900">已完成 <span class="text-blue-600 font-bold px-1 text-base">{{ selectedExpert.completed_tasks || 0 }}</span> 项任务</span></el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Skills & Experience -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
|
||||
<h3 class="text-base font-bold text-gray-800 border-b border-gray-100 px-6 py-4 bg-gray-50/50">业务能力</h3>
|
||||
<div class="p-6">
|
||||
<h4 class="text-sm text-gray-500 mb-3">认证技能领域</h4>
|
||||
<div class="flex flex-wrap gap-2 mb-8">
|
||||
<el-tag v-for="skill in selectedExpert.opc_certification?.skills || []" :key="skill" effect="light" size="large" type="primary" round class="px-4 py-1 h-auto font-medium">
|
||||
{{ skill }}
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<h4 class="text-sm text-gray-500 mb-3">项目经验简述</h4>
|
||||
<div class="bg-gray-50 p-5 rounded-lg text-sm text-gray-700 whitespace-pre-wrap leading-relaxed border border-gray-200 shadow-inner">
|
||||
{{ selectedExpert.opc_certification?.experience || selectedExpert.bio || '该专家暂未填写详细的项目经验描述。' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Resume Section -->
|
||||
<div class="px-8 pb-8 bg-gray-50" v-if="selectedExpert.opc_certification?.resume_url">
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden mb-10">
|
||||
<div class="flex items-center justify-between border-b border-gray-100 px-6 py-4 bg-gray-50/50">
|
||||
<h3 class="text-base font-bold text-gray-800 flex items-center gap-2"><FileText class="w-5 h-5 text-gray-500" /> 简历附件</h3>
|
||||
<div class="flex items-center gap-3">
|
||||
<el-button type="primary" plain size="small" @click="previewUrl = selectedExpert.opc_certification.resume_url; previewVisible = true" round>
|
||||
全屏放大预览
|
||||
</el-button>
|
||||
<el-button type="success" size="small" tag="a" :href="selectedExpert.opc_certification.resume_url" target="_blank" download round>
|
||||
<Download class="w-3 h-3 mr-1" /> 下载附件
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick embedded preview for PDF/Image or fallback -->
|
||||
<div class="h-[600px] bg-gray-100 p-2 relative">
|
||||
<template v-if="selectedExpert.opc_certification.resume_url.split('?')[0].toLowerCase().endsWith('.pdf')">
|
||||
<iframe :src="selectedExpert.opc_certification.resume_url" class="w-full h-full border border-gray-300 rounded shadow-sm bg-white"></iframe>
|
||||
</template>
|
||||
<template v-else-if="['.jpg', '.jpeg', '.png', '.webp'].some(ext => selectedExpert.opc_certification.resume_url.split('?')[0].toLowerCase().endsWith(ext))">
|
||||
<el-image :src="selectedExpert.opc_certification.resume_url" class="w-full h-full rounded shadow-sm bg-white" fit="contain" />
|
||||
</template>
|
||||
<template v-else-if="['.docx', '.doc'].some(ext => selectedExpert.opc_certification.resume_url.split('?')[0].toLowerCase().endsWith(ext))">
|
||||
<div class="w-full h-full border border-gray-300 rounded shadow-sm bg-white overflow-hidden">
|
||||
<VueOfficeDocx :src="selectedExpert.opc_certification.resume_url" class="w-full h-full" style="height: 100%" />
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="h-full flex flex-col items-center justify-center bg-white rounded shadow-sm border border-gray-200">
|
||||
<div class="text-5xl mb-4 opacity-50">📄</div>
|
||||
<div class="text-gray-500 mb-6 font-medium">该文件格式不支持内嵌预览</div>
|
||||
<el-button type="primary" tag="a" :href="selectedExpert.opc_certification.resume_url" target="_blank" download round size="large" class="px-8 shadow-md"><Download class="w-4 h-4 mr-2" /> 点击下载原文件</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-drawer>
|
||||
|
||||
<!-- Invite to Task Dialog -->
|
||||
<el-dialog v-model="showInviteDialog" :title="`邀约 ${inviteExpert?.nickname || inviteExpert?.username}`" width="500px">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="选择任务 (可多选)" required>
|
||||
<el-select v-model="inviteForm.task_ids" multiple placeholder="请选择要邀请专家参与的招募中任务" class="w-full">
|
||||
<el-option
|
||||
v-for="task in tasks"
|
||||
:key="task.id"
|
||||
:label="task.title"
|
||||
:value="task.id"
|
||||
/>
|
||||
</el-select>
|
||||
<div v-if="tasks.length === 0" class="text-xs text-orange-500 mt-1">您当前没有「招募中」的任务可以发送邀请,请先前往工作台发布任务。</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="邀约附言">
|
||||
<el-input v-model="inviteForm.message" type="textarea" :rows="3" placeholder="写点什么吸引专家..." />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="showInviteDialog = false">取消</el-button>
|
||||
<el-button type="primary" :loading="isInviting" @click="handleInvite" :disabled="tasks.length === 0">
|
||||
发送邀请
|
||||
</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- Fullscreen Document Preview Dialog -->
|
||||
<el-dialog v-model="previewVisible" title="简历在线全屏预览" width="850px" top="3vh" :destroy-on-close="true" center>
|
||||
<div v-if="['.jpg', '.jpeg', '.png', '.webp'].some(ext => previewUrl.split('?')[0].toLowerCase().endsWith(ext))" class="flex justify-center">
|
||||
<el-image :src="previewUrl" class="max-h-[85vh]" fit="contain" />
|
||||
</div>
|
||||
<div v-else-if="previewUrl.split('?')[0].toLowerCase().endsWith('.pdf')" class="flex justify-center">
|
||||
<iframe :src="previewUrl" width="100%" style="height: 80vh" border="0"></iframe>
|
||||
</div>
|
||||
<div v-else-if="['.docx', '.doc'].some(ext => previewUrl.split('?')[0].toLowerCase().endsWith(ext))" class="flex justify-center h-[80vh] overflow-y-auto">
|
||||
<vue-office-docx :src="previewUrl" />
|
||||
</div>
|
||||
<div v-else class="text-center py-10">
|
||||
<el-result icon="warning" title="无法预览该类型文件" sub-title="该格式不支持在线预览,请下载后查看。" />
|
||||
<el-button type="primary" tag="a" :href="previewUrl" target="_blank" download>下载文件</el-button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.custom-descriptions :deep(.el-descriptions__label) {
|
||||
width: 120px;
|
||||
color: #6b7280;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
221
src/views/enterprise/ProfileView.vue
Normal file
221
src/views/enterprise/ProfileView.vue
Normal file
@@ -0,0 +1,221 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { Building2, ShieldCheck, MapPin, Globe, Phone, Mail, Upload, X } from 'lucide-vue-next';
|
||||
import api from '@/api';
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
const isLoading = ref(true);
|
||||
const showLicenseModal = ref(false);
|
||||
const enterprise = ref({
|
||||
id: '',
|
||||
company_name: '',
|
||||
business_license: '',
|
||||
logo_url: '',
|
||||
contact_name: '',
|
||||
contact_phone: '',
|
||||
contact_email: '',
|
||||
address: '',
|
||||
description: '',
|
||||
status: 'PENDING',
|
||||
website: '',
|
||||
created_at: '',
|
||||
my_role: 'MEMBER'
|
||||
});
|
||||
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
const isUploading = ref(false);
|
||||
|
||||
const fetchEnterprise = async () => {
|
||||
try {
|
||||
const res = await api.get('/enterprises/me/');
|
||||
enterprise.value = res as any;
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch enterprise profile');
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(fetchEnterprise);
|
||||
|
||||
const verificationTag = computed(() => {
|
||||
if (enterprise.value.status === 'VERIFIED') return { text: '已认证', type: 'success' as const };
|
||||
if (enterprise.value.status === 'REJECTED') return { text: '已驳回', type: 'danger' as const };
|
||||
return { text: '待核验', type: 'warning' as const };
|
||||
});
|
||||
|
||||
const triggerUpload = () => { fileInput.value?.click(); };
|
||||
|
||||
const handleLogoUpload = async (event: Event) => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const file = target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const formDataObj = new FormData();
|
||||
formDataObj.append('file', file);
|
||||
formDataObj.append('folder', 'logos');
|
||||
|
||||
isUploading.value = true;
|
||||
try {
|
||||
const res: any = await api.post('/upload/', formDataObj, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
});
|
||||
enterprise.value.logo_url = res.url;
|
||||
await api.put(`/enterprises/${enterprise.value.id}/`, enterprise.value);
|
||||
ElMessage.success('Logo 更新成功');
|
||||
} catch (e) {
|
||||
ElMessage.error('Logo 更新失败');
|
||||
} finally {
|
||||
isUploading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const showEditDialog = ref(false);
|
||||
const editForm = ref({ ...enterprise.value });
|
||||
const isSaving = ref(false);
|
||||
|
||||
const openEditDialog = () => {
|
||||
editForm.value = { ...enterprise.value };
|
||||
showEditDialog.value = true;
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
isSaving.value = true;
|
||||
try {
|
||||
await api.put(`/enterprises/${enterprise.value.id}/`, editForm.value);
|
||||
ElMessage.success('企业信息已更新');
|
||||
Object.assign(enterprise.value, editForm.value);
|
||||
showEditDialog.value = false;
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e.response?.data?.detail || '保存失败');
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-5" v-loading="isLoading">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-800">企业信息</h1>
|
||||
<p class="text-sm text-gray-400 mt-1">查看和管理企业资料</p>
|
||||
</div>
|
||||
<el-tag :type="verificationTag.type" effect="dark" size="large" round>
|
||||
<ShieldCheck class="w-4 h-4 mr-1 inline" />
|
||||
{{ verificationTag.text }}
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<!-- Main Info Card -->
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<Building2 class="w-5 h-5 text-blue-500" />
|
||||
<span class="font-semibold">基本信息</span>
|
||||
</div>
|
||||
<el-button v-if="enterprise.my_role === 'ADMIN'" type="primary" link @click="openEditDialog">
|
||||
编辑信息
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="企业 Logo" :span="2">
|
||||
<div class="flex items-center gap-4">
|
||||
<input type="file" ref="fileInput" class="hidden" accept="image/png, image/jpeg" @change="handleLogoUpload" />
|
||||
<el-image
|
||||
v-if="enterprise.logo_url"
|
||||
:src="enterprise.logo_url"
|
||||
fit="contain"
|
||||
class="w-20 h-20 rounded-xl border border-gray-100 bg-gray-50"
|
||||
/>
|
||||
<div v-else class="w-20 h-20 rounded-xl border border-dashed border-gray-300 flex items-center justify-center bg-gray-50 text-gray-400">
|
||||
<span class="text-xs">无Logo</span>
|
||||
</div>
|
||||
<template v-if="enterprise.my_role === 'ADMIN'">
|
||||
<el-button type="primary" size="small" plain @click="triggerUpload" :loading="isUploading">
|
||||
更换 Logo
|
||||
</el-button>
|
||||
<span class="text-xs text-gray-400">支持 PNG, JPG,建议 200x200</span>
|
||||
</template>
|
||||
</div>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="企业名称">{{ enterprise.company_name || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="认证状态">
|
||||
<el-tag :type="verificationTag.type" size="small">{{ verificationTag.text }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="联系人">{{ enterprise.contact_name || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="联系电话">{{ enterprise.contact_phone || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="座机号码">{{ enterprise.landline || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="联系邮箱">{{ enterprise.contact_email || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="官方网站">
|
||||
<el-link v-if="enterprise.website" :href="enterprise.website" target="_blank" type="primary">{{ enterprise.website }}</el-link>
|
||||
<span v-else>—</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="企业地址" :span="2">{{ enterprise.address || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="企业简介" :span="2">
|
||||
<div class="whitespace-pre-wrap break-words">{{ enterprise.description || '—' }}</div>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="注册时间">{{ enterprise.created_at ? new Date(enterprise.created_at).toLocaleDateString() : '—' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<!-- Business License -->
|
||||
<el-card shadow="never" v-if="enterprise.business_license">
|
||||
<template #header>
|
||||
<span class="font-semibold">营业执照</span>
|
||||
</template>
|
||||
<div class="flex items-center gap-4">
|
||||
<el-image
|
||||
:src="enterprise.business_license"
|
||||
:preview-src-list="[enterprise.business_license]"
|
||||
fit="contain"
|
||||
class="w-48 h-32 rounded-lg border"
|
||||
/>
|
||||
<div class="text-sm text-gray-400">点击图片可预览大图</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- Edit Dialog -->
|
||||
<el-dialog v-model="showEditDialog" title="编辑企业信息" width="600px">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="企业名称">
|
||||
<el-input v-model="editForm.company_name" placeholder="请输入企业名称" />
|
||||
</el-form-item>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<el-form-item label="联系人">
|
||||
<el-input v-model="editForm.contact_name" placeholder="请输入主联系人姓名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="手机号(可选)">
|
||||
<el-input v-model="editForm.contact_phone" placeholder="手机号,选填" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<el-form-item label="座机号码(可选)">
|
||||
<el-input v-model="editForm.landline" placeholder="如 0592-5555555" />
|
||||
</el-form-item>
|
||||
<el-form-item label="联系邮箱">
|
||||
<el-input v-model="editForm.contact_email" placeholder="请输入联系邮箱" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<el-form-item label="企业地址">
|
||||
<el-input v-model="editForm.address" placeholder="请输入企业详细地址" />
|
||||
</el-form-item>
|
||||
<el-form-item label="官方网站">
|
||||
<el-input v-model="editForm.website" placeholder="https://..." />
|
||||
</el-form-item>
|
||||
<el-form-item label="企业简介">
|
||||
<el-input v-model="editForm.description" type="textarea" :rows="4" placeholder="请简要介绍贵公司的业务与愿景..." />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<el-button @click="showEditDialog = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSave" :loading="isSaving">保存修改</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
277
src/views/enterprise/RegisterView.vue
Normal file
277
src/views/enterprise/RegisterView.vue
Normal file
@@ -0,0 +1,277 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import api from '@/api';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { Building2, Upload, ShieldCheck, ArrowLeft, X } from 'lucide-vue-next';
|
||||
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
|
||||
const currentStep = ref(0);
|
||||
const isSubmitted = ref(false);
|
||||
const isLoading = ref(false);
|
||||
const errorMsg = ref('');
|
||||
|
||||
const formData = ref({
|
||||
email: '',
|
||||
password: '',
|
||||
companyName: '',
|
||||
creditCode: '',
|
||||
licenseImage: null as string | null,
|
||||
logoUrl: null as string | null
|
||||
});
|
||||
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
const logoInput = ref<HTMLInputElement | null>(null);
|
||||
const isUploading = ref(false);
|
||||
const isLogoUploading = ref(false);
|
||||
|
||||
const triggerUpload = () => { fileInput.value?.click(); };
|
||||
const triggerLogoUpload = () => { logoInput.value?.click(); };
|
||||
|
||||
import { uploadFileToMinIO } from '@/api/index';
|
||||
|
||||
const handleFileUpload = async (event: Event, type: 'license' | 'logo' = 'license') => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const file = target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
if (type === 'logo') {
|
||||
isLogoUploading.value = true;
|
||||
} else {
|
||||
isUploading.value = true;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = await uploadFileToMinIO(file, type === 'logo' ? 'logos' : 'registrations');
|
||||
if (type === 'logo') {
|
||||
formData.value.logoUrl = url;
|
||||
ElMessage.success('Logo上传成功');
|
||||
} else {
|
||||
formData.value.licenseImage = url;
|
||||
ElMessage.success('营业执照上传成功');
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('文件上传失败,请检查网络或 MinIO 状态');
|
||||
console.error(e);
|
||||
} finally {
|
||||
if (type === 'logo') {
|
||||
isLogoUploading.value = false;
|
||||
} else {
|
||||
isUploading.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const nextStep = () => {
|
||||
if (currentStep.value === 0) {
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.value.email)) {
|
||||
ElMessage.error('请输入有效的管理邮箱地址');
|
||||
return;
|
||||
}
|
||||
if (formData.value.password.length < 6) {
|
||||
ElMessage.error('密码长度至少为 6 位');
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (currentStep.value === 1) {
|
||||
if (!formData.value.companyName || formData.value.companyName.length < 2) {
|
||||
ElMessage.error('请输入完整的企业全称');
|
||||
return;
|
||||
}
|
||||
if (!/^[A-Z0-9]{18}$/.test(formData.value.creditCode)) {
|
||||
ElMessage.error('请输入有效的18位统一社会信用代码');
|
||||
return;
|
||||
}
|
||||
if (!formData.value.logoUrl) {
|
||||
ElMessage.error('请上传企业Logo');
|
||||
return;
|
||||
}
|
||||
if (!formData.value.licenseImage) {
|
||||
ElMessage.error('请上传营业执照扫描件');
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (currentStep.value < 2) currentStep.value++;
|
||||
};
|
||||
|
||||
const prevStep = () => {
|
||||
if (currentStep.value > 0) currentStep.value--;
|
||||
};
|
||||
|
||||
const submitFinal = async () => {
|
||||
try {
|
||||
isLoading.value = true;
|
||||
errorMsg.value = '';
|
||||
|
||||
await api.post('/auth/enterprise/register/', {
|
||||
email: formData.value.email,
|
||||
password: formData.value.password,
|
||||
company_name: formData.value.companyName,
|
||||
credit_code: formData.value.creditCode,
|
||||
business_license: formData.value.licenseImage,
|
||||
logo_url: formData.value.logoUrl
|
||||
});
|
||||
|
||||
await authStore.login({
|
||||
username: formData.value.email,
|
||||
password: formData.value.password
|
||||
});
|
||||
await authStore.fetchUser();
|
||||
|
||||
isSubmitted.value = true;
|
||||
} catch (e: any) {
|
||||
if (e.response?.data) {
|
||||
const data = e.response.data;
|
||||
if (typeof data === 'object') {
|
||||
const errors = Object.entries(data).map(([key, val]) => {
|
||||
const fieldName: any = {
|
||||
username: '用户名', email: '邮箱', password: '密码',
|
||||
company_name: '企业名称', credit_code: '信用代码', business_license: '营业执照',
|
||||
logo_url: '企业Logo'
|
||||
}[key] || key;
|
||||
return `${fieldName}: ${Array.isArray(val) ? val[0] : val}`;
|
||||
});
|
||||
errorMsg.value = errors.join('; ');
|
||||
} else {
|
||||
errorMsg.value = data.detail || '提交失败';
|
||||
}
|
||||
} else {
|
||||
errorMsg.value = '网络错误,请稍后重试';
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-50 flex flex-col items-center justify-center px-4 py-16">
|
||||
<div class="max-w-2xl w-full">
|
||||
<!-- Header -->
|
||||
<div v-if="!isSubmitted" class="flex items-center justify-between mb-8">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-12 h-12 bg-gray-900 rounded-xl flex items-center justify-center">
|
||||
<Building2 class="w-6 h-6 text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-800">企业入驻申请</h1>
|
||||
<p class="text-xs text-gray-400 uppercase tracking-widest">Enterprise Onboarding</p>
|
||||
</div>
|
||||
</div>
|
||||
<el-button text @click="router.push('/enterprise/login')">已有账户登录 →</el-button>
|
||||
</div>
|
||||
|
||||
<!-- Stepper Card -->
|
||||
<el-card v-if="!isSubmitted" shadow="always" class="!rounded-2xl !p-2">
|
||||
<el-steps :active="currentStep" finish-status="success" align-center class="mb-8 pt-4">
|
||||
<el-step title="账号建立" description="设置企业登录凭证" />
|
||||
<el-step title="身份核验" description="上传营业执照资质" />
|
||||
<el-step title="提交入驻" description="等待平台人工审核" />
|
||||
</el-steps>
|
||||
|
||||
<!-- Step 1: Account -->
|
||||
<div v-if="currentStep === 0">
|
||||
<el-form label-position="top" class="max-w-md mx-auto">
|
||||
<el-form-item label="管理者邮箱" required>
|
||||
<el-input v-model="formData.email" type="email" placeholder="admin@enterprise.com" size="large" />
|
||||
</el-form-item>
|
||||
<el-form-item label="设置访问密码" required>
|
||||
<el-input v-model="formData.password" type="password" placeholder="6位以上复杂密码" size="large" show-password />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-alert type="info" :closable="false" show-icon class="mb-6 mx-auto max-w-md">
|
||||
<template #title>企业账号将拥有发布任务、招募专家及建立自有数智团队的权限。</template>
|
||||
</el-alert>
|
||||
<div class="flex justify-end px-4 pb-4">
|
||||
<el-button type="primary" size="large" @click="nextStep">继续完善资质</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Verification -->
|
||||
<div v-if="currentStep === 1">
|
||||
<el-form label-position="top" class="max-w-md mx-auto">
|
||||
<el-form-item label="企业全称" required>
|
||||
<el-input v-model="formData.companyName" placeholder="需与公章描述一致" size="large" />
|
||||
</el-form-item>
|
||||
<el-form-item label="企业Logo" required>
|
||||
<div class="flex items-center gap-4 w-full">
|
||||
<input type="file" ref="logoInput" class="hidden" accept="image/png, image/jpeg" @change="(e) => handleFileUpload(e, 'logo')" />
|
||||
<div class="w-16 h-16 rounded-xl border border-dashed border-gray-300 flex flex-col items-center justify-center cursor-pointer hover:border-blue-400 bg-gray-50 overflow-hidden shrink-0" @click="triggerLogoUpload">
|
||||
<div v-if="isLogoUploading" class="w-5 h-5 border-2 border-blue-600 border-t-transparent rounded-full animate-spin"></div>
|
||||
<template v-else>
|
||||
<el-image v-if="formData.logoUrl" :src="formData.logoUrl" fit="contain" class="w-full h-full" />
|
||||
<Upload v-else class="w-5 h-5 text-gray-400" />
|
||||
</template>
|
||||
</div>
|
||||
<div class="text-xs text-gray-400">
|
||||
<p>建议尺寸: 200x200,支持透明底 PNG</p>
|
||||
<p v-if="formData.logoUrl" class="text-blue-500 cursor-pointer mt-1 hover:underline" @click.stop="formData.logoUrl = null">移除Logo</p>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="统一社会信用代码" required>
|
||||
<el-input v-model="formData.creditCode" placeholder="18位统一社会信用代码" size="large" />
|
||||
</el-form-item>
|
||||
<el-form-item label="营业执照" required>
|
||||
<input type="file" ref="fileInput" class="hidden" accept="image/*,.pdf" @change="(e) => handleFileUpload(e, 'license')" />
|
||||
<div v-if="!formData.licenseImage" @click="triggerUpload"
|
||||
class="w-full border-2 border-dashed border-gray-200 rounded-xl p-8 text-center cursor-pointer hover:border-blue-400 hover:bg-blue-50/30 transition-all"
|
||||
>
|
||||
<div v-if="isUploading" class="w-8 h-8 border-2 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto mb-2"></div>
|
||||
<Upload v-else class="w-8 h-8 text-gray-300 mx-auto mb-2" />
|
||||
<p class="font-medium text-sm text-gray-600">{{ isUploading ? '正在上传...' : '点击上传营业执照' }}</p>
|
||||
<p class="text-xs text-gray-400">PDF / JPG / PNG (最大 10MB)</p>
|
||||
</div>
|
||||
<div v-else class="text-center">
|
||||
<div class="relative inline-block">
|
||||
<el-image :src="formData.licenseImage" class="h-40 rounded-lg" fit="contain" />
|
||||
<el-button circle size="small" type="danger" class="!absolute -top-2 -right-2" @click="formData.licenseImage = null">
|
||||
<X class="w-3 h-3" />
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="flex justify-between px-4 pb-4">
|
||||
<el-button size="large" @click="prevStep">
|
||||
<ArrowLeft class="w-4 h-4 mr-1" />上一步
|
||||
</el-button>
|
||||
<el-button type="primary" size="large" @click="nextStep">下一步</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Review -->
|
||||
<div v-if="currentStep === 2" class="px-4 pb-4">
|
||||
<el-alert v-if="errorMsg" type="error" :title="errorMsg" :closable="false" class="mb-4" />
|
||||
<div class="text-center mb-6">
|
||||
<ShieldCheck class="w-12 h-12 text-blue-500 mx-auto mb-3" />
|
||||
<h3 class="text-xl font-bold text-gray-800">确认申请信息</h3>
|
||||
<p class="text-sm text-gray-400">请确认以下信息无误后提交</p>
|
||||
</div>
|
||||
<el-descriptions :column="1" border class="max-w-md mx-auto mb-6">
|
||||
<el-descriptions-item label="企业名称">{{ formData.companyName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="信用代码">{{ formData.creditCode }}</el-descriptions-item>
|
||||
<el-descriptions-item label="登录邮箱">{{ formData.email }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<div class="flex justify-center gap-4">
|
||||
<el-button size="large" @click="prevStep" :disabled="isLoading">返回修改</el-button>
|
||||
<el-button type="primary" size="large" :loading="isLoading" @click="submitFinal">
|
||||
{{ isLoading ? '正在提交...' : '提交认证申请' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- Success -->
|
||||
<el-result v-else icon="success" title="企业申请已送达" sub-title="核验团队将在 24 小时内完成企业资质审查。您可以先行登录完善工作台配置。">
|
||||
<template #extra>
|
||||
<el-button type="primary" @click="router.push('/enterprise/dashboard')">进入工作台</el-button>
|
||||
<el-button @click="router.push('/')">返回首页</el-button>
|
||||
</template>
|
||||
</el-result>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
133
src/views/enterprise/TaskApplicationsView.vue
Normal file
133
src/views/enterprise/TaskApplicationsView.vue
Normal file
@@ -0,0 +1,133 @@
|
||||
<script setup lang="ts">
|
||||
import { ElMessage } from "element-plus";
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { ArrowLeft, Star } from 'lucide-vue-next';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { getApplications, approveApplication, updateApplication } from '@/api/tasks';
|
||||
import ExpertProfileDrawer from '@/components/expert/ExpertProfileDrawer.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const taskId = route.params.id as string;
|
||||
const applications = ref<any[]>([]);
|
||||
const isLoading = ref(true);
|
||||
|
||||
const expertDrawerVisible = ref(false);
|
||||
const currentExpertId = ref('');
|
||||
|
||||
const openExpertProfile = (expertId: string) => {
|
||||
currentExpertId.value = expertId;
|
||||
expertDrawerVisible.value = true;
|
||||
};
|
||||
|
||||
const fetchApplications = async () => {
|
||||
try {
|
||||
isLoading.value = true;
|
||||
const res: any = await getApplications({ task: taskId });
|
||||
applications.value = res.results || [];
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(fetchApplications);
|
||||
|
||||
const handleApprove = async (appId: string) => {
|
||||
try {
|
||||
await approveApplication(appId);
|
||||
ElMessage.success('已录用');
|
||||
await fetchApplications();
|
||||
} catch (e) {
|
||||
ElMessage.error('审批失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleReject = async (appId: string) => {
|
||||
try {
|
||||
await updateApplication(appId, { status: 'REJECTED' });
|
||||
ElMessage.success('已驳回');
|
||||
await fetchApplications();
|
||||
} catch (e) {
|
||||
ElMessage.error('驳回失败');
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusType = (status: string) => {
|
||||
switch (status) {
|
||||
case 'APPROVED': return 'success';
|
||||
case 'REJECTED': return 'danger';
|
||||
case 'PENDING': return 'warning';
|
||||
case 'DELIVERED': return 'primary';
|
||||
case 'COMPLETED': return 'info';
|
||||
default: return 'info';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
switch (status) {
|
||||
case 'APPROVED': return '已录用';
|
||||
case 'REJECTED': return '已驳回';
|
||||
case 'PENDING': return '待审核';
|
||||
case 'DELIVERED': return '待验收';
|
||||
case 'COMPLETED': return '已结案';
|
||||
default: return status;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-5">
|
||||
<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">承接申请列表</h1>
|
||||
<p class="text-sm text-gray-400">Task #{{ taskId }} · 共 {{ applications.length }} 条申请</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-loading="isLoading" class="space-y-4">
|
||||
<el-card v-for="app in applications" :key="app.id" shadow="hover">
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div class="flex items-center gap-3 cursor-pointer hover:bg-gray-50 p-2 -ml-2 rounded-lg transition-colors" @click="openExpertProfile(app.applicant)">
|
||||
<el-avatar :size="48" :src="app.applicant_avatar">{{ (app.applicant_name || app.applicant_username || 'U')[0] }}</el-avatar>
|
||||
<div>
|
||||
<div class="font-semibold text-lg hover:text-blue-600 transition-colors">{{ app.applicant_name || app.applicant_username || '未知用户' }}</div>
|
||||
<div class="flex items-center gap-2 text-sm mt-0.5">
|
||||
<span class="flex items-center gap-0.5 text-orange-500">
|
||||
<Star class="w-3.5 h-3.5 fill-current" />
|
||||
<span class="font-bold">{{ app.applicant_rating || '-' }}</span>
|
||||
</span>
|
||||
<el-divider direction="vertical" />
|
||||
<span class="text-gray-400">{{ app.applicant_completed_tasks || 0 }} 项已完成</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-tag :type="getStatusType(app.status)" effect="dark" round>{{ getStatusLabel(app.status) }}</el-tag>
|
||||
</div>
|
||||
|
||||
<el-card shadow="never" class="!bg-gray-50 !rounded-lg mb-4" v-if="app.cover_letter">
|
||||
<div class="text-xs text-gray-400 mb-1">承接私信 / Cover Letter</div>
|
||||
<p class="text-sm text-gray-600 italic">"{{ app.cover_letter }}"</p>
|
||||
</el-card>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex gap-6">
|
||||
<div class="text-sm"><span class="text-gray-400">报价预期:</span><span class="font-bold text-blue-600 text-base">¥{{ app.expected_price }}</span></div>
|
||||
<div class="text-sm"><span class="text-gray-400">预计用时:</span><span class="font-bold text-base">{{ app.expected_days }} 天</span></div>
|
||||
</div>
|
||||
<div v-if="app.status === 'PENDING'" class="flex gap-2">
|
||||
<el-button type="danger" plain size="small" round @click="handleReject(app.id)">驳回</el-button>
|
||||
<el-button type="primary" size="small" round @click="handleApprove(app.id)" class="shadow-sm">确认录用</el-button>
|
||||
</div>
|
||||
<el-tag v-else-if="app.status === 'APPROVED'" type="success" effect="light" size="large">✓ 已确认合作</el-tag>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-empty v-if="!isLoading && applications.length === 0" description="暂无承接申请" />
|
||||
</div>
|
||||
|
||||
<ExpertProfileDrawer v-model="expertDrawerVisible" :expert-id="currentExpertId" />
|
||||
</div>
|
||||
</template>
|
||||
293
src/views/enterprise/TaskCreateView.vue
Normal file
293
src/views/enterprise/TaskCreateView.vue
Normal file
@@ -0,0 +1,293 @@
|
||||
<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>
|
||||
563
src/views/enterprise/TaskDetailView.vue
Normal file
563
src/views/enterprise/TaskDetailView.vue
Normal file
@@ -0,0 +1,563 @@
|
||||
<script setup lang="ts">
|
||||
import { ElMessage } from "element-plus";
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { ArrowLeft, UserPlus, Star, Search, Check, XCircle, FileText, CheckCircle2, Clock, Download, MoreHorizontal } from 'lucide-vue-next';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { getTaskDetail, getApplications, approveApplication, rejectApplication, updateApplication, deleteTask } from '@/api/tasks';
|
||||
import api from '@/api';
|
||||
import JSZip from 'jszip';
|
||||
import { saveAs } from 'file-saver';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const taskId = route.params.id as string;
|
||||
|
||||
// Detect if we are in admin or enterprise context for navigation
|
||||
const routePrefix = computed(() => route.path.startsWith('/admin') ? '/admin' : '/enterprise');
|
||||
|
||||
const task = ref<any>({});
|
||||
const applications = ref<any[]>([]);
|
||||
|
||||
const activeApplications = computed(() => {
|
||||
return applications.value.filter(a => ['PENDING', 'APPROVED', 'DELIVERED', 'COMPLETED'].includes(a.status));
|
||||
});
|
||||
|
||||
const archivedApplications = computed(() => {
|
||||
return applications.value.filter(a => ['REJECTED', 'WITHDRAWN'].includes(a.status));
|
||||
});
|
||||
|
||||
const canCompleteTask = computed(() => {
|
||||
return task.value?.status !== 'COMPLETED' &&
|
||||
task.value?.status !== 'CANCELLED' &&
|
||||
applications.value.some(a => ['APPROVED', 'DELIVERED', 'COMPLETED'].includes(a.status));
|
||||
});
|
||||
|
||||
const expertDrawerVisible = ref(false);
|
||||
const currentExpertId = ref('');
|
||||
|
||||
const openExpertProfile = (expertId: string) => {
|
||||
currentExpertId.value = expertId;
|
||||
expertDrawerVisible.value = true;
|
||||
};
|
||||
|
||||
const fetchTaskData = async () => {
|
||||
try {
|
||||
const res: any = await getTaskDetail(taskId);
|
||||
task.value = res;
|
||||
const appsRes: any = await getApplications({ task: taskId });
|
||||
applications.value = appsRes.results || [];
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApprove = async (appId: string) => {
|
||||
try {
|
||||
await approveApplication(appId);
|
||||
ElMessage.success('已确认录用');
|
||||
await fetchTaskData();
|
||||
} catch (e) {
|
||||
ElMessage.error('审批失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleReject = async (appId: string) => {
|
||||
try {
|
||||
const { value: reason } = await ElMessageBox.prompt('请输入驳回原因', '驳回申请', {
|
||||
confirmButtonText: '确定驳回',
|
||||
cancelButtonText: '取消',
|
||||
inputPlaceholder: '选填,将展示给申请者',
|
||||
});
|
||||
await rejectApplication(appId, reason);
|
||||
ElMessage.success('已驳回');
|
||||
await fetchTaskData();
|
||||
} catch (e: any) {
|
||||
if (e !== 'cancel') ElMessage.error('驳回失败');
|
||||
}
|
||||
};
|
||||
|
||||
import { ElMessageBox } from 'element-plus';
|
||||
|
||||
const closeTask = async () => {
|
||||
try {
|
||||
const res: any = await api.post(`/tasks/${taskId}/cancel_task/`);
|
||||
ElMessage.success(res.status || '任务已取消');
|
||||
router.push(`${routePrefix.value}/tasks`);
|
||||
} catch (e: any) {
|
||||
if (e.response?.data?.requires_batch_reject) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'此任务存在待审核的申请者,是否要批量拒绝所有申请并取消任务?',
|
||||
'警告:无法直接取消',
|
||||
{ confirmButtonText: '全部拒绝并取消', cancelButtonText: '暂不取消', type: 'warning' }
|
||||
);
|
||||
const retryRes: any = await api.post(`/tasks/${taskId}/cancel_task/`, { batch_reject: true });
|
||||
ElMessage.success(retryRes.status || '任务已取消');
|
||||
router.push(`${routePrefix.value}/tasks`);
|
||||
} catch {
|
||||
ElMessage.info('已取消操作');
|
||||
}
|
||||
} else {
|
||||
ElMessage.error(e.response?.data?.detail || '取消失败');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const completeTask = async () => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确认项目全部交付完成,并且要正式结项吗?', '确认结项', { confirmButtonText: '确定结项', type: 'warning' });
|
||||
const res: any = await api.post(`/tasks/${taskId}/complete_task/`);
|
||||
ElMessage.success(res.status || '项目已结项');
|
||||
fetchTaskData();
|
||||
} catch (e: any) {
|
||||
if (e !== 'cancel') {
|
||||
ElMessage.error(e.response?.data?.detail || '结项失败');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleApproveDelivery = async (appId: string, recordId: string) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确认验收此份交付成果吗?', '验收通过', { confirmButtonText: '确认验收', type: 'success' });
|
||||
const res: any = await api.post(`/applications/${appId}/approve_delivery/`, { record_id: recordId });
|
||||
ElMessage.success(res.status || '已验收');
|
||||
fetchTaskData();
|
||||
} catch (e: any) {
|
||||
if (e !== 'cancel') {
|
||||
ElMessage.error(e.response?.data?.detail || '验收失败');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleRejectDelivery = async (appId: string, recordId: string) => {
|
||||
try {
|
||||
await ElMessageBox.prompt('请输入驳回原因', '驳回交付', { confirmButtonText: '驳回', cancelButtonText: '取消', type: 'warning' });
|
||||
const res: any = await api.post(`/applications/${appId}/reject_delivery/`, { record_id: recordId });
|
||||
ElMessage.success(res.status || '已驳回');
|
||||
fetchTaskData();
|
||||
} catch (e: any) {
|
||||
if (e !== 'cancel') {
|
||||
ElMessage.error(e.response?.data?.detail || '驳回失败');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const batchApproveDelivery = async () => {
|
||||
const pendingApps = applications.value.filter(a => a.status === 'DELIVERED');
|
||||
if (pendingApps.length === 0) {
|
||||
ElMessage.info('当前没有待验收的成果');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认一次性验收所有 ${pendingApps.length} 份待验收的交付成果吗?`, '一键全部验收', { confirmButtonText: '全部验收', type: 'success' });
|
||||
for (const app of pendingApps) {
|
||||
await api.post(`/applications/${app.id}/approve_delivery/`);
|
||||
}
|
||||
ElMessage.success('已成功批量验收所有待验收交付物');
|
||||
fetchTaskData();
|
||||
} catch(e: any) {
|
||||
if (e !== 'cancel') ElMessage.error('批量验收过程中发生错误');
|
||||
}
|
||||
};
|
||||
|
||||
const downloadZip = async () => {
|
||||
const zip = new JSZip();
|
||||
let hasContent = false;
|
||||
|
||||
// Go through all applications
|
||||
for (const app of applications.value) {
|
||||
if (app.delivery_records && app.delivery_records.length > 0) {
|
||||
const expertName = app.applicant_name || app.applicant_username || '未知专家';
|
||||
const folder = zip.folder(expertName);
|
||||
|
||||
for (let i = 0; i < app.delivery_records.length; i++) {
|
||||
const record = app.delivery_records[i];
|
||||
const recordFolder = folder!.folder(`交付_${i + 1}_${new Date(record.created_at).toISOString().split('T')[0]}`);
|
||||
|
||||
// Add txt note
|
||||
if (record.note) {
|
||||
recordFolder!.file('交付说明.txt', record.note);
|
||||
hasContent = true;
|
||||
}
|
||||
|
||||
// Fetch and package delivery files
|
||||
if (record.files && record.files.length > 0) {
|
||||
for (const file of record.files) {
|
||||
if (file.url && file.url.startsWith('http')) {
|
||||
try {
|
||||
// Try to fetch file blob
|
||||
const response = await fetch(file.url);
|
||||
const blob = await response.blob();
|
||||
recordFolder!.file(file.name, blob);
|
||||
hasContent = true;
|
||||
} catch (e) {
|
||||
// CORS or other error
|
||||
recordFolder!.file(file.name, `无法下载远程文件: ${file.url}`);
|
||||
hasContent = true;
|
||||
}
|
||||
} else {
|
||||
recordFolder!.file(file.name, '文件内容为空 (未找到真实URL)');
|
||||
hasContent = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasContent) {
|
||||
ElMessage.warning('没有可打包的交付文件');
|
||||
return;
|
||||
}
|
||||
|
||||
const content = await zip.generateAsync({ type: 'blob' });
|
||||
const timeStr = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
saveAs(content, `项目成果_${task.value.title}_${timeStr}.zip`);
|
||||
};
|
||||
|
||||
// Expert invite logic
|
||||
const showInviteSearch = ref(false);
|
||||
const expertSearchQuery = ref('');
|
||||
const invitingExperts = ref<string[]>([]);
|
||||
const expertPool = ref<any[]>([]);
|
||||
|
||||
const fetchExpertPool = async () => {
|
||||
try {
|
||||
const res: any = await api.get('/users/', { params: { role: 'OPC_USER' } });
|
||||
expertPool.value = res.results || [];
|
||||
} catch (error) {
|
||||
console.error('Fetch experts failed', error);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchTaskData();
|
||||
fetchExpertPool();
|
||||
});
|
||||
|
||||
const filteredExperts = computed(() => {
|
||||
return expertPool.value.filter(e =>
|
||||
(e.nickname || e.username || '').toLowerCase().includes(expertSearchQuery.value.toLowerCase())
|
||||
);
|
||||
});
|
||||
|
||||
const toggleInvite = (id: string) => {
|
||||
const index = invitingExperts.value.indexOf(id);
|
||||
if (index > -1) invitingExperts.value.splice(index, 1);
|
||||
else invitingExperts.value.push(id);
|
||||
};
|
||||
|
||||
const sendBulkInvites = async () => {
|
||||
try {
|
||||
for (const expertId of invitingExperts.value) {
|
||||
await api.post('/invitations/', {
|
||||
task: taskId,
|
||||
expert: expertId,
|
||||
message: '诚邀您参与此项任务。'
|
||||
});
|
||||
}
|
||||
ElMessage.success(`已成功向 ${invitingExperts.value.length} 位专家发送邀约`);
|
||||
showInviteSearch.value = false;
|
||||
invitingExperts.value = [];
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.response?.data?.detail || '发送邀请失败');
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusType = (status: string) => {
|
||||
switch (status) {
|
||||
case 'APPROVED': return 'success';
|
||||
case 'REJECTED': return 'danger';
|
||||
case 'PENDING': return 'warning';
|
||||
case 'DELIVERED': return 'primary';
|
||||
case 'COMPLETED': return 'info';
|
||||
default: return 'info';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
switch (status) {
|
||||
case 'APPROVED': return '已录用';
|
||||
case 'REJECTED': return '已驳回';
|
||||
case 'PENDING': return '待审核';
|
||||
case 'DELIVERED': return '待验收';
|
||||
case 'COMPLETED': return '已结案';
|
||||
default: return status;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-5">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<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">任务管理详情</h1>
|
||||
<p class="text-sm text-gray-400">Task #{{ taskId }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<el-button type="primary" @click="showInviteSearch = true" v-if="task.status !== 'COMPLETED' && task.status !== 'CANCELLED'">
|
||||
<UserPlus class="w-4 h-4 mr-2" />主动邀约专家
|
||||
</el-button>
|
||||
<el-button @click="router.push(`${routePrefix}/tasks/${taskId}/applications`)">查看所有申请</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-row :gutter="16">
|
||||
<!-- Left: Applications -->
|
||||
<el-col :span="16">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<span class="font-semibold">收到承接申请 ({{ applications.length }})</span>
|
||||
</template>
|
||||
<div class="space-y-4" v-if="activeApplications.length > 0">
|
||||
<el-card v-for="app in activeApplications" :key="app.id" shadow="hover" class="!rounded-xl">
|
||||
<div class="flex flex-col mb-3">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex items-center gap-3 cursor-pointer hover:bg-gray-50 p-2 -ml-2 rounded-lg transition-colors" @click="openExpertProfile(app.applicant)">
|
||||
<el-avatar :size="44" :src="app.applicant_avatar">{{ (app.applicant_name || app.applicant_username || 'U')[0] }}</el-avatar>
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-semibold text-base group-hover:text-blue-600">{{ app.applicant_name || app.applicant_username || '未知用户' }}</span>
|
||||
<el-tag v-if="app.is_invited" size="small" type="info" effect="plain" class="h-5 px-1.5 text-xs rounded border-gray-200">定向邀约</el-tag>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 text-orange-500 text-sm mt-0.5">
|
||||
<Star class="w-3.5 h-3.5 fill-current" />
|
||||
<span class="font-bold">{{ app.applicant_rating || '-' }}</span>
|
||||
<span class="text-gray-400 ml-1">· {{ app.applicant_completed_tasks || 0 }} 项已完成</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-tag :type="getStatusType(app.status)" size="small">{{ getStatusLabel(app.status) }}</el-tag>
|
||||
</div>
|
||||
|
||||
<!-- Individual Progress Bar -->
|
||||
<div v-if="['APPROVED', 'DELIVERED', 'COMPLETED'].includes(app.status)" class="w-full mt-3 px-2">
|
||||
<el-progress :percentage="app.status === 'COMPLETED' ? 100 : app.status === 'DELIVERED' ? 75 : 25" :format="() => ''" :stroke-width="4" :color="app.status === 'COMPLETED' ? '#67c23a' : '#409eff'" />
|
||||
<div class="flex justify-between text-[10px] text-gray-400 mt-1">
|
||||
<span :class="app.status === 'APPROVED' ? 'text-blue-500 font-bold' : ''">已承接</span>
|
||||
<span :class="app.status === 'APPROVED' ? 'text-blue-500 font-bold' : ''">交付中</span>
|
||||
<span :class="app.status === 'DELIVERED' ? 'text-blue-500 font-bold' : ''">待验收</span>
|
||||
<span :class="app.status === 'COMPLETED' ? 'text-green-500 font-bold' : ''">已结案</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card shadow="never" class="!bg-gray-50 !rounded-lg mb-3 border-gray-200" v-if="app.cover_letter">
|
||||
<div class="text-xs text-gray-400 mb-1">承接私信</div>
|
||||
<p class="text-sm text-gray-600 italic">"{{ app.cover_letter }}"</p>
|
||||
</el-card>
|
||||
|
||||
<!-- Negotiation History -->
|
||||
<el-collapse v-if="app.negotiation_history?.length > 0" class="mb-3 border-none bg-gray-50 rounded-lg">
|
||||
<el-collapse-item name="1" title="前期洽谈备忘录" class="!bg-transparent px-3">
|
||||
<div class="space-y-3 max-h-[200px] overflow-y-auto pr-2 pt-2 border-t border-gray-200">
|
||||
<div v-for="(msg, idx) in app.negotiation_history" :key="idx" class="flex gap-2" :class="{'flex-row-reverse': msg.sender_role === 'ENTERPRISE'}">
|
||||
<el-avatar v-if="msg.sender_role !== 'SYSTEM'" :size="24" :src="msg.sender_avatar" class="shrink-0">{{ msg.sender_name?.charAt(0) }}</el-avatar>
|
||||
<div v-if="msg.sender_role !== 'SYSTEM'" class="p-2 rounded-xl shadow-sm max-w-[85%]"
|
||||
:class="msg.sender_role === 'ENTERPRISE' ? 'bg-blue-500 text-white rounded-tr-sm' : 'bg-white border border-gray-100 rounded-tl-sm text-gray-800'">
|
||||
<p class="text-[13px] whitespace-pre-wrap leading-relaxed">{{ msg.content }}</p>
|
||||
<div class="text-[9px] mt-0.5 opacity-70" :class="{'text-right': msg.sender_role === 'ENTERPRISE'}">
|
||||
{{ new Date(msg.created_at).toLocaleString() }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="w-full flex justify-center my-1">
|
||||
<div class="bg-gray-200 text-gray-500 text-[10px] px-2 py-0.5 rounded-full">
|
||||
{{ msg.content }} - {{ new Date(msg.created_at).toLocaleString() }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex gap-6 text-sm">
|
||||
<div><span class="text-gray-400">报价:</span><span class="font-bold text-blue-600">¥{{ app.expected_price }}</span></div>
|
||||
<div><span class="text-gray-400">预计:</span><span class="font-bold">{{ app.expected_days }} 天</span></div>
|
||||
</div>
|
||||
<div v-if="app.status === 'PENDING'" class="flex gap-2">
|
||||
<el-button type="danger" plain size="small" @click="handleReject(app.id)">驳回</el-button>
|
||||
<el-button type="primary" size="small" @click="handleApprove(app.id)">确认录用</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-collapse v-if="app.delivery_records?.length > 0" class="mt-4 border-none bg-blue-50/50 rounded-lg">
|
||||
<el-collapse-item name="1" title="查看专家历次交付记录" class="!bg-transparent px-3">
|
||||
<div class="pt-2 pb-1 border-t border-blue-100 space-y-4">
|
||||
<div v-for="(record, idx) in app.delivery_records" :key="record.id" class="border border-white bg-white/60 rounded-xl p-3 shadow-sm">
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<el-tag :type="record.status === 'APPROVED' ? 'success' : record.status === 'REJECTED' ? 'danger' : 'warning'" size="small">
|
||||
{{ record.status === 'APPROVED' ? '已验收' : record.status === 'REJECTED' ? '已驳回' : '待验收' }}
|
||||
</el-tag>
|
||||
<span class="text-xs text-gray-500">{{ new Date(record.created_at).toLocaleString() }} 交付</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-gray-600 mb-3 whitespace-pre-wrap">{{ record.note || '无附加说明' }}</p>
|
||||
|
||||
<div v-if="record.files && record.files.length > 0" class="mb-3">
|
||||
<div class="text-xs text-gray-500 mb-1">包含附件</div>
|
||||
<div class="space-y-1">
|
||||
<a v-for="file in record.files" :key="file.uid" :href="file.url" target="_blank" class="flex items-center gap-2 p-1.5 bg-white rounded border border-gray-100 shadow-sm text-sm hover:bg-gray-50 transition-colors">
|
||||
<FileText class="w-4 h-4 text-gray-400" />
|
||||
<span class="flex-1 truncate text-gray-700">{{ file.name }}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="record.status === 'PENDING'" class="flex justify-end mt-2 gap-2">
|
||||
<el-button type="danger" plain size="small" @click="handleRejectDelivery(app.id, record.id)">驳回</el-button>
|
||||
<el-button type="success" size="small" @click="handleApproveDelivery(app.id, record.id)">验收通过</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</el-card>
|
||||
</div>
|
||||
<el-empty v-if="activeApplications.length === 0" description="暂无待处理的申请" />
|
||||
|
||||
<!-- Archived Applications -->
|
||||
<el-collapse v-if="archivedApplications.length > 0" class="mt-6 border-none bg-gray-50/50 rounded-lg">
|
||||
<el-collapse-item name="archived" class="!bg-transparent px-3">
|
||||
<template #title>
|
||||
<div class="flex items-center gap-2 text-gray-500">
|
||||
<span class="font-medium">已归档记录 ({{ archivedApplications.length }})</span>
|
||||
<el-tooltip content="包含已驳回、已撤销或已结案的记录" placement="top">
|
||||
<CheckCircle2 class="w-4 h-4 opacity-50" />
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
<div class="pt-2 pb-1 space-y-3">
|
||||
<el-card v-for="app in archivedApplications" :key="app.id" shadow="never" class="!rounded-lg !bg-white/80 !border-gray-200">
|
||||
<div class="flex items-center justify-between opacity-80">
|
||||
<div class="flex items-center gap-3">
|
||||
<el-avatar :size="32" :src="app.applicant_avatar">{{ (app.applicant_name || app.applicant_username || 'U')[0] }}</el-avatar>
|
||||
<div class="flex flex-col">
|
||||
<span class="font-medium text-sm">{{ app.applicant_name || app.applicant_username || '未知用户' }}</span>
|
||||
<span class="text-xs text-gray-400">报价: ¥{{ app.expected_price }} / {{ app.expected_days }}天</span>
|
||||
</div>
|
||||
</div>
|
||||
<el-tag :type="getStatusType(app.status)" size="small" effect="plain">{{ getStatusLabel(app.status) }}</el-tag>
|
||||
</div>
|
||||
<div v-if="app.reject_reason" class="mt-2 text-xs text-red-400 bg-red-50 p-2 rounded">
|
||||
驳回原因: {{ app.reject_reason }}
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<!-- Right: Task Meta -->
|
||||
<el-col :span="8">
|
||||
<el-card shadow="never" class="sticky top-20">
|
||||
<template #header>
|
||||
<span class="font-semibold">{{ task.title }}</span>
|
||||
</template>
|
||||
<el-descriptions :column="1" size="small">
|
||||
<el-descriptions-item label="类型">
|
||||
<el-tag size="small" type="info">{{ task.task_type || '未分类' }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="预算">¥{{ task.budget_min }} - {{ task.budget_max }}</el-descriptions-item>
|
||||
<el-descriptions-item label="截止日期">{{ task.deadline }}</el-descriptions-item>
|
||||
<el-descriptions-item label="技能要求">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<el-tag v-for="tag in task.skill_tags" :key="tag" size="small" type="primary" effect="plain">{{ tag }}</el-tag>
|
||||
</div>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<el-divider />
|
||||
<p class="text-sm text-gray-500 mb-4 whitespace-pre-wrap break-words">{{ task.description }}</p>
|
||||
|
||||
<div class="flex gap-2 w-full mt-2">
|
||||
<el-button
|
||||
v-if="canCompleteTask"
|
||||
type="success"
|
||||
class="flex-1 shadow-sm"
|
||||
size="large"
|
||||
@click="completeTask"
|
||||
>
|
||||
项目验收完成并结项
|
||||
</el-button>
|
||||
<el-button
|
||||
v-else-if="task.status === 'OPEN'"
|
||||
type="primary"
|
||||
class="flex-1 shadow-sm"
|
||||
size="large"
|
||||
@click="showInviteSearch = true"
|
||||
>
|
||||
主动邀约专家
|
||||
</el-button>
|
||||
<el-button
|
||||
v-else-if="task.status === 'COMPLETED'"
|
||||
type="primary"
|
||||
class="flex-1 shadow-sm"
|
||||
size="large"
|
||||
@click="downloadZip"
|
||||
:icon="Download"
|
||||
>
|
||||
打包下载交付物
|
||||
</el-button>
|
||||
<el-dropdown trigger="click" class="flex-shrink-0" placement="bottom-end">
|
||||
<el-button size="large" class="!px-3"><MoreHorizontal class="w-5 h-5 text-gray-500" /></el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item @click="downloadZip" :icon="Download">打包下载交付成果 (ZIP)</el-dropdown-item>
|
||||
<el-dropdown-item @click="batchApproveDelivery" v-if="task.status === 'IN_PROGRESS' && applications.some(a => a.status === 'DELIVERED')">一键验收全部待验收交付</el-dropdown-item>
|
||||
<el-dropdown-item divided @click="closeTask" class="text-red-500" v-if="['OPEN', 'DRAFT', 'IN_PROGRESS'].includes(task.status)">取消并关闭此任务</el-dropdown-item>
|
||||
<el-dropdown-item v-if="task.status === 'COMPLETED'" disabled>该任务已结案归档</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- Invite Dialog -->
|
||||
<el-dialog v-model="showInviteSearch" title="定向邀约专家" width="640px" top="5vh">
|
||||
<el-input v-model="expertSearchQuery" placeholder="搜索昵称或技能关键词..." prefix-icon="Search" clearable class="mb-4" />
|
||||
<div class="grid grid-cols-2 gap-3 max-h-[400px] overflow-y-auto">
|
||||
<div
|
||||
v-for="expert in filteredExperts"
|
||||
:key="expert.id"
|
||||
:class="[
|
||||
'p-3 rounded-lg border-2 cursor-pointer flex items-center gap-3 transition-all',
|
||||
invitingExperts.includes(expert.id) ? 'border-blue-500 bg-blue-50' : 'border-gray-100 hover:border-gray-300'
|
||||
]"
|
||||
@click="toggleInvite(expert.id)"
|
||||
>
|
||||
<div class="flex-1 flex items-center gap-3" @click.stop="openExpertProfile(expert.id)">
|
||||
<el-avatar :size="40" :src="expert.avatar_url">{{ (expert.nickname || expert.username || 'U')[0] }}</el-avatar>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="font-medium text-sm truncate hover:text-blue-600 transition-colors">{{ expert.nickname || expert.username }}</div>
|
||||
<div class="flex items-center gap-1 text-orange-500 text-xs mt-0.5">
|
||||
<Star class="w-3 h-3 fill-current" />
|
||||
<span>{{ expert.rating }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-2 border rounded-full transition-colors w-8 h-8 flex-shrink-0 flex items-center justify-center" :class="invitingExperts.includes(expert.id) ? 'bg-blue-500 text-white border-blue-500 hover:bg-blue-600' : 'border-gray-300 text-gray-400 hover:bg-gray-100'">
|
||||
<el-icon><Check /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<span class="text-sm text-gray-400 mr-auto">已选择 {{ invitingExperts.length }} 位专家</span>
|
||||
<el-button @click="showInviteSearch = false">取消</el-button>
|
||||
<el-button type="primary" :disabled="invitingExperts.length === 0" @click="sendBulkInvites">
|
||||
发送邀约 ({{ invitingExperts.length }})
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<ExpertProfileDrawer v-model="expertDrawerVisible" :expert-id="currentExpertId" />
|
||||
</div>
|
||||
</template>
|
||||
233
src/views/enterprise/TaskListView.vue
Normal file
233
src/views/enterprise/TaskListView.vue
Normal file
@@ -0,0 +1,233 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue';
|
||||
import { PlusCircle, Calendar, ClipboardList, ChevronRight, MapPin, Building2, UserCircle, Search } from 'lucide-vue-next';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { getTasks } from '@/api/tasks';
|
||||
import api from '@/api';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
const tabs = [
|
||||
{ name: 'ALL', label: '全部' },
|
||||
{ name: 'OPEN', label: '招募中' },
|
||||
{ name: 'IN_PROGRESS', label: '进行中' },
|
||||
{ name: 'IN_REVIEW', label: '待验收' },
|
||||
{ name: 'COMPLETED', label: '已完成' },
|
||||
{ name: 'CANCELLED', label: '已取消' },
|
||||
{ name: 'DRAFT', label: '草稿箱' },
|
||||
];
|
||||
|
||||
const activeTab = ref('ALL');
|
||||
const searchQuery = ref('');
|
||||
const tasks = ref<any[]>([]);
|
||||
const isLoading = ref(true);
|
||||
|
||||
const enterprise = ref<any>(null);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
isLoading.value = true;
|
||||
const [tasksRes, entRes]: any[] = await Promise.all([
|
||||
getTasks(),
|
||||
api.get('/enterprises/me/').catch(() => null)
|
||||
]);
|
||||
tasks.value = tasksRes?.results || tasksRes || [];
|
||||
if (entRes) {
|
||||
enterprise.value = entRes;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchData();
|
||||
// Read status query param from dashboard cards
|
||||
const qStatus = route.query.status as string;
|
||||
if (qStatus) {
|
||||
const validTabs = tabs.map(t => t.name);
|
||||
if (validTabs.includes(qStatus)) {
|
||||
activeTab.value = qStatus;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Watch for route query changes (e.g. navigating from dashboard cards)
|
||||
watch(() => route.query.status, (newStatus) => {
|
||||
if (newStatus && typeof newStatus === 'string') {
|
||||
const validTabs = tabs.map(t => t.name);
|
||||
if (validTabs.includes(newStatus)) {
|
||||
activeTab.value = newStatus;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const filteredTasks = computed(() => {
|
||||
return tasks.value.filter(task => {
|
||||
// Filter by search
|
||||
const matchesSearch = task.title.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
|
||||
(task.publisher_name || '').toLowerCase().includes(searchQuery.value.toLowerCase());
|
||||
if (!matchesSearch) return false;
|
||||
|
||||
// Filter by tab
|
||||
if (activeTab.value === 'ALL') return true;
|
||||
return task.status === activeTab.value;
|
||||
});
|
||||
});
|
||||
|
||||
// Tab counts
|
||||
const tabCounts = computed(() => {
|
||||
const counts: Record<string, number> = { ALL: tasks.value.length };
|
||||
for (const t of tasks.value) {
|
||||
counts[t.status] = (counts[t.status] || 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
});
|
||||
|
||||
const getStatusType = (status: string) => {
|
||||
switch (status) {
|
||||
case 'OPEN': return 'primary';
|
||||
case 'DRAFT': return 'info';
|
||||
case 'IN_PROGRESS': return 'warning';
|
||||
case 'IN_REVIEW': return '';
|
||||
case 'COMPLETED': return 'success';
|
||||
case 'CANCELLED': return 'danger';
|
||||
default: return 'info';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
switch (status) {
|
||||
case 'OPEN': return '招募中';
|
||||
case 'DRAFT': return '草稿';
|
||||
case 'IN_PROGRESS': return '进行中';
|
||||
case 'IN_REVIEW': return '待验收';
|
||||
case 'COMPLETED': return '已结案';
|
||||
case 'CANCELLED': return '已取消';
|
||||
default: return status;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-800">企业任务管理</h1>
|
||||
<p class="text-sm text-gray-400 mt-1">查看和管理企业内所有成员发布的招募任务</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<el-tooltip :content="enterprise?.status !== 'VERIFIED' ? '请先完成企业认证' : ''" placement="top" :disabled="enterprise?.status === 'VERIFIED'">
|
||||
<div class="inline-block">
|
||||
<el-button type="primary" size="large" @click="router.push('/enterprise/tasks/create')" class="shadow-sm" :disabled="enterprise?.status !== 'VERIFIED'">
|
||||
<PlusCircle class="w-5 h-5 mr-2" />发布新任务
|
||||
</el-button>
|
||||
</div>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs & Search -->
|
||||
<el-card shadow="never" class="border-none bg-transparent !p-0">
|
||||
<div class="flex flex-col md:flex-row items-center justify-between gap-4">
|
||||
<el-tabs v-model="activeTab" class="flex-1 -mb-4 custom-tabs">
|
||||
<el-tab-pane v-for="tab in tabs" :key="tab.name" :name="tab.name">
|
||||
<template #label>
|
||||
{{ tab.label }}
|
||||
<el-badge :value="tabCounts[tab.name] || 0" :max="99" class="ml-1" type="primary" />
|
||||
</template>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
<el-input
|
||||
v-model="searchQuery"
|
||||
placeholder="搜索任务名称或发布人..."
|
||||
class="!w-72"
|
||||
clearable
|
||||
>
|
||||
<template #prefix><Search class="w-4 h-4 text-gray-400" /></template>
|
||||
</el-input>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- Task Cards -->
|
||||
<div v-loading="isLoading">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-5" v-if="filteredTasks.length > 0">
|
||||
<el-card v-for="task in filteredTasks" :key="task.id" shadow="hover"
|
||||
class="cursor-pointer group border-gray-100 hover:border-blue-300 hover:shadow-lg transition-all duration-300 rounded-xl overflow-hidden flex flex-col h-full"
|
||||
:body-style="{ padding: '0px', display: 'flex', flexDirection: 'column', height: '100%' }"
|
||||
@click="router.push(`/enterprise/tasks/${task.id}`)"
|
||||
>
|
||||
<div class="p-6 pb-4 flex-1">
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div class="flex gap-2">
|
||||
<el-tag :type="getStatusType(task.status)" effect="light" size="small" class="font-medium px-2 py-0.5 h-auto">{{ getStatusLabel(task.status) }}</el-tag>
|
||||
<el-tag size="small" type="info" effect="plain" class="!border-gray-200">{{ task.task_type || '未分类' }}</el-tag>
|
||||
</div>
|
||||
<span class="text-xs text-gray-400">{{ new Date(task.created_at).toLocaleDateString() }} 发布</span>
|
||||
</div>
|
||||
|
||||
<h3 class="text-lg font-bold text-gray-800 leading-snug group-hover:text-blue-600 transition-colors line-clamp-2 mb-3">
|
||||
{{ task.title }}
|
||||
</h3>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-x-6 gap-y-2 text-sm text-gray-500 mb-4">
|
||||
<span class="flex items-center gap-1.5 bg-gray-50 px-2 py-1 rounded text-gray-600 font-medium">
|
||||
<span class="text-orange-500 font-bold">¥{{ task.budget_min || 0 }}-{{ task.budget_max || '不限' }}</span>
|
||||
</span>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<Calendar class="w-4 h-4 text-gray-400" />
|
||||
截止: {{ task.deadline || '无期限' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Tags if any -->
|
||||
<div class="flex gap-2 mt-auto" v-if="task.required_skills && task.required_skills.length">
|
||||
<span v-for="skill in task.required_skills.slice(0, 3)" :key="skill" class="text-xs px-2 py-1 bg-blue-50 text-blue-600 rounded">
|
||||
{{ skill }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50/80 w-full flex justify-between items-center px-6 py-3 border-t border-gray-100">
|
||||
<!-- Publisher Info -->
|
||||
<div class="flex items-center gap-2">
|
||||
<el-avatar :size="24" :src="task.publisher_avatar" class="bg-blue-100 text-blue-700 font-bold text-xs">
|
||||
{{ task.publisher_name?.[0] || 'U' }}
|
||||
</el-avatar>
|
||||
<div class="text-xs text-gray-500 flex flex-col">
|
||||
<span class="text-gray-400 leading-none mb-0.5" style="font-size: 10px;">发布人</span>
|
||||
<span class="font-medium text-gray-700 leading-none">{{ task.publisher_name || '企业成员' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="flex items-center gap-4 text-sm">
|
||||
<div class="flex flex-col items-end">
|
||||
<span class="text-gray-400 leading-none mb-0.5" style="font-size: 10px;">申请人数</span>
|
||||
<span class="font-bold text-gray-700 flex items-center leading-none">
|
||||
<ClipboardList class="w-3.5 h-3.5 mr-1 text-gray-400" /> {{ task.applications?.length || 0 }}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronRight class="w-5 h-5 text-gray-300 group-hover:text-blue-500 transition-colors ml-2" />
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<!-- Empty -->
|
||||
<el-empty v-else-if="!isLoading" :description="activeTab === 'ALL' ? '暂无任务' : `暂无${tabs.find(t => t.name === activeTab)?.label || ''}任务`">
|
||||
<el-button type="primary" @click="router.push('/enterprise/tasks/create')">发布新任务</el-button>
|
||||
</el-empty>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.custom-tabs :deep(.el-tabs__item) {
|
||||
font-size: 15px;
|
||||
}
|
||||
</style>
|
||||
173
src/views/enterprise/TeamView.vue
Normal file
173
src/views/enterprise/TeamView.vue
Normal file
@@ -0,0 +1,173 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { UserPlus, Users, Mail, Shield } from 'lucide-vue-next';
|
||||
import api from '@/api';
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
const members = ref<any[]>([]);
|
||||
const isLoading = ref(false);
|
||||
const showAddModal = ref(false);
|
||||
const isAdding = ref(false);
|
||||
const addForm = ref({ username: '', nickname: '', password: '', role: 'MEMBER' });
|
||||
|
||||
const fetchMembers = async () => {
|
||||
try {
|
||||
isLoading.value = true;
|
||||
const res: any = await api.get('/enterprise-members/');
|
||||
members.value = res.results || res;
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch members');
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddMember = async () => {
|
||||
if (!addForm.value.username || addForm.value.username.length < 3) {
|
||||
ElMessage.error('请输入有效的员工用户名(至少3位)');
|
||||
return;
|
||||
}
|
||||
if (!addForm.value.password || addForm.value.password.length < 6) {
|
||||
ElMessage.error('初始密码至少为6位');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
isAdding.value = true;
|
||||
await api.post('/enterprise-members/add_member/', addForm.value);
|
||||
ElMessage.success(`已成功添加内部成员 ${addForm.value.username}`);
|
||||
showAddModal.value = false;
|
||||
addForm.value = { username: '', nickname: '', password: '', role: 'MEMBER' };
|
||||
fetchMembers();
|
||||
} catch (e: any) {
|
||||
let msg = '添加成员失败';
|
||||
if (e.response?.data?.username) msg = '用户名: ' + e.response.data.username[0];
|
||||
else if (e.response?.data?.detail) msg = e.response.data.detail;
|
||||
ElMessage.error(msg);
|
||||
} finally {
|
||||
isAdding.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(fetchMembers);
|
||||
|
||||
const getRoleTag = (role: string) => {
|
||||
if (role === 'ADMIN') return 'danger';
|
||||
if (role === 'GUEST') return 'info';
|
||||
return '';
|
||||
};
|
||||
|
||||
const getRoleLabel = (role: string) => {
|
||||
switch (role) {
|
||||
case 'ADMIN': return '管理员';
|
||||
case 'MEMBER': return '成员';
|
||||
case 'GUEST': return '观察员';
|
||||
default: return role;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusTag = (status: string) => {
|
||||
if (status === 'ACTIVE') return 'success';
|
||||
if (status === 'PENDING') return 'warning';
|
||||
return 'info';
|
||||
};
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
if (status === 'ACTIVE') return '正常';
|
||||
if (status === 'PENDING') return '待激活';
|
||||
if (status === 'INACTIVE') return '已停用';
|
||||
return status;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-5">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-800">团队管理</h1>
|
||||
<p class="text-sm text-gray-400 mt-1">管理企业内部员工账号</p>
|
||||
</div>
|
||||
<el-button type="primary" @click="showAddModal = true">
|
||||
<UserPlus class="w-4 h-4 mr-2" />新增内部成员
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="8" v-for="stat in [
|
||||
{ label: '总成员', value: members.length, icon: Users, color: '#409eff' },
|
||||
{ label: '管理员', value: members.filter(m => m.role === 'ADMIN').length, icon: Shield, color: '#9b59b6' }
|
||||
]" :key="stat.label">
|
||||
<el-card shadow="never">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-sm text-gray-400">{{ stat.label }}</div>
|
||||
<div class="text-2xl font-bold mt-1" :style="{ color: stat.color }">{{ stat.value }}</div>
|
||||
</div>
|
||||
<div class="w-10 h-10 rounded-lg flex items-center justify-center" :style="{ background: stat.color + '15', color: stat.color }">
|
||||
<component :is="stat.icon" class="w-5 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- Table -->
|
||||
<el-card shadow="never">
|
||||
<el-table :data="members" v-loading="isLoading" stripe empty-text="暂无团队成员" style="width: 100%">
|
||||
<el-table-column label="成员信息" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<div class="flex items-center gap-3">
|
||||
<el-avatar :size="36" :src="row.user_details?.avatar_url">{{ (row.user_details?.nickname || row.user_details?.username || 'U')[0] }}</el-avatar>
|
||||
<div>
|
||||
<div class="font-medium text-sm">{{ row.user_details?.nickname || row.user_details?.username }}</div>
|
||||
<div class="text-xs text-gray-400">@{{ row.user_details?.username }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="角色" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getRoleTag(row.role)" size="small">{{ getRoleLabel(row.role) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getStatusTag(row.status)" size="small" effect="light">{{ getStatusLabel(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="joined_at" label="加入时间" width="180">
|
||||
<template #default="{ row }">
|
||||
<span class="text-sm text-gray-400">{{ row.joined_at ? new Date(row.joined_at).toLocaleDateString() : '—' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<!-- Add Dialog -->
|
||||
<el-dialog v-model="showAddModal" title="新增内部员工" width="440px" top="5vh">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="员工用户名 (必填)">
|
||||
<el-input v-model="addForm.username" placeholder="如:zhangsan,供员工登录使用" />
|
||||
</el-form-item>
|
||||
<el-form-item label="员工显示姓名 (可选)">
|
||||
<el-input v-model="addForm.nickname" placeholder="如:张三" />
|
||||
</el-form-item>
|
||||
<el-form-item label="初始密码 (必填)">
|
||||
<el-input v-model="addForm.password" placeholder="至少 6 位密码" type="password" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item label="分配角色">
|
||||
<el-select v-model="addForm.role" class="w-full">
|
||||
<el-option label="普通成员" value="MEMBER" />
|
||||
<el-option label="管理员" value="ADMIN" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showAddModal = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleAddMember" :loading="isAdding">创建员工账号</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
185
src/views/enterprise/UserSettingsView.vue
Normal file
185
src/views/enterprise/UserSettingsView.vue
Normal file
@@ -0,0 +1,185 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import api from '@/api';
|
||||
import { Camera, Settings, Lock } from 'lucide-vue-next';
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const user = computed(() => authStore.user);
|
||||
|
||||
const formData = ref({ nickname: '', phone: '', email: '', avatar_url: '' });
|
||||
|
||||
watch(user, (v) => {
|
||||
if (v) {
|
||||
formData.value = {
|
||||
nickname: v.nickname || '',
|
||||
phone: v.phone || '',
|
||||
email: v.email || '',
|
||||
avatar_url: v.avatar_url || ''
|
||||
};
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
const isSaving = ref(false);
|
||||
|
||||
const handleAvatarUpload = async (options: any) => {
|
||||
const file = options.file;
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
fd.append('folder', 'avatars');
|
||||
try {
|
||||
const res: any = await api.post('/upload/', fd, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
});
|
||||
formData.value.avatar_url = res.url;
|
||||
ElMessage.success('头像上传成功');
|
||||
} catch (error) {
|
||||
ElMessage.error('头像上传失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveProfile = async () => {
|
||||
isSaving.value = true;
|
||||
try {
|
||||
await api.put('/users/update_profile/', formData.value);
|
||||
await authStore.fetchUser();
|
||||
ElMessage.success('个人信息已保存');
|
||||
} catch (error: any) {
|
||||
const data = error.response?.data;
|
||||
let msg = '未知错误';
|
||||
if (data?.detail) msg = data.detail;
|
||||
ElMessage.error(`保存失败: ${msg}`);
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const passwordForm = ref({ old_password: '', new_password: '', confirm_password: '' });
|
||||
const isChangingPwd = ref(false);
|
||||
|
||||
const handleChangePassword = async () => {
|
||||
if (!passwordForm.value.old_password || !passwordForm.value.new_password) {
|
||||
ElMessage.warning('密码不能为空'); return;
|
||||
}
|
||||
if (passwordForm.value.new_password.length < 6) {
|
||||
ElMessage.warning('新密码不能少于6个字符'); return;
|
||||
}
|
||||
if (passwordForm.value.new_password !== passwordForm.value.confirm_password) {
|
||||
ElMessage.warning('两次输入的新密码不一致'); return;
|
||||
}
|
||||
try {
|
||||
isChangingPwd.value = true;
|
||||
await api.post('/users/change_password/', {
|
||||
old_password: passwordForm.value.old_password,
|
||||
new_password: passwordForm.value.new_password
|
||||
});
|
||||
ElMessage.success('密码修改成功,请重新登录');
|
||||
passwordForm.value = { old_password: '', new_password: '', confirm_password: '' };
|
||||
setTimeout(() => {
|
||||
authStore.logout();
|
||||
window.location.href = '/enterprise/login';
|
||||
}, 1500);
|
||||
} catch (error: any) {
|
||||
const data = error.response?.data;
|
||||
if (data?.old_password) ElMessage.error(data.old_password[0]);
|
||||
else ElMessage.error('密码修改失败');
|
||||
} finally { isChangingPwd.value = false; }
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="max-w-4xl mx-auto space-y-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-800">个人设置</h1>
|
||||
<p class="text-sm text-gray-400 mt-1">管理您在企业工作台的个人显示信息</p>
|
||||
</div>
|
||||
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<Settings class="w-5 h-5 text-blue-500" />
|
||||
<span class="font-semibold">基本信息</span>
|
||||
</div>
|
||||
</template>
|
||||
<el-form label-position="top">
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="24" class="mb-6">
|
||||
<div class="flex items-center gap-6">
|
||||
<el-upload
|
||||
class="avatar-uploader group relative rounded-full overflow-hidden w-24 h-24 border border-gray-200 cursor-pointer flex-shrink-0"
|
||||
action=""
|
||||
:http-request="handleAvatarUpload"
|
||||
:show-file-list="false"
|
||||
>
|
||||
<el-avatar v-if="formData.avatar_url" :size="96" :src="formData.avatar_url" class="bg-blue-100 text-blue-700 font-bold" />
|
||||
<el-avatar v-else :size="96" class="bg-blue-100 text-blue-700 text-3xl font-bold">
|
||||
{{ user?.nickname?.[0] || user?.username?.[0] || 'U' }}
|
||||
</el-avatar>
|
||||
<div class="absolute inset-0 bg-black/40 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Camera class="w-6 h-6 text-white" />
|
||||
</div>
|
||||
</el-upload>
|
||||
<div>
|
||||
<div class="text-sm font-medium text-gray-800 mb-1">个人头像</div>
|
||||
<div class="text-xs text-gray-400">点击头像进行更换。支持 JPG/PNG 格式,建议尺寸 200x200px</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
|
||||
<el-col :span="12">
|
||||
<el-form-item label="显示姓名 (Nickname)">
|
||||
<el-input v-model="formData.nickname" placeholder="如:张三" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
|
||||
<el-col :span="12">
|
||||
<el-form-item label="联系电话 (可选)">
|
||||
<el-input v-model="formData.phone" placeholder="11位手机号" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
|
||||
<el-col :span="12">
|
||||
<el-form-item label="联系邮箱 (可选)">
|
||||
<el-input v-model="formData.email" placeholder="example@company.com" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<div class="mt-4">
|
||||
<el-button type="primary" :loading="isSaving" @click="handleSaveProfile">
|
||||
{{ isSaving ? '保存中...' : '保存修改' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<Lock class="w-5 h-5 text-red-500" />
|
||||
<span class="font-semibold">账号安全</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="max-w-md">
|
||||
<h3 class="text-sm font-bold text-gray-800 mb-4">修改登录密码</h3>
|
||||
<el-form @submit.prevent="handleChangePassword" label-position="top">
|
||||
<el-form-item label="当前密码" required>
|
||||
<el-input v-model="passwordForm.old_password" type="password" show-password placeholder="请输入当前使用的密码" />
|
||||
</el-form-item>
|
||||
<el-form-item label="新密码" required>
|
||||
<el-input v-model="passwordForm.new_password" type="password" show-password placeholder="至少6个字符" />
|
||||
</el-form-item>
|
||||
<el-form-item label="确认新密码" required>
|
||||
<el-input v-model="passwordForm.confirm_password" type="password" show-password placeholder="再次输入新密码" />
|
||||
</el-form-item>
|
||||
<div class="mt-4 flex items-center gap-3">
|
||||
<el-button type="primary" native-type="submit" :loading="isChangingPwd">确认修改密码</el-button>
|
||||
<span class="text-xs text-gray-400">修改成功后将要求重新登录</span>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
187
src/views/enterprise/VerificationView.vue
Normal file
187
src/views/enterprise/VerificationView.vue
Normal file
@@ -0,0 +1,187 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { ShieldCheck, Upload, CheckCircle2, FileText, X } from 'lucide-vue-next';
|
||||
import api from '@/api';
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
const currentStep = ref(0);
|
||||
const isSubmitted = ref(false);
|
||||
|
||||
const formData = ref({
|
||||
companyName: '',
|
||||
creditCode: '',
|
||||
legalPerson: '',
|
||||
licenseImage: null as string | null
|
||||
});
|
||||
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
const isUploading = ref(false);
|
||||
|
||||
const triggerUpload = () => {
|
||||
fileInput.value?.click();
|
||||
};
|
||||
|
||||
const handleFileUpload = async (event: Event) => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const file = target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const formDataObj = new FormData();
|
||||
formDataObj.append('file', file);
|
||||
formDataObj.append('folder', 'verifications');
|
||||
|
||||
isUploading.value = true;
|
||||
try {
|
||||
const res: any = await api.post('/upload/', formDataObj, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
});
|
||||
formData.value.licenseImage = res.url;
|
||||
ElMessage.success('营业执照上传成功');
|
||||
} catch (e) {
|
||||
ElMessage.error('文件上传失败');
|
||||
} finally {
|
||||
isUploading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const nextStep = () => {
|
||||
if (currentStep.value === 0) {
|
||||
if (!formData.value.companyName || formData.value.companyName.length < 2) {
|
||||
ElMessage.error('请输入正确的企业全称');
|
||||
return;
|
||||
}
|
||||
const creditCodeRegex = /^[A-Z0-9]{18}$/;
|
||||
if (!creditCodeRegex.test(formData.value.creditCode)) {
|
||||
ElMessage.error('请输入有效的18位统一社会信用代码');
|
||||
return;
|
||||
}
|
||||
if (!formData.value.legalPerson) {
|
||||
ElMessage.error('请输入法定代表人姓名');
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (currentStep.value === 1) {
|
||||
if (!formData.value.licenseImage) {
|
||||
ElMessage.error('请上传营业执照扫描件');
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (currentStep.value < 2) currentStep.value++;
|
||||
};
|
||||
|
||||
const prevStep = () => {
|
||||
if (currentStep.value > 0) currentStep.value--;
|
||||
};
|
||||
|
||||
const submitVerification = () => {
|
||||
if (!formData.value.licenseImage) {
|
||||
ElMessage.error('请上传营业执照扫描件');
|
||||
return;
|
||||
}
|
||||
isSubmitted.value = true;
|
||||
ElMessage.success('企业认证申请已提交');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="max-w-3xl mx-auto space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-800">企业认证</h1>
|
||||
<p class="text-sm text-gray-400 mt-1">完成认证后可享受全功能算力支持</p>
|
||||
</div>
|
||||
<el-tag v-if="!isSubmitted" type="primary" effect="light">
|
||||
<ShieldCheck class="w-3.5 h-3.5 mr-1 inline" />认证后解锁全部功能
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<!-- Stepper -->
|
||||
<el-card v-if="!isSubmitted" shadow="never">
|
||||
<el-steps :active="currentStep" finish-status="success" align-center class="mb-8">
|
||||
<el-step title="企业信息" description="完善企业基础资料" />
|
||||
<el-step title="资质上传" description="上传营业执照及其它证件" />
|
||||
<el-step title="人工审核" description="平台核验信息真实性" />
|
||||
</el-steps>
|
||||
|
||||
<!-- Step 1: Info -->
|
||||
<div v-if="currentStep === 0">
|
||||
<el-form label-position="top" class="max-w-lg mx-auto">
|
||||
<el-form-item label="企业全称" required>
|
||||
<el-input v-model="formData.companyName" placeholder="请与营业执照保持一致" size="large" />
|
||||
</el-form-item>
|
||||
<el-form-item label="统一社会信用代码" required>
|
||||
<el-input v-model="formData.creditCode" placeholder="18位信用代码" size="large" />
|
||||
</el-form-item>
|
||||
<el-form-item label="法定代表人" required>
|
||||
<el-input v-model="formData.legalPerson" placeholder="请输入姓名" size="large" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="flex justify-end mt-6">
|
||||
<el-button type="primary" size="large" @click="nextStep">下一步</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Upload -->
|
||||
<div v-if="currentStep === 1">
|
||||
<div class="max-w-lg mx-auto">
|
||||
<!-- Hidden File Input -->
|
||||
<input type="file" ref="fileInput" class="hidden" accept="image/*,.pdf" @change="handleFileUpload" />
|
||||
|
||||
<div v-if="!formData.licenseImage" @click="triggerUpload"
|
||||
class="border-2 border-dashed border-gray-200 rounded-xl p-10 text-center cursor-pointer hover:border-blue-400 hover:bg-blue-50/30 transition-all"
|
||||
>
|
||||
<el-icon class="text-4xl text-gray-300 mb-3">
|
||||
<Upload v-if="!isUploading" class="w-10 h-10" />
|
||||
</el-icon>
|
||||
<div v-if="isUploading" class="w-8 h-8 border-2 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto mb-3"></div>
|
||||
<p class="font-semibold text-gray-700 mb-1">{{ isUploading ? '正在上传...' : '上传营业执照扫描件' }}</p>
|
||||
<p class="text-sm text-gray-400">PDF, JPG or PNG (最大 10MB)</p>
|
||||
</div>
|
||||
<div v-else class="text-center">
|
||||
<div class="relative inline-block">
|
||||
<el-image :src="formData.licenseImage" class="h-40 rounded-lg" fit="contain" />
|
||||
<el-button circle size="small" type="danger" class="!absolute -top-2 -right-2" @click="formData.licenseImage = null">
|
||||
<X class="w-3 h-3" />
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-alert type="info" :closable="false" show-icon class="mt-4">
|
||||
<template #title>请上传加盖公章的复印件或原件扫描件。信息仅用于企业身份核验,我们严格保密。</template>
|
||||
</el-alert>
|
||||
</div>
|
||||
<div class="flex justify-between mt-6">
|
||||
<el-button size="large" @click="prevStep">上一步</el-button>
|
||||
<el-button type="primary" size="large" @click="nextStep">确认无误</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Review -->
|
||||
<div v-if="currentStep === 2" class="text-center">
|
||||
<div class="w-20 h-20 bg-blue-50 text-blue-600 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<ShieldCheck class="w-10 h-10" />
|
||||
</div>
|
||||
<h2 class="text-xl font-bold text-gray-800 mb-2">准备提交审核</h2>
|
||||
<p class="text-gray-500 mb-6">请核对您的企业信息,一旦提交后在审核期间将无法修改。</p>
|
||||
|
||||
<el-descriptions :column="1" border class="max-w-sm mx-auto text-left mb-8">
|
||||
<el-descriptions-item label="企业全称">{{ formData.companyName || '未填写' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="信用代码">{{ formData.creditCode || '未填写' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="法定代表人">{{ formData.legalPerson || '未填写' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<div class="flex justify-center gap-4">
|
||||
<el-button size="large" @click="prevStep">返回修改</el-button>
|
||||
<el-button type="primary" size="large" @click="submitVerification">提交申请</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- Success State -->
|
||||
<el-result v-else icon="success" title="认证申请已提交" sub-title="我们将在 1-2 个工作日内完成审核。审核结果将通过站内信和邮件通知您。">
|
||||
<template #extra>
|
||||
<el-button type="primary" @click="$router.push('/enterprise/dashboard')">返回工作台</el-button>
|
||||
</template>
|
||||
</el-result>
|
||||
</div>
|
||||
</template>
|
||||
501
src/views/user/DashboardView.vue
Normal file
501
src/views/user/DashboardView.vue
Normal file
@@ -0,0 +1,501 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, computed } from 'vue';
|
||||
import {
|
||||
ClipboardList, ArrowRight, History, Calendar, Newspaper,
|
||||
Briefcase, Star, ShieldCheck, CheckCircle, XCircle, Clock,
|
||||
ShoppingBag, Mail, Send, TrendingUp, Sparkles
|
||||
} from 'lucide-vue-next';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { getAnnouncements } from '@/api/system';
|
||||
import { getApplications, getTasks } from '@/api/tasks';
|
||||
import { MdPreview } from 'md-editor-v3';
|
||||
import 'md-editor-v3/lib/preview.css';
|
||||
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
const user = computed(() => authStore.user);
|
||||
|
||||
const pendingApps = ref(0);
|
||||
const approvedApps = ref(0);
|
||||
const rejectedApps = ref(0);
|
||||
const withdrawnApps = ref(0);
|
||||
const deliveredApps = ref(0);
|
||||
const completedApps = ref(0);
|
||||
const cancelledApps = ref(0);
|
||||
const openTasks = ref(0);
|
||||
const myProjectCount = ref(0);
|
||||
const recentActivities = ref<any[]>([]);
|
||||
const announcements = ref<any[]>([]);
|
||||
const currentAnnIndex = ref(0);
|
||||
const recommendedTasks = ref<any[]>([]);
|
||||
let annTimer: any = null;
|
||||
|
||||
const annPageSize = 2;
|
||||
const totalAnnPages = computed(() => Math.ceil(announcements.value.length / annPageSize));
|
||||
const nextAnn = () => { if (totalAnnPages.value > 1) currentAnnIndex.value = (currentAnnIndex.value + 1) % totalAnnPages.value; };
|
||||
const prevAnn = () => { if (totalAnnPages.value > 1) currentAnnIndex.value = (currentAnnIndex.value - 1 + totalAnnPages.value) % totalAnnPages.value; };
|
||||
const visibleAnns = computed(() => {
|
||||
const start = currentAnnIndex.value * annPageSize;
|
||||
return announcements.value.slice(start, start + annPageSize);
|
||||
});
|
||||
|
||||
const annDetailVisible = ref(false);
|
||||
const selectedAnn = ref<any>(null);
|
||||
const openAnnDetail = (ann: any) => { selectedAnn.value = ann; annDetailVisible.value = true; };
|
||||
|
||||
const isLoading = ref(true);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
isLoading.value = true;
|
||||
const [annRes, appRes, tasksRes] = await Promise.all([
|
||||
getAnnouncements({ audience: 'USER' }), getApplications(), getTasks(),
|
||||
]);
|
||||
announcements.value = (annRes as any).results || [];
|
||||
const apps = (appRes as any).results || [];
|
||||
pendingApps.value = apps.filter((a: any) => a.status === 'PENDING').length;
|
||||
approvedApps.value = apps.filter((a: any) => a.status === 'APPROVED').length;
|
||||
rejectedApps.value = apps.filter((a: any) => a.status === 'REJECTED').length;
|
||||
withdrawnApps.value = apps.filter((a: any) => a.status === 'WITHDRAWN').length;
|
||||
deliveredApps.value = apps.filter((a: any) => a.status === 'DELIVERED').length;
|
||||
|
||||
const getAppStatus = (app: any) => {
|
||||
if (app.task_detail?.status === 'CANCELLED') return 'CANCELLED';
|
||||
if (app.task_detail?.status === 'COMPLETED' || app.status === 'COMPLETED') return 'COMPLETED';
|
||||
return app.status;
|
||||
};
|
||||
|
||||
completedApps.value = apps.filter((a: any) => getAppStatus(a) === 'COMPLETED').length;
|
||||
cancelledApps.value = apps.filter((a: any) => getAppStatus(a) === 'CANCELLED').length;
|
||||
|
||||
myProjectCount.value = apps.filter((a: any) => {
|
||||
const s = getAppStatus(a);
|
||||
return ['APPROVED', 'DELIVERED', 'COMPLETED', 'CANCELLED'].includes(s);
|
||||
}).length;
|
||||
|
||||
const allTasks = (tasksRes as any).results || (tasksRes as any) || [];
|
||||
openTasks.value = allTasks.filter((t: any) => t.status === 'OPEN').length;
|
||||
recommendedTasks.value = allTasks.filter((t: any) => t.status === 'OPEN' && t.is_recommended).slice(0, 3);
|
||||
if (!recommendedTasks.value.length) recommendedTasks.value = allTasks.filter((t: any) => t.status === 'OPEN').slice(0, 3);
|
||||
|
||||
recentActivities.value = apps.slice(0, 5).map((a: any) => ({
|
||||
id: a.id, taskId: a.task, status: a.status,
|
||||
taskName: a.task_detail?.title || `任务 #${a.task}`,
|
||||
timeAgo: getTimeAgo(a.created_at),
|
||||
}));
|
||||
} catch (e) { console.error(e); } finally { isLoading.value = false; }
|
||||
};
|
||||
|
||||
const getTimeAgo = (d: string) => {
|
||||
const m = Math.floor((Date.now() - new Date(d).getTime()) / 60000);
|
||||
if (m < 60) return `${m}分钟前`;
|
||||
const h = Math.floor(m / 60);
|
||||
return h < 24 ? `${h}小时前` : `${Math.floor(h / 24)}天前`;
|
||||
};
|
||||
|
||||
onMounted(() => { fetchData(); annTimer = setInterval(nextAnn, 6000); });
|
||||
onUnmounted(() => { if (annTimer) clearInterval(annTimer); });
|
||||
|
||||
const statusType = (s: string) => ({ APPROVED: 'success', REJECTED: 'danger', PENDING: 'warning', DELIVERED: 'primary' }[s] || 'info');
|
||||
const statusLabel = (s: string) => ({ APPROVED: '执行中', DELIVERED: '待验收', COMPLETED: '已结案', PENDING: '待审核', REJECTED: '被拒绝' }[s] || s);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dash" v-loading="isLoading">
|
||||
<!-- ===== Stats Row ===== -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
||||
<!-- 我的申请 -->
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<div class="card-icon" style="--c:#3b82f6;--bg:#eff6ff"><Send class="w-4 h-4" /></div>
|
||||
<h2 class="card-title">我的申请</h2>
|
||||
<button class="card-link" @click="router.push('/user/applications')">查看全部 <ArrowRight class="w-3 h-3" /></button>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 gap-3">
|
||||
<div class="stat-chip" @click="router.push({ path: '/user/applications', query: { tab: 'PENDING' } })">
|
||||
<div class="stat-chip-dot" style="background:#3b82f6"></div>
|
||||
<div class="stat-chip-body">
|
||||
<span class="stat-chip-label">待审核</span>
|
||||
<span class="stat-chip-num" style="color:#3b82f6">{{ pendingApps }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-chip" @click="router.push({ path: '/user/applications', query: { tab: 'APPROVED' } })">
|
||||
<div class="stat-chip-dot" style="background:#10b981"></div>
|
||||
<div class="stat-chip-body">
|
||||
<span class="stat-chip-label">已通过</span>
|
||||
<span class="stat-chip-num" style="color:#10b981">{{ approvedApps }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-chip stat-chip--danger" @click="router.push({ path: '/user/applications', query: { tab: 'REJECTED' } })">
|
||||
<div class="stat-chip-dot" style="background:#ef4444"></div>
|
||||
<div class="stat-chip-body">
|
||||
<span class="stat-chip-label">被拒绝</span>
|
||||
<span class="stat-chip-num" style="color:#ef4444">{{ rejectedApps }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-chip" @click="router.push({ path: '/user/applications', query: { tab: 'WITHDRAWN' } })">
|
||||
<div class="stat-chip-dot" style="background:#9ca3af"></div>
|
||||
<div class="stat-chip-body">
|
||||
<span class="stat-chip-label">已撤回</span>
|
||||
<span class="stat-chip-num" style="color:#9ca3af">{{ withdrawnApps }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 项目管理 -->
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<div class="card-icon" style="--c:#059669;--bg:#ecfdf5"><Briefcase class="w-4 h-4" /></div>
|
||||
<h2 class="card-title">项目管理</h2>
|
||||
<button class="card-link" @click="router.push('/user/tasks/my')">全部项目 <ArrowRight class="w-3 h-3" /></button>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 gap-3">
|
||||
<div class="stat-chip" @click="router.push({ path: '/user/tasks/my', query: { tab: 'APPROVED' } })">
|
||||
<div class="stat-chip-dot" style="background:#10b981"></div>
|
||||
<div class="stat-chip-body">
|
||||
<span class="stat-chip-label">执行中</span>
|
||||
<span class="stat-chip-num" style="color:#10b981">{{ approvedApps }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-chip" @click="router.push({ path: '/user/tasks/my', query: { tab: 'DELIVERED' } })">
|
||||
<div class="stat-chip-dot" style="background:#8b5cf6"></div>
|
||||
<div class="stat-chip-body">
|
||||
<span class="stat-chip-label">待验收</span>
|
||||
<span class="stat-chip-num" style="color:#8b5cf6">{{ deliveredApps }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-chip" @click="router.push({ path: '/user/tasks/my', query: { tab: 'COMPLETED' } })">
|
||||
<div class="stat-chip-dot" style="background:#059669"></div>
|
||||
<div class="stat-chip-body">
|
||||
<span class="stat-chip-label">已完成</span>
|
||||
<span class="stat-chip-num" style="color:#059669">{{ completedApps }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-chip stat-chip--danger" @click="router.push({ path: '/user/tasks/my', query: { tab: 'CANCELLED' } })">
|
||||
<div class="stat-chip-dot" style="background:#ef4444"></div>
|
||||
<div class="stat-chip-body">
|
||||
<span class="stat-chip-label">已取消</span>
|
||||
<span class="stat-chip-num" style="color:#ef4444">{{ cancelledApps }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== Quick Links + Recommended ===== -->
|
||||
<div class="grid grid-cols-4 gap-4 mt-5">
|
||||
<div class="qlink" @click="router.push('/user/tasks/market')">
|
||||
<div class="qlink-icon" style="background:#fef3c7;color:#d97706"><ShoppingBag class="w-5 h-5" /></div>
|
||||
<span class="qlink-name">接单大厅</span>
|
||||
<span class="qlink-sub">{{ openTasks }} 个可接任务</span>
|
||||
</div>
|
||||
<div class="qlink" @click="router.push('/user/invitations')">
|
||||
<div class="qlink-icon" style="background:#fce7f3;color:#db2777"><Mail class="w-5 h-5" /></div>
|
||||
<span class="qlink-name">邀请我的</span>
|
||||
<span class="qlink-sub">定向邀约</span>
|
||||
</div>
|
||||
<div class="qlink" @click="router.push('/user/tasks/my')">
|
||||
<div class="qlink-icon" style="background:#ecfdf5;color:#059669"><ClipboardList class="w-5 h-5" /></div>
|
||||
<span class="qlink-name">我的项目</span>
|
||||
<span class="qlink-sub">{{ myProjectCount }} 个进行中</span>
|
||||
</div>
|
||||
<div class="qlink" @click="router.push('/user/applications')">
|
||||
<div class="qlink-icon" style="background:#eff6ff;color:#3b82f6"><Send class="w-5 h-5" /></div>
|
||||
<span class="qlink-name">我的申请</span>
|
||||
<span class="qlink-sub">{{ pendingApps + approvedApps + rejectedApps }} 条记录</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== Bottom: Announcements + Recommended + Activity ===== -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-12 gap-5 mt-5">
|
||||
<!-- Announcements -->
|
||||
<div class="lg:col-span-5 card">
|
||||
<div class="card-head">
|
||||
<div class="card-icon" style="--c:#7c3aed;--bg:#ede9fe"><Newspaper class="w-4 h-4" /></div>
|
||||
<h2 class="card-title">平台公告</h2>
|
||||
<div v-if="totalAnnPages > 1" class="flex items-center gap-1 ml-auto">
|
||||
<button @click.stop="prevAnn" class="ann-btn">‹</button>
|
||||
<span class="text-[11px] text-gray-400 tabular-nums w-9 text-center">{{ currentAnnIndex + 1 }}/{{ totalAnnPages }}</span>
|
||||
<button @click.stop="nextAnn" class="ann-btn">›</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="visibleAnns.length" class="space-y-3">
|
||||
<div v-for="ann in visibleAnns" :key="ann.id"
|
||||
class="ann-card" @click="openAnnDetail(ann)">
|
||||
<img v-if="ann.cover_url" :src="ann.cover_url" class="ann-cover" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<el-tag size="small" :type="ann.target_audience === 'USER' ? 'primary' : 'info'" effect="light" round>
|
||||
{{ ann.target_audience === 'USER' ? '用户' : '全平台' }}
|
||||
</el-tag>
|
||||
<span class="text-[11px] text-gray-400">{{ new Date(ann.created_at).toLocaleDateString() }}</span>
|
||||
</div>
|
||||
<h3 class="text-sm font-bold text-gray-800 truncate">{{ ann.title }}</h3>
|
||||
<p class="text-xs text-gray-400 mt-0.5 truncate">{{ ann.content?.replace(/[#*`_~>\-\[\]()!]/g, '').substring(0, 60) }}</p>
|
||||
</div>
|
||||
<ArrowRight class="w-3.5 h-3.5 text-gray-300 flex-shrink-0 self-center" />
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else description="暂无公告" :image-size="50" />
|
||||
</div>
|
||||
|
||||
<!-- Recommended Tasks -->
|
||||
<div class="lg:col-span-4 card">
|
||||
<div class="card-head">
|
||||
<div class="card-icon" style="--c:#d97706;--bg:#fef3c7"><Sparkles class="w-4 h-4" /></div>
|
||||
<h2 class="card-title">推荐任务</h2>
|
||||
<button class="card-link" @click="router.push('/user/tasks/market')">更多 <ArrowRight class="w-3 h-3" /></button>
|
||||
</div>
|
||||
<div v-if="recommendedTasks.length" class="space-y-1">
|
||||
<div v-for="task in recommendedTasks" :key="task.id"
|
||||
class="rec-item" @click="router.push(`/user/tasks/${task.id}`)">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-semibold text-gray-700 truncate">{{ task.title }}</div>
|
||||
<div class="text-xs text-gray-400 mt-0.5">¥{{ task.budget_min?.toLocaleString() || 0 }} - {{ task.budget_max?.toLocaleString() || 0 }}</div>
|
||||
</div>
|
||||
<ArrowRight class="w-3.5 h-3.5 text-gray-300 flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else description="暂无推荐" :image-size="50" />
|
||||
</div>
|
||||
|
||||
<!-- Activity -->
|
||||
<div class="lg:col-span-3 card">
|
||||
<div class="card-head">
|
||||
<div class="card-icon" style="--c:#d97706;--bg:#fef3c7"><History class="w-4 h-4" /></div>
|
||||
<h2 class="card-title">最近动态</h2>
|
||||
</div>
|
||||
<div v-if="recentActivities.length" class="space-y-1">
|
||||
<div v-for="act in recentActivities" :key="act.id"
|
||||
class="act-item" @click="router.push(`/user/tasks/${act.taskId}`)">
|
||||
<div class="act-dot"
|
||||
:class="act.status === 'APPROVED' ? 'bg-emerald-400' : act.status === 'PENDING' ? 'bg-yellow-400' : act.status === 'REJECTED' ? 'bg-red-400' : 'bg-blue-400'">
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-xs text-gray-700 truncate">{{ act.taskName }}</div>
|
||||
<div class="text-[10px] text-gray-400">{{ act.timeAgo }}</div>
|
||||
</div>
|
||||
<el-tag :type="(statusType(act.status) as any)" size="small" effect="light" round class="!text-[10px]">{{ statusLabel(act.status) }}</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else description="暂无动态" :image-size="50" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Announcement Detail -->
|
||||
<el-dialog v-model="annDetailVisible" :title="selectedAnn?.title" width="700px" top="5vh">
|
||||
<div v-if="selectedAnn" class="space-y-4">
|
||||
<div class="flex items-center gap-3 text-sm text-gray-400">
|
||||
<el-tag size="small" type="primary">{{ selectedAnn.target_audience === 'USER' ? '用户专属' : '综合公告' }}</el-tag>
|
||||
<span class="flex items-center gap-1"><Calendar class="w-3 h-3" />{{ new Date(selectedAnn.created_at).toLocaleDateString() }}</span>
|
||||
</div>
|
||||
<div v-if="selectedAnn.cover_url" class="rounded-lg overflow-hidden">
|
||||
<img :src="selectedAnn.cover_url" class="w-full max-h-64 object-cover" />
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded-xl p-6 border border-gray-100 min-h-[200px]">
|
||||
<MdPreview :modelValue="selectedAnn.content" />
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dash { max-width: 100%; }
|
||||
|
||||
|
||||
|
||||
/* Card */
|
||||
.card {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
padding: 18px 22px;
|
||||
border: 1px solid #f0f0f0;
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
.card:hover { box-shadow: 0 2px 12px rgba(0,0,0,0.03); }
|
||||
.card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.card-icon {
|
||||
width: 30px; height: 30px;
|
||||
border-radius: 9px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: var(--bg);
|
||||
color: var(--c);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.card-title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
flex: 1;
|
||||
}
|
||||
.card-link {
|
||||
font-size: 12px;
|
||||
color: #3b82f6;
|
||||
font-weight: 500;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
transition: color 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.card-link:hover { color: #2563eb; }
|
||||
|
||||
/* Stat Chip */
|
||||
.stat-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
background: #fafbfc;
|
||||
border: 1px solid #f0f1f3;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.stat-chip:hover {
|
||||
background: #f0f7ff;
|
||||
border-color: #c7dafb;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(59,130,246,0.06);
|
||||
}
|
||||
.stat-chip--danger:hover {
|
||||
background: #fef2f2;
|
||||
border-color: #fecaca;
|
||||
box-shadow: 0 4px 12px rgba(239,68,68,0.06);
|
||||
}
|
||||
.stat-chip-dot {
|
||||
width: 8px; height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.stat-chip-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.stat-chip-label {
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
}
|
||||
.stat-chip-num {
|
||||
font-size: 20px;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Quick Links */
|
||||
.qlink {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 18px 10px 14px;
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
border: 1px solid #f0f0f0;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
text-align: center;
|
||||
}
|
||||
.qlink:hover {
|
||||
border-color: #e0e7ff;
|
||||
background: #fafbff;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px -4px rgba(99,102,241,0.08);
|
||||
}
|
||||
.qlink-icon {
|
||||
width: 42px; height: 42px;
|
||||
border-radius: 13px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.qlink-name {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #374151;
|
||||
}
|
||||
.qlink-sub {
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* Recommended */
|
||||
.rec-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.rec-item:hover { background: #f9fafb; }
|
||||
|
||||
/* Activity */
|
||||
.act-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 6px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.act-item:hover { background: #f9fafb; }
|
||||
.act-dot {
|
||||
width: 6px; height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Announcement nav */
|
||||
.ann-btn {
|
||||
width: 22px; height: 22px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #e5e7eb;
|
||||
background: #fff;
|
||||
color: #6b7280;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.ann-btn:hover { background: #f3f4f6; border-color: #d1d5db; }
|
||||
|
||||
/* Announcement card */
|
||||
.ann-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.ann-card:hover {
|
||||
background: #f9fafb;
|
||||
border-color: #f0f0f0;
|
||||
}
|
||||
.ann-cover {
|
||||
width: 72px;
|
||||
height: 50px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid #f0f0f0;
|
||||
}
|
||||
</style>
|
||||
256
src/views/user/InvitationsView.vue
Normal file
256
src/views/user/InvitationsView.vue
Normal file
@@ -0,0 +1,256 @@
|
||||
<script setup lang="ts">
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { onMounted, ref, computed } from 'vue';
|
||||
import { useTasksStore } from '@/stores/tasks';
|
||||
import { Building2, Phone, ShieldCheck, Zap, Mail } from 'lucide-vue-next';
|
||||
|
||||
const tasksStore = useTasksStore();
|
||||
const invitations = computed(() => tasksStore.invitations);
|
||||
|
||||
const activeTab = ref('PENDING');
|
||||
const showContactDialog = ref(false);
|
||||
const showActionDialog = ref(false);
|
||||
const selectedInv = ref<any>(null);
|
||||
const actionType = ref<'ACCEPT' | 'REJECT' | 'DISCUSS'>('ACCEPT');
|
||||
const responseMessage = ref('');
|
||||
const showChatDialog = ref(false);
|
||||
|
||||
onMounted(async () => { await tasksStore.fetchInvitations(); });
|
||||
|
||||
const filteredInvitations = computed(() => {
|
||||
if (activeTab.value === 'PENDING') return invitations.value.filter(i => i.status === 'PENDING');
|
||||
if (activeTab.value === 'ACTIVE') return invitations.value.filter(i => i.status === 'DISCUSSING');
|
||||
if (activeTab.value === 'ARCHIVED') return invitations.value.filter(i => i.status === 'REJECTED' || i.status === 'ACCEPTED');
|
||||
return invitations.value;
|
||||
});
|
||||
|
||||
const stats = computed(() => ({
|
||||
pending: invitations.value.filter(i => i.status === 'PENDING').length,
|
||||
active: invitations.value.filter(i => i.status === 'DISCUSSING').length,
|
||||
archived: invitations.value.filter(i => i.status === 'REJECTED' || i.status === 'ACCEPTED').length,
|
||||
}));
|
||||
|
||||
const openActionModal = (inv: any, type: 'ACCEPT' | 'REJECT' | 'DISCUSS') => {
|
||||
selectedInv.value = inv;
|
||||
actionType.value = type;
|
||||
responseMessage.value = '';
|
||||
showActionDialog.value = true;
|
||||
};
|
||||
|
||||
const openContact = (inv: any) => {
|
||||
selectedInv.value = inv;
|
||||
showContactDialog.value = true;
|
||||
};
|
||||
|
||||
const openChat = (inv: any) => {
|
||||
selectedInv.value = inv;
|
||||
responseMessage.value = '';
|
||||
showChatDialog.value = true;
|
||||
};
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (!selectedInv.value) return;
|
||||
try {
|
||||
if (actionType.value === 'ACCEPT') {
|
||||
await tasksStore.acceptInvitation(selectedInv.value.id);
|
||||
activeTab.value = 'ACTIVE';
|
||||
} else if (actionType.value === 'REJECT') {
|
||||
await tasksStore.rejectInvitation(selectedInv.value.id);
|
||||
activeTab.value = 'ARCHIVED';
|
||||
} else if (actionType.value === 'DISCUSS') {
|
||||
if (!responseMessage.value.trim()) {
|
||||
ElMessage.warning('请输入留言内容');
|
||||
return;
|
||||
}
|
||||
await tasksStore.discussInvitation(selectedInv.value.id, responseMessage.value);
|
||||
activeTab.value = 'ACTIVE';
|
||||
}
|
||||
showActionDialog.value = false;
|
||||
} catch (error) {
|
||||
ElMessage.error('操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendChat = async () => {
|
||||
if (!selectedInv.value || !responseMessage.value.trim()) return;
|
||||
try {
|
||||
await tasksStore.discussInvitation(selectedInv.value.id, responseMessage.value);
|
||||
responseMessage.value = '';
|
||||
// Refresh the selectedInv from store
|
||||
selectedInv.value = invitations.value.find(i => i.id === selectedInv.value.id);
|
||||
} catch (error) {
|
||||
ElMessage.error('发送失败');
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusType = (status: string) => {
|
||||
switch (status) {
|
||||
case 'ACCEPTED': return 'success';
|
||||
case 'REJECTED': return 'info';
|
||||
case 'DISCUSSING': return 'primary';
|
||||
case 'PENDING': return 'warning';
|
||||
default: return 'info';
|
||||
}
|
||||
};
|
||||
const getStatusLabel = (status: string) => {
|
||||
switch (status) {
|
||||
case 'PENDING': return '待确认';
|
||||
case 'ACCEPTED': return '已承接';
|
||||
case 'REJECTED': return '已婉拒';
|
||||
case 'DISCUSSING': return '商讨中';
|
||||
default: return status;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-5">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 bg-gray-900 rounded-xl flex items-center justify-center">
|
||||
<Zap class="w-5 h-5 text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-gray-800">我的定向邀约</h1>
|
||||
<p class="text-xs text-gray-400">管理企业发来的项目邀请</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane name="PENDING">
|
||||
<template #label>待确认 ({{ stats.pending }})</template>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane name="ACTIVE">
|
||||
<template #label>商讨中 ({{ stats.active }})</template>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane name="ARCHIVED">
|
||||
<template #label>历史 ({{ stats.archived }})</template>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<!-- List -->
|
||||
<div v-if="filteredInvitations.length > 0" class="space-y-4">
|
||||
<div v-for="inv in filteredInvitations" :key="inv.id" class="flex gap-4 p-4 hover:bg-gray-50 transition-colors rounded-2xl border-none border-gray-50 shadow-sm cursor-pointer group" @click="$router.push(`/user/tasks/${inv.task}`)">
|
||||
<!-- Avatar -->
|
||||
<el-avatar :size="50" :src="inv.enterprise_logo" shape="circle" class="bg-blue-100 text-blue-600 font-bold border border-gray-200 shrink-0">
|
||||
{{ inv.enterprise_name?.charAt(0) || 'C' }}
|
||||
</el-avatar>
|
||||
|
||||
<!-- Message Body -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex justify-between items-start mb-1">
|
||||
<h3 class="font-bold text-gray-800 text-base truncate pr-4">
|
||||
{{ inv.enterprise_name }} <span class="text-xs font-normal text-gray-400 ml-2">项目: {{ inv.task_title }}</span>
|
||||
</h3>
|
||||
<span class="text-xs text-gray-400 whitespace-nowrap">{{ new Date(inv.created_at).toLocaleDateString() }}</span>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-gray-600 line-clamp-2 leading-relaxed mb-2" v-if="!inv.messages || inv.messages.length === 0">
|
||||
<span class="font-medium text-gray-800">{{ inv.inviter_name || '招募专员' }}:</span> {{ inv.message || '邀请您参与我们的任务。' }}
|
||||
</p>
|
||||
<div v-else class="text-sm line-clamp-2 leading-relaxed mb-2 bg-gray-50 p-2 rounded-lg border border-gray-100">
|
||||
<span class="font-medium" :class="inv.messages[inv.messages.length - 1].sender_role === 'ENTERPRISE' ? 'text-blue-600' : 'text-green-600'">
|
||||
{{ inv.messages[inv.messages.length - 1].sender_name }}:
|
||||
</span>
|
||||
<span class="text-gray-600">{{ inv.messages[inv.messages.length - 1].content }}</span>
|
||||
<span class="text-xs text-gray-400 ml-2">({{ inv.messages.length }}条对话记录)</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between mt-2 pt-2 border-t border-gray-50">
|
||||
<el-tag :type="getStatusType(inv.status)" size="small" effect="light" class="!rounded-md px-2 border-none">{{ getStatusLabel(inv.status) }}</el-tag>
|
||||
|
||||
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity" @click.stop>
|
||||
<template v-if="inv.status === 'PENDING'">
|
||||
<el-button size="small" text type="info" @click="openActionModal(inv, 'REJECT')">婉拒</el-button>
|
||||
<el-button size="small" text type="primary" @click="openActionModal(inv, 'DISCUSS')">商议</el-button>
|
||||
<el-button size="small" type="primary" plain round @click="openActionModal(inv, 'ACCEPT')">接受</el-button>
|
||||
</template>
|
||||
<template v-else-if="inv.status === 'DISCUSSING'">
|
||||
<el-button size="small" text type="primary" @click="openChat(inv)">打开对话</el-button>
|
||||
<el-button size="small" type="success" plain round @click="openActionModal(inv, 'ACCEPT')">同意承接</el-button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-button size="small" text @click="openContact(inv)">联系企业</el-button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-empty v-else :description="activeTab === 'PENDING' ? '暂无待确认邀约' : '暂无记录'" :image-size="100">
|
||||
<template #image><Mail class="w-16 h-16 text-gray-200" /></template>
|
||||
</el-empty>
|
||||
|
||||
<!-- Contact Dialog -->
|
||||
<el-dialog v-model="showContactDialog" title="企业联系方式" width="400px" top="5vh">
|
||||
<div class="text-center space-y-4">
|
||||
<el-avatar :size="64" :src="selectedInv?.enterprise_avatar" class="bg-gray-900 text-white text-2xl font-bold">{{ selectedInv?.enterprise_name?.charAt(0) }}</el-avatar>
|
||||
<div>
|
||||
<el-tag type="success" size="small" class="mb-1"><ShieldCheck class="w-3 h-3 mr-1 inline" />认证企业</el-tag>
|
||||
<h3 class="text-lg font-bold">{{ selectedInv?.enterprise_name }}</h3>
|
||||
</div>
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="联系电话">
|
||||
<div class="flex items-center gap-2">
|
||||
<Phone class="w-4 h-4 text-blue-500" />
|
||||
<span class="font-mono font-bold">{{ selectedInv?.enterprise_phone }}</span>
|
||||
</div>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<p class="text-xs text-gray-400">请在工作时间内联系,并确认已查阅任务细节。</p>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="showContactDialog = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- Action Dialog -->
|
||||
<el-dialog v-model="showActionDialog" :title="actionType === 'ACCEPT' ? '确认承接任务' : actionType === 'REJECT' ? '婉拒项目邀约' : '发起深度沟通'" width="480px" top="5vh">
|
||||
<el-input v-model="responseMessage" type="textarea" :rows="4" placeholder="留言备注..." />
|
||||
<template #footer>
|
||||
<el-button @click="showActionDialog = false">取消</el-button>
|
||||
<el-button :type="actionType === 'REJECT' ? 'danger' : 'primary'" @click="handleConfirm">确认回复</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- Chat Drawer -->
|
||||
<el-drawer v-model="showChatDialog" title="洽谈中心" size="400px" destroy-on-close>
|
||||
<div class="flex flex-col h-full -m-5">
|
||||
<div class="flex-1 overflow-y-auto p-5 space-y-4 bg-gray-50">
|
||||
<div class="text-center text-xs text-gray-400 my-2">{{ new Date(selectedInv?.created_at).toLocaleString() }} 发起邀约</div>
|
||||
|
||||
<!-- Initial Message -->
|
||||
<div class="flex gap-3">
|
||||
<el-avatar :size="32" :src="selectedInv?.enterprise_logo">{{ selectedInv?.enterprise_name?.charAt(0) }}</el-avatar>
|
||||
<div class="bg-white p-3 rounded-2xl rounded-tl-sm border border-gray-100 shadow-sm max-w-[80%]">
|
||||
<p class="text-sm text-gray-800">{{ selectedInv?.message || '邀请您参与我们的任务。' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Discussion Messages -->
|
||||
<template v-if="selectedInv?.messages && selectedInv.messages.length > 0">
|
||||
<div v-for="(msg, idx) in selectedInv.messages" :key="idx" class="flex gap-3" :class="{'flex-row-reverse': msg.sender_role === 'EXPERT'}">
|
||||
<el-avatar :size="32" :src="msg.sender_avatar">{{ msg.sender_name?.charAt(0) }}</el-avatar>
|
||||
<div class="p-3 rounded-2xl shadow-sm max-w-[80%]"
|
||||
:class="msg.sender_role === 'EXPERT' ? 'bg-blue-500 text-white rounded-tr-sm' : 'bg-white border border-gray-100 rounded-tl-sm text-gray-800'">
|
||||
<p class="text-sm">{{ msg.content }}</p>
|
||||
<div class="text-[10px] mt-1 opacity-70" :class="{'text-right': msg.sender_role === 'EXPERT'}">
|
||||
{{ new Date(msg.created_at).toLocaleTimeString() }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="p-4 bg-white border-t border-gray-100 flex gap-2">
|
||||
<el-input v-model="responseMessage" placeholder="输入回复内容..." @keyup.enter="handleSendChat" />
|
||||
<el-button type="primary" @click="handleSendChat">发送</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
523
src/views/user/ProfileView.vue
Normal file
523
src/views/user/ProfileView.vue
Normal file
@@ -0,0 +1,523 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted } from 'vue';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import api from '@/api';
|
||||
import { User, Shield, Settings, Camera, ShieldCheck, ExternalLink, Lock, PanelLeft } from 'lucide-vue-next';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import VueOfficeDocx from '@vue-office/docx';
|
||||
import '@vue-office/docx/lib/index.css';
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const user = computed(() => authStore.user);
|
||||
|
||||
const formData = ref({ nickname: '', email: '', phone: '', bio: '', location: '', avatar_url: '', face_url: '' });
|
||||
watch(user, (v) => {
|
||||
if (v) {
|
||||
formData.value = {
|
||||
nickname: v.nickname || '',
|
||||
email: v.email || '',
|
||||
phone: v.phone || '',
|
||||
bio: v.bio || '',
|
||||
location: v.location || '',
|
||||
avatar_url: v.avatar_url || '',
|
||||
face_url: v.face_url || ''
|
||||
};
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
const isSaving = ref(false);
|
||||
const activeTab = ref('basic');
|
||||
const sidebarVisible = ref(localStorage.getItem('sidebarVisible') === 'true');
|
||||
const toggleSidebar = (val: boolean) => {
|
||||
sidebarVisible.value = val;
|
||||
localStorage.setItem('sidebarVisible', String(val));
|
||||
window.dispatchEvent(new Event('sidebar-visibility-change'));
|
||||
};
|
||||
|
||||
import { uploadFileToMinIO } from '@/api/index';
|
||||
|
||||
const handleAvatarUpload = async (options: any) => {
|
||||
try {
|
||||
const url = await uploadFileToMinIO(options.file, 'avatars');
|
||||
formData.value.avatar_url = url;
|
||||
ElMessage.success('头像上传成功');
|
||||
} catch (error) {
|
||||
ElMessage.error('头像上传失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleFaceUpload = async (options: any) => {
|
||||
try {
|
||||
const url = await uploadFileToMinIO(options.file, 'faces');
|
||||
formData.value.face_url = url;
|
||||
ElMessage.success('真实照片上传成功');
|
||||
} catch (error) {
|
||||
ElMessage.error('真实照片上传失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveProfile = async () => {
|
||||
if (formData.value.email && !/^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+/.test(formData.value.email)) {
|
||||
ElMessage.error('请输入有效的邮箱地址'); return;
|
||||
}
|
||||
if (formData.value.phone && !/^1[3-9]\d{9}$/.test(formData.value.phone)) {
|
||||
ElMessage.error('请输入有效的11位手机号'); return;
|
||||
}
|
||||
isSaving.value = true;
|
||||
try {
|
||||
await api.put('/users/update_profile/', formData.value);
|
||||
await authStore.fetchUser();
|
||||
ElMessage.success('个人信息已保存');
|
||||
} catch (error: any) {
|
||||
const data = error.response?.data;
|
||||
let msg = '未知错误';
|
||||
if (data?.detail) {
|
||||
msg = data.detail;
|
||||
} else if (data && typeof data === 'object') {
|
||||
const firstError = Object.values(data)[0];
|
||||
msg = Array.isArray(firstError) ? firstError[0] : firstError;
|
||||
}
|
||||
ElMessage.error(`保存失败: ${msg}`);
|
||||
} finally { isSaving.value = false; }
|
||||
};
|
||||
|
||||
const certData = ref<any>(null);
|
||||
const isLoadingCert = ref(true);
|
||||
const isUpdatingResume = ref(false);
|
||||
|
||||
const handleCertResumeUpload = async (options: any) => {
|
||||
const file = options.file;
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
ElMessage.error('简历文件大小不能超过 10MB');
|
||||
return;
|
||||
}
|
||||
isUpdatingResume.value = true;
|
||||
try {
|
||||
const url = await uploadFileToMinIO(file, 'resumes');
|
||||
// Update certification
|
||||
await api.put(`/certifications/${certData.value.id}/`, {
|
||||
...certData.value,
|
||||
resume_url: url
|
||||
});
|
||||
certData.value.resume_url = url;
|
||||
ElMessage.success('简历附件更新成功');
|
||||
} catch (error) {
|
||||
ElMessage.error('简历更新失败');
|
||||
} finally {
|
||||
isUpdatingResume.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchCertStatus = async () => {
|
||||
try {
|
||||
const res: any = await api.get('/certifications/');
|
||||
const list = res.results || res;
|
||||
if (Array.isArray(list) && list.length > 0) certData.value = list[0];
|
||||
} catch (e) { console.error('Failed to fetch cert status'); }
|
||||
finally { isLoadingCert.value = false; }
|
||||
};
|
||||
|
||||
const handleRevoke = async () => {
|
||||
if (!certData.value) return;
|
||||
try {
|
||||
await api.delete(`/certifications/${certData.value.id}/`);
|
||||
certData.value = null;
|
||||
ElMessage.success('申请已成功撤销');
|
||||
} catch (e) { ElMessage.error('撤销失败'); }
|
||||
};
|
||||
|
||||
onMounted(() => { fetchCertStatus(); });
|
||||
|
||||
const certTagType = computed(() => {
|
||||
if (!certData.value) return 'warning';
|
||||
if (certData.value.status === 'APPROVED') return 'success';
|
||||
if (certData.value.status === 'REJECTED') return 'danger';
|
||||
return 'primary';
|
||||
});
|
||||
|
||||
const previewVisible = ref(false);
|
||||
const previewUrl = ref('');
|
||||
|
||||
const passwordForm = ref({ old_password: '', new_password: '', confirm_password: '' });
|
||||
const isChangingPwd = ref(false);
|
||||
|
||||
const handleChangePassword = async () => {
|
||||
if (!passwordForm.value.old_password || !passwordForm.value.new_password) {
|
||||
ElMessage.warning('密码不能为空'); return;
|
||||
}
|
||||
if (passwordForm.value.new_password.length < 6) {
|
||||
ElMessage.warning('新密码不能少于6个字符'); return;
|
||||
}
|
||||
if (passwordForm.value.new_password !== passwordForm.value.confirm_password) {
|
||||
ElMessage.warning('两次输入的新密码不一致'); return;
|
||||
}
|
||||
try {
|
||||
isChangingPwd.value = true;
|
||||
await api.post('/users/change_password/', {
|
||||
old_password: passwordForm.value.old_password,
|
||||
new_password: passwordForm.value.new_password
|
||||
});
|
||||
ElMessage.success('密码修改成功,请重新登录');
|
||||
passwordForm.value = { old_password: '', new_password: '', confirm_password: '' };
|
||||
setTimeout(() => {
|
||||
authStore.logout();
|
||||
window.location.href = '/login';
|
||||
}, 1500);
|
||||
} catch (error: any) {
|
||||
const data = error.response?.data;
|
||||
if (data?.old_password) ElMessage.error(data.old_password[0]);
|
||||
else ElMessage.error('密码修改失败');
|
||||
} finally { isChangingPwd.value = false; }
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="profile-page">
|
||||
<!-- Profile Header -->
|
||||
<div class="profile-banner">
|
||||
<div class="banner-deco"></div>
|
||||
<div class="banner-deco2"></div>
|
||||
<div class="banner-content">
|
||||
<div class="avatar-wrap">
|
||||
<el-upload action="" :http-request="handleAvatarUpload" :show-file-list="false" class="avatar-upload-trigger">
|
||||
<el-avatar v-if="formData.avatar_url" :size="80" :src="formData.avatar_url" class="avatar-img" />
|
||||
<el-avatar v-else :size="80" class="avatar-img bg-indigo-100 text-indigo-600 text-3xl font-bold">
|
||||
{{ user?.nickname?.[0] || user?.username?.[0] || '?' }}
|
||||
</el-avatar>
|
||||
<div class="avatar-hover">
|
||||
<Camera class="w-5 h-5 text-white" />
|
||||
</div>
|
||||
</el-upload>
|
||||
</div>
|
||||
<div style="flex:1;min-width:0">
|
||||
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
|
||||
<h1 style="font-size:20px;font-weight:800;color:#111827;margin:0">{{ user?.nickname || user?.username }}</h1>
|
||||
<el-tag :type="user?.roles?.includes('OPC_USER') ? 'success' : 'warning'" effect="dark" size="small" round class="!font-semibold">
|
||||
<ShieldCheck class="w-3 h-3 mr-0.5 inline" />
|
||||
{{ user?.roles?.includes('OPC_USER') ? 'OPC 认证专家' : '普通用户' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<p style="font-size:13px;color:#7c3aed;margin:6px 0 0;font-style:italic;font-weight:500">✦ {{ user?.bio || '超级个体 / 超越界限' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="profile-body">
|
||||
<!-- Sidebar Nav -->
|
||||
<div class="profile-sidebar">
|
||||
<div
|
||||
v-for="tab in [
|
||||
{ key: 'basic', icon: User, label: '基本设置' },
|
||||
{ key: 'cert', icon: Shield, label: '我的认证' },
|
||||
{ key: 'notify', icon: PanelLeft, label: '界面设置' },
|
||||
{ key: 'security', icon: Settings, label: '账号安全' },
|
||||
]" :key="tab.key"
|
||||
class="profile-tab" :class="{ active: activeTab === tab.key }"
|
||||
@click="activeTab = tab.key"
|
||||
>
|
||||
<component :is="tab.icon" class="w-4 h-4" />
|
||||
<span>{{ tab.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="profile-content">
|
||||
<!-- Basic Settings -->
|
||||
<el-card v-if="activeTab === 'basic'" shadow="never">
|
||||
<template #header><span class="font-semibold">基本设置</span></template>
|
||||
<el-form label-position="top">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="个人昵称"><el-input v-model="formData.nickname" placeholder="您希望如何被称呼" /></el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="联系电话"><el-input v-model="formData.phone" placeholder="请输入11位手机号" /></el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="登录邮箱"><el-input v-model="formData.email" placeholder="example@company.com" /></el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="地理位置"><el-input v-model="formData.location" placeholder="例如:北京 / Remote" /></el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="个人简介">
|
||||
<el-input v-model="formData.bio" type="textarea" :rows="4" placeholder="简单介绍一下您的专业背景..." />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="真实照片 (用于人脸核身)">
|
||||
<div class="flex items-start gap-4">
|
||||
<el-upload
|
||||
class="avatar-uploader border-2 border-dashed border-gray-200 rounded-lg w-28 h-36 flex flex-col items-center justify-center hover:border-blue-400 overflow-hidden relative cursor-pointer bg-gray-50/50"
|
||||
action=""
|
||||
:http-request="handleFaceUpload"
|
||||
:show-file-list="false"
|
||||
>
|
||||
<el-image v-if="formData.face_url" :src="formData.face_url" class="w-full h-full object-cover" fit="cover" />
|
||||
<div v-else class="flex flex-col items-center justify-center text-gray-400 p-2 text-center">
|
||||
<Camera class="w-6 h-6 mb-1 opacity-50" />
|
||||
<span class="text-[10px]">上传照片</span>
|
||||
</div>
|
||||
</el-upload>
|
||||
<div class="text-xs text-gray-400 flex-1 bg-blue-50/30 p-3 rounded-lg border border-blue-100">
|
||||
<p class="font-bold text-blue-800 mb-1">为什么需要上传真实照片?</p>
|
||||
<p class="mb-2">为保障平台交易安全,您的面部特征将用于后续提现、大额交易或敏感操作时的人脸验证对比。</p>
|
||||
<p class="text-gray-500">请上传一张近期、清晰、五官可见的正面免冠照片,支持 JPG/PNG 格式。</p>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="专家能力评级 (平台评分)">
|
||||
<div class="flex items-center gap-3 bg-gray-50 px-4 py-2 rounded-lg border border-gray-100">
|
||||
<el-rate :model-value="user?.rating || 0" disabled show-score text-color="#ff9900" score-template="{value} 分" />
|
||||
<span class="text-xs text-gray-400">· 综合能力评估,不可手动修改</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-button type="primary" :loading="isSaving" @click="handleSaveProfile">
|
||||
{{ isSaving ? '正在保存...' : '保存个人设置' }}
|
||||
</el-button>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<!-- Certification Tab -->
|
||||
<div v-if="activeTab === 'cert'" class="space-y-4">
|
||||
<el-card v-if="certData" shadow="never"
|
||||
:class="certData.status === 'APPROVED' ? '!border-green-200' : certData.status === 'REJECTED' ? '!border-red-200' : '!border-blue-200'"
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<el-tag :type="certTagType" effect="dark" size="large" class="mb-2">
|
||||
{{ certData.status === 'PENDING' ? '专家资格审核中' : certData.status === 'APPROVED' ? '认证专家 (Verified)' : '申请被驳回' }}
|
||||
</el-tag>
|
||||
<p class="text-sm text-gray-500 max-w-md mb-4">
|
||||
<span v-if="certData.status === 'PENDING'">您的 OPC 专家认证申请已进入人工复核阶段,预计 24 小时内完成。</span>
|
||||
<span v-else-if="certData.status === 'APPROVED'">恭喜!您已正式成为 OPC 认证专家。</span>
|
||||
<span v-else>很抱歉,您的申请未通过审核。原因:{{ certData.reject_reason || '资料不全' }}。</span>
|
||||
</p>
|
||||
|
||||
<div v-if="certData.status === 'APPROVED'" class="mt-4 border-t border-gray-100 pt-4">
|
||||
<div class="text-sm font-semibold mb-2">更新简历附件</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<el-upload
|
||||
action=""
|
||||
:http-request="handleCertResumeUpload"
|
||||
:show-file-list="false"
|
||||
accept=".pdf,.doc,.docx"
|
||||
>
|
||||
<el-button size="small" :loading="isUpdatingResume">上传新简历</el-button>
|
||||
</el-upload>
|
||||
<el-button v-if="certData.resume_url" size="small" type="primary" plain @click="previewUrl = certData.resume_url; previewVisible = true">
|
||||
在线预览简历 <ExternalLink class="w-3 h-3 ml-1" />
|
||||
</el-button>
|
||||
<span v-else class="text-xs text-gray-400">尚未上传附件简历</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<el-button @click="$router.push('/user/certification/status')">查看详细进度</el-button>
|
||||
<el-popconfirm v-if="certData.status !== 'APPROVED'" title="确定要撤销此认证申请吗?" @confirm="handleRevoke">
|
||||
<template #reference><el-button type="danger" plain>撤销申请</el-button></template>
|
||||
</el-popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card v-else-if="!isLoadingCert" shadow="never">
|
||||
<el-empty description="尚未开启 OPC 专家认证" :image-size="80">
|
||||
<el-button type="primary" @click="$router.push('/user/certification/apply')">立即申请认证</el-button>
|
||||
</el-empty>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<ExternalLink class="w-5 h-5 text-gray-400" />
|
||||
<div>
|
||||
<div class="font-medium">查看认证权益</div>
|
||||
<div class="text-xs text-gray-400">了解认证能为您带来的收益</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-button text type="primary">查看 →</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<!-- Interface Settings Tab -->
|
||||
<el-card v-if="activeTab === 'notify'" shadow="never">
|
||||
<template #header><span class="font-semibold">界面设置</span></template>
|
||||
<div class="flex items-center justify-between py-4">
|
||||
<div>
|
||||
<div class="font-medium text-sm">显示侧边栏导航</div>
|
||||
<div class="text-xs text-gray-400 mt-0.5">开启后将在左侧固定显示导航栏,关闭则仅显示浮动工具按钮</div>
|
||||
</div>
|
||||
<el-switch
|
||||
:model-value="sidebarVisible"
|
||||
@change="toggleSidebar"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- Security Tab -->
|
||||
<el-card v-if="activeTab === 'security'" shadow="never">
|
||||
<template #header><span class="font-semibold">账号安全</span></template>
|
||||
|
||||
<div class="max-w-lg mb-8">
|
||||
<h3 class="text-sm font-bold text-gray-800 mb-4 flex items-center">
|
||||
<Lock class="w-4 h-4 mr-2 text-blue-600" /> 修改登录密码
|
||||
</h3>
|
||||
<el-form @submit.prevent="handleChangePassword" label-width="100px" label-position="left">
|
||||
<el-form-item label="当前密码" required>
|
||||
<el-input v-model="passwordForm.old_password" type="password" show-password placeholder="请输入当前使用的密码" />
|
||||
</el-form-item>
|
||||
<el-form-item label="新密码" required>
|
||||
<el-input v-model="passwordForm.new_password" type="password" show-password placeholder="至少6个字符" />
|
||||
</el-form-item>
|
||||
<el-form-item label="确认新密码" required>
|
||||
<el-input v-model="passwordForm.confirm_password" type="password" show-password placeholder="再次输入新密码" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" native-type="submit" :loading="isChangingPwd">确认修改</el-button>
|
||||
<div class="text-xs text-gray-400 mt-2 w-full">修改成功后将强制退出,需要使用新密码重新登录。</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<el-divider />
|
||||
|
||||
<div class="flex items-center justify-between py-4">
|
||||
<div>
|
||||
<div class="font-medium text-sm text-gray-800">账号注销</div>
|
||||
<div class="text-xs text-gray-400">注销后账号数据将无法恢复,请谨慎操作</div>
|
||||
</div>
|
||||
<el-button type="danger" plain disabled>申请注销</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Document Preview Dialog -->
|
||||
<el-dialog v-model="previewVisible" title="文档在线预览" width="80%" top="5vh">
|
||||
<div class="h-[75vh] w-full bg-gray-50 rounded border border-gray-200 overflow-hidden relative flex flex-col items-center justify-center">
|
||||
<template v-if="previewUrl.split('?')[0].toLowerCase().endsWith('.pdf')">
|
||||
<iframe :src="previewUrl" class="w-full h-full border-none"></iframe>
|
||||
</template>
|
||||
<template v-else-if="['.jpg', '.jpeg', '.png', '.webp'].some(ext => previewUrl.split('?')[0].toLowerCase().endsWith(ext))">
|
||||
<el-image :src="previewUrl" class="w-full h-full" fit="contain" />
|
||||
</template>
|
||||
<template v-else-if="['.docx', '.doc'].some(ext => previewUrl.split('?')[0].toLowerCase().endsWith(ext))">
|
||||
<VueOfficeDocx :src="previewUrl" class="w-full h-full" style="height: 100%" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="text-center">
|
||||
<div class="text-4xl mb-4">📄</div>
|
||||
<div class="text-gray-500 mb-4">该文件格式不支持在线直接预览</div>
|
||||
<el-button type="primary" tag="a" :href="previewUrl" target="_blank" download>点击下载文件</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-400">如果预览为空白,可能是浏览器拦截或格式不支持</span>
|
||||
<div class="space-x-2">
|
||||
<el-button @click="previewVisible = false">关闭预览</el-button>
|
||||
<el-button type="primary" tag="a" :href="previewUrl" target="_blank" download>下载到本地</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.profile-page { max-width: 960px; margin: 0 auto; }
|
||||
|
||||
/* Banner */
|
||||
.profile-banner {
|
||||
position: relative;
|
||||
padding: 36px 36px 32px;
|
||||
background: linear-gradient(135deg, #f8faff 0%, #eef2ff 50%, #e0e7ff 100%);
|
||||
border-radius: 20px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #ddd6fe;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.banner-deco {
|
||||
position: absolute; top: -60px; right: -20px;
|
||||
width: 220px; height: 220px;
|
||||
background: radial-gradient(circle, rgba(99,102,241,0.15), transparent 70%);
|
||||
border-radius: 50%;
|
||||
}
|
||||
.banner-deco2 {
|
||||
position: absolute; bottom: -40px; left: 20%;
|
||||
width: 180px; height: 180px;
|
||||
background: radial-gradient(circle, rgba(168,85,247,0.1), transparent 70%);
|
||||
border-radius: 50%;
|
||||
}
|
||||
.banner-content {
|
||||
position: relative; z-index: 1;
|
||||
display: flex !important; align-items: center; gap: 28px;
|
||||
}
|
||||
|
||||
/* Avatar */
|
||||
.avatar-wrap {
|
||||
width: 88px; height: 88px;
|
||||
border-radius: 50%;
|
||||
padding: 3px;
|
||||
background: linear-gradient(135deg, #6366f1, #a78bfa, #f472b6);
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
box-shadow: 0 4px 20px rgba(99,102,241,0.25);
|
||||
}
|
||||
.avatar-upload-trigger { display: block !important; width: 100%; height: 100%; }
|
||||
:deep(.avatar-upload-trigger .el-upload) {
|
||||
width: 82px !important; height: 82px !important;
|
||||
border-radius: 50% !important; overflow: hidden;
|
||||
display: block !important;
|
||||
}
|
||||
.avatar-img { border: 3px solid #fff; border-radius: 50%; }
|
||||
.avatar-hover {
|
||||
position: absolute; inset: 3px;
|
||||
border-radius: 50%;
|
||||
background: rgba(0,0,0,0.4);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
opacity: 0; transition: opacity 0.2s; cursor: pointer;
|
||||
}
|
||||
.avatar-wrap:hover .avatar-hover { opacity: 1; }
|
||||
|
||||
/* Body layout */
|
||||
.profile-body {
|
||||
display: flex; gap: 24px;
|
||||
}
|
||||
.profile-sidebar {
|
||||
width: 180px; flex-shrink: 0;
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
border: 1px solid #f0f0f0;
|
||||
padding: 10px;
|
||||
height: fit-content;
|
||||
position: sticky; top: 88px;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.03);
|
||||
}
|
||||
.profile-tab {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 11px 14px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
transition: all 0.15s;
|
||||
margin-bottom: 2px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.profile-tab:hover { background: #f9fafb; color: #374151; }
|
||||
.profile-tab.active {
|
||||
background: linear-gradient(135deg, #eff6ff, #eef2ff);
|
||||
color: #4f46e5;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 1px 3px rgba(79,70,229,0.08);
|
||||
}
|
||||
.profile-content { flex: 1; min-width: 0; }
|
||||
</style>
|
||||
153
src/views/user/ReservationView.vue
Normal file
153
src/views/user/ReservationView.vue
Normal file
@@ -0,0 +1,153 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { Calendar, MapPin, Users, Zap, CheckCircle2 } from 'lucide-vue-next';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import api from '@/api';
|
||||
|
||||
const resources = ref<any[]>([]);
|
||||
const isLoading = ref(true);
|
||||
const bookingDialogVisible = ref(false);
|
||||
const selectedResource = ref<any>(null);
|
||||
const isSubmitting = ref(false);
|
||||
|
||||
const form = ref({
|
||||
quantity: 1,
|
||||
start_time: '',
|
||||
end_time: ''
|
||||
});
|
||||
|
||||
const fetchResources = async () => {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const res: any = await api.get('/reservations/resources/');
|
||||
resources.value = res.results || res || [];
|
||||
} catch (error) {
|
||||
ElMessage.error('获取资源列表失败');
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => fetchResources());
|
||||
|
||||
const openBookingDialog = (resource: any) => {
|
||||
selectedResource.value = resource;
|
||||
form.value = { quantity: 1, start_time: '', end_time: '' };
|
||||
|
||||
// Set default times (tomorrow 9 AM to 10 AM)
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
tomorrow.setHours(9, 0, 0, 0);
|
||||
|
||||
const tomorrowEnd = new Date(tomorrow);
|
||||
tomorrowEnd.setHours(10, 0, 0, 0);
|
||||
|
||||
form.value.start_time = tomorrow.toISOString();
|
||||
form.value.end_time = tomorrowEnd.toISOString();
|
||||
|
||||
bookingDialogVisible.value = true;
|
||||
};
|
||||
|
||||
const handleBook = async () => {
|
||||
if (!form.value.start_time || !form.value.end_time) {
|
||||
ElMessage.warning('请选择预约时间');
|
||||
return;
|
||||
}
|
||||
isSubmitting.value = true;
|
||||
try {
|
||||
const res: any = await api.post('/reservations/orders/', {
|
||||
resource: selectedResource.value.id,
|
||||
quantity: form.value.quantity,
|
||||
start_time: form.value.start_time,
|
||||
end_time: form.value.end_time
|
||||
});
|
||||
|
||||
// Trigger payment
|
||||
await api.post(`/reservations/orders/${res.id}/pay/`);
|
||||
|
||||
ElMessage.success('预约并支付成功!');
|
||||
bookingDialogVisible.value = false;
|
||||
} catch (error) {
|
||||
ElMessage.error('预约失败,请检查时间或重试');
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-800">空间预约</h1>
|
||||
<p class="text-sm text-gray-400 mt-1">预定线下会议室或开通园区门禁权限</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-loading="isLoading" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<el-card v-for="item in resources" :key="item.id" shadow="hover" class="rounded-xl overflow-hidden group cursor-pointer border-transparent hover:border-blue-200 transition-all">
|
||||
<div class="p-6 pb-4">
|
||||
<div class="flex justify-between items-start mb-4">
|
||||
<div class="w-12 h-12 rounded-xl flex items-center justify-center" :class="item.type === 'MEETING_ROOM' ? 'bg-blue-100 text-blue-600' : 'bg-green-100 text-green-600'">
|
||||
<Calendar v-if="item.type === 'MEETING_ROOM'" class="w-6 h-6" />
|
||||
<Zap v-else class="w-6 h-6" />
|
||||
</div>
|
||||
<el-tag :type="item.type === 'MEETING_ROOM' ? 'primary' : 'success'" round effect="light">
|
||||
{{ item.type === 'MEETING_ROOM' ? '会议室' : '门禁权限' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<h3 class="text-lg font-bold text-gray-800 mb-2 group-hover:text-blue-600 transition-colors">{{ item.name }}</h3>
|
||||
<p class="text-sm text-gray-500 h-10 line-clamp-2 mb-4">{{ item.description }}</p>
|
||||
|
||||
<div class="flex flex-col gap-2 text-sm text-gray-600 mb-6">
|
||||
<div class="flex items-center gap-2" v-if="item.location"><MapPin class="w-4 h-4 text-gray-400" /> {{ item.location }}</div>
|
||||
<div class="flex items-center gap-2" v-if="item.capacity"><Users class="w-4 h-4 text-gray-400" /> 容纳 {{ item.capacity }} 人</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-end justify-between pt-4 border-t border-gray-100">
|
||||
<div>
|
||||
<span class="text-2xl font-bold text-orange-500">¥{{ item.price_per_unit }}</span>
|
||||
<span class="text-gray-400 text-sm"> / {{ item.price_unit }}</span>
|
||||
</div>
|
||||
<el-button type="primary" round plain @click="openBookingDialog(item)">立即预约</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<!-- Booking Dialog -->
|
||||
<el-dialog v-model="bookingDialogVisible" :title="`预约: ${selectedResource?.name}`" width="500px">
|
||||
<div v-if="selectedResource">
|
||||
<div class="bg-gray-50 p-4 rounded-lg mb-6 flex items-center justify-between border border-gray-100">
|
||||
<div>
|
||||
<div class="text-sm text-gray-500 mb-1">收费标准</div>
|
||||
<div class="font-bold text-gray-800">¥{{ selectedResource.price_per_unit }} / {{ selectedResource.price_unit }}</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-sm text-gray-500 mb-1">总计</div>
|
||||
<div class="text-2xl font-bold text-orange-500">¥{{ (selectedResource.price_per_unit * form.quantity).toFixed(2) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-form label-position="top">
|
||||
<el-form-item :label="`预定数量 (${selectedResource.price_unit})`">
|
||||
<el-input-number v-model="form.quantity" :min="1" class="w-full" />
|
||||
</el-form-item>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<el-form-item label="开始时间">
|
||||
<el-date-picker v-model="form.start_time" type="datetime" placeholder="选择开始时间" class="w-full" />
|
||||
</el-form-item>
|
||||
<el-form-item label="结束时间">
|
||||
<el-date-picker v-model="form.end_time" type="datetime" placeholder="选择结束时间" class="w-full" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="bookingDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="isSubmitting" @click="handleBook">确认支付</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
371
src/views/user/certification/ApplyView.vue
Normal file
371
src/views/user/certification/ApplyView.vue
Normal file
@@ -0,0 +1,371 @@
|
||||
<script setup lang="ts">
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { FileText, Shield, Camera, X, Upload } from 'lucide-vue-next';
|
||||
import { getCertifications, submitCertification } from '@/api/certifications';
|
||||
|
||||
const router = useRouter();
|
||||
const currentStep = ref(0);
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
const isSubmitting = ref(false);
|
||||
const isLoading = ref(true);
|
||||
|
||||
const sliderValue = ref(0);
|
||||
import { computed, onMounted } from 'vue';
|
||||
const isVerified = computed(() => sliderValue.value === 100);
|
||||
|
||||
const hasExistingIdentity = ref(false);
|
||||
|
||||
const form = ref({
|
||||
real_name: '', id_card: '', skills: [] as string[],
|
||||
experience: '', resume_url: '', facePhoto: null as string | null,
|
||||
attachments: [] as any[]
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res: any = await getCertifications();
|
||||
if (res.results && res.results.length > 0) {
|
||||
const cert = res.results[0];
|
||||
form.value.real_name = cert.real_name || '';
|
||||
form.value.id_card = cert.id_card || '';
|
||||
form.value.skills = cert.skills || [];
|
||||
form.value.experience = cert.experience || '';
|
||||
form.value.resume_url = cert.resume_url || '';
|
||||
form.value.attachments = cert.attachments || [];
|
||||
|
||||
if (form.value.real_name && form.value.id_card) {
|
||||
hasExistingIdentity.value = true;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch existing certification', error);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
const availableSkills = [
|
||||
'CV 图像标注',
|
||||
'NLP 语义分析',
|
||||
'语音转写',
|
||||
'大模型微调',
|
||||
'提示词工程',
|
||||
'前后端全栈开发',
|
||||
'UI/UX 设计',
|
||||
'数据爬虫与清洗',
|
||||
'机器视觉算法',
|
||||
'数字绘图与创意设计'
|
||||
];
|
||||
|
||||
const submitApply = async () => {
|
||||
if (isSubmitting.value) return;
|
||||
const idCardRegex = /^[1-9]\d{5}(18|19|20)\d{2}((0[1-9])|(1[0-2]))(([0-2][1-9])|10|20|30|31)\d{3}[0-9Xx]$/;
|
||||
if (!idCardRegex.test(form.value.id_card.trim())) { ElMessage.error('请输入有效的18位身份证号码'); currentStep.value = 0; return; }
|
||||
if (!form.value.real_name.trim() || form.value.real_name.trim().length < 2) { ElMessage.error('请输入真实姓名'); currentStep.value = 0; return; }
|
||||
if (form.value.skills.length === 0) { ElMessage.error('请至少选择或输入一个擅长领域'); currentStep.value = 1; return; }
|
||||
if (!form.value.experience) { ElMessage.error('请填写职业经历简述'); currentStep.value = 1; return; }
|
||||
if (!form.value.resume_url) { ElMessage.error('请上传简历附件'); currentStep.value = 1; return; }
|
||||
|
||||
isSubmitting.value = true;
|
||||
try {
|
||||
if (form.value.facePhoto && !hasExistingIdentity.value) {
|
||||
const api = (await import('@/api')).default;
|
||||
await api.put('/users/update_profile/', {
|
||||
real_name: form.value.real_name,
|
||||
face_url: form.value.facePhoto
|
||||
});
|
||||
}
|
||||
|
||||
await submitCertification({
|
||||
real_name: form.value.real_name, id_card: form.value.id_card,
|
||||
skills: form.value.skills, experience: form.value.experience, resume_url: form.value.resume_url,
|
||||
attachments: form.value.attachments
|
||||
});
|
||||
ElMessage.success('认证信息已提交');
|
||||
router.push('/user/certification/status');
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e.response?.data?.detail || '提交失败');
|
||||
} finally { isSubmitting.value = false; }
|
||||
};
|
||||
|
||||
import { uploadFileToMinIO } from '@/api/index';
|
||||
|
||||
const handlePhotoUpload = async (e: Event) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (file) {
|
||||
try {
|
||||
const url = await uploadFileToMinIO(file, 'faces');
|
||||
form.value.facePhoto = url;
|
||||
ElMessage.success('人脸照片上传成功');
|
||||
} catch (error) {
|
||||
ElMessage.error('照片上传失败,请重试');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleIDCardUpload = async (e: Event) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (file) {
|
||||
try {
|
||||
const url = await uploadFileToMinIO(file, 'id_cards');
|
||||
form.value.attachments.push({ url, name: file.name });
|
||||
ElMessage.success('身份证照片上传成功');
|
||||
} catch (error) {
|
||||
ElMessage.error('照片上传失败,请重试');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const removeIDCardPhoto = (index: number) => {
|
||||
form.value.attachments.splice(index, 1);
|
||||
};
|
||||
|
||||
const idCardInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
const resumeInput = ref<HTMLInputElement | null>(null);
|
||||
const isUploadingResume = ref(false);
|
||||
|
||||
const handleResumeUpload = async (e: Event) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
ElMessage.error('简历文件大小不能超过 10MB');
|
||||
return;
|
||||
}
|
||||
|
||||
isUploadingResume.value = true;
|
||||
try {
|
||||
const url = await uploadFileToMinIO(file, 'resumes');
|
||||
form.value.resume_url = url;
|
||||
ElMessage.success('简历上传成功');
|
||||
} catch (error) {
|
||||
ElMessage.error('上传失败,请重试');
|
||||
} finally {
|
||||
isUploadingResume.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
import Vcode from "vue3-puzzle-vcode";
|
||||
import VueOfficeDocx from '@vue-office/docx';
|
||||
import '@vue-office/docx/lib/index.css';
|
||||
|
||||
const isShowCaptcha = ref(false);
|
||||
|
||||
const handleCaptchaSuccess = () => {
|
||||
isShowCaptcha.value = false;
|
||||
submitApply();
|
||||
};
|
||||
|
||||
const previewVisible = ref(false);
|
||||
const previewUrl = ref('');
|
||||
const previewType = ref('');
|
||||
|
||||
const getExtensionFromUrl = (url: string) => {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
const pathname = urlObj.pathname;
|
||||
const parts = pathname.split('.');
|
||||
return parts.length > 1 ? parts.pop()?.toLowerCase() : '';
|
||||
} catch (e) {
|
||||
const parts = url.split('?')[0].split('.');
|
||||
return parts.length > 1 ? parts.pop()?.toLowerCase() : '';
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreview = (url: string) => {
|
||||
if (!url) return;
|
||||
previewUrl.value = url;
|
||||
const ext = getExtensionFromUrl(url);
|
||||
|
||||
if (['jpg', 'jpeg', 'png', 'webp'].includes(ext || '')) {
|
||||
previewType.value = 'image';
|
||||
} else if (ext === 'pdf') {
|
||||
previewType.value = 'pdf';
|
||||
} else if (ext === 'docx' || ext === 'doc') {
|
||||
previewType.value = 'docx';
|
||||
} else {
|
||||
previewType.value = 'unknown';
|
||||
}
|
||||
previewVisible.value = true;
|
||||
};
|
||||
|
||||
const downloadFile = (url: string) => {
|
||||
if (!url) return;
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.target = '_blank';
|
||||
a.click();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="max-w-3xl mx-auto space-y-5" v-loading="isLoading">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-800">OPC 专业认证申请</h1>
|
||||
<p class="text-sm text-gray-400 mt-1">完成认证即可解锁高阶平台专属任务与更高的报酬倍率</p>
|
||||
</div>
|
||||
|
||||
<el-card shadow="never">
|
||||
<el-steps :active="currentStep" finish-status="success" class="mb-8">
|
||||
<el-step title="实名核验" description="基本信息与人脸采集" />
|
||||
<el-step title="专业领域" description="技能详情认定" />
|
||||
<el-step title="确认提交" description="信息确认" />
|
||||
</el-steps>
|
||||
|
||||
<!-- Step 1 -->
|
||||
<div v-if="currentStep === 0">
|
||||
<el-form label-position="top">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="真实姓名" required>
|
||||
<el-input v-model="form.real_name" placeholder="请填写证件上的姓名" :disabled="hasExistingIdentity" />
|
||||
</el-form-item>
|
||||
<el-form-item label="身份证号" required>
|
||||
<el-input v-model="form.id_card" placeholder="请填写18位身份证号码" :disabled="hasExistingIdentity" />
|
||||
</el-form-item>
|
||||
<el-alert v-if="hasExistingIdentity" type="info" :closable="false" show-icon class="mb-4">
|
||||
<template #title>实名信息已锁定。如需修改,请联系平台客服解绑。</template>
|
||||
</el-alert>
|
||||
<el-alert type="warning" :closable="false" show-icon>
|
||||
<template #title>您的隐私对我们至关重要。实名信息仅用于平台财务结算及法律合规校验。</template>
|
||||
</el-alert>
|
||||
</el-col>
|
||||
<el-col :span="12" v-if="!hasExistingIdentity">
|
||||
<el-form-item label="本人人脸照片">
|
||||
<input type="file" ref="fileInput" class="hidden" accept="image/*" @change="handlePhotoUpload" />
|
||||
<div v-if="!form.facePhoto" @click="fileInput?.click()"
|
||||
class="w-full h-48 border-2 border-dashed border-gray-200 rounded-lg flex flex-col items-center justify-center cursor-pointer hover:border-blue-400 hover:bg-blue-50/30 transition-all"
|
||||
>
|
||||
<Camera class="w-8 h-8 text-gray-300 mb-2" />
|
||||
<p class="text-sm font-medium text-gray-600">点击上传人脸正面照</p>
|
||||
<p class="text-xs text-gray-400">JPG / PNG,不超过 5MB</p>
|
||||
</div>
|
||||
<div v-else class="relative inline-block">
|
||||
<el-image :src="form.facePhoto" class="h-48 rounded-lg" fit="contain" />
|
||||
<el-button circle size="small" type="danger" class="!absolute -top-2 -right-2" @click="form.facePhoto = null">
|
||||
<X class="w-3 h-3" />
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12" v-else>
|
||||
<div class="h-full flex flex-col items-center justify-center border border-gray-100 rounded-lg bg-gray-50 p-6">
|
||||
<Shield class="w-12 h-12 text-green-500 mb-4" />
|
||||
<h3 class="font-bold text-gray-700">实名核验已通过</h3>
|
||||
<p class="text-xs text-gray-400 text-center mt-2">您的身份信息已录入区块链节点,无需再次采集人脸</p>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="16" class="mt-4" v-if="!hasExistingIdentity">
|
||||
<el-col :span="24">
|
||||
<el-form-item label="身份证正反面照片 (请上传 2 张)">
|
||||
<div class="w-full flex gap-4 overflow-x-auto pb-2">
|
||||
<div v-for="(file, index) in form.attachments" :key="index" class="relative flex-shrink-0 w-40 h-28 border border-gray-200 rounded-lg overflow-hidden group">
|
||||
<el-image :src="file.url || file" class="w-full h-full object-cover" :preview-src-list="[file.url || file]" />
|
||||
<div class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 flex items-center justify-center transition-opacity">
|
||||
<el-button type="danger" circle size="small" @click="removeIDCardPhoto(index)"><X class="w-4 h-4" /></el-button>
|
||||
</div>
|
||||
</div>
|
||||
<input type="file" ref="idCardInput" class="hidden" accept="image/*" @change="handleIDCardUpload" />
|
||||
<div
|
||||
v-if="form.attachments.length < 2"
|
||||
@click="idCardInput?.click()"
|
||||
class="flex-shrink-0 w-40 h-28 border-2 border-dashed border-gray-200 rounded-lg hover:border-primary flex items-center justify-center cursor-pointer bg-gray-50 transition-all"
|
||||
>
|
||||
<div class="text-gray-400 flex flex-col items-center">
|
||||
<Upload class="w-6 h-6 mb-1 text-gray-300" />
|
||||
<span class="text-xs">上传照片</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- Step 2 -->
|
||||
<div v-if="currentStep === 1">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="擅长领域(可搜索与多选,支持输入新增)" required>
|
||||
<el-select
|
||||
v-model="form.skills"
|
||||
multiple
|
||||
filterable
|
||||
allow-create
|
||||
default-first-option
|
||||
placeholder="请选择或输入擅长领域"
|
||||
class="w-full"
|
||||
>
|
||||
<el-option v-for="skill in availableSkills" :key="skill" :label="skill" :value="skill" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="职业经历简述" required>
|
||||
<el-input v-model="form.experience" type="textarea" :rows="4" placeholder="请简要描述您在相关领域的从业时间及主要项目产出..." />
|
||||
</el-form-item>
|
||||
<el-form-item label="上传简历附件" required>
|
||||
<div class="w-full flex flex-col items-start gap-2">
|
||||
<div class="flex items-center gap-4">
|
||||
<input type="file" ref="resumeInput" class="hidden" accept=".pdf,.doc,.docx" @change="handleResumeUpload" />
|
||||
<el-button type="primary" plain @click="resumeInput?.click()" :loading="isUploadingResume">
|
||||
<Upload class="w-4 h-4 mr-2" />
|
||||
{{ isUploadingResume ? '上传中...' : '选择简历文件' }}
|
||||
</el-button>
|
||||
<el-link v-if="form.resume_url" @click.prevent="handlePreview(form.resume_url)" type="primary" class="ml-2 font-medium">
|
||||
<FileText class="w-4 h-4 mr-1" /> 查看已上传的简历
|
||||
</el-link>
|
||||
</div>
|
||||
<div class="text-xs text-gray-400">支持 PDF/DOCX 格式,不超过 10MB</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- Step 3 -->
|
||||
<div v-if="currentStep === 2" class="text-center py-6">
|
||||
<el-result icon="info" title="信息确认完毕" sub-title="请确认以上提交的所有信息真实有效。提交后进入1-2个工作日人工审核。" />
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<el-divider />
|
||||
<div class="flex items-center justify-between">
|
||||
<el-button v-if="currentStep > 0" @click="currentStep--">上一步</el-button>
|
||||
<div v-else></div>
|
||||
|
||||
<el-button v-if="currentStep === 0" type="primary" @click="currentStep++"
|
||||
:disabled="!form.real_name || !form.id_card || (!hasExistingIdentity && !form.facePhoto)"
|
||||
>下一步</el-button>
|
||||
|
||||
<el-button v-else-if="currentStep === 1" type="primary" @click="currentStep++"
|
||||
:disabled="form.skills.length === 0 || !form.experience || !form.resume_url"
|
||||
>下一步</el-button>
|
||||
|
||||
<el-button v-else-if="currentStep === 2" type="primary" :loading="isSubmitting" @click="isShowCaptcha = true">
|
||||
确认并提交认证申请
|
||||
</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<Vcode :show="isShowCaptcha" @success="handleCaptchaSuccess" @close="isShowCaptcha = false" />
|
||||
|
||||
<el-dialog v-model="previewVisible" title="简历在线预览" width="850px" top="5vh" :destroy-on-close="true" center>
|
||||
<div v-if="previewType === 'image'" class="flex justify-center">
|
||||
<el-image :src="previewUrl" class="max-h-[85vh]" fit="contain" />
|
||||
</div>
|
||||
<div v-else-if="previewType === 'pdf'" class="flex justify-center">
|
||||
<iframe :src="previewUrl" width="100%" style="height: 80vh" border="0"></iframe>
|
||||
</div>
|
||||
<div v-else-if="previewType === 'docx'" class="flex justify-center h-[80vh] overflow-y-auto">
|
||||
<vue-office-docx :src="previewUrl" />
|
||||
</div>
|
||||
<div v-else class="text-center py-10">
|
||||
<el-result icon="warning" title="无法预览该类型文件" sub-title="该格式不支持在线预览,请下载后查看。" />
|
||||
<el-button type="primary" @click="downloadFile(previewUrl)">下载文件</el-button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
85
src/views/user/certification/StatusView.vue
Normal file
85
src/views/user/certification/StatusView.vue
Normal file
@@ -0,0 +1,85 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { ShieldCheck, FileSearch, ArrowRight } from 'lucide-vue-next';
|
||||
import { getCertifications } from '@/api/certifications';
|
||||
|
||||
const router = useRouter();
|
||||
const certification = ref<any>(null);
|
||||
const isLoading = ref(true);
|
||||
|
||||
const steps = ref([
|
||||
{ title: '提交申请', desc: '您的申请资料已成功提交至系统。' },
|
||||
{ title: '实名核验', desc: '系统与人工核实实名信息。' },
|
||||
{ title: '专家评估', desc: '行业专家正在评估您的专业能力。' },
|
||||
{ title: '结果反馈', desc: '最终认证结果将在此显示。' }
|
||||
]);
|
||||
|
||||
const activeStep = ref(0);
|
||||
|
||||
const fetchStatus = async () => {
|
||||
try {
|
||||
const res: any = await getCertifications();
|
||||
if (res.results && res.results.length > 0) {
|
||||
certification.value = res.results[0];
|
||||
updateSteps(certification.value);
|
||||
} else {
|
||||
router.push('/user/certification/apply');
|
||||
}
|
||||
} catch (e) { console.error(e); }
|
||||
finally { isLoading.value = false; }
|
||||
};
|
||||
|
||||
const updateSteps = (cert: any) => {
|
||||
if (cert.status === 'PENDING') {
|
||||
activeStep.value = 1;
|
||||
} else if (cert.status === 'APPROVED') {
|
||||
activeStep.value = 4;
|
||||
steps.value[3].desc = '恭喜!您已成功获得 OPC 专家认证。';
|
||||
} else if (cert.status === 'REJECTED') {
|
||||
activeStep.value = 3;
|
||||
steps.value[3].desc = `很抱歉,您的申请已被驳回。原因:${cert.reject_reason || '资料不全'}`;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => { fetchStatus(); });
|
||||
|
||||
const headerIcon = () => {
|
||||
if (!certification.value) return 'info';
|
||||
if (certification.value.status === 'APPROVED') return 'success';
|
||||
if (certification.value.status === 'REJECTED') return 'error';
|
||||
return 'info';
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="max-w-3xl mx-auto space-y-5 py-4">
|
||||
<div v-if="isLoading" v-loading="true" class="py-20"></div>
|
||||
|
||||
<template v-else>
|
||||
<el-result
|
||||
:icon="headerIcon()"
|
||||
:title="certification?.status === 'APPROVED' ? '认证已通过' : certification?.status === 'REJECTED' ? '认证未通过' : '审核进行中'"
|
||||
:sub-title="certification?.status === 'APPROVED' ? '您现在可以承接更高等级的任务了。' : certification?.status === 'REJECTED' ? '您可以修改资料后重新提交。' : '我们已收到您的 OPC 专家认证申请,请耐心等待专家评估。'"
|
||||
/>
|
||||
|
||||
<el-card shadow="never">
|
||||
<el-steps :active="activeStep" finish-status="success"
|
||||
:process-status="certification?.status === 'REJECTED' ? 'error' : 'process'"
|
||||
direction="vertical"
|
||||
>
|
||||
<el-step v-for="(step, idx) in steps" :key="idx" :title="step.title" :description="step.desc" />
|
||||
</el-steps>
|
||||
</el-card>
|
||||
|
||||
<div class="text-center mt-8 space-x-4">
|
||||
<el-button type="primary" @click="router.push('/user/certification/apply')">
|
||||
{{ certification?.status === 'REJECTED' ? '重新提交申请' : (certification?.status === 'APPROVED' ? '更新认证资料' : '修改认证信息') }}
|
||||
</el-button>
|
||||
<el-button text @click="router.push('/user/dashboard')">
|
||||
先回工作台看看 <ArrowRight class="w-4 h-4 ml-1 inline" />
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
93
src/views/user/models/ApiKeyListView.vue
Normal file
93
src/views/user/models/ApiKeyListView.vue
Normal file
@@ -0,0 +1,93 @@
|
||||
<script setup lang="ts">
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { onMounted, ref } from 'vue';
|
||||
import api from '@/api';
|
||||
import { Key, Copy, Check, Trash2 } from 'lucide-vue-next';
|
||||
|
||||
const apiKeys = ref<any[]>([]);
|
||||
|
||||
const fetchKeys = async () => {
|
||||
try {
|
||||
const res: any = await api.get('/tokens/');
|
||||
apiKeys.value = res;
|
||||
} catch (error) { console.error('Fetch keys failed', error); }
|
||||
};
|
||||
|
||||
onMounted(fetchKeys);
|
||||
|
||||
const copiedId = ref<string | null>(null);
|
||||
const copyKey = (id: string, key: string) => {
|
||||
navigator.clipboard.writeText(key);
|
||||
copiedId.value = id;
|
||||
ElMessage.success('已复制到剪贴板');
|
||||
setTimeout(() => { copiedId.value = null; }, 2000);
|
||||
};
|
||||
|
||||
const revokeKey = async (id: string) => {
|
||||
try {
|
||||
await api.delete(`/tokens/${id}/`);
|
||||
await fetchKeys();
|
||||
ElMessage.success('凭证已注销');
|
||||
} catch (error) { ElMessage.error('操作失败'); }
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-gray-800">API 管理</h1>
|
||||
<p class="text-sm text-gray-400">您申请的所有模型访问凭证</p>
|
||||
</div>
|
||||
<router-link to="/user/models">
|
||||
<el-button type="primary">申请新模型</el-button>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<el-table :data="apiKeys" stripe v-if="apiKeys.length > 0">
|
||||
<el-table-column label="模型" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 rounded-lg bg-gray-50 flex items-center justify-center text-gray-400">
|
||||
<Key class="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-semibold">{{ row.model_name }} Access</div>
|
||||
<div class="text-xs text-gray-400">模型: {{ row.model_name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default><el-tag type="success" size="small">Active</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="Token" min-width="280">
|
||||
<template #default="{ row }">
|
||||
<div class="flex items-center gap-2">
|
||||
<code class="text-xs font-mono text-gray-600 bg-gray-50 px-2 py-1 rounded flex-1 overflow-hidden text-ellipsis">{{ row.token_value }}</code>
|
||||
<el-button text size="small" @click="copyKey(row.id, row.token_value)">
|
||||
<Check v-if="copiedId === row.id" class="w-4 h-4 text-green-500" />
|
||||
<Copy v-else class="w-4 h-4" />
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建时间" width="130">
|
||||
<template #default="{ row }">{{ new Date(row.created_at).toLocaleDateString() }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-popconfirm title="确定要注销此凭证吗?" @confirm="revokeKey(row.id)">
|
||||
<template #reference>
|
||||
<el-button text type="danger" size="small"><Trash2 class="w-4 h-4" /></el-button>
|
||||
</template>
|
||||
</el-popconfirm>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-empty v-else description="暂无可用凭证" :image-size="80">
|
||||
<el-button type="primary" @click="$router.push('/user/models')">前往模型市场申请</el-button>
|
||||
</el-empty>
|
||||
</div>
|
||||
</template>
|
||||
142
src/views/user/models/ModelMarketView.vue
Normal file
142
src/views/user/models/ModelMarketView.vue
Normal file
@@ -0,0 +1,142 @@
|
||||
<script setup lang="ts">
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { onMounted, ref, computed } from 'vue';
|
||||
import { useSystemStore } from '@/stores/system';
|
||||
import { Box, Key, Star, Zap, Copy, Check } from 'lucide-vue-next';
|
||||
|
||||
const systemStore = useSystemStore();
|
||||
const models = computed(() => systemStore.models);
|
||||
|
||||
onMounted(async () => { await systemStore.fetchModels(); });
|
||||
|
||||
const showDialog = ref(false);
|
||||
const selectedModel = ref<any>(null);
|
||||
const keyPurpose = ref('');
|
||||
const generatedKey = ref('');
|
||||
const isGenerating = ref(false);
|
||||
const showKeyResult = ref(false);
|
||||
const copied = ref(false);
|
||||
|
||||
const openApplyModal = (model: any) => {
|
||||
selectedModel.value = model;
|
||||
keyPurpose.value = '';
|
||||
generatedKey.value = '';
|
||||
showKeyResult.value = false;
|
||||
showDialog.value = true;
|
||||
};
|
||||
|
||||
const generateApiKey = async () => {
|
||||
if (!keyPurpose.value) return;
|
||||
isGenerating.value = true;
|
||||
try {
|
||||
const res = await systemStore.getToken(selectedModel.value.id);
|
||||
generatedKey.value = res.token_value;
|
||||
showKeyResult.value = true;
|
||||
} catch (error) {
|
||||
ElMessage.error('申请失败');
|
||||
} finally { isGenerating.value = false; }
|
||||
};
|
||||
|
||||
const copyKey = () => {
|
||||
navigator.clipboard.writeText(generatedKey.value);
|
||||
copied.value = true;
|
||||
setTimeout(() => { copied.value = false; }, 2000);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-gray-800">模型市场</h1>
|
||||
<p class="text-sm text-gray-400">加速您的业务数字化、智能化进程</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<router-link to="/user/models/keys">
|
||||
<el-button plain><Key class="w-4 h-4 mr-1" />API 管理</el-button>
|
||||
</router-link>
|
||||
<el-input placeholder="搜索模型..." prefix-icon="Search" class="!w-48" clearable />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Models Grid -->
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="6" v-for="model in models" :key="model.id" class="mb-4">
|
||||
<el-card shadow="hover" class="h-full">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="w-9 h-9 bg-gray-900 rounded-lg flex items-center justify-center text-white">
|
||||
<Box class="w-4 h-4" />
|
||||
</div>
|
||||
<el-tag size="small" type="primary" effect="plain">{{ model.category }}</el-tag>
|
||||
</div>
|
||||
<h3 class="font-bold text-gray-800 mb-1 line-clamp-1">{{ model.name }}</h3>
|
||||
<p class="text-sm text-gray-500 line-clamp-2 mb-3 h-10">{{ model.description }}</p>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center gap-0.5">
|
||||
<Star v-for="i in 5" :key="i" :class="['w-3 h-3', i > 4 ? 'text-gray-200' : 'text-yellow-400 fill-current']" />
|
||||
<span class="text-xs text-gray-400 ml-1">{{ model.rating }}</span>
|
||||
</div>
|
||||
<el-tag size="small" type="warning" effect="plain">{{ model.tag }}</el-tag>
|
||||
</div>
|
||||
<el-divider class="!my-2" />
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<span class="text-xs text-gray-400">起步价</span>
|
||||
<div class="font-bold text-gray-800">¥{{ model.price_per_token }}<span class="text-xs font-normal text-gray-400">/token</span></div>
|
||||
</div>
|
||||
<el-button type="primary" size="small" @click="openApplyModal(model)">申请模型</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<el-col :span="6" class="mb-4">
|
||||
<el-card shadow="hover" class="h-full flex items-center justify-center cursor-pointer">
|
||||
<div class="text-center py-6">
|
||||
<div class="w-10 h-10 bg-gray-50 rounded-full flex items-center justify-center mx-auto mb-2">
|
||||
<Zap class="w-5 h-5 text-gray-400" />
|
||||
</div>
|
||||
<div class="font-bold text-sm">定制专属模型</div>
|
||||
<div class="text-xs text-gray-400">Contact Support</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- API Key Dialog -->
|
||||
<el-dialog v-model="showDialog" title="申请访问凭证" width="440px" top="5vh">
|
||||
<div v-if="!showKeyResult" class="space-y-4">
|
||||
<el-alert type="info" :closable="false" show-icon>
|
||||
<template #title>
|
||||
正在申请访问 <strong>{{ selectedModel?.name }}</strong>。凭证可用于 API 调用,请妥善保管。
|
||||
</template>
|
||||
</el-alert>
|
||||
<el-input v-model="keyPurpose" placeholder="凭证用途名称(如:在线聊天生产环境)" />
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4 text-center">
|
||||
<el-result icon="success" title="凭证生成成功" sub-title="请立即复制并保存,离开后将无法再次查看" />
|
||||
<div class="bg-gray-900 rounded-lg p-4 relative">
|
||||
<code class="text-sm font-mono text-blue-400 break-all">{{ generatedKey }}</code>
|
||||
<el-button circle size="small" class="!absolute right-3 top-3" @click="copyKey">
|
||||
<Check v-if="copied" class="w-3 h-3 text-green-400" />
|
||||
<Copy v-else class="w-3 h-3" />
|
||||
</el-button>
|
||||
</div>
|
||||
<el-tag v-if="copied" type="success" effect="dark">已复制到剪贴板!</el-tag>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<template v-if="!showKeyResult">
|
||||
<el-button @click="showDialog = false">取消</el-button>
|
||||
<el-button type="primary" :loading="isGenerating" :disabled="!keyPurpose" @click="generateApiKey">
|
||||
{{ isGenerating ? '正在生成...' : '确认生成 API Key' }}
|
||||
</el-button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<router-link to="/user/models/keys"><el-button>查看全部凭证</el-button></router-link>
|
||||
<el-button type="primary" @click="showDialog = false">完成</el-button>
|
||||
</template>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
262
src/views/user/tasks/MyApplicationsView.vue
Normal file
262
src/views/user/tasks/MyApplicationsView.vue
Normal file
@@ -0,0 +1,262 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { Send, Clock, CheckCircle, XCircle, ArrowRight, Search } from 'lucide-vue-next';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { getApplications } from '@/api/tasks';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const allApps = ref<any[]>([]);
|
||||
const isLoading = ref(true);
|
||||
const searchQuery = ref('');
|
||||
|
||||
const tabs = [
|
||||
{ key: 'ALL', label: '全部' },
|
||||
{ key: 'PENDING', label: '待审核' },
|
||||
{ key: 'APPROVED', label: '已通过' },
|
||||
{ key: 'REJECTED', label: '被拒绝' },
|
||||
{ key: 'WITHDRAWN', label: '已撤回' },
|
||||
];
|
||||
|
||||
const validKeys = tabs.map(t => t.key);
|
||||
const initTab = typeof route.query.tab === 'string' && validKeys.includes(route.query.tab)
|
||||
? route.query.tab : 'ALL';
|
||||
const activeTab = ref(initTab);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
isLoading.value = true;
|
||||
const res: any = await getApplications();
|
||||
allApps.value = res.results || [];
|
||||
} catch (e) { console.error(e); }
|
||||
finally { isLoading.value = false; }
|
||||
};
|
||||
|
||||
onMounted(fetchData);
|
||||
|
||||
import { watch } from 'vue';
|
||||
watch(() => route.query.tab, (newTab) => {
|
||||
if (typeof newTab === 'string' && validKeys.includes(newTab)) {
|
||||
activeTab.value = newTab;
|
||||
}
|
||||
});
|
||||
|
||||
const filteredApps = computed(() => {
|
||||
return allApps.value.filter(app => {
|
||||
// DELIVERED/COMPLETED belong to project management, not applications
|
||||
if (['DELIVERED', 'COMPLETED'].includes(app.status)) return false;
|
||||
const task = app.task_detail || {};
|
||||
const matchSearch = !searchQuery.value ||
|
||||
task.title?.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
|
||||
task.description?.toLowerCase().includes(searchQuery.value.toLowerCase());
|
||||
if (!matchSearch) return false;
|
||||
if (activeTab.value === 'ALL') return true;
|
||||
return app.status === activeTab.value;
|
||||
});
|
||||
});
|
||||
|
||||
const tabCounts = computed(() => {
|
||||
const appOnly = allApps.value.filter(a => !['DELIVERED', 'COMPLETED'].includes(a.status));
|
||||
const counts: Record<string, number> = { ALL: appOnly.length };
|
||||
for (const app of appOnly) {
|
||||
counts[app.status] = (counts[app.status] || 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
});
|
||||
|
||||
const getStatusType = (status: string) => {
|
||||
switch (status) {
|
||||
case 'APPROVED': return 'success';
|
||||
case 'DELIVERED': return 'primary';
|
||||
case 'REJECTED': return 'danger';
|
||||
case 'PENDING': return 'warning';
|
||||
case 'COMPLETED': return 'info';
|
||||
case 'WITHDRAWN': return 'info';
|
||||
default: return 'info';
|
||||
}
|
||||
};
|
||||
|
||||
const translateStatus = (status: string) => {
|
||||
const map: Record<string, string> = {
|
||||
'PENDING': '待审核',
|
||||
'APPROVED': '已通过',
|
||||
'DELIVERED': '已交付',
|
||||
'REJECTED': '被拒绝',
|
||||
'WITHDRAWN': '已撤回',
|
||||
'COMPLETED': '已完成',
|
||||
};
|
||||
return map[status] || status;
|
||||
};
|
||||
|
||||
const getTimeAgo = (dateStr: string) => {
|
||||
const diff = Date.now() - new Date(dateStr).getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 60) return `${mins}分钟前`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return `${hours}小时前`;
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days < 30) return `${days}天前`;
|
||||
return new Date(dateStr).toLocaleDateString();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-5">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-xl bg-blue-50 text-blue-500 flex items-center justify-center">
|
||||
<Send class="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-gray-800">我的申请</h1>
|
||||
<p class="text-xs text-gray-400">管理所有投递的任务申请</p>
|
||||
</div>
|
||||
</div>
|
||||
<el-button type="primary" @click="router.push('/user/tasks/market')">
|
||||
去接单大厅
|
||||
<ArrowRight class="w-4 h-4 ml-1" />
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- Tabs + Search -->
|
||||
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-1 bg-gray-50 rounded-lg p-1">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
@click="activeTab = tab.key"
|
||||
class="tab-btn"
|
||||
:class="{ active: activeTab === tab.key }"
|
||||
>
|
||||
{{ tab.label }}
|
||||
<span class="tab-count">{{ tabCounts[tab.key] || 0 }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<el-input v-model="searchQuery" placeholder="搜索任务名称..." clearable style="width: 240px" size="default">
|
||||
<template #prefix><Search class="w-4 h-4 text-gray-400" /></template>
|
||||
</el-input>
|
||||
</div>
|
||||
|
||||
<!-- List -->
|
||||
<div v-loading="isLoading">
|
||||
<div v-if="filteredApps.length > 0" class="space-y-3">
|
||||
<div
|
||||
v-for="app in filteredApps"
|
||||
:key="app.id"
|
||||
class="app-card"
|
||||
@click="router.push(`/user/tasks/${app.task}`)"
|
||||
>
|
||||
<!-- Status indicator bar -->
|
||||
<div class="status-bar"
|
||||
:class="{
|
||||
'bg-yellow-400': app.status === 'PENDING',
|
||||
'bg-green-400': app.status === 'APPROVED',
|
||||
'bg-red-400': app.status === 'REJECTED',
|
||||
'bg-blue-400': app.status === 'DELIVERED',
|
||||
'bg-gray-300': ['WITHDRAWN', 'COMPLETED'].includes(app.status),
|
||||
}"
|
||||
></div>
|
||||
|
||||
<div class="flex-1 min-w-0 p-4">
|
||||
<!-- Top row -->
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<el-tag :type="(getStatusType(app.status) as any)" size="small" effect="light" round>
|
||||
{{ translateStatus(app.status) }}
|
||||
</el-tag>
|
||||
<span class="text-xs text-gray-400 font-mono">#{{ app.id?.toString().substring(0, 8) }}</span>
|
||||
<span class="text-xs text-gray-400 ml-auto">{{ getTimeAgo(app.created_at) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Task title -->
|
||||
<h3 class="font-bold text-gray-800 truncate group-hover:text-blue-600 transition-colors">
|
||||
{{ app.task_detail?.title || '任务加载中...' }}
|
||||
</h3>
|
||||
|
||||
<!-- Bottom row -->
|
||||
<div class="flex items-center gap-4 mt-2 text-sm">
|
||||
<span class="text-orange-500 font-bold">
|
||||
¥{{ app.expected_price?.toLocaleString() || 0 }}
|
||||
</span>
|
||||
<span class="text-gray-400 flex items-center gap-1">
|
||||
<Clock class="w-3.5 h-3.5" />{{ app.expected_days || 0 }} 天
|
||||
</span>
|
||||
<span v-if="app.task_detail?.enterprise_name" class="text-gray-400 text-xs">
|
||||
{{ app.task_detail.enterprise_name }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Rejection reason -->
|
||||
<div v-if="app.status === 'REJECTED' && app.reject_reason" class="mt-2 text-xs text-red-500 bg-red-50 rounded-lg px-3 py-2">
|
||||
拒绝原因:{{ app.reject_reason }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ArrowRight class="w-4 h-4 text-gray-300 mr-4 flex-shrink-0 self-center" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-empty v-else-if="!isLoading" :description="activeTab === 'ALL' ? '暂无申请记录' : `暂无${tabs.find(t => t.key === activeTab)?.label}的申请`" class="bg-white rounded-xl py-16">
|
||||
<el-button type="primary" @click="router.push('/user/tasks/market')">去接单大厅</el-button>
|
||||
</el-empty>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tab-btn {
|
||||
padding: 6px 14px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #6b7280;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tab-btn:hover { color: #374151; }
|
||||
.tab-btn.active {
|
||||
background: #fff;
|
||||
color: #3b82f6;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
|
||||
}
|
||||
.tab-count {
|
||||
font-size: 11px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 10px;
|
||||
background: #f3f4f6;
|
||||
color: #9ca3af;
|
||||
font-weight: 500;
|
||||
}
|
||||
.tab-btn.active .tab-count {
|
||||
background: #eff6ff;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.app-card {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #f0f0f0;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
overflow: hidden;
|
||||
}
|
||||
.app-card:hover {
|
||||
border-color: #bfdbfe;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.04);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.status-bar {
|
||||
width: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
262
src/views/user/tasks/MyTasksView.vue
Normal file
262
src/views/user/tasks/MyTasksView.vue
Normal file
@@ -0,0 +1,262 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed, watch } from 'vue';
|
||||
import { Briefcase, Clock, ArrowRight, CheckCircle, Truck, Search, Ban } from 'lucide-vue-next';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { getApplications } from '@/api/tasks';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const allApps = ref<any[]>([]);
|
||||
const isLoading = ref(true);
|
||||
const searchQuery = ref('');
|
||||
|
||||
const tabs = [
|
||||
{ key: 'ALL', label: '全部项目' },
|
||||
{ key: 'APPROVED', label: '执行中' },
|
||||
{ key: 'DELIVERED', label: '待验收' },
|
||||
{ key: 'COMPLETED', label: '已完成' },
|
||||
{ key: 'CANCELLED', label: '已取消' },
|
||||
];
|
||||
|
||||
const validKeys = tabs.map(t => t.key);
|
||||
const initTab = typeof route.query.tab === 'string' && validKeys.includes(route.query.tab)
|
||||
? route.query.tab : 'ALL';
|
||||
const activeTab = ref(initTab);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
isLoading.value = true;
|
||||
const res: any = await getApplications();
|
||||
allApps.value = res.results || [];
|
||||
} catch (e) { console.error(e); }
|
||||
finally { isLoading.value = false; }
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchData();
|
||||
});
|
||||
|
||||
watch(() => route.query.tab, (newTab) => {
|
||||
if (typeof newTab === 'string' && validKeys.includes(newTab)) {
|
||||
activeTab.value = newTab;
|
||||
}
|
||||
});
|
||||
|
||||
const getAppStatus = (app: any) => {
|
||||
if (app.task_detail?.status === 'CANCELLED') return 'CANCELLED';
|
||||
if (app.task_detail?.status === 'COMPLETED' || app.status === 'COMPLETED') return 'COMPLETED';
|
||||
return app.status;
|
||||
};
|
||||
|
||||
const validApps = computed(() => {
|
||||
return allApps.value.filter(app => {
|
||||
const s = getAppStatus(app);
|
||||
return ['APPROVED', 'DELIVERED', 'COMPLETED', 'CANCELLED'].includes(s);
|
||||
});
|
||||
});
|
||||
|
||||
const filteredProjects = computed(() => {
|
||||
return validApps.value.filter(app => {
|
||||
const s = getAppStatus(app);
|
||||
const task = app.task_detail || {};
|
||||
const matchSearch = !searchQuery.value ||
|
||||
task.title?.toLowerCase().includes(searchQuery.value.toLowerCase());
|
||||
|
||||
if (!matchSearch) return false;
|
||||
if (activeTab.value === 'ALL') return true;
|
||||
return s === activeTab.value;
|
||||
});
|
||||
});
|
||||
|
||||
const tabCounts = computed(() => {
|
||||
const counts: Record<string, number> = { ALL: validApps.value.length, APPROVED: 0, DELIVERED: 0, COMPLETED: 0, CANCELLED: 0 };
|
||||
validApps.value.forEach(app => {
|
||||
const s = getAppStatus(app);
|
||||
if (counts[s] !== undefined) counts[s]++;
|
||||
});
|
||||
return counts;
|
||||
});
|
||||
|
||||
const getStatusType = (status: string) => {
|
||||
switch (status) {
|
||||
case 'APPROVED': return 'success';
|
||||
case 'DELIVERED': return 'primary';
|
||||
case 'COMPLETED': return 'success';
|
||||
case 'CANCELLED': return 'danger';
|
||||
default: return 'info';
|
||||
}
|
||||
};
|
||||
|
||||
const translateStatus = (status: string) => {
|
||||
const map: Record<string, string> = {
|
||||
'APPROVED': '执行中',
|
||||
'DELIVERED': '待验收',
|
||||
'COMPLETED': '已完成',
|
||||
'CANCELLED': '已取消',
|
||||
};
|
||||
return map[status] || status;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-5">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-xl bg-green-50 text-green-500 flex items-center justify-center">
|
||||
<Briefcase class="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-gray-800">我的项目</h1>
|
||||
<p class="text-xs text-gray-400">正在执行和交付中的任务</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<el-button type="primary" @click="router.push('/user/tasks/market')">
|
||||
接更多任务
|
||||
<ArrowRight class="w-4 h-4 ml-1" />
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs + Search -->
|
||||
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-1 bg-gray-50 rounded-lg p-1">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
@click="activeTab = tab.key"
|
||||
class="tab-btn"
|
||||
:class="{ active: activeTab === tab.key }"
|
||||
>
|
||||
{{ tab.label }}
|
||||
<span class="tab-count">{{ tabCounts[tab.key] || 0 }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<el-input v-model="searchQuery" placeholder="搜索项目名称..." clearable style="width: 240px">
|
||||
<template #prefix><Search class="w-4 h-4 text-gray-400" /></template>
|
||||
</el-input>
|
||||
</div>
|
||||
|
||||
<!-- Project Cards -->
|
||||
<div v-loading="isLoading">
|
||||
<div v-if="filteredProjects.length > 0" class="grid grid-cols-1 gap-4">
|
||||
<div
|
||||
v-for="app in filteredProjects"
|
||||
:key="app.id"
|
||||
class="project-card group"
|
||||
@click="router.push(`/user/tasks/${app.task}`)"
|
||||
>
|
||||
<div class="flex items-start gap-4 p-5">
|
||||
<!-- Status icon -->
|
||||
<div class="w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0"
|
||||
:class="getAppStatus(app) === 'APPROVED' ? 'bg-green-50 text-green-500' :
|
||||
getAppStatus(app) === 'DELIVERED' ? 'bg-blue-50 text-blue-500' :
|
||||
getAppStatus(app) === 'CANCELLED' ? 'bg-red-50 text-red-500' :
|
||||
'bg-gray-100 text-gray-500'">
|
||||
<CheckCircle v-if="getAppStatus(app) === 'APPROVED'" class="w-5 h-5" />
|
||||
<Truck v-else-if="getAppStatus(app) === 'DELIVERED'" class="w-5 h-5" />
|
||||
<Ban v-else-if="getAppStatus(app) === 'CANCELLED'" class="w-5 h-5" />
|
||||
<CheckCircle v-else class="w-5 h-5" />
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<el-tag :type="(getStatusType(getAppStatus(app)) as any)" size="small" effect="light" round>
|
||||
{{ translateStatus(getAppStatus(app)) }}
|
||||
</el-tag>
|
||||
<span class="text-xs text-gray-400 font-mono">#{{ app.id?.toString().substring(0, 8) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Title -->
|
||||
<h3 class="font-bold text-lg text-gray-800 group-hover:text-blue-600 transition-colors truncate">
|
||||
{{ app.task_detail?.title || '任务加载中...' }}
|
||||
</h3>
|
||||
|
||||
<!-- Meta -->
|
||||
<div class="flex items-center gap-4 mt-2 text-sm">
|
||||
<span class="text-orange-500 font-bold">¥{{ app.expected_price?.toLocaleString() || 0 }}</span>
|
||||
<span class="text-gray-400 flex items-center gap-1">
|
||||
<Clock class="w-3.5 h-3.5" />{{ app.expected_days || 0 }} 天
|
||||
</span>
|
||||
<span v-if="app.task_detail?.deadline" class="text-gray-400 text-xs">
|
||||
截止 {{ app.task_detail.deadline }}
|
||||
</span>
|
||||
<span v-if="app.task_detail?.enterprise_name" class="text-gray-400 text-xs ml-auto">
|
||||
{{ app.task_detail.enterprise_name }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ArrowRight class="w-4 h-4 text-gray-300 flex-shrink-0 self-center opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
|
||||
<!-- Progress bar (visual indicator) -->
|
||||
<div class="h-1 bg-gray-50" v-if="['APPROVED', 'DELIVERED'].includes(getAppStatus(app))">
|
||||
<div class="h-full transition-all duration-500"
|
||||
:class="getAppStatus(app) === 'DELIVERED' ? 'bg-blue-400' : 'bg-green-400'"
|
||||
:style="{ width: getAppStatus(app) === 'DELIVERED' ? '80%' : '40%' }">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-empty v-else-if="!isLoading" description="暂无项目" class="bg-white rounded-xl py-16">
|
||||
<el-button type="primary" @click="router.push('/user/tasks/market')">去接单大厅</el-button>
|
||||
</el-empty>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tab-btn {
|
||||
padding: 6px 14px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #6b7280;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tab-btn:hover { color: #374151; }
|
||||
.tab-btn.active {
|
||||
background: #fff;
|
||||
color: #10b981;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
|
||||
}
|
||||
.tab-count {
|
||||
font-size: 11px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 10px;
|
||||
background: #f3f4f6;
|
||||
color: #9ca3af;
|
||||
font-weight: 500;
|
||||
}
|
||||
.tab-btn.active .tab-count {
|
||||
background: #ecfdf5;
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.project-card {
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
border: 1px solid #f0f0f0;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.project-card:hover {
|
||||
border-color: #86efac;
|
||||
box-shadow: 0 6px 20px rgba(0,0,0,0.04);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
</style>
|
||||
124
src/views/user/tasks/TaskApplyView.vue
Normal file
124
src/views/user/tasks/TaskApplyView.vue
Normal file
@@ -0,0 +1,124 @@
|
||||
<script setup lang="ts">
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { ArrowLeft, FileText, Target } from 'lucide-vue-next';
|
||||
import { getTaskDetail, applyForTask } from '@/api/tasks';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
const taskId = route.params.id as string;
|
||||
|
||||
const isSubmitting = ref(false);
|
||||
const showSuccess = ref(false);
|
||||
|
||||
const form = ref({
|
||||
cover_letter: '',
|
||||
expected_price: 0,
|
||||
expected_days: 0,
|
||||
experience: '',
|
||||
confirm: false
|
||||
});
|
||||
|
||||
const taskSummary = ref<any>({});
|
||||
|
||||
const fetchTaskSummary = async () => {
|
||||
try {
|
||||
const res: any = await getTaskDetail(taskId);
|
||||
taskSummary.value = res;
|
||||
form.value.expected_price = res.budget_min || 0;
|
||||
} catch (e) { console.error(e); }
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
if (authStore.user?.opc_certification?.status !== 'APPROVED') {
|
||||
ElMessage.error('必须是通过审核的 OPC 专家才能承接任务');
|
||||
router.back();
|
||||
return;
|
||||
}
|
||||
fetchTaskSummary();
|
||||
});
|
||||
|
||||
const handleSubmit = async () => {
|
||||
isSubmitting.value = true;
|
||||
try {
|
||||
await applyForTask({
|
||||
task: taskId,
|
||||
cover_letter: form.value.cover_letter + (form.value.experience ? `\n作品链接: ${form.value.experience}` : ''),
|
||||
expected_price: form.value.expected_price,
|
||||
expected_days: form.value.expected_days
|
||||
});
|
||||
showSuccess.value = true;
|
||||
setTimeout(() => { router.push('/user/tasks/my'); }, 2000);
|
||||
} catch (e) {
|
||||
ElMessage.error('申请提交失败,请重试');
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="max-w-3xl mx-auto space-y-5">
|
||||
<el-button text @click="router.back()">
|
||||
<ArrowLeft class="w-4 h-4 mr-1" />返回详情
|
||||
</el-button>
|
||||
|
||||
<!-- Task Info -->
|
||||
<el-card shadow="never" class="!bg-blue-600 !border-0">
|
||||
<div class="flex items-center justify-between text-white">
|
||||
<div>
|
||||
<p class="text-blue-200 text-xs mb-1">正在申请任务</p>
|
||||
<h1 class="text-xl font-bold text-white">{{ taskSummary.title }}</h1>
|
||||
<p class="text-blue-100 text-sm mt-1">ID: {{ taskSummary.id }} • 预估报酬 ¥{{ taskSummary.budget_min }} - {{ taskSummary.budget_max }}</p>
|
||||
</div>
|
||||
<FileText class="w-10 h-10 text-white/20" />
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- Form -->
|
||||
<el-card shadow="never">
|
||||
<div v-if="!showSuccess">
|
||||
<el-form label-position="top">
|
||||
<el-form-item>
|
||||
<template #label>
|
||||
<div class="flex items-center gap-1"><Target class="w-4 h-4 text-blue-500" />申请理由及优势自述</div>
|
||||
</template>
|
||||
<el-input v-model="form.cover_letter" type="textarea" :rows="5" placeholder="请描述您为什么能胜任此任务..." />
|
||||
</el-form-item>
|
||||
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="期望报酬 (元)">
|
||||
<el-input-number v-model="form.expected_price" :min="0" class="w-full" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="预计用时 (天)">
|
||||
<el-input-number v-model="form.expected_days" :min="1" class="w-full" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form-item label="相关作品/经历链接 (选填)">
|
||||
<el-input v-model="form.experience" placeholder="https://github.com/your-project" />
|
||||
</el-form-item>
|
||||
|
||||
<el-divider />
|
||||
|
||||
<el-checkbox v-model="form.confirm" class="mb-4">
|
||||
我已确认具备任务所需权限与技能(虚假申请可能导致信用分扣减)
|
||||
</el-checkbox>
|
||||
|
||||
<el-button type="primary" class="w-full" size="large" :disabled="!form.confirm" :loading="isSubmitting" @click="handleSubmit">
|
||||
{{ isSubmitting ? '正在提交...' : '提交申请' }}
|
||||
</el-button>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<el-result v-else icon="success" title="申请已成功提交!" sub-title="系统已录入您的申请,正在为您跳转至我的任务页面..." />
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
354
src/views/user/tasks/TaskDetailView.vue
Normal file
354
src/views/user/tasks/TaskDetailView.vue
Normal file
@@ -0,0 +1,354 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { ArrowLeft, ShieldCheck, Activity, Phone, FileText, CheckCircle2, Clock, XCircle } from 'lucide-vue-next';
|
||||
import { getTaskDetail, getApplications } from '@/api/tasks';
|
||||
import { uploadFileToMinIO } from '@/api/index';
|
||||
import EnterpriseProfileDrawer from '@/components/enterprise/EnterpriseProfileDrawer.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
const taskId = route.params.id as string;
|
||||
const isJoined = ref(false);
|
||||
const task = ref<any>({});
|
||||
const userApplication = ref<any>(null);
|
||||
|
||||
const drawerVisible = ref(false);
|
||||
const currentEnterpriseId = ref('');
|
||||
const showContactDialog = ref(false);
|
||||
|
||||
const translateStatus = (status: string) => {
|
||||
const map: Record<string, string> = {
|
||||
'PENDING': '待审核',
|
||||
'APPROVED': '执行中',
|
||||
'DELIVERED': '待验收',
|
||||
'REJECTED': '已驳回',
|
||||
'COMPLETED': '已结案'
|
||||
};
|
||||
return map[status] || status;
|
||||
};
|
||||
|
||||
const fetchTaskData = async () => {
|
||||
try {
|
||||
const res: any = await getTaskDetail(taskId);
|
||||
task.value = res;
|
||||
const appsRes: any = await getApplications();
|
||||
const apps = Array.isArray(appsRes) ? appsRes : appsRes.results || [];
|
||||
userApplication.value = apps.find((app: any) => app.task === parseInt(taskId) || app.task === taskId);
|
||||
if (userApplication.value) isJoined.value = true;
|
||||
} catch (e) { console.error(e); }
|
||||
};
|
||||
|
||||
onMounted(() => { fetchTaskData(); });
|
||||
|
||||
const openEnterpriseProfile = () => {
|
||||
if (task.value?.enterprise) {
|
||||
currentEnterpriseId.value = task.value.enterprise;
|
||||
drawerVisible.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
const currentStep = computed(() => {
|
||||
if (userApplication.value?.status === 'COMPLETED') return 5;
|
||||
if (userApplication.value?.status === 'DELIVERED') return 4;
|
||||
if (userApplication.value?.status === 'APPROVED') return 3;
|
||||
if (userApplication.value?.status === 'PENDING') return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
const statusDescription = computed(() => {
|
||||
if (userApplication.value?.status === 'COMPLETED') return '您的成果已验收通过,任务已圆满完成!';
|
||||
if (userApplication.value?.status === 'DELIVERED') return '成果已提交,企业正在验收中。您仍可以重新提交覆盖成果。';
|
||||
if (userApplication.value?.status === 'APPROVED') return '任务执行中,请按时交付成果。';
|
||||
if (userApplication.value?.status === 'PENDING') return '您的申请正在审核中,请耐心等待。';
|
||||
if (userApplication.value?.status === 'REJECTED') return '很遗憾,您的申请未获批准。';
|
||||
return '未知的状态。';
|
||||
});
|
||||
|
||||
const showDeliverablesDialog = ref(false);
|
||||
const deliverablesForm = ref({ note: '', files: [] as any[] });
|
||||
const isSubmitting = ref(false);
|
||||
|
||||
const openDeliverablesDialog = () => {
|
||||
deliverablesForm.value = { note: '', files: [] };
|
||||
showDeliverablesDialog.value = true;
|
||||
};
|
||||
|
||||
const submitDeliverables = async () => {
|
||||
if (!deliverablesForm.value.note) {
|
||||
import('element-plus').then(({ ElMessage }) => ElMessage.warning('请填写结项说明'));
|
||||
return;
|
||||
}
|
||||
isSubmitting.value = true;
|
||||
try {
|
||||
// Upload files to MinIO
|
||||
const uploadedFiles = [];
|
||||
for (const file of deliverablesForm.value.files) {
|
||||
if (file.raw) {
|
||||
const url = await uploadFileToMinIO(file.raw, 'deliverables');
|
||||
uploadedFiles.push({ name: file.name, size: file.size, uid: file.uid, url });
|
||||
} else {
|
||||
uploadedFiles.push({ name: file.name, size: file.size, uid: file.uid, url: file.url });
|
||||
}
|
||||
}
|
||||
|
||||
const api = (await import('@/api')).default;
|
||||
await api.post(`/applications/${userApplication.value.id}/submit_deliverables/`, {
|
||||
completion_note: deliverablesForm.value.note,
|
||||
deliverables: uploadedFiles
|
||||
});
|
||||
import('element-plus').then(({ ElMessage }) => ElMessage.success('成果已成功提交!'));
|
||||
showDeliverablesDialog.value = false;
|
||||
fetchTaskData();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
import('element-plus').then(({ ElMessage }) => ElMessage.error('提交失败,请重试'));
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const goBack = () => { window.history.length > 1 ? router.back() : router.push('/user/tasks/my'); };
|
||||
const goToApply = () => router.push(`/user/tasks/${taskId}/apply`);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-5">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<el-button text @click="goBack">
|
||||
<ArrowLeft class="w-4 h-4 mr-1" />返回任务列表
|
||||
</el-button>
|
||||
<el-tag v-if="isJoined" :type="userApplication?.status === 'APPROVED' ? 'success' : 'warning'" effect="dark">
|
||||
<Activity class="w-3 h-3 mr-1 inline" />{{ translateStatus(userApplication?.status) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<el-row :gutter="20">
|
||||
<!-- Main Content -->
|
||||
<el-col :span="16">
|
||||
<div class="space-y-4">
|
||||
<!-- Task Header -->
|
||||
<el-card shadow="never">
|
||||
<!-- System Alert Banners -->
|
||||
<el-alert v-if="task.status === 'CANCELLED' && task.cancel_reason" type="error" :closable="false" show-icon class="mb-4">
|
||||
<template #title>任务已取消</template>
|
||||
{{ task.cancel_reason }}
|
||||
</el-alert>
|
||||
<el-alert v-if="userApplication?.status === 'REJECTED' && userApplication?.reject_reason" type="warning" :closable="false" show-icon class="mb-4">
|
||||
<template #title>申请被驳回</template>
|
||||
{{ userApplication.reject_reason }}
|
||||
</el-alert>
|
||||
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<el-tag size="small" type="info">#{{ task.id }}</el-tag>
|
||||
<el-tag v-if="task.is_opc_required" size="small" type="warning" effect="dark">
|
||||
<ShieldCheck class="w-3 h-3 mr-0.5 inline" />OPC 认证
|
||||
</el-tag>
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-gray-800 mb-3">{{ task.title }}</h1>
|
||||
<p class="text-gray-500 leading-relaxed mb-6 whitespace-pre-wrap break-words">{{ task.description }}</p>
|
||||
|
||||
<el-descriptions :column="3" border>
|
||||
<el-descriptions-item label="预计报酬">
|
||||
<span class="font-bold text-blue-600">¥{{ task.budget_min }} - {{ task.budget_max }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="截止日期">{{ task.deadline }}</el-descriptions-item>
|
||||
<el-descriptions-item label="地点">{{ task.location || '远程/不限' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<!-- Requirements -->
|
||||
<el-card shadow="never">
|
||||
<template #header><span class="font-semibold">申请要求</span></template>
|
||||
<el-tag v-for="(req, idx) in task.skill_tags || []" :key="idx" class="mr-2 mb-2" effect="plain">{{ req }}</el-tag>
|
||||
<el-empty v-if="!task.skill_tags?.length" description="暂无特殊要求" :image-size="40" />
|
||||
</el-card>
|
||||
|
||||
<!-- Negotiation History -->
|
||||
<el-collapse v-if="userApplication?.negotiation_history?.length > 0" class="mb-4 bg-white border border-gray-100 rounded-xl overflow-hidden shadow-sm !border-b-0">
|
||||
<el-collapse-item name="1">
|
||||
<template #title>
|
||||
<div class="font-semibold px-5 text-gray-800">前期洽谈备忘录</div>
|
||||
</template>
|
||||
<div class="space-y-4 bg-gray-50/50 p-5 max-h-[300px] overflow-y-auto border-t border-gray-50">
|
||||
<div v-for="(msg, idx) in userApplication.negotiation_history" :key="idx" class="flex gap-3" :class="{'flex-row-reverse': msg.sender_role === 'EXPERT'}">
|
||||
<el-avatar v-if="msg.sender_role !== 'SYSTEM'" :size="32" :src="msg.sender_avatar" class="shrink-0">{{ msg.sender_name?.charAt(0) }}</el-avatar>
|
||||
<div v-if="msg.sender_role !== 'SYSTEM'" class="p-3 rounded-2xl shadow-sm max-w-[80%]"
|
||||
:class="msg.sender_role === 'EXPERT' ? 'bg-blue-500 text-white rounded-tr-sm' : 'bg-white border border-gray-100 rounded-tl-sm text-gray-800'">
|
||||
<p class="text-sm whitespace-pre-wrap">{{ msg.content }}</p>
|
||||
<div class="text-[10px] mt-1 opacity-70" :class="{'text-right': msg.sender_role === 'EXPERT'}">
|
||||
{{ new Date(msg.created_at).toLocaleString() }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="w-full flex justify-center my-2">
|
||||
<div class="bg-gray-200 text-gray-500 text-xs px-3 py-1 rounded-full">
|
||||
{{ msg.content }} - {{ new Date(msg.created_at).toLocaleString() }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 mt-3 text-center">以上记录为邀约阶段双方的沟通备忘,仅供查阅不支持修改。</p>
|
||||
</div>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
|
||||
<!-- Delivery History -->
|
||||
<el-card shadow="never" v-if="userApplication?.delivery_records?.length > 0">
|
||||
<template #header><span class="font-semibold">交付历史记录</span></template>
|
||||
<div class="space-y-4">
|
||||
<div v-for="(record, idx) in userApplication.delivery_records" :key="record.id" class="border border-gray-100 rounded-xl p-4 relative">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<el-tag :type="record.status === 'APPROVED' ? 'success' : record.status === 'REJECTED' ? 'danger' : 'warning'" size="small" effect="light">
|
||||
{{ record.status === 'APPROVED' ? '已验收' : record.status === 'REJECTED' ? '已驳回' : '待验收' }}
|
||||
</el-tag>
|
||||
<span class="text-xs text-gray-400">{{ new Date(record.created_at).toLocaleString() }} 提交</span>
|
||||
</div>
|
||||
<CheckCircle2 v-if="record.status === 'APPROVED'" class="w-5 h-5 text-green-500" />
|
||||
<XCircle v-else-if="record.status === 'REJECTED'" class="w-5 h-5 text-red-500" />
|
||||
<Clock v-else class="w-5 h-5 text-yellow-500" />
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 mb-3 whitespace-pre-wrap">{{ record.note }}</p>
|
||||
|
||||
<div v-if="record.files?.length > 0" class="space-y-2">
|
||||
<div class="text-xs text-gray-500 mb-1">包含附件:</div>
|
||||
<a v-for="file in record.files" :key="file.uid" :href="file.url" target="_blank" class="flex items-center gap-2 p-2 bg-gray-50 rounded border border-gray-100 shadow-sm text-sm hover:bg-gray-100 transition-colors">
|
||||
<FileText class="w-4 h-4 text-gray-400" />
|
||||
<span class="flex-1 truncate text-gray-700">{{ file.name }}</span>
|
||||
<span class="text-xs text-gray-400" v-if="file.size">{{ Math.round(file.size/1024) }}KB</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</el-col>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<el-col :span="8">
|
||||
<div class="sticky top-24 space-y-4">
|
||||
|
||||
<!-- Publisher Profile Card -->
|
||||
<el-card shadow="never" class="border-gray-100">
|
||||
<template #header><span class="font-bold text-gray-800 text-sm">发布方信息</span></template>
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<el-avatar :size="48" :src="task.enterprise_logo || task.publisher_avatar" shape="square" class="rounded-xl bg-blue-100 text-blue-600 font-bold overflow-hidden">
|
||||
{{ task.enterprise_name?.[0] || task.publisher_name?.[0] || 'U' }}
|
||||
</el-avatar>
|
||||
<div>
|
||||
<div class="font-bold text-gray-800 text-lg">{{ task.publisher_name }}</div>
|
||||
<div v-if="task.enterprise_name" class="text-sm text-blue-600 hover:text-blue-800 hover:underline cursor-pointer mt-0.5" @click="openEnterpriseProfile">
|
||||
@ {{ task.enterprise_name }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-button class="w-full" plain @click="showContactDialog = true">联系项目经理</el-button>
|
||||
</el-card>
|
||||
|
||||
<!-- Submit Deliverables Card -->
|
||||
<el-card v-if="userApplication?.status === 'APPROVED' || userApplication?.status === 'DELIVERED'" shadow="never" class="!border-blue-200 bg-blue-50/30">
|
||||
<template #header>
|
||||
<span class="font-bold text-blue-800 text-sm">
|
||||
{{ userApplication?.status === 'DELIVERED' ? '企业正在验收中' : '当前任务正在进行中' }}
|
||||
</span>
|
||||
</template>
|
||||
<p class="text-sm text-gray-600 mb-4">
|
||||
{{ userApplication?.status === 'DELIVERED' ? '您已提交过成果。如需修改成果,可以再次提交覆盖。' : '任务执行完成后,请提交您的最终成果和结项说明,供企业验收。' }}
|
||||
</p>
|
||||
<el-button type="primary" class="w-full" @click="openDeliverablesDialog">
|
||||
{{ userApplication?.status === 'DELIVERED' ? '重新提交成果' : '提交交付成果' }}
|
||||
</el-button>
|
||||
</el-card>
|
||||
|
||||
<el-card v-else-if="!isJoined" shadow="never" class="!bg-blue-600 !text-white !border-0">
|
||||
<h3 class="text-lg font-bold mb-2 text-white">立即承接此项目</h3>
|
||||
<p class="text-blue-100 text-sm mb-4">请确认您已了解项目需求并具备相关专业技能。</p>
|
||||
<el-tooltip :content="authStore.user?.opc_certification?.status !== 'APPROVED' ? '请先通过 OPC 专家认证' : ''" placement="top" :disabled="authStore.user?.opc_certification?.status === 'APPROVED'">
|
||||
<div class="w-full">
|
||||
<el-button type="default" class="w-full" size="large" @click="goToApply" :disabled="task.status === 'CANCELLED' || authStore.user?.opc_certification?.status !== 'APPROVED'">
|
||||
{{ task.status === 'CANCELLED' ? '任务已取消' : '提交承接申请' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</el-tooltip>
|
||||
</el-card>
|
||||
|
||||
<el-card v-else shadow="never">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<span class="text-sm text-gray-500">当前任务阶段</span>
|
||||
<el-tag :type="userApplication?.status === 'APPROVED' ? 'success' : 'warning'" size="small">{{ translateStatus(userApplication?.status) }}</el-tag>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-gray-500 text-center bg-gray-50 py-2 rounded-lg mb-0">
|
||||
{{ statusDescription }}
|
||||
</p>
|
||||
</el-card>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<EnterpriseProfileDrawer v-model="drawerVisible" :enterpriseId="currentEnterpriseId" />
|
||||
|
||||
<!-- Contact Dialog -->
|
||||
<el-dialog v-model="showContactDialog" title="项目经理联络卡" width="400px" top="5vh">
|
||||
<div class="text-center space-y-4 pt-2">
|
||||
<el-avatar :size="64" :src="task.publisher_avatar" class="bg-gray-100 text-blue-600 text-2xl font-bold border-2 border-white shadow-sm">{{ task.contact_name?.[0] || task.publisher_name?.[0] || 'U' }}</el-avatar>
|
||||
<div>
|
||||
<h3 class="text-xl font-bold text-gray-800">{{ task.contact_name || task.publisher_name || '项目经理' }}</h3>
|
||||
<div class="text-xs text-gray-400 mt-1" v-if="task.enterprise_name">{{ task.enterprise_name }}</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-blue-50/50 rounded-xl p-5 border border-blue-100 text-left space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-500 flex items-center gap-2">手机号码</span>
|
||||
<span class="font-mono font-bold text-gray-800">{{ task.contact_phone || task.publisher_phone || '未填写' }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-500 flex items-center gap-2">微信号</span>
|
||||
<span class="font-mono font-bold text-gray-800">{{ task.contact_wechat || '未填写' }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-500 flex items-center gap-2">电子邮箱</span>
|
||||
<span class="font-mono font-bold text-gray-800">{{ task.contact_email || '未填写' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<el-button type="primary" class="w-full" plain @click="showContactDialog = false">我知道了</el-button>
|
||||
<p class="text-xs text-gray-400">请在工作时间内联系,并说明您是该任务的承接者。</p>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<!-- Submit Deliverables Dialog -->
|
||||
<el-dialog v-model="showDeliverablesDialog" title="提交交付成果" width="500px" top="5vh">
|
||||
<div class="space-y-4 pt-2">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">结项说明</label>
|
||||
<el-input v-model="deliverablesForm.note" type="textarea" rows="4" placeholder="请简要说明交付的内容或结果..." />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">成果附件 (选填)</label>
|
||||
<el-upload
|
||||
v-model:file-list="deliverablesForm.files"
|
||||
action=""
|
||||
:auto-upload="false"
|
||||
drag
|
||||
multiple
|
||||
>
|
||||
<div class="el-upload__text">
|
||||
将文件拖到此处,或 <em>点击上传</em>
|
||||
</div>
|
||||
<template #tip>
|
||||
<div class="text-xs text-gray-400 mt-2">支持上传文档、压缩包等交付物文件</div>
|
||||
</template>
|
||||
</el-upload>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="showDeliverablesDialog = false">取消</el-button>
|
||||
<el-button type="primary" :loading="isSubmitting" @click="submitDeliverables">确认提交</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
166
src/views/user/tasks/TaskMarketView.vue
Normal file
166
src/views/user/tasks/TaskMarketView.vue
Normal file
@@ -0,0 +1,166 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { ShieldCheck, Building2 } from 'lucide-vue-next';
|
||||
import { Priority } from '@/types/enums';
|
||||
import { getTasks } from '@/api/tasks';
|
||||
import { useRouter } from 'vue-router';
|
||||
import EnterpriseProfileDrawer from '@/components/enterprise/EnterpriseProfileDrawer.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const searchQuery = ref('');
|
||||
const selectedTab = ref('all');
|
||||
const selectedSkill = ref('');
|
||||
const tasks = ref<any[]>([]);
|
||||
|
||||
const availableSkills = computed(() => {
|
||||
const skills = new Set<string>();
|
||||
tasks.value.forEach(t => {
|
||||
if (t.skill_tags && Array.isArray(t.skill_tags)) {
|
||||
t.skill_tags.forEach((s: string) => skills.add(s));
|
||||
}
|
||||
});
|
||||
return Array.from(skills);
|
||||
});
|
||||
|
||||
const drawerVisible = ref(false);
|
||||
const currentEnterpriseId = ref('');
|
||||
|
||||
const fetchTasks = async () => {
|
||||
try {
|
||||
const res: any = await getTasks({ status: 'OPEN' });
|
||||
tasks.value = res.results || res || [];
|
||||
} catch (e) { console.error(e); }
|
||||
};
|
||||
|
||||
onMounted(() => { fetchTasks(); });
|
||||
|
||||
const filteredTasks = computed(() => {
|
||||
return tasks.value.filter(task => {
|
||||
const matchesSearch = task.title?.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
|
||||
task.description?.toLowerCase().includes(searchQuery.value.toLowerCase());
|
||||
const matchesTab = true;
|
||||
const matchesSkill = !selectedSkill.value || (task.skill_tags && task.skill_tags.includes(selectedSkill.value));
|
||||
return matchesSearch && matchesTab && matchesSkill;
|
||||
});
|
||||
});
|
||||
|
||||
const openEnterpriseProfile = (enterpriseId: string) => {
|
||||
if (!enterpriseId) return;
|
||||
currentEnterpriseId.value = enterpriseId;
|
||||
drawerVisible.value = true;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- Filters -->
|
||||
<el-card shadow="never" class="border-none bg-transparent !p-0">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||
<div class="overflow-x-auto max-w-full">
|
||||
<h2 class="text-xl font-bold text-gray-800">接单大厅</h2>
|
||||
</div>
|
||||
<div class="w-full sm:w-auto flex-1 sm:flex-none min-w-[200px]">
|
||||
<el-input v-model="searchQuery" placeholder="搜索任务关键词..." prefix-icon="Search" class="w-full sm:w-72" clearable size="large" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Skill Tags -->
|
||||
<div class="flex flex-wrap items-center gap-2" v-if="availableSkills.length > 0">
|
||||
<span class="text-sm text-gray-500 mr-2">技能筛选:</span>
|
||||
<el-tag
|
||||
effect="plain"
|
||||
round
|
||||
:type="selectedSkill === '' ? 'primary' : 'info'"
|
||||
class="cursor-pointer transition-colors !border-gray-200 hover:!border-blue-300"
|
||||
:class="{'!bg-blue-50 !text-blue-600 !border-blue-300 font-bold': selectedSkill === ''}"
|
||||
@click="selectedSkill = ''"
|
||||
>
|
||||
全部技能
|
||||
</el-tag>
|
||||
<el-tag
|
||||
v-for="skill in availableSkills"
|
||||
:key="skill"
|
||||
effect="plain"
|
||||
round
|
||||
:type="selectedSkill === skill ? 'primary' : 'info'"
|
||||
class="cursor-pointer transition-colors !border-gray-200 hover:!border-blue-300"
|
||||
:class="{'!bg-blue-50 !text-blue-600 !border-blue-300 font-bold': selectedSkill === skill}"
|
||||
@click="selectedSkill = selectedSkill === skill ? '' : skill"
|
||||
>
|
||||
{{ skill }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- Task Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
<el-card v-for="task in filteredTasks" :key="task.id" shadow="hover"
|
||||
class="cursor-pointer group border-gray-100 hover:border-blue-300 hover:shadow-lg transition-all duration-300 rounded-xl flex flex-col h-full"
|
||||
:body-style="{ padding: '0px', display: 'flex', flexDirection: 'column', height: '100%' }"
|
||||
@click="router.push(`/user/tasks/${task.id}`)"
|
||||
>
|
||||
<!-- Card Body -->
|
||||
<div class="p-6 pb-4 flex-1">
|
||||
<!-- Top Enterprise Header -->
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<el-avatar :size="40" :src="task.enterprise_logo" shape="square" class="rounded-lg bg-blue-50 text-blue-600 font-bold shadow-sm border border-gray-100">
|
||||
{{ task.enterprise_name?.[0] || 'C' }}
|
||||
</el-avatar>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-bold text-gray-800 text-sm truncate flex items-center gap-1">
|
||||
<template v-if="task.enterprise_name">
|
||||
{{ task.enterprise_name }}
|
||||
<ShieldCheck class="w-3.5 h-3.5 text-blue-500" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-tag type="danger" size="small" effect="dark" round>管理员发布</el-tag>
|
||||
</template>
|
||||
</div>
|
||||
<div class="text-xs text-gray-400 truncate mt-0.5">{{ task.task_type || '常规项目' }}</div>
|
||||
</div>
|
||||
<el-tag v-if="task.is_recommended" size="small" type="warning" effect="dark" round class="border-none shadow-sm">
|
||||
⭐ 推荐
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<!-- Task Info -->
|
||||
<h3 class="text-lg font-bold text-gray-900 group-hover:text-blue-600 transition-colors line-clamp-2 mb-2 leading-snug">
|
||||
{{ task.title }}
|
||||
</h3>
|
||||
<div class="text-orange-500 font-bold text-lg mb-3">
|
||||
¥{{ task.budget_max || task.budget_min || '面议' }}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2 mb-4 h-6 overflow-hidden">
|
||||
<el-tag v-for="skill in (task.skill_tags || []).slice(0, 3)" :key="skill" size="small" type="info" effect="plain" class="!border-gray-200">
|
||||
{{ skill }}
|
||||
</el-tag>
|
||||
<el-tag v-if="(task.skill_tags || []).length > 3" size="small" type="info" effect="plain" class="!border-gray-200">
|
||||
+{{ task.skill_tags.length - 3 }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card Footer -->
|
||||
<div class="bg-gray-50/80 w-full flex justify-between items-center px-6 py-3 border-t border-gray-100">
|
||||
<div class="flex items-center gap-2">
|
||||
<el-avatar :size="24" :src="task.publisher_avatar" class="bg-blue-100 text-blue-700 font-bold text-xs">
|
||||
{{ task.publisher_name?.[0] || 'U' }}
|
||||
</el-avatar>
|
||||
<div class="text-xs text-gray-500 flex flex-col">
|
||||
<span class="text-gray-400 leading-none mb-0.5" style="font-size: 10px;">由谁发布</span>
|
||||
<span class="font-medium text-gray-700 leading-none">{{ task.publisher_name || '企业招募官' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<el-button type="primary" size="small" plain round class="opacity-0 group-hover:opacity-100 transition-opacity">查看详情</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<el-empty v-if="filteredTasks.length === 0" description="未找到匹配任务" :image-size="80" />
|
||||
|
||||
<EnterpriseProfileDrawer v-model="drawerVisible" :enterpriseId="currentEnterpriseId" />
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user