'init'
This commit is contained in:
3
mp-sc-admin/src/App.vue
Normal file
3
mp-sc-admin/src/App.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
192
mp-sc-admin/src/api/index.ts
Normal file
192
mp-sc-admin/src/api/index.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// ============ 用户管理 ============
|
||||
|
||||
export function getUsers(params: { page: number; page_size: number; keyword?: string }) {
|
||||
return request.Get('/v1/admin/users', { params })
|
||||
}
|
||||
|
||||
export function updateUser(id: number, data: Record<string, any>) {
|
||||
return request.Put(`/v1/admin/users/${id}`, data)
|
||||
}
|
||||
|
||||
export function getUserRoles(id: number) {
|
||||
return request.Get(`/v1/admin/users/${id}/roles`)
|
||||
}
|
||||
|
||||
// ============ Banner 管理 ============
|
||||
|
||||
export function getBanners() {
|
||||
return request.Get('/v1/admin/banners')
|
||||
}
|
||||
|
||||
export function createBanner(data: Record<string, any>) {
|
||||
return request.Post('/v1/admin/banners', data)
|
||||
}
|
||||
|
||||
export function updateBanner(id: number, data: Record<string, any>) {
|
||||
return request.Put(`/v1/admin/banners/${id}`, data)
|
||||
}
|
||||
|
||||
export function deleteBanner(id: number) {
|
||||
return request.Delete(`/v1/admin/banners/${id}`)
|
||||
}
|
||||
|
||||
// ============ 通知管理 ============
|
||||
|
||||
export function getNotices() {
|
||||
return request.Get('/v1/admin/notices')
|
||||
}
|
||||
|
||||
export function createNotice(data: Record<string, any>) {
|
||||
return request.Post('/v1/admin/notices', data)
|
||||
}
|
||||
|
||||
export function updateNotice(id: number, data: Record<string, any>) {
|
||||
return request.Put(`/v1/admin/notices/${id}`, data)
|
||||
}
|
||||
|
||||
export function deleteNotice(id: number) {
|
||||
return request.Delete(`/v1/admin/notices/${id}`)
|
||||
}
|
||||
|
||||
// ============ 认证审核 ============
|
||||
|
||||
export function getCertifications(params?: Record<string, any>) {
|
||||
return request.Get('/v1/admin/certifications', { params })
|
||||
}
|
||||
|
||||
export function auditCertification(id: number, data: Record<string, any>) {
|
||||
return request.Put(`/v1/admin/certifications/${id}/audit`, data)
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ============ 员工列表 ============
|
||||
|
||||
/** 获取员工列表(含部门信息) */
|
||||
export function getEmployees() {
|
||||
return request.Get('/v1/admin/employees')
|
||||
}
|
||||
|
||||
// ============ 员工-部门关联 ============
|
||||
|
||||
/** 设置员工所属部门(可选传 position) */
|
||||
export function setEmployeeDepartment(userId: number, departmentId: number, position?: string) {
|
||||
const data: Record<string, any> = { department_id: departmentId }
|
||||
if (position !== undefined) data.position = position
|
||||
return request.Put(`/v1/admin/employees/${userId}/department`, data)
|
||||
}
|
||||
|
||||
/** 清除员工所属部门 */
|
||||
export function clearEmployeeDepartment(userId: number) {
|
||||
return request.Delete(`/v1/admin/employees/${userId}/department`)
|
||||
}
|
||||
|
||||
/** 获取所有员工-部门关联 */
|
||||
export function getEmployeeDepartments() {
|
||||
return request.Get('/v1/admin/employee-departments')
|
||||
}
|
||||
|
||||
// ============ 角色 ============
|
||||
|
||||
export function getRoles() {
|
||||
return request.Get('/v1/admin/roles')
|
||||
}
|
||||
|
||||
// ============ 部门管理 ============
|
||||
|
||||
export function getDepartmentTree() {
|
||||
return request.Get('/v1/admin/department-tree')
|
||||
}
|
||||
|
||||
export function getDepartments() {
|
||||
return request.Get('/v1/admin/departments')
|
||||
}
|
||||
|
||||
export function createDepartment(data: Record<string, any>) {
|
||||
return request.Post('/v1/admin/departments', data)
|
||||
}
|
||||
|
||||
export function updateDepartment(id: number, data: Record<string, any>) {
|
||||
return request.Put(`/v1/admin/departments/${id}`, data)
|
||||
}
|
||||
|
||||
export function deleteDepartment(id: number) {
|
||||
return request.Delete(`/v1/admin/departments/${id}`)
|
||||
}
|
||||
|
||||
export function listAllDepartments() {
|
||||
return request.Get('/v1/admin/departments-all')
|
||||
}
|
||||
|
||||
// ============ 微信素材上传 ============
|
||||
|
||||
/** 通过文件上传图片到微信服务器 */
|
||||
export function uploadImage(file: File) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
// 不手动设置 Content-Type:浏览器/ fetch 会自动追加 boundary
|
||||
return request.Post('/v1/upload/image', formData)
|
||||
}
|
||||
|
||||
/** 通过链接上传图片到微信服务器 */
|
||||
export function uploadImageByURL(url: string) {
|
||||
return request.Post<{ url: string }>('/v1/upload/image-by-url', { url })
|
||||
}
|
||||
|
||||
// ============ 审批流配置(新) ============
|
||||
|
||||
/** 设置部门的分管领导用户ID列表 */
|
||||
export function setDepartmentVPs(departmentId: number, vpUserIds: number[]) {
|
||||
return request.Put(`/v1/admin/departments/${departmentId}/vp-users`, { vp_user_ids: vpUserIds })
|
||||
}
|
||||
|
||||
/** 设置部门指定审批人(可多人) */
|
||||
export function setDepartmentApprover(departmentId: number, approverUserIds: number[]) {
|
||||
return request.Put(`/v1/admin/departments/${departmentId}/approver`, { approver_user_ids: approverUserIds })
|
||||
}
|
||||
|
||||
// ============ 员工通讯录导入 ============
|
||||
|
||||
/** 批量导入待确认员工 */
|
||||
export function importPendingEmployees(data: { employees: { real_name: string; phone: string; department_id: number; position?: string }[] }) {
|
||||
return request.Post('/v1/admin/employees/import', data)
|
||||
}
|
||||
|
||||
/** 待确认员工列表 */
|
||||
export function getPendingEmployees(params?: { page?: number; page_size?: number; status?: string; keyword?: string; department_id?: number }) {
|
||||
return request.Get('/v1/admin/employees/pending', { params })
|
||||
}
|
||||
|
||||
/** 手动匹配待确认员工到用户 */
|
||||
export function matchPendingEmployee(id: number) {
|
||||
return request.Post(`/v1/admin/employees/pending/${id}/match`)
|
||||
}
|
||||
|
||||
/** 待确认员工统计 */
|
||||
export function getPendingEmployeeStats() {
|
||||
return request.Get('/v1/admin/employees/pending/stats')
|
||||
}
|
||||
|
||||
/** 一键同步所有待确认员工信息到用户 */
|
||||
export function syncAllPendingEmployees() {
|
||||
return request.Post('/v1/admin/employees/pending/sync-all')
|
||||
}
|
||||
|
||||
// ============ 全局兜底审批人 ============
|
||||
|
||||
/** 获取全局兜底审批人列表 */
|
||||
export function getGlobalApprovers() {
|
||||
return request.Get('/v1/admin/global-approvers')
|
||||
}
|
||||
|
||||
/** 添加全局兜底审批人 */
|
||||
export function createGlobalApprover(userId: number) {
|
||||
return request.Post('/v1/admin/global-approvers', { user_id: userId })
|
||||
}
|
||||
|
||||
/** 删除全局兜底审批人 */
|
||||
export function deleteGlobalApprover(id: number) {
|
||||
return request.Delete(`/v1/admin/global-approvers/${id}`)
|
||||
}
|
||||
13
mp-sc-admin/src/main.ts
Normal file
13
mp-sc-admin/src/main.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './style.css'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(ElementPlus, { locale: zhCn })
|
||||
app.mount('#app')
|
||||
345
mp-sc-admin/src/pages/Appointments.vue
Normal file
345
mp-sc-admin/src/pages/Appointments.vue
Normal file
@@ -0,0 +1,345 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h1 class="text-xl font-bold text-gray-800">预约管理</h1>
|
||||
</div>
|
||||
|
||||
<el-card shadow="never" class="mb-3">
|
||||
<el-row :gutter="12" align="middle">
|
||||
<el-col :span="4">
|
||||
<el-select v-model="statusFilter" placeholder="审批状态" clearable class="w-full">
|
||||
<el-option label="全部" value="" />
|
||||
<el-option label="待审核" :value="0" />
|
||||
<el-option label="已通过" :value="1" />
|
||||
<el-option label="已拒绝" :value="2" />
|
||||
<el-option label="已取消" :value="3" />
|
||||
</el-select>
|
||||
</el-col>
|
||||
<el-col :span="2">
|
||||
<el-button type="primary" @click="page = 1; fetchData()">查询</el-button>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm border overflow-hidden">
|
||||
<el-table :data="appointments" stripe class="w-full" v-loading="loading">
|
||||
<el-table-column prop="id" label="ID" width="60" />
|
||||
<el-table-column label="外部来访人员" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<div class="flex flex-col text-sm leading-relaxed">
|
||||
<template v-if="parseVisitors(row.visitor_info)?.length">
|
||||
<span class="text-gray-800">
|
||||
{{ parseVisitors(row.visitor_info)[0].name }}
|
||||
<span v-if="parseVisitors(row.visitor_info).length > 1" class="text-gray-400 font-normal">
|
||||
(等{{ parseVisitors(row.visitor_info).length }}人)
|
||||
</span>
|
||||
</span>
|
||||
<span v-if="parseVisitors(row.visitor_info)[0]?.company" class="text-gray-400 text-xs">{{ parseVisitors(row.visitor_info)[0]?.company }}</span>
|
||||
</template>
|
||||
<span v-else class="text-gray-800">-</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="公司接待人员" min-width="100">
|
||||
<template #default="{ row }">
|
||||
<span>{{ parseEmployee(row.employee_info)?.name || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="来访事由" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<span>{{ row.visit_purpose || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="来访时间" min-width="130">
|
||||
<template #default="{ row }">
|
||||
<span class="text-sm">{{ formatTime(row.visit_start_time) }} ~ {{ formatTime(row.visit_end_time) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="['warning','success','danger','info'][row.status]">
|
||||
{{ ['待审核','已通过','已拒绝','已取消'][row.status] }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="primary" link @click="openDetail(row)">详情</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div v-if="total > 0" class="p-4 flex justify-center">
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
:page-size="pageSize"
|
||||
:total="total"
|
||||
layout="total,prev,pager,next"
|
||||
@current-change="fetchData"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 详情弹窗 -->
|
||||
<el-dialog v-model="detailVisible" title="预约详情" width="600px" :close-on-click-modal="false">
|
||||
<template v-if="detailRow">
|
||||
<div class="space-y-5">
|
||||
<!-- 基本信息 -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="w-1 h-4 bg-blue-500 rounded" />
|
||||
<span class="text-sm font-medium text-gray-700">基本信息</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-x-6 gap-y-2 text-sm bg-gray-50 rounded-lg p-4">
|
||||
<div><span class="text-gray-400">预约ID:</span><span class="text-gray-700">{{ detailRow.id }}</span></div>
|
||||
<div><span class="text-gray-400">来访事由:</span><span class="text-gray-700">{{ detailRow.visit_purpose || '-' }}</span></div>
|
||||
<div class="col-span-2">
|
||||
<span class="text-gray-400">来访时间:</span>
|
||||
<span class="text-gray-700">{{ formatTime(detailRow.visit_start_time) }} ~ {{ formatTime(detailRow.visit_end_time) }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-400">状态:</span>
|
||||
<el-tag :type="['warning','success','danger','info'][detailRow.status]" size="small">
|
||||
{{ ['待审核','已通过','已拒绝','已取消'][detailRow.status] }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div><span class="text-gray-400">创建时间:</span><span class="text-gray-700">{{ formatTime(detailRow.created_at) }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 访客信息 -->
|
||||
<div v-if="parsedVisitors?.length">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="w-1 h-4 bg-blue-500 rounded" />
|
||||
<span class="text-sm font-medium text-gray-700">外部来访人员</span>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded-lg p-4 space-y-2">
|
||||
<div v-for="(v, i) in parsedVisitors" :key="i" class="flex items-center gap-3 text-sm">
|
||||
<span class="text-gray-800 font-medium">{{ v.name }}</span>
|
||||
<span v-if="v.phone" class="text-gray-500">{{ v.phone }}</span>
|
||||
<span v-if="v.company" class="text-gray-400">{{ v.company }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 被访人 -->
|
||||
<div v-if="parsedEmployee">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="w-1 h-4 bg-blue-500 rounded" />
|
||||
<span class="text-sm font-medium text-gray-700">公司接待人员</span>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded-lg p-4 text-sm">
|
||||
<span class="text-gray-800 font-medium">{{ parsedEmployee.name }}</span>
|
||||
<span v-if="parsedEmployee.phone" class="text-gray-500 ml-2">{{ parsedEmployee.phone }}</span>
|
||||
<span v-if="parsedEmployee.department" class="text-gray-400 ml-2">{{ parsedEmployee.department }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 创建人 -->
|
||||
<div v-if="parsedCreator">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="w-1 h-4 bg-blue-500 rounded" />
|
||||
<span class="text-sm font-medium text-gray-700">创建人</span>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded-lg p-4 text-sm">
|
||||
<span class="text-gray-800 font-medium">{{ parsedCreator.name }}</span>
|
||||
<span v-if="parsedCreator.phone" class="text-gray-500 ml-2">{{ parsedCreator.phone }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 携带物品 -->
|
||||
<div v-if="parsedGoods?.length">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="w-1 h-4 bg-blue-500 rounded" />
|
||||
<span class="text-sm font-medium text-gray-700">携带物品</span>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded-lg p-4 space-y-1.5">
|
||||
<div v-for="(g, i) in parsedGoods" :key="i" class="text-sm">
|
||||
<span class="text-gray-800">{{ g.name }}</span>
|
||||
<span v-if="g.quantity" class="text-gray-500 ml-1">x{{ g.quantity }}</span>
|
||||
<span v-if="g.remark" class="text-gray-400 ml-2">{{ g.remark }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 车辆信息 -->
|
||||
<div v-if="parsedVehicles?.length">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="w-1 h-4 bg-blue-500 rounded" />
|
||||
<span class="text-sm font-medium text-gray-700">车辆信息</span>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded-lg p-4 space-y-1.5">
|
||||
<div v-for="(v, i) in parsedVehicles" :key="i" class="text-sm">
|
||||
<span class="text-gray-400">车辆{{ i + 1 }}:</span>
|
||||
<span class="text-gray-800">{{ [v.brand, v.plate, v.color].filter(Boolean).join(' ') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 到访区域 -->
|
||||
<div v-if="parsedAreas?.length">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="w-1 h-4 bg-blue-500 rounded" />
|
||||
<span class="text-sm font-medium text-gray-700">到访区域</span>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded-lg p-4 text-sm text-gray-700">{{ parsedAreas.join('、') }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 备注 -->
|
||||
<div v-if="detailRow.remark">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="w-1 h-4 bg-blue-500 rounded" />
|
||||
<span class="text-sm font-medium text-gray-700">备注</span>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded-lg p-4 text-sm text-gray-600">{{ detailRow.remark }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 审核意见 -->
|
||||
<div v-if="detailRow.comment">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="w-1 h-4 bg-blue-500 rounded" />
|
||||
<span class="text-sm font-medium text-gray-700">审核意见</span>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded-lg p-4 text-sm text-gray-600">{{ detailRow.comment }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 审批进度 -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="w-1 h-4 bg-blue-500 rounded" />
|
||||
<span class="text-sm font-medium text-gray-700">审批进度</span>
|
||||
</div>
|
||||
<div v-if="workflowTasks.length === 0 && !workflowLoading" class="text-sm text-gray-400 bg-gray-50 rounded-lg p-4">暂无审批记录</div>
|
||||
<div v-else-if="workflowLoading" class="text-sm text-gray-400 bg-gray-50 rounded-lg p-4">加载中...</div>
|
||||
<div v-else class="space-y-0">
|
||||
<div
|
||||
v-for="(t, idx) in workflowTasks"
|
||||
:key="t.id"
|
||||
class="flex gap-3 pb-4"
|
||||
:class="{ 'pb-0': idx === workflowTasks.length - 1 }"
|
||||
>
|
||||
<div class="flex flex-col items-center">
|
||||
<div
|
||||
class="w-3 h-3 rounded-full border-2 shrink-0 mt-1"
|
||||
:class="{
|
||||
'bg-green-500 border-green-500': t.status === 1,
|
||||
'bg-red-500 border-red-500': t.status === 2,
|
||||
'bg-orange-400 border-orange-400': t.status === 0,
|
||||
'bg-gray-200 border-gray-300': t.status === 3 || !t.status,
|
||||
}"
|
||||
/>
|
||||
<div v-if="idx < workflowTasks.length - 1" class="w-0.5 flex-1 mt-1"
|
||||
:class="t.status === 1 ? 'bg-green-300' : 'bg-gray-200'" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-gray-700">{{ t.node_name }}</span>
|
||||
<el-tag v-if="t.status === 0" type="warning" size="small">待审批</el-tag>
|
||||
<el-tag v-else-if="t.status === 1" type="success" size="small">已通过</el-tag>
|
||||
<el-tag v-else-if="t.status === 2" type="danger" size="small">已拒绝</el-tag>
|
||||
<el-tag v-else size="small" type="info">已取消</el-tag>
|
||||
</div>
|
||||
<div class="text-xs text-gray-400 mt-0.5">
|
||||
<template v-if="t.assignee">{{ t.assignee.real_name || t.assignee.nickname }}</template>
|
||||
<template v-if="t.handled_at"> · {{ formatTime(t.handled_at) }}</template>
|
||||
</div>
|
||||
<div v-if="t.comment" class="text-xs text-gray-500 mt-0.5 bg-white rounded px-2 py-1 border">
|
||||
{{ t.comment }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import request from '@/utils/request'
|
||||
import { onMounted } from 'vue'
|
||||
|
||||
const appointments = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const total = ref(0)
|
||||
const statusFilter = ref('')
|
||||
|
||||
|
||||
const detailVisible = ref(false)
|
||||
const detailRow = ref<any>(null)
|
||||
const workflowTasks = ref<any[]>([])
|
||||
const workflowLoading = ref(false)
|
||||
const parsedVisitors = computed(() => detailRow.value ? parseVisitors(detailRow.value.visitor_info) : null)
|
||||
const parsedEmployee = computed(() => detailRow.value ? parseEmployee(detailRow.value.employee_info) : null)
|
||||
const parsedCreator = computed(() => detailRow.value ? parseCreator(detailRow.value.creator_info) : null)
|
||||
const parsedAreas = computed(() => detailRow.value ? parseAreas(detailRow.value.areas_info) : null)
|
||||
const parsedGoods = computed(() => detailRow.value ? parseGoods(detailRow.value.goods_info) : null)
|
||||
const parsedVehicles = computed(() => detailRow.value ? parseVehicles(detailRow.value.vehicle_info) : null)
|
||||
|
||||
function parseVisitors(info) {
|
||||
if (!info) return null
|
||||
try {
|
||||
const parsed = JSON.parse(info)
|
||||
// 对象格式:{ name, phone, company, visitors: [...] }
|
||||
if (!Array.isArray(parsed) && parsed?.visitors) {
|
||||
return parsed.visitors
|
||||
}
|
||||
// 已经是数组
|
||||
if (Array.isArray(parsed)) return parsed
|
||||
// 单个对象包装为数组
|
||||
return [parsed]
|
||||
} catch { return null }
|
||||
}
|
||||
function parseEmployee(info) { if (!info) return null; try { return JSON.parse(info) } catch { return null } }
|
||||
function parseCreator(info) { if (!info) return null; try { const p = JSON.parse(info); return p.name ? p : null } catch { return null } }
|
||||
function parseAreas(info) { if (!info) return null; try { return JSON.parse(info).map(a => a.area || a) } catch { return null } }
|
||||
function parseGoods(info) { if (!info) return null; try { return JSON.parse(info) } catch { return null } }
|
||||
function parseVehicles(info) { if (!info) return null; try { return JSON.parse(info) } catch { return null } }
|
||||
function formatTime(t) {
|
||||
if (!t) return '-'
|
||||
const d = new Date(t)
|
||||
const pad = n => n.toString().padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
}
|
||||
async function fetchData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = { page: page.value, page_size: pageSize.value }
|
||||
if (statusFilter.value !== '') params.status = statusFilter.value
|
||||
const res = await request.Get('/v1/admin/appointments', { params })
|
||||
appointments.value = res?.data?.list || []
|
||||
total.value = res?.data?.total || 0
|
||||
} catch { ElMessage.error('加载失败') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
// 加载工作流审批记录
|
||||
async function openDetail(row) {
|
||||
detailRow.value = row
|
||||
detailVisible.value = true
|
||||
workflowTasks.value = []
|
||||
workflowLoading.value = true
|
||||
try {
|
||||
// 查询工作流实例
|
||||
const instances = await request.Get('/v1/workflow/instances', {
|
||||
params: { business_type: 'appointment', business_id: row.id },
|
||||
}) as any
|
||||
const list = instances?.data?.list || []
|
||||
if (list.length > 0) {
|
||||
const detail = await request.Get(`/v1/workflow/instances/${list[0].id}`) as any
|
||||
workflowTasks.value = detail?.data?.tasks || []
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
finally { workflowLoading.value = false }
|
||||
}
|
||||
|
||||
onMounted(fetchData)
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
213
mp-sc-admin/src/pages/ApprovalConfig.vue
Normal file
213
mp-sc-admin/src/pages/ApprovalConfig.vue
Normal file
@@ -0,0 +1,213 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-gray-800 mb-4">审批配置</h1>
|
||||
|
||||
<!-- 部门审批配置 -->
|
||||
<div class="bg-white rounded-xl shadow-sm border overflow-hidden">
|
||||
<div class="px-4 py-3 border-b flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-gray-700">部门审批配置</h2>
|
||||
<p class="text-xs text-gray-400 mt-0.5">为每个部门配置「指定审批人」和「分管领导」</p>
|
||||
</div>
|
||||
</div>
|
||||
<el-table :data="departments" stripe class="w-full">
|
||||
<el-table-column prop="name" label="部门名称" min-width="140" />
|
||||
<el-table-column label="部门负责人" min-width="120">
|
||||
<template #default="{ row }">{{ row.leader_name || '未设置' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="指定审批人(节点2)" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<template v-if="parsedArr(row.approver_user_ids).length">
|
||||
<el-tag v-for="uid in parsedArr(row.approver_user_ids)" :key="uid" type="success" size="small" class="mr-1">{{ getUserName(uid) }}</el-tag>
|
||||
</template>
|
||||
<span v-else class="text-gray-400 text-sm">未配置(将跳过该节点)</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="分管领导(节点3)" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<template v-if="parsedArr(row.vp_user_ids).length">
|
||||
<el-tag v-for="uid in parsedArr(row.vp_user_ids)" :key="uid" type="primary" size="small" class="mr-1">{{ getUserName(uid) }}</el-tag>
|
||||
</template>
|
||||
<span v-else class="text-gray-400 text-sm">未配置(将跳过该节点)</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="primary" link @click="openEdit(row)">编辑</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<!-- 全局兜底审批人 -->
|
||||
<div class="bg-white rounded-xl shadow-sm border overflow-hidden mt-6">
|
||||
<div class="px-4 py-3 border-b flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-gray-700">全局兜底审批人</h2>
|
||||
<p class="text-xs text-gray-400 mt-0.5">未匹配到员工或部门未配置审批人时,推送给这些人审批</p>
|
||||
</div>
|
||||
<el-button type="primary" size="small" @click="globalDialogVisible = true">+ 添加</el-button>
|
||||
</div>
|
||||
<el-table :data="globalApprovers" stripe class="w-full">
|
||||
<el-table-column label="姓名" min-width="200">
|
||||
<template #default="{ row }">
|
||||
{{ row.user?.real_name || row.user?.nickname || `#${row.user_id}` }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="danger" :loading="deletingId === row.id" @click="handleGlobalDelete(row)">移除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<!-- 编辑部门弹窗 -->
|
||||
<el-dialog v-model="dialogVisible" :title="'审批配置 — ' + (currentDept?.name || '')" width="580px">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="指定审批人(节点2)">
|
||||
<el-select v-model="approverUserIds" multiple filterable placeholder="可选择多人(会签审批)" class="w-full" collapse-tags collapse-tags-tooltip :max-collapse-tags="5">
|
||||
<el-option v-for="e in employees" :key="e.id" :label="`${displayName(e)}(${[e.department_name, e.position].filter(Boolean).join(' · ') || '未分配'})`" :value="e.id" />
|
||||
</el-select>
|
||||
<div class="text-xs text-gray-400 mt-1">不选择时将跳过该节点</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="分管领导(节点3)">
|
||||
<el-select v-model="vpUserIds" multiple filterable placeholder="可选择多人(会签审批)" class="w-full" collapse-tags collapse-tags-tooltip :max-collapse-tags="5">
|
||||
<el-option v-for="e in employees" :key="e.id" :label="`${displayName(e)}(${[e.department_name, e.position].filter(Boolean).join(' · ') || '未分配'})`" :value="e.id" />
|
||||
</el-select>
|
||||
<div class="text-xs text-gray-400 mt-1">不选择时将跳过该节点</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 添加兜底审批人弹窗 -->
|
||||
<el-dialog v-model="globalDialogVisible" title="添加兜底审批人" width="480px">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="选择员工">
|
||||
<el-select v-model="globalUserId" filterable placeholder="请选择员工" class="w-full">
|
||||
<el-option v-for="e in employees" :key="e.id" :label="`${displayName(e)}(${[e.department_name, e.position].filter(Boolean).join(' · ') || '未分配'})`" :value="e.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="globalDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="globalSaving" @click="handleGlobalAdd">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
setDepartmentApprover,
|
||||
setDepartmentVPs,
|
||||
getEmployees,
|
||||
listAllDepartments,
|
||||
getGlobalApprovers,
|
||||
createGlobalApprover,
|
||||
deleteGlobalApprover,
|
||||
} from '@/api'
|
||||
|
||||
const departments = ref<any[]>([])
|
||||
const employees = ref<any[]>([])
|
||||
const saving = ref(false)
|
||||
|
||||
// ===== 部门编辑 =====
|
||||
const dialogVisible = ref(false)
|
||||
const currentDept = ref<any>(null)
|
||||
const approverUserIds = ref<number[]>([])
|
||||
const vpUserIds = ref<number[]>([])
|
||||
|
||||
function openEdit(dept: any) {
|
||||
currentDept.value = dept
|
||||
approverUserIds.value = parsedArr(dept.approver_user_ids)
|
||||
vpUserIds.value = parsedArr(dept.vp_user_ids)
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!currentDept.value) return
|
||||
saving.value = true
|
||||
try {
|
||||
await Promise.all([
|
||||
setDepartmentApprover(currentDept.value.id, approverUserIds.value),
|
||||
setDepartmentVPs(currentDept.value.id, vpUserIds.value),
|
||||
])
|
||||
ElMessage.success('保存成功')
|
||||
dialogVisible.value = false
|
||||
await loadDepartments()
|
||||
} catch { /* request.ts handled */ }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
|
||||
// ===== 全局兜底 =====
|
||||
const globalApprovers = ref<any[]>([])
|
||||
const globalDialogVisible = ref(false)
|
||||
const globalUserId = ref<number | null>(null)
|
||||
const globalSaving = ref(false)
|
||||
const deletingId = ref<number | null>(null)
|
||||
|
||||
async function loadGlobalApprovers() {
|
||||
const res = await getGlobalApprovers() as any
|
||||
globalApprovers.value = res?.data || []
|
||||
}
|
||||
|
||||
async function handleGlobalAdd() {
|
||||
if (!globalUserId.value) { ElMessage.warning('请选择员工'); return }
|
||||
globalSaving.value = true
|
||||
try {
|
||||
await createGlobalApprover(globalUserId.value)
|
||||
ElMessage.success('添加成功')
|
||||
globalDialogVisible.value = false
|
||||
globalUserId.value = null
|
||||
await loadGlobalApprovers()
|
||||
} catch { /* request.ts handled */ }
|
||||
finally { globalSaving.value = false }
|
||||
}
|
||||
|
||||
async function handleGlobalDelete(row: any) {
|
||||
await ElMessageBox.confirm(`确定移除 ${row.user?.real_name || row.user?.nickname || ''}?`)
|
||||
deletingId.value = row.id
|
||||
try {
|
||||
await deleteGlobalApprover(row.id)
|
||||
ElMessage.success('已移除')
|
||||
await loadGlobalApprovers()
|
||||
} catch { /* request.ts handled */ }
|
||||
finally { deletingId.value = null }
|
||||
}
|
||||
|
||||
// ===== 工具 =====
|
||||
function displayName(e: any): string {
|
||||
return e.real_name || e.nickname || e.name || `#${e.id}`
|
||||
}
|
||||
function getUserName(id: number): string {
|
||||
const emp = employees.value.find((e) => e.id === id)
|
||||
return emp ? displayName(emp) : `#${id}`
|
||||
}
|
||||
function parsedArr(str: string | null | undefined): number[] {
|
||||
if (!str) return []
|
||||
try { return JSON.parse(str) } catch { return [] }
|
||||
}
|
||||
|
||||
async function loadDepartments() {
|
||||
const res = await listAllDepartments() as any
|
||||
// 过滤掉根节点(四川凌空,无实际部门审批意义)
|
||||
departments.value = (res?.data || []).filter((d: any) => d.id !== 1)
|
||||
}
|
||||
async function loadEmployees() {
|
||||
const res = await getEmployees() as any
|
||||
employees.value = res?.data || []
|
||||
}
|
||||
|
||||
async function load() {
|
||||
await Promise.all([loadDepartments(), loadEmployees(), loadGlobalApprovers()])
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
252
mp-sc-admin/src/pages/Approvals.vue
Normal file
252
mp-sc-admin/src/pages/Approvals.vue
Normal file
@@ -0,0 +1,252 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-gray-800 mb-4">认证审核</h1>
|
||||
<div class="bg-white rounded-xl shadow-sm border overflow-hidden">
|
||||
<el-table :data="certs" stripe class="w-full">
|
||||
<el-table-column prop="real_name" label="姓名" min-width="100" />
|
||||
<el-table-column prop="phone" label="手机号" width="140" />
|
||||
<el-table-column label="角色类型" width="100">
|
||||
<template #default="{ row }">
|
||||
<span class="text-gray-600">{{ { employee: '员工', guard: '保安' }[row.role] }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="申请部门" width="120">
|
||||
<template #default="{ row }">
|
||||
<span class="text-gray-600">{{ row.department?.name || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="申请职位" width="120">
|
||||
<template #default="{ row }">
|
||||
<span class="text-gray-600">{{ row.position || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="['warning','success','danger'][row.status]">{{ ['待审核','已通过','已拒绝'][row.status] }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="100" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="row.status===0" size="small" type="primary" @click="openReview(row)">审核</el-button>
|
||||
<el-button v-else size="small" type="primary" plain @click="openReview(row)">详情</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<!-- 审核弹窗(固定高度 + 内容滚动 + 底部按钮固定) -->
|
||||
<el-dialog v-model="reviewVisible" title="审核认证申请" width="560px" :close-on-click-modal="false" top="5vh">
|
||||
<div class="review-dialog-body" v-if="reviewRow">
|
||||
<!-- ===== 申请人信息 ===== -->
|
||||
<div class="mb-4">
|
||||
<h3 class="text-sm font-medium text-gray-500 mb-2">申请人信息</h3>
|
||||
<div class="grid grid-cols-2 gap-3 text-sm">
|
||||
<div><span class="text-gray-400">姓名:</span>{{ reviewRow.real_name }}</div>
|
||||
<div><span class="text-gray-400">手机号:</span>{{ reviewRow.phone }}</div>
|
||||
<div><span class="text-gray-400">角色类型:</span>{{ { employee: '员工', guard: '保安' }[reviewRow.role] }}</div>
|
||||
<div><span class="text-gray-400">申请职位:</span>{{ reviewRow.position || '-' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== 证明材料 ===== -->
|
||||
<div class="mb-4">
|
||||
<h3 class="text-sm font-medium text-gray-500 mb-2">证明材料</h3>
|
||||
<div v-if="reviewRow.snapshot?.length" class="grid grid-cols-3 gap-2">
|
||||
<el-image
|
||||
v-for="(url, i) in reviewRow.snapshot"
|
||||
:key="i"
|
||||
:src="url"
|
||||
:preview-src-list="reviewRow.snapshot"
|
||||
:initial-index="i"
|
||||
fit="cover"
|
||||
class="rounded-lg border border-gray-200 cursor-pointer"
|
||||
style="width: 100%; height: 120px;"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="text-gray-400 text-sm">无证明材料</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== 审核操作(仅待审核) ===== -->
|
||||
<template v-if="reviewRow.status === 0">
|
||||
<el-divider />
|
||||
|
||||
<!-- 步骤1:选择操作类型 -->
|
||||
<div v-if="!reviewAction" class="text-center py-4">
|
||||
<p class="text-gray-500 text-sm mb-4">请选择审核结果</p>
|
||||
<div class="flex gap-4 justify-center">
|
||||
<el-button size="large" class="w-32" @click="reviewAction = 'reject'">
|
||||
<span class="text-lg mr-1">✕</span> 拒绝
|
||||
</el-button>
|
||||
<el-button size="large" type="primary" class="w-32" @click="reviewAction = 'approve'">
|
||||
<span class="text-lg mr-1">✓</span> 通过
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 步骤2:填写详情 -->
|
||||
<div v-if="reviewAction === 'approve'" class="space-y-3">
|
||||
<div class="text-xs text-gray-400 mb-2 border-b pb-2">申请信息确认</div>
|
||||
<el-form-item label="姓名" label-width="80px">
|
||||
<el-input v-model="reviewName" placeholder="姓名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="手机号" label-width="80px">
|
||||
<el-input v-model="reviewPhone" placeholder="手机号" maxlength="11" />
|
||||
</el-form-item>
|
||||
<el-form-item label="角色类型" label-width="80px">
|
||||
<el-select v-model="reviewEmployeeType" class="w-full">
|
||||
<el-option label="员工" value="employee" />
|
||||
<el-option label="保安" value="guard" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="申请部门" label-width="80px">
|
||||
<el-select v-model="reviewDeptId" filterable placeholder="请选择部门" class="w-full">
|
||||
<el-option v-for="d in departments" :key="d.id" :label="d.name" :value="d.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="职位" label-width="80px">
|
||||
<el-input v-model="reviewPosition" placeholder="如:应用系统开发工程师" />
|
||||
</el-form-item>
|
||||
<el-form-item label="审核备注" label-width="80px">
|
||||
<el-input v-model="reviewComment" placeholder="可选" type="textarea" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<div v-if="reviewAction === 'reject'" class="space-y-3">
|
||||
<el-form-item label="拒绝原因">
|
||||
<el-input v-model="reviewComment" placeholder="请输入拒绝原因" type="textarea" :rows="3" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 已审核状态 -->
|
||||
<template v-else>
|
||||
<el-divider />
|
||||
<div class="text-sm">
|
||||
<div>
|
||||
<span class="text-gray-400">审核结果:</span>
|
||||
<el-tag :type="reviewRow.status===1?'success':'danger'" size="small">
|
||||
{{ reviewRow.status===1?'已通过':'已拒绝' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div v-if="reviewRow.comment" class="mt-1">
|
||||
<span class="text-gray-400">备注:</span>{{ reviewRow.comment }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 底部固定按钮 -->
|
||||
<template #footer>
|
||||
<div class="flex gap-3 justify-end">
|
||||
<el-button @click="reviewVisible = false">关闭</el-button>
|
||||
|
||||
<template v-if="reviewRow?.status === 0 && reviewAction === 'approve'">
|
||||
<el-button @click="reviewAction = ''">返回</el-button>
|
||||
<el-button type="success" :loading="reviewing" @click="handleApprove">确认通过</el-button>
|
||||
</template>
|
||||
|
||||
<template v-if="reviewRow?.status === 0 && reviewAction === 'reject'">
|
||||
<el-button @click="reviewAction = ''">返回</el-button>
|
||||
<el-button type="danger" :loading="reviewing" @click="handleReject">确认拒绝</el-button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getCertifications, auditCertification, listAllDepartments } from '@/api'
|
||||
|
||||
const certs = ref<any[]>([])
|
||||
const departments = ref<any[]>([])
|
||||
|
||||
// 审核弹窗
|
||||
const reviewVisible = ref(false)
|
||||
const reviewRow = ref<any>(null)
|
||||
const reviewAction = ref<'approve' | 'reject' | ''>('')
|
||||
const reviewName = ref('')
|
||||
const reviewPhone = ref('')
|
||||
const reviewPosition = ref('')
|
||||
const reviewEmployeeType = ref('employee')
|
||||
const reviewDeptId = ref<number | null>(null)
|
||||
const reviewComment = ref('')
|
||||
const reviewing = ref(false)
|
||||
|
||||
async function load() {
|
||||
const [certRes, deptRes] = await Promise.all([
|
||||
getCertifications(),
|
||||
listAllDepartments(),
|
||||
])
|
||||
certs.value = (certRes as any)?.data?.list || []
|
||||
departments.value = (deptRes as any)?.data || []
|
||||
}
|
||||
|
||||
function openReview(row: any) {
|
||||
reviewRow.value = row
|
||||
reviewAction.value = ''
|
||||
reviewName.value = row.real_name || ''
|
||||
reviewPhone.value = row.phone || ''
|
||||
reviewPosition.value = row.position || ''
|
||||
reviewEmployeeType.value = row.role || 'employee'
|
||||
reviewDeptId.value = row.department_id || row.department?.id || null
|
||||
reviewComment.value = ''
|
||||
reviewVisible.value = true
|
||||
}
|
||||
|
||||
async function handleApprove() {
|
||||
if (!reviewDeptId.value) {
|
||||
ElMessage.warning('请选择部门')
|
||||
return
|
||||
}
|
||||
reviewing.value = true
|
||||
try {
|
||||
await auditCertification(reviewRow.value.id, {
|
||||
status: 1,
|
||||
real_name: reviewName.value || '',
|
||||
phone: reviewPhone.value || '',
|
||||
department_id: reviewDeptId.value,
|
||||
position: reviewPosition.value || '',
|
||||
role: reviewEmployeeType.value,
|
||||
comment: reviewComment.value || '',
|
||||
})
|
||||
ElMessage.success('审核通过')
|
||||
reviewVisible.value = false
|
||||
await load()
|
||||
} catch (err: any) {
|
||||
ElMessage.error(err.message || '审核失败')
|
||||
} finally {
|
||||
reviewing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReject() {
|
||||
reviewing.value = true
|
||||
try {
|
||||
await auditCertification(reviewRow.value.id, {
|
||||
status: 2,
|
||||
comment: reviewComment.value || '管理员拒绝',
|
||||
})
|
||||
ElMessage.success('已拒绝')
|
||||
reviewVisible.value = false
|
||||
await load()
|
||||
} catch (err: any) {
|
||||
ElMessage.error(err.message || '操作失败')
|
||||
} finally {
|
||||
reviewing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.review-dialog-body {
|
||||
max-height: 55vh;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
</style>
|
||||
|
||||
211
mp-sc-admin/src/pages/Banners.vue
Normal file
211
mp-sc-admin/src/pages/Banners.vue
Normal file
@@ -0,0 +1,211 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h1 class="text-lg font-bold text-gray-800">Banner 管理</h1>
|
||||
<el-button type="primary" @click="openCreate">
|
||||
<el-icon class="mr-1"><Plus /></el-icon>新增 Banner
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- Banner List -->
|
||||
<div class="space-y-3">
|
||||
<div v-for="item in banners" :key="item.id"
|
||||
class="bg-white rounded-xl border border-gray-200 p-4 flex items-start gap-4 hover:shadow-sm transition-shadow">
|
||||
<!-- Image Preview -->
|
||||
<div class="w-56 h-28 shrink-0 rounded-lg overflow-hidden bg-gray-100 border">
|
||||
<img :src="item.image_url" class="w-full h-full object-cover" @error="onImgError($event)" />
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<el-tag size="small" :type="item.is_valid ? 'success' : 'danger'">
|
||||
{{ item.is_valid ? '启用' : '禁用' }}
|
||||
</el-tag>
|
||||
<el-tag v-if="item.is_jump" size="small" type="warning">可跳转</el-tag>
|
||||
<el-tag size="small" type="info">排序: {{ item.sort }}</el-tag>
|
||||
</div>
|
||||
<div class="text-sm text-gray-500 truncate mb-1">
|
||||
<span class="text-gray-400">图片:</span> {{ item.image_url }}
|
||||
</div>
|
||||
<div v-if="item.is_jump && item.jump_path" class="text-sm text-gray-500 truncate">
|
||||
<span class="text-gray-400">跳转:</span> {{ item.jump_path }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-400 mt-1">创建时间: {{ item.created_at }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-2 shrink-0">
|
||||
<el-button size="small" @click="openEdit(item)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="handleDelete(item.id)">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty -->
|
||||
<div v-if="banners.length === 0" class="bg-white rounded-xl border border-gray-200 p-12 text-center text-gray-400">
|
||||
<div class="i-carbon-image text-5xl mx-auto mb-3 opacity-50" />
|
||||
<p>暂无 Banner,点击右上角「新增 Banner」添加</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create/Edit Dialog -->
|
||||
<el-dialog v-model="dialogVisible" :title="isEditing ? '编辑 Banner' : '新增 Banner'" width="520px" destroy-on-close>
|
||||
<el-form :model="form" label-width="100px" size="small">
|
||||
<!-- Image URL -->
|
||||
<el-form-item label="图片地址" required>
|
||||
<el-input v-model="form.image_url" placeholder="输入图片URL或上传图片" class="mb-2" />
|
||||
<div class="flex gap-2">
|
||||
<el-upload :show-file-list="false" :auto-upload="false" accept="image/*" @change="handleUpload">
|
||||
<el-button size="small">选择图片</el-button>
|
||||
</el-upload>
|
||||
<span v-if="form.image_url" class="text-xs text-gray-400 self-center ml-1">已选择: {{ previewName }}</span>
|
||||
</div>
|
||||
<img v-if="form.image_url" :src="form.image_url" class="mt-2 w-full h-24 object-cover rounded border" @error="onImgError($event)" />
|
||||
</el-form-item>
|
||||
|
||||
<!-- Jump Settings -->
|
||||
<el-form-item label="可跳转">
|
||||
<el-switch v-model="form.is_jump" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="form.is_jump" label="跳转链接">
|
||||
<el-input v-model="form.jump_path" placeholder="输入跳转URL" />
|
||||
</el-form-item>
|
||||
|
||||
<!-- Sort -->
|
||||
<el-form-item label="排序号">
|
||||
<el-input-number v-model="form.sort" :min="0" :max="999" />
|
||||
</el-form-item>
|
||||
|
||||
<!-- Valid -->
|
||||
<el-form-item label="状态">
|
||||
<el-switch v-model="form.is_valid" active-text="启用" inactive-text="禁用" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import { getBanners, createBanner, updateBanner, deleteBanner, uploadImage } from '@/api'
|
||||
|
||||
const banners = ref<any[]>([])
|
||||
const dialogVisible = ref(false)
|
||||
const isEditing = ref(false)
|
||||
const saving = ref(false)
|
||||
const previewUrl = ref('')
|
||||
const previewName = ref('')
|
||||
|
||||
const form = reactive({
|
||||
id: 0,
|
||||
image_url: '',
|
||||
jump_path: '',
|
||||
is_jump: false,
|
||||
sort: 0,
|
||||
is_valid: true,
|
||||
})
|
||||
|
||||
async function loadBanners() {
|
||||
const res: any = await getBanners()
|
||||
banners.value = res?.data || []
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
isEditing.value = false
|
||||
form.id = 0
|
||||
form.image_url = ''
|
||||
form.jump_path = ''
|
||||
form.is_jump = false
|
||||
form.sort = 0
|
||||
form.is_valid = true
|
||||
previewUrl.value = ''
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(item: any) {
|
||||
isEditing.value = true
|
||||
form.id = item.id
|
||||
form.image_url = item.image_url
|
||||
form.jump_path = item.jump_path || ''
|
||||
form.is_jump = item.is_jump
|
||||
form.sort = item.sort
|
||||
form.is_valid = item.is_valid
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleUpload(uploadFile: any) {
|
||||
const file = uploadFile.raw
|
||||
if (!file) return
|
||||
previewName.value = file.name
|
||||
saving.value = true
|
||||
try {
|
||||
const res: any = await uploadImage(file)
|
||||
const url = res?.data?.url || res?.url || ''
|
||||
if (url) {
|
||||
form.image_url = url
|
||||
} else {
|
||||
ElMessage.error('上传失败:响应中未找到图片 URL')
|
||||
}
|
||||
} catch {
|
||||
// handled by interceptor
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!form.image_url) {
|
||||
ElMessage.warning('请填写图片地址')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
if (isEditing.value) {
|
||||
await updateBanner(form.id, {
|
||||
image_url: form.image_url,
|
||||
jump_path: form.jump_path,
|
||||
is_jump: form.is_jump,
|
||||
sort: form.sort,
|
||||
is_valid: form.is_valid,
|
||||
})
|
||||
ElMessage.success('更新成功')
|
||||
} else {
|
||||
await createBanner({
|
||||
image_url: form.image_url,
|
||||
jump_path: form.jump_path,
|
||||
is_jump: form.is_jump,
|
||||
sort: form.sort,
|
||||
})
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
loadBanners()
|
||||
} catch {
|
||||
// handled by interceptor
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定删除该 Banner?', '删除确认', { type: 'warning' })
|
||||
await deleteBanner(id)
|
||||
ElMessage.success('删除成功')
|
||||
loadBanners()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function onImgError(e: Event) {
|
||||
(e.target as HTMLImageElement).src = 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 100"><rect fill="%23f3f4f6" width="200" height="100"/><text x="100" y="55" text-anchor="middle" fill="%239ca3af" font-size="12">加载失败</text></svg>'
|
||||
}
|
||||
|
||||
onMounted(loadBanners)
|
||||
</script>
|
||||
33
mp-sc-admin/src/pages/Dashboard.vue
Normal file
33
mp-sc-admin/src/pages/Dashboard.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-gray-800 mb-4">概览</h1>
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<div v-for="s in stats" :key="s.label" class="bg-white rounded-xl p-5 shadow-sm border">
|
||||
<div class="text-2xl font-bold text-blue-600">{{ s.value }}</div>
|
||||
<div class="text-sm text-gray-500 mt-1">{{ s.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { getUsers, getCertifications } from '@/api'
|
||||
|
||||
const stats = ref([
|
||||
{ label: '用户总数', value: 0 },
|
||||
{ label: '待审核认证', value: 0 },
|
||||
{ label: '今日预约', value: '-' },
|
||||
])
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const [users, certs] = await Promise.all([
|
||||
getUsers({ page: 1, page_size: 1 }),
|
||||
getCertifications({ status: 0 }),
|
||||
])
|
||||
stats.value[0].value = users?.data?.total || 0
|
||||
stats.value[1].value = certs?.data?.total || 0
|
||||
} catch {}
|
||||
})
|
||||
</script>
|
||||
453
mp-sc-admin/src/pages/Departments.vue
Normal file
453
mp-sc-admin/src/pages/Departments.vue
Normal file
@@ -0,0 +1,453 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h1 class="text-xl font-bold text-gray-800">组织架构</h1>
|
||||
<el-button type="primary" size="small" @click="openAddDept(null)">+ 新增根部门</el-button>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm border overflow-hidden">
|
||||
<!-- 搜索栏 -->
|
||||
<div class="flex items-center gap-3 px-4 py-3 border-b">
|
||||
<el-input v-model="search" placeholder="搜索部门名称" clearable class="max-w-xs" size="small" @clear="onSearch" @input="onSearch" />
|
||||
<el-button size="small" @click="loadData">
|
||||
<template #icon><span class="i-carbon-refresh" /></template>
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 部门表格(可展开行) -->
|
||||
<div v-loading="loading">
|
||||
<el-table ref="tableRef" :data="flatDepts" row-key="id" border stripe class="w-full" @expand-change="onExpandChange" @row-click="onRowClick">
|
||||
<el-table-column type="expand" width="50">
|
||||
<template #default="{ row }">
|
||||
<!-- 展开后显示该部门的员工列表(迷你表格) -->
|
||||
<div class="px-6 py-4" v-if="row._empList && row._empList.length">
|
||||
<el-table :data="row._empList" size="small" stripe class="w-full">
|
||||
<el-table-column label="姓名" min-width="100">
|
||||
<template #default="{ row: emp }">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="i-carbon-user text-blue-400 text-sm shrink-0" />
|
||||
<span class="text-gray-700 font-medium">{{ emp.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="职位" min-width="100">
|
||||
<template #default="{ row: emp }">
|
||||
<span class="text-gray-500">{{ emp.position || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="员工类型" min-width="110">
|
||||
<template #default="{ row: emp }">
|
||||
<el-tag v-if="emp.employeeType === 'department_leader'" size="small" type="warning">部门负责人</el-tag>
|
||||
<el-tag v-else-if="emp.employeeType === 'deputy_leader'" size="small">副负责人</el-tag>
|
||||
<el-tag v-else size="small" type="info">{{ emp.employeeTypeLabel || '员工' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80" fixed="right">
|
||||
<template #default="{ row: emp }">
|
||||
<el-button size="small" type="danger" link @click="handleEmpRemove(emp)">移出</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<div v-else class="px-6 py-3 text-sm text-gray-400">暂无员工</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="部门名称" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<div class="flex items-center gap-2" :style="{ paddingLeft: row._depth * 24 + 'px' }">
|
||||
<span class="i-carbon-folder text-yellow-500 text-sm shrink-0" />
|
||||
<span class="font-medium text-gray-700">{{ row.name }}</span>
|
||||
<span class="text-gray-400 text-xs">({{ row._empCount }}人)</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="操作" min-width="340">
|
||||
<template #default="{ row }">
|
||||
<div class="flex items-center gap-1">
|
||||
<el-button size="small" type="primary" link @click="openAddDept(row)">新增子部门</el-button>
|
||||
<el-button size="small" type="primary" link @click="openEmpManage(row)">管理员工</el-button>
|
||||
<el-button size="small" type="primary" link @click="openEditDept(row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" link @click="handleDeleteDept(row)">删除</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div v-if="!flatDepts.length && !loading" class="text-center text-gray-400 py-12">暂无部门数据</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新增/编辑部门对话框 -->
|
||||
<el-dialog v-model="deptDialogVisible" :title="deptEditId ? '编辑部门' : '新增部门'" width="450px" :close-on-click-modal="false">
|
||||
<el-form :model="deptForm" label-width="100px">
|
||||
<el-form-item label="部门名称">
|
||||
<el-input v-model="deptForm.name" placeholder="请输入部门名称" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="deptForm.parent_id" label="上级部门">
|
||||
<span class="text-gray-500">{{ getDeptName(deptForm.parent_id) }}</span>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="deptDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="deptSaving" @click="handleSaveDept">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- ============ 员工管理对话框 ============ -->
|
||||
<el-dialog v-model="empManageVisible" :title="'管理员工 — ' + (empManageDept?.name || '')" width="500px" :close-on-click-modal="false">
|
||||
<div v-loading="empManageLoading">
|
||||
<div class="text-sm font-medium text-gray-700 mb-2">当前部门员工</div>
|
||||
<div v-if="empManageList.length === 0" class="text-gray-400 text-sm py-3">暂无员工</div>
|
||||
<div v-for="emp in empManageList" :key="emp.id"
|
||||
class="flex items-center justify-between py-2 px-3 rounded-lg hover:bg-gray-50">
|
||||
<div>
|
||||
<span class="text-sm text-gray-700">{{ emp.real_name || emp.nickname || emp.name }}</span>
|
||||
<span v-if="emp.position" class="text-xs text-gray-400 ml-2">{{ emp.position }}</span>
|
||||
</div>
|
||||
<el-button size="small" type="danger" plain @click="handleEmpRemove(emp)">移出</el-button>
|
||||
</div>
|
||||
|
||||
<el-divider />
|
||||
|
||||
<div class="text-sm font-medium text-gray-700 mb-2">添加员工</div>
|
||||
<div class="flex gap-2 items-start">
|
||||
<div class="flex-1 space-y-2">
|
||||
<el-select v-model="empManageNewId" filterable placeholder="选择员工添加" class="w-full" size="default">
|
||||
<el-option
|
||||
v-for="e in availableEmployees"
|
||||
:key="e.id"
|
||||
:label="(e.real_name || e.nickname || e.name) + (e.position ? '(' + e.position + ')' : '')"
|
||||
:value="e.id"
|
||||
/>
|
||||
</el-select>
|
||||
<el-input v-model="empManageNewPosition" placeholder="职位(如:前端工程师)" size="default" />
|
||||
</div>
|
||||
<el-button type="primary" :disabled="!empManageNewId" :loading="empManageAdding" @click="handleEmpAdd" class="mt-0">添加</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="empManageVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
getDepartmentTree,
|
||||
createDepartment,
|
||||
updateDepartment,
|
||||
deleteDepartment,
|
||||
getEmployees,
|
||||
getEmployeeDepartments,
|
||||
setEmployeeDepartment,
|
||||
clearEmployeeDepartment,
|
||||
} from '@/api'
|
||||
|
||||
// ============ 状态 ============
|
||||
|
||||
const loading = ref(false)
|
||||
const search = ref('')
|
||||
const tableRef = ref<any>(null)
|
||||
|
||||
/** 平铺后的部门列表(含层级 depth) */
|
||||
const flatDepts = ref<any[]>([])
|
||||
|
||||
// 部门表单
|
||||
const deptDialogVisible = ref(false)
|
||||
const deptEditId = ref<number | null>(null)
|
||||
const deptSaving = ref(false)
|
||||
const deptForm = ref<{ name: string; parent_id: number | null }>({
|
||||
name: '',
|
||||
parent_id: null,
|
||||
})
|
||||
|
||||
// 原始数据缓存
|
||||
let rawTree: any[] = []
|
||||
let flatDeptMap = new Map<number, any>()
|
||||
let allEmployees: any[] = []
|
||||
|
||||
// ============ 数据加载 ============
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
|
||||
const [treeRes, empRes, mappingRes] = await Promise.all([
|
||||
getDepartmentTree().catch(() => null),
|
||||
getEmployees().catch(() => null),
|
||||
getEmployeeDepartments().catch(() => null),
|
||||
])
|
||||
|
||||
rawTree = (treeRes as any)?.data || []
|
||||
|
||||
const empList: any[] = (empRes as any)?.data?.list || (empRes as any)?.data || []
|
||||
const empInfoMap: Record<number, any> = {}
|
||||
for (const e of empList) {
|
||||
empInfoMap[e.id || e.user_id] = e
|
||||
}
|
||||
|
||||
const mappingList: any[] = (mappingRes as any)?.data?.list || (mappingRes as any)?.data || []
|
||||
|
||||
// 关联表映射:user_id → { department_id, department_name }
|
||||
const mappingByUser: Record<number, any> = {}
|
||||
for (const m of mappingList) {
|
||||
mappingByUser[m.user_id] = m
|
||||
}
|
||||
|
||||
// 所有员工(含未分配部门的),department_id/position 优先从关联表取
|
||||
allEmployees = empList.map((e: any) => {
|
||||
const uid = e.id || e.user_id
|
||||
const map = mappingByUser[uid]
|
||||
return {
|
||||
id: uid,
|
||||
real_name: e.name || e.real_name || '',
|
||||
name: e.name || e.real_name || '',
|
||||
position: map?.position || e.position || '',
|
||||
employee_type: e.employee_type || '',
|
||||
department_id: map?.department_id ?? null,
|
||||
department_name: map?.department_name || '',
|
||||
}
|
||||
})
|
||||
|
||||
rebuildFlatList()
|
||||
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
/** 将部门树平铺为一维数组,附带 depth 和员工列表 */
|
||||
function rebuildFlatList() {
|
||||
flatDeptMap.clear()
|
||||
const result: any[] = []
|
||||
|
||||
function flatten(nodes: any[], depth: number) {
|
||||
for (const dept of nodes) {
|
||||
flatDeptMap.set(dept.id, dept)
|
||||
|
||||
// 该部门的直属员工
|
||||
const empList = allEmployees
|
||||
.filter((e) => e.department_id === dept.id)
|
||||
.map((e) => ({
|
||||
id: e.id,
|
||||
name: e.real_name || e.nickname || e.name || `#${e.id}`,
|
||||
position: e.position || '',
|
||||
employeeType: e.employee_type || '',
|
||||
employeeTypeLabel: e.employee_type === 'department_leader' ? '部门负责人'
|
||||
: e.employee_type === 'deputy_leader' ? '副负责人'
|
||||
: e.employee_type === 'vice_president' ? '副总裁'
|
||||
: e.employee_type === 'general_manager' ? '总经理'
|
||||
: '员工',
|
||||
}))
|
||||
|
||||
// 子孙部门员工总数
|
||||
function countSub(n: any[]): number {
|
||||
let c = 0
|
||||
for (const d of n) {
|
||||
c += allEmployees.filter((e) => e.department_id === d.id).length
|
||||
if (d.children?.length) c += countSub(d.children)
|
||||
}
|
||||
return c
|
||||
}
|
||||
const subCount = dept.children?.length ? countSub(dept.children) : 0
|
||||
const empCount = empList.length + subCount
|
||||
|
||||
result.push({
|
||||
id: dept.id,
|
||||
name: dept.name,
|
||||
parent_id: dept.parent_id,
|
||||
_depth: depth,
|
||||
_empList: empList,
|
||||
_empCount: empCount,
|
||||
_children: dept.children || [],
|
||||
})
|
||||
|
||||
if (dept.children?.length) {
|
||||
flatten(dept.children, depth + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flatten(rawTree, 0)
|
||||
flatDepts.value = result
|
||||
}
|
||||
|
||||
// ============ 搜索 ============
|
||||
|
||||
function onSearch() {
|
||||
// 简单的前端过滤:搜索时只显示名称匹配的部门
|
||||
if (!search.value) {
|
||||
rebuildFlatList()
|
||||
return
|
||||
}
|
||||
const kw = search.value.toLowerCase()
|
||||
flatDepts.value = flatDepts.value.filter((d) => d.name.toLowerCase().includes(kw))
|
||||
}
|
||||
|
||||
/** 展开/收起某行时 */
|
||||
function onExpandChange(row: any, expanded: boolean) {
|
||||
// 暂不需要额外逻辑
|
||||
}
|
||||
|
||||
/** 点击行切换展开/折叠(点击操作按钮时不触发) */
|
||||
function onRowClick(row: any, _column: any, event: Event) {
|
||||
// 如果点击目标是按钮或按钮内的元素,不切换
|
||||
const target = event.target as HTMLElement
|
||||
if (target.closest('.el-button, .el-button *')) return
|
||||
tableRef.value?.toggleRowExpansion(row)
|
||||
}
|
||||
|
||||
// ============ 部门名称查询 ============
|
||||
|
||||
function getDeptName(id: number): string {
|
||||
const dept = flatDeptMap.get(id)
|
||||
return dept?.name || '(已删除)'
|
||||
}
|
||||
|
||||
/** 获取原始树节点(用于删除判断子部门) */
|
||||
function getRawNode(id: number): any {
|
||||
function find(nodes: any[]): any {
|
||||
for (const n of nodes) {
|
||||
if (n.id === id) return n
|
||||
if (n.children?.length) {
|
||||
const found = find(n.children)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
return find(rawTree)
|
||||
}
|
||||
|
||||
// ============ 新增/编辑部门 ============
|
||||
|
||||
function openAddDept(parent: any) {
|
||||
deptEditId.value = null
|
||||
deptForm.value = {
|
||||
name: '',
|
||||
parent_id: parent?.id ?? null,
|
||||
}
|
||||
deptDialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEditDept(data: any) {
|
||||
deptEditId.value = data.id
|
||||
deptForm.value = {
|
||||
name: data.name || '',
|
||||
parent_id: data.parent_id ?? null,
|
||||
}
|
||||
deptDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleSaveDept() {
|
||||
if (!deptForm.value.name.trim()) {
|
||||
ElMessage.warning('请输入部门名称')
|
||||
return
|
||||
}
|
||||
|
||||
deptSaving.value = true
|
||||
try {
|
||||
if (deptEditId.value) {
|
||||
await updateDepartment(deptEditId.value, {
|
||||
name: deptForm.value.name.trim(),
|
||||
parent_id: deptForm.value.parent_id,
|
||||
})
|
||||
ElMessage.success('修改成功')
|
||||
} else {
|
||||
await createDepartment({
|
||||
name: deptForm.value.name.trim(),
|
||||
parent_id: deptForm.value.parent_id,
|
||||
})
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
deptDialogVisible.value = false
|
||||
await loadData()
|
||||
} catch (err: any) {
|
||||
ElMessage.error(err.message || '操作失败')
|
||||
} finally {
|
||||
deptSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 删除部门 ============
|
||||
|
||||
async function handleDeleteDept(data: any) {
|
||||
const raw = getRawNode(data.id)
|
||||
const hasChildren = raw?.children?.length > 0
|
||||
const hasEmployees = data._empCount > 0
|
||||
|
||||
let msg = `确定删除部门「${data.name}」?`
|
||||
if (hasChildren) msg += ' 其子部门也将一并删除。'
|
||||
if (hasEmployees) msg += ' 该部门下的员工将被移除关联。'
|
||||
|
||||
try {
|
||||
await ElMessageBox.confirm(msg, '确认删除', {
|
||||
confirmButtonText: '删除',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
await deleteDepartment(data.id)
|
||||
ElMessage.success('删除成功')
|
||||
await loadData()
|
||||
} catch {
|
||||
// 取消则不处理
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 员工管理 ============
|
||||
|
||||
const empManageVisible = ref(false)
|
||||
const empManageLoading = ref(false)
|
||||
const empManageAdding = ref(false)
|
||||
const empManageDept = ref<any>(null)
|
||||
const empManageList = ref<any[]>([])
|
||||
const empManageNewId = ref<number | null>(null)
|
||||
const empManageNewPosition = ref('')
|
||||
|
||||
const availableEmployees = computed(() =>
|
||||
allEmployees.filter((e) => e.department_id !== empManageDept.value?.id),
|
||||
)
|
||||
|
||||
function openEmpManage(dept: any) {
|
||||
empManageDept.value = dept
|
||||
empManageList.value = allEmployees.filter((e) => e.department_id === dept.id)
|
||||
empManageNewId.value = null
|
||||
empManageNewPosition.value = ''
|
||||
empManageVisible.value = true
|
||||
}
|
||||
|
||||
async function handleEmpAdd() {
|
||||
if (!empManageNewId.value || !empManageDept.value) return
|
||||
empManageAdding.value = true
|
||||
try {
|
||||
await setEmployeeDepartment(empManageNewId.value, empManageDept.value.id, empManageNewPosition.value || undefined)
|
||||
ElMessage.success('添加成功')
|
||||
empManageNewId.value = null
|
||||
empManageNewPosition.value = ''
|
||||
await loadData()
|
||||
empManageList.value = allEmployees.filter((e) => e.department_id === empManageDept.value.id)
|
||||
} catch (err: any) {
|
||||
ElMessage.error(err.message || '添加失败')
|
||||
} finally {
|
||||
empManageAdding.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEmpRemove(emp: any) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定将 ${emp.real_name || emp.nickname || emp.name} 移出该部门?`, '确认')
|
||||
await clearEmployeeDepartment(emp.id)
|
||||
ElMessage.success('已移出')
|
||||
await loadData()
|
||||
empManageList.value = allEmployees.filter((e) => e.department_id === empManageDept.value?.id)
|
||||
} catch {
|
||||
// 取消则不处理
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 初始化 ============
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
114
mp-sc-admin/src/pages/Layout.vue
Normal file
114
mp-sc-admin/src/pages/Layout.vue
Normal file
@@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<div class="h-screen flex bg-gray-50">
|
||||
<!-- Sidebar -->
|
||||
<aside class="w-56 bg-white border-r border-gray-200 flex flex-col shrink-0 shadow-sm">
|
||||
<!-- Logo -->
|
||||
<div class="h-16 flex items-center gap-2.5 px-5 border-b border-gray-100">
|
||||
<div class="w-8 h-8 rounded-lg bg-blue-600 flex items-center justify-center">
|
||||
<span class="text-white font-bold text-sm">凌</span>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-bold text-gray-800">凌空天行</div>
|
||||
<div class="text-[10px] text-gray-400">后台管理系统</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Menu -->
|
||||
<nav class="flex-1 overflow-y-auto py-3 px-3 space-y-1">
|
||||
<div v-for="group in menuGroups" :key="group.label">
|
||||
<div class="text-[11px] text-gray-400 font-medium px-3 py-2 uppercase tracking-wider">{{ group.label }}</div>
|
||||
<router-link v-for="item in group.items" :key="item.path" :to="item.path"
|
||||
class="flex items-center gap-3 px-3 py-2.5 text-sm rounded-lg transition-all duration-200"
|
||||
:class="$route.path === item.path
|
||||
? 'bg-blue-50 text-blue-700 font-medium shadow-sm'
|
||||
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-800'">
|
||||
<div :class="item.icon" class="text-lg shrink-0" />
|
||||
<span>{{ item.label }}</span>
|
||||
</router-link>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Bottom -->
|
||||
<div class="p-4 border-t border-gray-100">
|
||||
<div class="flex items-center gap-3 text-sm text-gray-500">
|
||||
<div class="w-8 h-8 rounded-full bg-blue-100 flex items-center justify-center">
|
||||
<span class="text-blue-600 font-medium text-xs">{{ adminName.charAt(0) }}</span>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-gray-700 text-sm truncate">{{ adminName }}</div>
|
||||
<div class="text-gray-400 text-xs">管理员</div>
|
||||
</div>
|
||||
<button class="p-1.5 rounded-lg hover:bg-gray-100 text-gray-400 hover:text-gray-600 transition" @click="handleLogout" title="退出登录">
|
||||
<div class="i-carbon-logout text-lg" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main -->
|
||||
<div class="flex-1 flex flex-col min-w-0">
|
||||
<!-- Header -->
|
||||
<header class="h-16 bg-white border-b border-gray-200 flex items-center justify-between px-6 shrink-0">
|
||||
<div class="flex items-center gap-2 text-sm text-gray-500">
|
||||
<span class="text-gray-400">/</span>
|
||||
<span class="text-gray-700 font-medium">{{ $route.meta.title }}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Content -->
|
||||
<main class="flex-1 overflow-auto p-6">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const adminName = ref(localStorage.getItem('admin_name') || '管理员')
|
||||
|
||||
const menuGroups = [
|
||||
{
|
||||
label: '系统管理',
|
||||
items: [
|
||||
{ path: '/dashboard', label: '工作台', icon: 'i-carbon-dashboard' },
|
||||
{ path: '/users', label: '用户管理', icon: 'i-carbon-user-multiple' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '业务管理',
|
||||
items: [
|
||||
{ path: '/approvals', label: '认证审核', icon: 'i-carbon-certificate-check' },
|
||||
{ path: '/departments', label: '组织架构', icon: 'i-carbon-tree-view-alt' },
|
||||
{ path: '/pending-employees', label: '通讯录导入', icon: 'i-carbon-import-export' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '访客模块',
|
||||
items: [
|
||||
{ path: '/appointments', label: '预约管理', icon: 'i-carbon-calendar' },
|
||||
{ path: '/visit-types', label: '来访目的', icon: 'i-carbon-list-dropdown' },
|
||||
{ path: '/visitor-areas', label: '到访区域', icon: 'i-carbon-location' },
|
||||
{ path: '/workflow-designer', label: '流程设计器', icon: 'i-carbon-diagram' },
|
||||
{ path: '/config', label: '审批配置', icon: 'i-carbon-settings-adjust' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '内容管理',
|
||||
items: [
|
||||
{ path: '/banners', label: 'Banner管理', icon: 'i-carbon-image' },
|
||||
{ path: '/materials', label: '素材上传', icon: 'i-carbon-upload' },
|
||||
{ path: '/notices', label: '通知管理', icon: 'i-carbon-notification' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
function handleLogout() {
|
||||
localStorage.clear()
|
||||
router.push('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
54
mp-sc-admin/src/pages/Login.vue
Normal file
54
mp-sc-admin/src/pages/Login.vue
Normal file
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100">
|
||||
<el-card class="w-[420px] shadow-xl rounded-2xl">
|
||||
<template #header>
|
||||
<div class="text-center">
|
||||
<div class="i-carbon-cloud-foundation text-5xl text-blue-500 mb-3" />
|
||||
<h2 class="text-xl font-bold text-gray-800">凌空天行后台管理</h2>
|
||||
<p class="text-sm text-gray-400 mt-1">请登录管理员账号</p>
|
||||
</div>
|
||||
</template>
|
||||
<el-form :model="form" @submit.prevent="handleLogin" class="mt-2">
|
||||
<el-form-item>
|
||||
<el-input v-model="form.account" placeholder="账号" size="large" :prefix-icon="iconUser" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-input v-model="form.password" type="password" placeholder="密码" size="large" show-password :prefix-icon="iconLock" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" native-type="submit" :loading="loading" size="large" class="w-full !bg-blue-500">
|
||||
{{ loading ? '登录中...' : '登 录' }}
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="text-center text-xs text-gray-400 mt-2">默认账号: admin / admin123</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { User, Lock } from '@element-plus/icons-vue'
|
||||
import request from '@/utils/request'
|
||||
|
||||
const iconUser = User
|
||||
const iconLock = Lock
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const form = ref({ account: 'admin', password: 'admin123' })
|
||||
|
||||
async function handleLogin() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await request.Post('/v1/admin/login', form.value)
|
||||
localStorage.setItem('admin_token', res.data.token)
|
||||
localStorage.setItem('admin_name', res.data.name)
|
||||
router.push('/dashboard')
|
||||
} catch {
|
||||
// handled by alova interceptor
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
192
mp-sc-admin/src/pages/Materials.vue
Normal file
192
mp-sc-admin/src/pages/Materials.vue
Normal file
@@ -0,0 +1,192 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h1 class="text-lg font-bold text-gray-800">素材上传(微信)</h1>
|
||||
</div>
|
||||
|
||||
<!-- 上传方式切换 -->
|
||||
<el-card class="mb-4">
|
||||
<div class="flex gap-4 mb-4">
|
||||
<el-radio-group v-model="uploadMode">
|
||||
<el-radio value="file">选择文件上传</el-radio>
|
||||
<el-radio value="url">通过链接上传</el-radio>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
|
||||
<!-- 文件上传模式 -->
|
||||
<template v-if="uploadMode === 'file'">
|
||||
<el-upload
|
||||
drag
|
||||
:auto-upload="false"
|
||||
accept="image/*"
|
||||
:show-file-list="false"
|
||||
@change="handleFileChange"
|
||||
>
|
||||
<div class="flex flex-col items-center py-4">
|
||||
<div class="i-carbon-cloud-upload text-4xl text-gray-400 mb-2" />
|
||||
<div class="text-sm text-gray-500">将图片拖到此处,或<em class="text-blue-600">点击选择</em></div>
|
||||
<div class="text-xs text-gray-400 mt-1">支持 JPG / PNG / GIF 格式</div>
|
||||
</div>
|
||||
</el-upload>
|
||||
<div v-if="selectedFile" class="mt-3 flex items-center gap-3">
|
||||
<span class="text-sm text-gray-600">已选择: {{ selectedFile.name }}</span>
|
||||
<el-button type="primary" size="small" :loading="uploading" @click="handleFileUpload">
|
||||
开始上传
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 链接上传模式 -->
|
||||
<template v-if="uploadMode === 'url'">
|
||||
<div class="flex gap-2">
|
||||
<el-input
|
||||
v-model="imageLink"
|
||||
placeholder="请输入图片链接地址,例如 https://example.com/image.jpg"
|
||||
clearable
|
||||
class="flex-1"
|
||||
/>
|
||||
<el-button type="primary" :loading="uploading" @click="handleUrlUpload">
|
||||
上传
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-card>
|
||||
|
||||
<!-- 上传结果 -->
|
||||
<el-card v-if="uploadedUrl" class="mb-4">
|
||||
<template #header>
|
||||
<span class="text-sm font-medium">上传结果</span>
|
||||
</template>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-24 h-24 shrink-0 rounded-lg overflow-hidden bg-gray-100 border">
|
||||
<img :src="uploadedUrl" class="w-full h-full object-cover" @error="onImgError" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-xs text-gray-400 mb-1">微信图片 URL:</div>
|
||||
<div class="text-sm text-blue-600 break-all mb-2">{{ uploadedUrl }}</div>
|
||||
<el-button size="small" type="warning" @click="copyUrl">复制链接</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 上传记录 -->
|
||||
<el-card>
|
||||
<template #header>
|
||||
<span class="text-sm font-medium">上传记录</span>
|
||||
</template>
|
||||
<div v-if="history.length === 0" class="text-center text-gray-400 py-6 text-sm">
|
||||
暂无上传记录
|
||||
</div>
|
||||
<div v-else class="space-y-2">
|
||||
<div v-for="(item, index) in history" :key="index"
|
||||
class="flex items-center gap-3 p-2 rounded-lg hover:bg-gray-50 transition-colors">
|
||||
<div class="w-10 h-10 shrink-0 rounded overflow-hidden bg-gray-100 border">
|
||||
<img :src="item.url" class="w-full h-full object-cover" @error="onImgError" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-xs text-gray-500 truncate">{{ item.url }}</div>
|
||||
<div class="text-xs text-gray-400">{{ item.time }}</div>
|
||||
</div>
|
||||
<el-button size="small" text type="primary" @click="copyText(item.url)">复制</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { uploadImage, uploadImageByURL } from '@/api'
|
||||
|
||||
const uploadMode = ref<'file' | 'url'>('file')
|
||||
const selectedFile = ref<File | null>(null)
|
||||
const imageLink = ref('')
|
||||
const uploading = ref(false)
|
||||
const uploadedUrl = ref('')
|
||||
|
||||
interface HistoryItem {
|
||||
url: string
|
||||
time: string
|
||||
}
|
||||
const history = ref<HistoryItem[]>([])
|
||||
|
||||
function handleFileChange(uploadFile: any) {
|
||||
const file = uploadFile.raw as File
|
||||
if (!file) return
|
||||
selectedFile.value = file
|
||||
}
|
||||
|
||||
async function handleFileUpload() {
|
||||
if (!selectedFile.value) {
|
||||
ElMessage.warning('请先选择图片文件')
|
||||
return
|
||||
}
|
||||
uploading.value = true
|
||||
uploadedUrl.value = ''
|
||||
try {
|
||||
const res: any = await uploadImage(selectedFile.value)
|
||||
const url = res?.data?.url || res?.url || ''
|
||||
if (url) {
|
||||
uploadedUrl.value = url
|
||||
addHistory(url)
|
||||
ElMessage.success('上传成功')
|
||||
} else {
|
||||
ElMessage.error('上传失败:响应中未找到图片 URL')
|
||||
}
|
||||
} catch {
|
||||
// handled by interceptor
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUrlUpload() {
|
||||
if (!imageLink.value.trim()) {
|
||||
ElMessage.warning('请输入图片链接')
|
||||
return
|
||||
}
|
||||
uploading.value = true
|
||||
uploadedUrl.value = ''
|
||||
try {
|
||||
const res: any = await uploadImageByURL(imageLink.value.trim())
|
||||
const url = res?.data?.url || res?.url || ''
|
||||
if (url) {
|
||||
uploadedUrl.value = url
|
||||
addHistory(url)
|
||||
ElMessage.success('上传成功')
|
||||
imageLink.value = ''
|
||||
} else {
|
||||
ElMessage.error('上传失败:响应中未找到图片 URL')
|
||||
}
|
||||
} catch {
|
||||
// handled by interceptor
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function addHistory(url: string) {
|
||||
history.value.unshift({
|
||||
url,
|
||||
time: new Date().toLocaleString(),
|
||||
})
|
||||
}
|
||||
|
||||
function copyUrl() {
|
||||
copyText(uploadedUrl.value)
|
||||
}
|
||||
|
||||
function copyText(text: string) {
|
||||
if (!text) return
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
ElMessage.success('已复制到剪贴板')
|
||||
}).catch(() => {
|
||||
ElMessage.error('复制失败')
|
||||
})
|
||||
}
|
||||
|
||||
function onImgError(e: Event) {
|
||||
(e.target as HTMLImageElement).src = 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 100"><rect fill="%23f3f4f6" width="200" height="100"/><text x="100" y="55" text-anchor="middle" fill="%239ca3af" font-size="12">加载失败</text></svg>'
|
||||
}
|
||||
</script>
|
||||
6
mp-sc-admin/src/pages/Notices.vue
Normal file
6
mp-sc-admin/src/pages/Notices.vue
Normal file
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-gray-800 mb-4">通知管理</h1>
|
||||
<p class="text-gray-500">系统通知管理(开发中)</p>
|
||||
</div>
|
||||
</template>
|
||||
254
mp-sc-admin/src/pages/PendingEmployees.vue
Normal file
254
mp-sc-admin/src/pages/PendingEmployees.vue
Normal file
@@ -0,0 +1,254 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-gray-800 mb-4">通讯录导入</h1>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<el-row :gutter="16" class="mb-4">
|
||||
<el-col :span="8">
|
||||
<el-card shadow="never" class="text-center">
|
||||
<div class="text-3xl font-bold text-blue-600">{{ stats.total }}</div>
|
||||
<div class="text-sm text-gray-500 mt-1">总计导入</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-card shadow="never" class="text-center">
|
||||
<div class="text-3xl font-bold text-amber-500">{{ stats.pending }}</div>
|
||||
<div class="text-sm text-gray-500 mt-1">待匹配</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-card shadow="never" class="text-center">
|
||||
<div class="text-3xl font-bold text-green-500">{{ stats.matched }}</div>
|
||||
<div class="text-sm text-gray-500 mt-1">已匹配</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 导入工具条 -->
|
||||
<el-card shadow="never" class="mb-4">
|
||||
<el-row :gutter="12" align="middle">
|
||||
<el-col>
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
accept=".xlsx,.xls"
|
||||
style="display:none"
|
||||
@change="handleFileUpload"
|
||||
/>
|
||||
<el-button type="success" :loading="uploading" @click="fileInputRef?.click()">
|
||||
{{ uploading ? '导入中...' : '上传 Excel 导入' }}
|
||||
</el-button>
|
||||
<span class="text-xs text-gray-400 ml-2">支持 .xlsx / .xls 格式,自动提取四川凌空员工</span>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
|
||||
<!-- 搜索/筛选栏 -->
|
||||
<el-card shadow="never" class="mb-4">
|
||||
<el-row :gutter="12" align="middle">
|
||||
<el-col :span="7">
|
||||
<el-input v-model="keyword" placeholder="搜索姓名/手机号" clearable />
|
||||
</el-col>
|
||||
<el-col :span="5">
|
||||
<el-select v-model="deptFilter" filterable placeholder="选择部门" clearable class="w-full" @change="doSearch">
|
||||
<el-option v-for="d in departments" :key="d.id" :label="d.name" :value="d.id" />
|
||||
</el-select>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-select v-model="statusFilter" placeholder="状态" clearable class="w-full" @change="doSearch">
|
||||
<el-option label="全部" value="" />
|
||||
<el-option label="待匹配" value="pending" />
|
||||
<el-option label="已匹配" value="matched" />
|
||||
</el-select>
|
||||
</el-col>
|
||||
<el-col :span="2">
|
||||
<el-button type="primary" @click="doSearch">搜索</el-button>
|
||||
</el-col>
|
||||
<el-col :span="4" :push="2">
|
||||
<el-button type="warning" plain :loading="syncingAll" @click="handleSyncAll">一键同步</el-button>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
|
||||
<!-- 导入记录 -->
|
||||
<div class="bg-white rounded-xl shadow-sm border overflow-hidden">
|
||||
<div class="px-4 py-3 border-b">
|
||||
<h2 class="text-base font-semibold text-gray-700">导入记录</h2>
|
||||
</div>
|
||||
<el-table :data="list" stripe class="w-full" v-loading="loading">
|
||||
<el-table-column prop="real_name" label="姓名" min-width="100" />
|
||||
<el-table-column prop="phone" label="手机号" min-width="140" />
|
||||
<el-table-column prop="department_name" label="部门" min-width="140" />
|
||||
<el-table-column prop="position" label="职位" min-width="120">
|
||||
<template #default="{ row }">
|
||||
{{ row.position || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="dingtalk_user_id" label="钉钉UserId" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<span class="text-sm text-gray-600">{{ row.dingtalk_user_id || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.is_matched" type="success" size="small">已匹配</el-tag>
|
||||
<el-tag v-else type="warning" size="small">待匹配</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="匹配用户" min-width="100">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.matched_user_id" class="text-sm">#{{ row.matched_user_id }}</span>
|
||||
<span v-else class="text-gray-400 text-sm">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="matched_at" label="匹配时间" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<span class="text-sm text-gray-500">{{ row.matched_at ? dayjs(row.matched_at).format('YYYY-MM-DD HH:mm') : '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="110" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
size="small"
|
||||
type="primary"
|
||||
:loading="matchingId === row.id"
|
||||
@click="handleMatch(row)"
|
||||
>{{ row.is_matched ? '重新同步' : '手动匹配' }}</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="flex justify-center p-4" v-if="total > pageSize">
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
v-model:page-size="pageSize"
|
||||
:page-sizes="[15, 30, 50, 100]"
|
||||
:total="total"
|
||||
background
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@current-change="loadData"
|
||||
@size-change="loadData(true)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { getPendingEmployees, matchPendingEmployee, getPendingEmployeeStats, syncAllPendingEmployees, listAllDepartments } from '@/api'
|
||||
import request from '@/utils/request'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
const list = ref<any[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(15)
|
||||
const statusFilter = ref('')
|
||||
const keyword = ref('')
|
||||
const deptFilter = ref<number | undefined>(undefined)
|
||||
const departments = ref<any[]>([])
|
||||
const stats = ref({ total: 0, pending: 0, matched: 0 })
|
||||
const matchingId = ref<number | null>(null)
|
||||
const syncingAll = ref(false)
|
||||
const uploading = ref(false)
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||
const loading = ref(false)
|
||||
|
||||
async function loadData(reset = false) {
|
||||
// reset 可能是布尔值或页码数字(来自 pagination 事件)
|
||||
if (reset === true) {
|
||||
page.value = 1
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getPendingEmployees({
|
||||
page: page.value,
|
||||
page_size: pageSize.value,
|
||||
status: statusFilter.value || undefined,
|
||||
keyword: keyword.value || undefined,
|
||||
department_id: deptFilter.value || undefined,
|
||||
})
|
||||
const data = res as any
|
||||
list.value = data?.data?.list || []
|
||||
total.value = data?.data?.total || 0
|
||||
} catch {
|
||||
// request.ts 已自动弹出错误
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const res = await getPendingEmployeeStats() as any
|
||||
stats.value = res?.data || { total: 0, pending: 0, matched: 0 }
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function doSearch() {
|
||||
loadData(true)
|
||||
}
|
||||
|
||||
async function handleSyncAll() {
|
||||
await ElMessageBox.confirm('将一键同步所有待确认员工的信息到已绑定手机号的用户,确定执行?', '确认', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '确定同步',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
syncingAll.value = true
|
||||
try {
|
||||
const res = await syncAllPendingEmployees() as any
|
||||
const d = res?.data
|
||||
ElMessage.success(`同步完成:成功 ${d?.synced || 0} 人,跳过 ${d?.skipped || 0} 人(共 ${d?.total || 0} 人)`)
|
||||
await Promise.all([loadData(), loadStats()])
|
||||
} catch {
|
||||
// request.ts 已自动弹出错误
|
||||
} finally {
|
||||
syncingAll.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFileUpload(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
uploading.value = true
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
const res = await request.Post('/v1/admin/employees/import-excel', formData) as any
|
||||
const d = res?.data || {}
|
||||
ElMessage.success(`导入完成:成功 ${d.imported || 0} 人,更新 ${d.updated || 0} 人,未匹配部门 ${d.noDept || 0} 人`)
|
||||
await Promise.all([loadData(true), loadStats()])
|
||||
} catch {
|
||||
// request.ts 已自动提示
|
||||
} finally {
|
||||
uploading.value = false
|
||||
input.value = '' // 清空 input 以便重新选择同一个文件
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMatch(row: any) {
|
||||
matchingId.value = row.id
|
||||
try {
|
||||
const res = await matchPendingEmployee(row.id) as any
|
||||
ElMessage.success(`匹配成功:${res?.data?.real_name || ''}(${res?.data?.phone || ''})`)
|
||||
await Promise.all([loadData(), loadStats()])
|
||||
} catch {
|
||||
// request.ts 已自动弹出错误提示
|
||||
} finally {
|
||||
matchingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const deptRes = await listAllDepartments()
|
||||
departments.value = (deptRes as any)?.data || []
|
||||
loadData()
|
||||
loadStats()
|
||||
})
|
||||
</script>
|
||||
288
mp-sc-admin/src/pages/Users.vue
Normal file
288
mp-sc-admin/src/pages/Users.vue
Normal file
@@ -0,0 +1,288 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-gray-800 mb-4">用户管理</h1>
|
||||
<el-card shadow="never" class="mb-3">
|
||||
<el-row :gutter="12" align="middle">
|
||||
<el-col :span="7">
|
||||
<el-input v-model="keyword" placeholder="搜索姓名/昵称/手机号" clearable @clear="fetchData" @keyup.enter="fetchData" />
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-select v-model="roleFilter" placeholder="角色" clearable class="w-full" @change="fetchData">
|
||||
<el-option label="全部" value="" />
|
||||
<el-option label="访客" value="visitor" />
|
||||
<el-option label="员工" value="employee" />
|
||||
<el-option label="保安" value="guard" />
|
||||
</el-select>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-select v-model="statusFilter" placeholder="状态" clearable class="w-full" @change="fetchData">
|
||||
<el-option label="全部" value="" />
|
||||
<el-option label="正常" :value="1" />
|
||||
<el-option label="禁用" :value="0" />
|
||||
</el-select>
|
||||
</el-col>
|
||||
<el-col :span="2">
|
||||
<el-button type="primary" @click="fetchData">搜索</el-button>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
<div class="bg-white rounded-xl shadow-sm border overflow-hidden">
|
||||
<el-table :data="users" stripe class="w-full" v-loading="loading">
|
||||
<el-table-column prop="id" label="ID" width="60" />
|
||||
<el-table-column label="头像" width="60">
|
||||
<template #default="{ row }">
|
||||
<el-avatar v-if="row.avatar_url" :src="row.avatar_url" :size="32" />
|
||||
<el-avatar v-else :size="32">{{ (row.real_name || row.nickname || '?').charAt(0) }}</el-avatar>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="姓名">
|
||||
<template #default="{ row }">{{ row.real_name || row.nickname || '未命名' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="phone" label="手机号" width="140" />
|
||||
<el-table-column label="角色" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.role === 'employee'" type="success" size="small">员工</el-tag>
|
||||
<el-tag v-else-if="row.role === 'guard'" type="warning" size="small">保安</el-tag>
|
||||
<el-tag v-else size="small" type="info">{{ row.role_name || '访客' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status===1?'success':'danger'" size="small">{{ row.status===1?'正常':'禁用' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="所属部门" min-width="120">
|
||||
<template #default="{ row }">
|
||||
<span class="text-gray-600">{{ getUserDept(row.id) || '未分配' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="100" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link size="small" @click="openEdit(row)">编辑</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div v-if="total > 0" class="p-4 flex justify-center">
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
:page-size="pageSize"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="total"
|
||||
layout="total,sizes,prev,pager,next"
|
||||
@current-change="fetchData"
|
||||
@size-change="handleSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 编辑对话框 -->
|
||||
<el-dialog v-model="dialogVisible" title="编辑用户" width="500px" :close-on-click-modal="false" @closed="onDialogClosed">
|
||||
<el-form v-if="editForm" ref="formRef" :model="editForm" label-width="100px">
|
||||
<el-form-item label="用户ID">
|
||||
<span class="text-gray-500">{{ editForm.id }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="头像">
|
||||
<el-avatar v-if="editForm.avatar_url" :src="editForm.avatar_url" :size="48" />
|
||||
<el-avatar v-else :size="48">{{ (editForm.real_name || editForm.nickname || '?').charAt(0) }}</el-avatar>
|
||||
</el-form-item>
|
||||
<el-form-item label="真实姓名" prop="real_name">
|
||||
<el-input v-model="editForm.real_name" placeholder="输入真实姓名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="昵称" prop="nickname">
|
||||
<el-input v-model="editForm.nickname" placeholder="输入微信昵称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="手机号" prop="phone">
|
||||
<el-input v-model="editForm.phone" placeholder="输入手机号" maxlength="11" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-switch v-model="editForm.status" :active-value="1" :inactive-value="0" active-text="正常" inactive-text="禁用" />
|
||||
</el-form-item>
|
||||
<el-form-item label="角色">
|
||||
<el-radio-group v-model="editForm.role">
|
||||
<el-radio value="visitor">访客</el-radio>
|
||||
<el-radio value="employee">员工</el-radio>
|
||||
<el-radio value="guard">保安</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="职位">
|
||||
<el-input v-model="editForm.position" placeholder="如:前端工程师" />
|
||||
</el-form-item>
|
||||
<el-form-item label="所属部门">
|
||||
<el-select v-model="editForm.department_id" filterable clearable placeholder="请选择部门" class="w-full">
|
||||
<el-option
|
||||
v-for="d in departments"
|
||||
:key="d.id"
|
||||
:label="d.name"
|
||||
:value="d.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getUsers, updateUser, listAllDepartments, setEmployeeDepartment, clearEmployeeDepartment, getEmployeeDepartments } from '@/api'
|
||||
|
||||
const users = ref<any[]>([])
|
||||
const keyword = ref('')
|
||||
const roleFilter = ref('')
|
||||
const statusFilter = ref('')
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
|
||||
// 编辑对话框
|
||||
const dialogVisible = ref(false)
|
||||
const saving = ref(false)
|
||||
const editForm = ref<any>(null)
|
||||
|
||||
// 部门列表(用于下拉选择)
|
||||
const departments = ref<any[]>([])
|
||||
|
||||
// 员工-部门关联映射:userId -> { department_id, department_name }
|
||||
const deptMap = ref<Record<number, { department_id: number; department_name: string }>>({})
|
||||
|
||||
function getUserDept(userId: number): string {
|
||||
return deptMap.value[userId]?.department_name || ''
|
||||
}
|
||||
|
||||
function getUserDeptId(userId: number): number | null {
|
||||
return deptMap.value[userId]?.department_id ?? null
|
||||
}
|
||||
|
||||
function getUserPosition(userId: number): string {
|
||||
return deptMap.value[userId]?.position || ''
|
||||
}
|
||||
|
||||
async function loadDepartments() {
|
||||
try {
|
||||
const res: any = await listAllDepartments()
|
||||
departments.value = res?.data || []
|
||||
} catch {
|
||||
// 部门列表非关键,静默失败
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDeptMap() {
|
||||
try {
|
||||
const res: any = await getEmployeeDepartments()
|
||||
const list: any[] = res?.data?.list || res?.data || []
|
||||
const map: Record<number, { department_id: number; department_name: string; position: string }> = {}
|
||||
for (const item of list) {
|
||||
map[item.user_id] = {
|
||||
department_id: item.department_id,
|
||||
department_name: item.department_name,
|
||||
position: item.position || '',
|
||||
}
|
||||
}
|
||||
deptMap.value = map
|
||||
} catch {
|
||||
deptMap.value = {}
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: Record<string, any> = { page: page.value, page_size: pageSize.value }
|
||||
if (keyword.value) params.keyword = keyword.value
|
||||
if (roleFilter.value) params.type = roleFilter.value
|
||||
if (statusFilter.value !== '') params.status = statusFilter.value
|
||||
|
||||
const [userRes] = await Promise.all([
|
||||
getUsers(params),
|
||||
loadDeptMap(),
|
||||
])
|
||||
users.value = (userRes as any)?.data?.list || []
|
||||
total.value = (userRes as any)?.data?.total || 0
|
||||
} catch {
|
||||
ElMessage.error('加载用户列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSizeChange(val: number) {
|
||||
pageSize.value = val
|
||||
page.value = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
function openEdit(row: any) {
|
||||
editForm.value = {
|
||||
id: row.id,
|
||||
real_name: row.real_name || '',
|
||||
nickname: row.nickname || '',
|
||||
phone: row.phone || '',
|
||||
avatar_url: row.avatar_url || '',
|
||||
status: row.status,
|
||||
role: row.role || 'visitor',
|
||||
position: getUserPosition(row.id),
|
||||
_origDeptId: getUserDeptId(row.id),
|
||||
_origPosition: getUserPosition(row.id),
|
||||
department_id: getUserDeptId(row.id),
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function onDialogClosed() {
|
||||
// 对话框关闭动画结束后清理
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!editForm.value) return
|
||||
saving.value = true
|
||||
try {
|
||||
// 1. 更新用户基本信息(不含部门)
|
||||
await updateUser(editForm.value.id, {
|
||||
real_name: editForm.value.real_name,
|
||||
nickname: editForm.value.nickname,
|
||||
phone: editForm.value.phone,
|
||||
status: editForm.value.status,
|
||||
role: editForm.value.role,
|
||||
})
|
||||
|
||||
// 2. 部门/职位变更走关联表接口
|
||||
const newDeptId = editForm.value.department_id
|
||||
const origDeptId = editForm.value._origDeptId
|
||||
const newPos = editForm.value.position
|
||||
const origPos = editForm.value._origPosition
|
||||
const deptChanged = newDeptId !== origDeptId
|
||||
const posChanged = newPos !== origPos
|
||||
|
||||
if (deptChanged || posChanged) {
|
||||
if (newDeptId) {
|
||||
await setEmployeeDepartment(editForm.value.id, newDeptId, newPos)
|
||||
} else {
|
||||
// 无部门:清除关联(职位也不存了)
|
||||
await clearEmployeeDepartment(editForm.value.id)
|
||||
}
|
||||
}
|
||||
|
||||
ElMessage.success('保存成功')
|
||||
dialogVisible.value = false
|
||||
page.value = 1
|
||||
await fetchData()
|
||||
} catch (err: any) {
|
||||
ElMessage.error(err.message || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
loadDepartments()
|
||||
})
|
||||
</script>
|
||||
122
mp-sc-admin/src/pages/VisitTypes.vue
Normal file
122
mp-sc-admin/src/pages/VisitTypes.vue
Normal file
@@ -0,0 +1,122 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h1 class="text-xl font-bold text-gray-800">来访目的管理</h1>
|
||||
<el-button type="primary" size="small" @click="openAdd">+ 新增类型</el-button>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm border overflow-hidden">
|
||||
<el-table :data="types" stripe class="w-full" v-loading="loading">
|
||||
<el-table-column prop="id" label="ID" width="60" />
|
||||
<el-table-column prop="name" label="类型名称" min-width="160" />
|
||||
<el-table-column prop="sort" label="排序" width="80" />
|
||||
<el-table-column label="状态" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 1 ? 'success' : 'danger'" size="small">
|
||||
{{ row.status === 1 ? '启用' : '禁用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="160" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="primary" link @click="openEdit(row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" link @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div v-if="!types.length && !loading" class="text-center text-gray-400 py-12">暂无来访目的</div>
|
||||
</div>
|
||||
|
||||
<!-- 新增/编辑对话框 -->
|
||||
<el-dialog v-model="dialogVisible" :title="editId ? '编辑类型' : '新增类型'" width="420px" :close-on-click-modal="false">
|
||||
<el-form :model="form" label-width="100px">
|
||||
<el-form-item label="类型名称">
|
||||
<el-input v-model="form.name" placeholder="如:政府" />
|
||||
</el-form-item>
|
||||
<el-form-item label="排序">
|
||||
<el-input-number v-model="form.sort" :min="0" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="editId" label="状态">
|
||||
<el-switch v-model="form.status" :active-value="1" :inactive-value="0" active-text="启用" inactive-text="禁用" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import request from '@/utils/request'
|
||||
|
||||
const types = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
const dialogVisible = ref(false)
|
||||
const saving = ref(false)
|
||||
const editId = ref<number | null>(null)
|
||||
const form = ref({ name: '', sort: 0, status: 1 })
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await request.Get('/v1/admin/visit-types')
|
||||
types.value = res?.data || []
|
||||
} catch {
|
||||
ElMessage.error('加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openAdd() {
|
||||
editId.value = null
|
||||
form.value = { name: '', sort: 0, status: 1 }
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(row: any) {
|
||||
editId.value = row.id
|
||||
form.value = { name: row.name, sort: row.sort, status: row.status }
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!form.value.name.trim()) {
|
||||
ElMessage.warning('请输入类型名称')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
if (editId.value) {
|
||||
await request.Put(`/v1/admin/visit-types/${editId.value}`, form.value)
|
||||
ElMessage.success('更新成功')
|
||||
} else {
|
||||
await request.Post('/v1/admin/visit-types', form.value)
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
await loadData()
|
||||
} catch (err: any) {
|
||||
ElMessage.error(err.message || '操作失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(row: any) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除类型「${row.name}」?`, '确认')
|
||||
await request.Delete(`/v1/admin/visit-types/${row.id}`)
|
||||
ElMessage.success('删除成功')
|
||||
await loadData()
|
||||
} catch {
|
||||
// 取消
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
122
mp-sc-admin/src/pages/VisitorAreas.vue
Normal file
122
mp-sc-admin/src/pages/VisitorAreas.vue
Normal file
@@ -0,0 +1,122 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h1 class="text-xl font-bold text-gray-800">到访区域管理</h1>
|
||||
<el-button type="primary" size="small" @click="openAdd">+ 新增区域</el-button>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm border overflow-hidden">
|
||||
<el-table :data="areas" stripe class="w-full" v-loading="loading">
|
||||
<el-table-column prop="id" label="ID" width="60" />
|
||||
<el-table-column prop="name" label="区域名称" min-width="160" />
|
||||
<el-table-column prop="sort" label="排序" width="80" />
|
||||
<el-table-column label="状态" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 1 ? 'success' : 'danger'" size="small">
|
||||
{{ row.status === 1 ? '启用' : '禁用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="160" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="primary" link @click="openEdit(row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" link @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div v-if="!areas.length && !loading" class="text-center text-gray-400 py-12">暂无到访区域</div>
|
||||
</div>
|
||||
|
||||
<!-- 新增/编辑对话框 -->
|
||||
<el-dialog v-model="dialogVisible" :title="editId ? '编辑区域' : '新增区域'" width="420px" :close-on-click-modal="false">
|
||||
<el-form :model="form" label-width="100px">
|
||||
<el-form-item label="区域名称">
|
||||
<el-input v-model="form.name" placeholder="如:A区域" />
|
||||
</el-form-item>
|
||||
<el-form-item label="排序">
|
||||
<el-input-number v-model="form.sort" :min="0" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="editId" label="状态">
|
||||
<el-switch v-model="form.status" :active-value="1" :inactive-value="0" active-text="启用" inactive-text="禁用" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import request from '@/utils/request'
|
||||
|
||||
const areas = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
const dialogVisible = ref(false)
|
||||
const saving = ref(false)
|
||||
const editId = ref<number | null>(null)
|
||||
const form = ref({ name: '', sort: 0, status: 1 })
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await request.Get('/v1/admin/visitor-areas')
|
||||
areas.value = res?.data || []
|
||||
} catch {
|
||||
ElMessage.error('加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openAdd() {
|
||||
editId.value = null
|
||||
form.value = { name: '', sort: 0, status: 1 }
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(row: any) {
|
||||
editId.value = row.id
|
||||
form.value = { name: row.name, sort: row.sort, status: row.status }
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!form.value.name.trim()) {
|
||||
ElMessage.warning('请输入区域名称')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
if (editId.value) {
|
||||
await request.Put(`/v1/admin/visitor-areas/${editId.value}`, form.value)
|
||||
ElMessage.success('更新成功')
|
||||
} else {
|
||||
await request.Post('/v1/admin/visitor-areas', form.value)
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
await loadData()
|
||||
} catch (err: any) {
|
||||
ElMessage.error(err.message || '操作失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(row: any) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除区域「${row.name}」?`, '确认')
|
||||
await request.Delete(`/v1/admin/visitor-areas/${row.id}`)
|
||||
ElMessage.success('删除成功')
|
||||
await loadData()
|
||||
} catch {
|
||||
// 取消
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
389
mp-sc-admin/src/pages/WorkflowDesigner.vue
Normal file
389
mp-sc-admin/src/pages/WorkflowDesigner.vue
Normal file
@@ -0,0 +1,389 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-gray-800 mb-4">流程设计器</h1>
|
||||
|
||||
<!-- 流程定义列表 -->
|
||||
<div class="bg-white rounded-xl shadow-sm border overflow-hidden">
|
||||
<div class="px-4 py-3 border-b flex items-center justify-between">
|
||||
<h2 class="text-base font-semibold text-gray-700">流程定义</h2>
|
||||
<el-button type="primary" size="small" @click="openCreateDef">+ 新建流程</el-button>
|
||||
</div>
|
||||
<el-table :data="defList" stripe class="w-full" v-loading="loading">
|
||||
<el-table-column prop="process_name" label="流程名称" min-width="160" />
|
||||
<el-table-column prop="process_code" label="编码" min-width="140" />
|
||||
<el-table-column prop="source" label="来源" min-width="100" />
|
||||
<el-table-column label="状态" width="80">
|
||||
<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 prop="created_at" label="创建时间" min-width="160" />
|
||||
<el-table-column label="操作" width="180">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="primary" link @click="openEditDef(row)">编辑节点</el-button>
|
||||
<el-button size="small" type="warning" link @click="toggleActive(row)">{{ row.is_active ? '禁用' : '启用' }}</el-button>
|
||||
<el-button size="small" type="danger" @click="handleDeleteDef(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<!-- 新建/编辑弹窗 -->
|
||||
<el-dialog v-model="defDialogVisible" :title="editingDef ? '编辑流程定义' : '新建流程定义'" width="420px">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="流程名称">
|
||||
<el-input v-model="defForm.process_name" placeholder="如:访客预约审批" />
|
||||
</el-form-item>
|
||||
<el-form-item label="流程编码">
|
||||
<el-input v-model="defForm.process_code" placeholder="如:visitor_approval" :disabled="!!editingDef" />
|
||||
</el-form-item>
|
||||
<el-form-item label="来源">
|
||||
<el-input v-model="defForm.source" placeholder="如:访客系统" />
|
||||
</el-form-item>
|
||||
<el-form-item label="描述">
|
||||
<el-input v-model="defForm.description" type="textarea" :rows="2" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="defDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="defSaving" @click="handleDefSave">保存基本信息</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 节点编辑器 -->
|
||||
<el-drawer v-model="nodeEditorVisible" :title="'节点编辑 — ' + (currentDef?.process_name || '')" size="800px">
|
||||
<div class="flex gap-4 h-full">
|
||||
<!-- 左侧:设计器 -->
|
||||
<div class="flex-1 overflow-y-auto px-1">
|
||||
<div class="text-xs text-gray-400 mb-3">第一个节点为开始节点(不可删除/移动)</div>
|
||||
|
||||
<div
|
||||
v-for="(node, idx) in nodeList"
|
||||
:key="node._key"
|
||||
class="border rounded-lg mb-2 p-3"
|
||||
:class="{
|
||||
'border-blue-400 bg-blue-50': node._editing,
|
||||
'border-green-200 bg-green-50': idx === 0,
|
||||
}"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<el-tag size="small" :type="nodeTypeTag(node.node_type)">{{ nodeTypeLabel(node.node_type) }}</el-tag>
|
||||
<span class="text-sm font-medium">{{ node.node_name || '未命名' }}</span>
|
||||
<span class="text-xs text-gray-400">#{{ node.node_id }}</span>
|
||||
</div>
|
||||
<div class="flex gap-1">
|
||||
<el-button size="small" circle @click="moveNode(idx, -1)" :disabled="idx === 0 || idx === 1">▲</el-button>
|
||||
<el-button size="small" circle @click="moveNode(idx, 1)" :disabled="idx === nodeList.length - 1 || idx === 0">▼</el-button>
|
||||
<el-button size="small" type="primary" link @click="toggleEditNode(node)">{{ node._editing ? '完成' : '编辑' }}</el-button>
|
||||
<el-button size="small" type="danger" link :disabled="idx === 0" @click="nodeList.splice(idx, 1)">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 展开编辑 -->
|
||||
<div v-if="node._editing" class="border-t pt-2 mt-1 space-y-2">
|
||||
<el-form label-position="top" size="small">
|
||||
<el-row :gutter="8">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="节点ID">
|
||||
<el-input v-model="node.node_id" placeholder="如:Employee" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="节点名称">
|
||||
<el-input v-model="node.node_name" placeholder="如:员工审批" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="审批人变量">
|
||||
<el-select v-model="node.user_var" filterable allow-create default-first-option class="w-full" placeholder="选择或输入变量名" :disabled="idx === 0">
|
||||
<el-option label="$employee_id(公司接待员工)" value="$employee_id" />
|
||||
<el-option label="$dept_approver_ids(部门审批人)" value="$dept_approver_ids" />
|
||||
<el-option label="$vp_ids(分管领导)" value="$vp_ids" />
|
||||
<el-option label="$default_approver_ids(兜底审批人)" value="$default_approver_ids" />
|
||||
<el-option label="$starter(发起人)" value="$starter" />
|
||||
</el-select>
|
||||
<div v-if="idx === 0" class="text-xs text-gray-400 mt-0.5">开始节点固定为发起人</div>
|
||||
</el-form-item>
|
||||
<el-row :gutter="8">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="会签(全部通过)">
|
||||
<el-switch v-model="node.is_cosigned" :active-value="1" :inactive-value="0" />
|
||||
<div class="text-xs text-gray-400 mt-0.5">开启后需所有审批人通过才流转</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-button type="primary" plain size="small" class="w-full mt-2" @click="addNode">+ 添加审批节点</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:JSON 预览 -->
|
||||
<div class="w-80 border-l pl-4 flex flex-col h-full">
|
||||
<div class="text-sm font-medium text-gray-700 mb-2 shrink-0">JSON 预览</div>
|
||||
<div class="flex-1 overflow-y-auto bg-gray-50 p-2 rounded text-xs">
|
||||
<JsonViewer :value="parsedJSON" :expand-depth="3" copyable boxed sort />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="nodeEditorVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="nodeSaving" @click="handleNodeSave">保存节点配置</el-button>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import request from '@/utils/request'
|
||||
import { JsonViewer } from 'vue3-json-viewer'
|
||||
import 'vue3-json-viewer/dist/vue3-json-viewer.css'
|
||||
|
||||
const loading = ref(false)
|
||||
const defList = ref<any[]>([])
|
||||
|
||||
// ===== 流程定义 CRUD =====
|
||||
const defDialogVisible = ref(false)
|
||||
const editingDef = ref<any>(null)
|
||||
const defSaving = ref(false)
|
||||
const defForm = ref({ process_name: '', process_code: '', source: '', description: '' })
|
||||
|
||||
async function loadDefs() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await request.Get('/v1/admin/workflow/definitions') as any
|
||||
const raw = res?.data
|
||||
if (Array.isArray(raw)) {
|
||||
defList.value = raw
|
||||
} else if (raw && typeof raw === 'object' && Array.isArray(raw.list)) {
|
||||
// 可能是分页格式 { list: [...], total }
|
||||
defList.value = raw.list
|
||||
} else if (raw && typeof raw === 'object' && Array.isArray(raw.data)) {
|
||||
// 双层嵌套 { code, msg, data: { data: [...] } }
|
||||
defList.value = raw.data
|
||||
} else {
|
||||
defList.value = []
|
||||
console.warn('[WF] unexpected format:', raw)
|
||||
}
|
||||
} catch { defList.value = [] }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
function openCreateDef() {
|
||||
editingDef.value = null
|
||||
defForm.value = { process_name: '', process_code: '', source: '', description: '' }
|
||||
defDialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEditDef(row: any) {
|
||||
currentDef.value = row
|
||||
// 解析节点
|
||||
if (row.nodes_json) {
|
||||
try {
|
||||
nodeList.value = JSON.parse(row.nodes_json).map((n: any, i: number) => ({
|
||||
...n,
|
||||
_key: Date.now() + i,
|
||||
_editing: false,
|
||||
_isStart: n.node_type === 0,
|
||||
user_var: (n.user_ids || []).join(', '),
|
||||
}))
|
||||
} catch { nodeList.value = [] }
|
||||
} else {
|
||||
nodeList.value = []
|
||||
}
|
||||
nodeEditorVisible.value = true
|
||||
}
|
||||
|
||||
async function handleDefSave() {
|
||||
if (!defForm.value.process_name || !defForm.value.process_code) {
|
||||
ElMessage.warning('请填写流程名称和编码'); return
|
||||
}
|
||||
defSaving.value = true
|
||||
try {
|
||||
if (editingDef.value) {
|
||||
await request.Put(`/v1/workflow/definitions/${editingDef.value.id}`, defForm.value)
|
||||
} else {
|
||||
await request.Post('/v1/admin/workflow/definitions', defForm.value)
|
||||
}
|
||||
ElMessage.success('保存成功')
|
||||
defDialogVisible.value = false
|
||||
await loadDefs()
|
||||
} catch { /* */ }
|
||||
finally { defSaving.value = false }
|
||||
}
|
||||
|
||||
async function toggleActive(row: any) {
|
||||
await request.Put(`/v1/workflow/definitions/${row.id}`, { is_active: !row.is_active })
|
||||
ElMessage.success(row.is_active ? '已禁用' : '已启用')
|
||||
await loadDefs()
|
||||
}
|
||||
|
||||
async function handleDeleteDef(row: any) {
|
||||
await ElMessageBox.confirm(`确定删除「${row.process_name}」?`)
|
||||
await request.Delete(`/v1/workflow/definitions/${row.id}`)
|
||||
ElMessage.success('已删除')
|
||||
await loadDefs()
|
||||
}
|
||||
|
||||
// ===== 节点编辑器 =====
|
||||
const nodeEditorVisible = ref(false)
|
||||
const currentDef = ref<any>(null)
|
||||
const nodeSaving = ref(false)
|
||||
let keyCounter = 0
|
||||
|
||||
const nodeList = ref<any[]>([])
|
||||
|
||||
function genKey() { return Date.now() + (keyCounter++) }
|
||||
|
||||
function addNode() {
|
||||
// 插入到第一个节点之后
|
||||
const insertIdx = 1
|
||||
const newNode = {
|
||||
node_id: 'Node' + (nodeList.value.length + 1),
|
||||
node_name: '审批节点',
|
||||
node_type: 1,
|
||||
prev_node_ids: nodeList.value.length > 0 ? [nodeList.value[nodeList.value.length - 1].node_id] : [],
|
||||
user_ids: ['$starter'],
|
||||
_key: genKey(),
|
||||
_editing: true,
|
||||
_isStart: false,
|
||||
user_var: '$starter',
|
||||
}
|
||||
nodeList.value.splice(insertIdx, 0, newNode)
|
||||
rebuildPrevNodeIDs()
|
||||
}
|
||||
|
||||
function toggleEditNode(node: any) {
|
||||
node._editing = !node._editing
|
||||
}
|
||||
|
||||
function moveNode(idx: number, dir: number) {
|
||||
const target = idx + dir
|
||||
if (target < 0 || target >= nodeList.value.length) return
|
||||
const tmp = nodeList.value[idx]
|
||||
nodeList.value[idx] = nodeList.value[target]
|
||||
nodeList.value[target] = tmp
|
||||
rebuildPrevNodeIDs()
|
||||
}
|
||||
|
||||
let dragIdx = -1
|
||||
function onDragStart(e: DragEvent, idx: number) { dragIdx = idx }
|
||||
function onDrop(e: DragEvent, idx: number) {
|
||||
if (dragIdx >= 0 && dragIdx !== idx) {
|
||||
const tmp = nodeList.value[dragIdx]
|
||||
nodeList.value.splice(dragIdx, 1)
|
||||
nodeList.value.splice(idx, 0, tmp)
|
||||
rebuildPrevNodeIDs()
|
||||
}
|
||||
dragIdx = -1
|
||||
}
|
||||
|
||||
function rebuildPrevNodeIDs() {
|
||||
nodeList.value.forEach((n, i) => {
|
||||
if (i === 0) {
|
||||
n.node_type = 0
|
||||
n._isStart = true
|
||||
n.prev_node_ids = []
|
||||
} else {
|
||||
if (n.node_type === 0) n.node_type = 1
|
||||
n._isStart = false
|
||||
n.prev_node_ids = [nodeList.value[i - 1].node_id]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const previewJSON = computed(() => {
|
||||
// 从 nodeList 构建预览,过滤内部属性
|
||||
const nodes = nodeList.value.map((n: any) => {
|
||||
const { _key, _editing, _isStart, user_var, ...rest } = n
|
||||
// 同步 user_var → user_ids
|
||||
if (user_var) {
|
||||
rest.user_ids = user_var.split(',').map((s: string) => s.trim()).filter(Boolean)
|
||||
}
|
||||
return rest
|
||||
})
|
||||
return JSON.stringify(nodes, null, 2)
|
||||
})
|
||||
|
||||
// 供 JsonViewer 使用的对象格式
|
||||
const parsedJSON = computed(() => {
|
||||
try { return JSON.parse(previewJSON.value) } catch { return {} }
|
||||
})
|
||||
|
||||
async function handleNodeSave() {
|
||||
if (!currentDef.value) return
|
||||
|
||||
// 1. 同步所有节点的 user_var → user_ids
|
||||
nodeList.value.forEach((n: any) => {
|
||||
if (n.user_var) {
|
||||
n.user_ids = n.user_var.split(',').map((s: string) => s.trim()).filter(Boolean)
|
||||
}
|
||||
})
|
||||
|
||||
// 2. 固定第一个节点为开始节点
|
||||
if (nodeList.value.length > 0) {
|
||||
const first = nodeList.value[0]
|
||||
first.node_id = 'Start'
|
||||
first.node_type = 0
|
||||
first.user_ids = ['$starter']
|
||||
first.user_var = '$starter'
|
||||
first._isStart = true
|
||||
first.is_cosigned = 0
|
||||
first.prev_node_ids = []
|
||||
}
|
||||
|
||||
// 3. 重建后续节点的 node_id 和 prev_node_ids(从第2个开始)
|
||||
for (let i = 1; i < nodeList.value.length; i++) {
|
||||
const n = nodeList.value[i]
|
||||
n.node_id = 'Node' + (i + 1)
|
||||
n.node_type = 1
|
||||
n._isStart = false
|
||||
n.prev_node_ids = [nodeList.value[i - 1].node_id]
|
||||
}
|
||||
|
||||
// 4. 构建最终 JSON(去掉内部属性)
|
||||
const finalNodes = nodeList.value.map((n: any) => {
|
||||
const { _key, _editing, _isStart, user_var, ...rest } = n
|
||||
return rest
|
||||
})
|
||||
// 5. 添加结束节点
|
||||
finalNodes.push({
|
||||
node_id: 'End',
|
||||
node_name: '审批完成',
|
||||
node_type: 3,
|
||||
prev_node_ids: [nodeList.value[nodeList.value.length - 1]?.node_id || 'Node2'],
|
||||
})
|
||||
|
||||
nodeSaving.value = true
|
||||
try {
|
||||
await request.Put(`/v1/admin/workflow/definitions/${currentDef.value.id}`, {
|
||||
nodes_json: JSON.stringify(finalNodes),
|
||||
})
|
||||
ElMessage.success('节点配置已保存')
|
||||
nodeEditorVisible.value = false
|
||||
await loadDefs()
|
||||
} catch { /* */ }
|
||||
finally { nodeSaving.value = false }
|
||||
}
|
||||
|
||||
function nodeTypeLabel(t: number) {
|
||||
return ['开始', '审批', '网关', '结束'][t] || '未知'
|
||||
}
|
||||
function nodeTypeTag(t: number) {
|
||||
return ['success', 'primary', 'warning', 'info'][t] || 'info'
|
||||
}
|
||||
|
||||
onMounted(loadDefs)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
pre { white-space: pre-wrap; word-break: break-all; }
|
||||
[draggable] { cursor: grab; }
|
||||
[draggable]:active { cursor: grabbing; opacity: 0.8; }
|
||||
.el-drawer__body { padding: 16px; overflow: hidden; }
|
||||
</style>
|
||||
35
mp-sc-admin/src/router/index.ts
Normal file
35
mp-sc-admin/src/router/index.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import {createRouter, createWebHashHistory} from 'vue-router'
|
||||
import Login from '@/pages/Login.vue'
|
||||
import Layout from '@/pages/Layout.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes: [
|
||||
{ path: '/login', component: Login },
|
||||
{
|
||||
path: '/', component: Layout, redirect: '/dashboard',
|
||||
children: [
|
||||
{ path: 'dashboard', component: () => import('@/pages/Dashboard.vue'), meta: { title: '概览' } },
|
||||
{ path: 'users', component: () => import('@/pages/Users.vue'), meta: { title: '用户管理' } },
|
||||
{ path: 'approvals', component: () => import('@/pages/Approvals.vue'), meta: { title: '认证审核' } },
|
||||
{ path: 'departments', component: () => import('@/pages/Departments.vue'), meta: { title: '组织架构' } },
|
||||
{ path: 'banners', component: () => import('@/pages/Banners.vue'), meta: { title: 'Banner管理' } },
|
||||
{ path: 'notices', component: () => import('@/pages/Notices.vue'), meta: { title: '通知管理' } },
|
||||
{ path: 'materials', component: () => import('@/pages/Materials.vue'), meta: { title: '素材上传' } },
|
||||
{ path: 'config', component: () => import('@/pages/ApprovalConfig.vue'), meta: { title: '审批配置' } },
|
||||
{ path: 'visit-types', component: () => import('@/pages/VisitTypes.vue'), meta: { title: '来访目的' } },
|
||||
{ path: 'visitor-areas', component: () => import('@/pages/VisitorAreas.vue'), meta: { title: '到访区域' } },
|
||||
{ path: 'appointments', component: () => import('@/pages/Appointments.vue'), meta: { title: '预约管理' } },
|
||||
{ path: 'pending-employees', component: () => import('@/pages/PendingEmployees.vue'), meta: { title: '通讯录导入' } },
|
||||
{ path: 'workflow-designer', component: () => import('@/pages/WorkflowDesigner.vue'), meta: { title: '流程设计器' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
router.beforeEach((to) => {
|
||||
const token = localStorage.getItem('admin_token')
|
||||
if (to.path !== '/login' && !token) return '/login'
|
||||
})
|
||||
|
||||
export default router
|
||||
9
mp-sc-admin/src/style.css
Normal file
9
mp-sc-admin/src/style.css
Normal file
@@ -0,0 +1,9 @@
|
||||
@import "tailwindcss";
|
||||
@import "element-plus/dist/index.css";
|
||||
|
||||
/* 默认边框颜色:避免 Tailwind base reset 导致未显式设置颜色的边框显示为黑色 */
|
||||
*,
|
||||
::before,
|
||||
::after {
|
||||
border-color: #e5e7eb;
|
||||
}
|
||||
44
mp-sc-admin/src/utils/request.ts
Normal file
44
mp-sc-admin/src/utils/request.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { createAlova } from 'alova'
|
||||
import VueHook from 'alova/vue'
|
||||
import adapterFetch from 'alova/fetch'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
export const request = createAlova({
|
||||
baseURL: import.meta.env.VITE_API_BASEURL || '',
|
||||
// 设置为null即可全局关闭全部请求缓存
|
||||
cacheFor: null,
|
||||
statesHook: VueHook,
|
||||
requestAdapter: adapterFetch(),
|
||||
beforeRequest(method) {
|
||||
const token = localStorage.getItem('admin_token')
|
||||
if (token) {
|
||||
method.config.headers = { ...method.config.headers, Authorization: `Bearer ${token}` }
|
||||
}
|
||||
},
|
||||
responded: {
|
||||
onSuccess: async (response) => {
|
||||
const data = await response.json()
|
||||
if (data.code !== 200) {
|
||||
if (response.status === 401 || data.code === 401) {
|
||||
localStorage.removeItem('admin_token')
|
||||
window.location.href = '/login'
|
||||
throw new Error('redirect')
|
||||
}
|
||||
ElMessage.error(data.msg)
|
||||
throw new Error(data.msg)
|
||||
}
|
||||
return data
|
||||
},
|
||||
onError: (error) => {
|
||||
if (error?.message === 'redirect') return
|
||||
if (error?.response?.status === 401) {
|
||||
localStorage.removeItem('admin_token')
|
||||
window.location.href = '/login'
|
||||
return
|
||||
}
|
||||
ElMessage.error(error.message || '请求失败')
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export default request
|
||||
6
mp-sc-admin/src/vite-env.d.ts
vendored
Normal file
6
mp-sc-admin/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
/// <reference types="vite/client" />
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
Reference in New Issue
Block a user