This commit is contained in:
huangjin
2026-06-29 17:34:59 +08:00
commit fe3ad20fe2
271 changed files with 51767 additions and 0 deletions

3
mp-sc-admin/src/App.vue Normal file
View File

@@ -0,0 +1,3 @@
<template>
<router-view />
</template>

View 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
View 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')

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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

View File

@@ -0,0 +1,9 @@
@import "tailwindcss";
@import "element-plus/dist/index.css";
/* 默认边框颜色:避免 Tailwind base reset 导致未显式设置颜色的边框显示为黑色 */
*,
::before,
::after {
border-color: #e5e7eb;
}

View 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
View File

@@ -0,0 +1,6 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}