新增用户管理模块
This commit is contained in:
@@ -55,7 +55,7 @@
|
||||
type="button"
|
||||
class="icon-button"
|
||||
aria-label="通知中心"
|
||||
@click="handlePrototypeNotice"
|
||||
@click="router.push({ name: 'NotificationCenter' })"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path
|
||||
@@ -116,7 +116,7 @@ const menuItems = [
|
||||
label: '上级来访/督导',
|
||||
},
|
||||
{
|
||||
label: '红黄牌处罚',
|
||||
label: '红黄牌处置',
|
||||
},
|
||||
{
|
||||
label: '监控视频',
|
||||
@@ -125,7 +125,7 @@ const menuItems = [
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '招投标信息管理',
|
||||
label: '公共资源平台',
|
||||
routeName: 'TenderProjectManagement',
|
||||
activeNames: [
|
||||
'TenderProjectManagement',
|
||||
@@ -135,26 +135,32 @@ const menuItems = [
|
||||
],
|
||||
children: [
|
||||
{
|
||||
label: '招标项目管理',
|
||||
label: '招标公告管理',
|
||||
note: '管理招标金额、发布日期与详情链接',
|
||||
routeName: 'TenderProjectManagement',
|
||||
},
|
||||
{
|
||||
label: '中标项目管理',
|
||||
label: '中标公告管理',
|
||||
note: '管理中标金额与中标单位信息',
|
||||
routeName: 'AwardProjectManagement',
|
||||
},
|
||||
{
|
||||
label: '板块管理',
|
||||
routeName: 'SectorManagement',
|
||||
},
|
||||
{
|
||||
label: '任务配置',
|
||||
routeName: 'TenderTaskConfig',
|
||||
},
|
||||
],
|
||||
},
|
||||
{ label: '系统管理' },
|
||||
{
|
||||
label: '系统管理',
|
||||
routeName: 'UserManagement',
|
||||
activeNames: ['UserManagement', 'UserManagementEdit'],
|
||||
children: [
|
||||
{
|
||||
label: '用户管理',
|
||||
routeName: 'UserManagement',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const handlePrototypeNotice = () => {
|
||||
|
||||
@@ -5,12 +5,14 @@ import homeRoutes from './modules/home';
|
||||
import userRoutes from './modules/user';
|
||||
import supervisionRoutes from './modules/supervision';
|
||||
import tenderingRoutes from './modules/tendering';
|
||||
import notificationRoutes from './modules/notification';
|
||||
|
||||
const routes = [
|
||||
...homeRoutes,
|
||||
...userRoutes,
|
||||
...supervisionRoutes,
|
||||
...tenderingRoutes,
|
||||
...notificationRoutes,
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
|
||||
10
src/router/modules/notification.js
Normal file
10
src/router/modules/notification.js
Normal file
@@ -0,0 +1,10 @@
|
||||
export default [
|
||||
{
|
||||
path: '/notifications',
|
||||
name: 'NotificationCenter',
|
||||
component: () => import('@/views/notification/index.vue'),
|
||||
meta: {
|
||||
title: '通知中心',
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -4,7 +4,7 @@ export default [
|
||||
name: 'TenderProjectManagement',
|
||||
component: () => import('@/views/tendering/tender/index.vue'),
|
||||
meta: {
|
||||
title: '招标项目管理',
|
||||
title: '招标公告管理',
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -12,15 +12,7 @@ export default [
|
||||
name: 'AwardProjectManagement',
|
||||
component: () => import('@/views/tendering/award/index.vue'),
|
||||
meta: {
|
||||
title: '中标项目管理',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/tendering/sector-management',
|
||||
name: 'SectorManagement',
|
||||
component: () => import('@/views/tendering/sector/index.vue'),
|
||||
meta: {
|
||||
title: '板块管理',
|
||||
title: '中标公告管理',
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
/**
|
||||
* 用户相关路由配置
|
||||
* 用户管理路由配置
|
||||
*/
|
||||
|
||||
export default [
|
||||
{
|
||||
path: '/user',
|
||||
name: 'User',
|
||||
path: '/system/user-management',
|
||||
alias: '/user',
|
||||
name: 'UserManagement',
|
||||
component: () => import('@/views/user/index.vue'),
|
||||
meta: {
|
||||
title: '用户中心',
|
||||
title: '用户管理',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/system/user-management/:id/edit',
|
||||
name: 'UserManagementEdit',
|
||||
component: () => import('@/views/user/edit.vue'),
|
||||
meta: {
|
||||
title: '用户编辑',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
260
src/views/notification/index.vue
Normal file
260
src/views/notification/index.vue
Normal file
@@ -0,0 +1,260 @@
|
||||
<template>
|
||||
<section class="notification-page">
|
||||
<div class="panel-card">
|
||||
<div class="filter-bar">
|
||||
<el-input
|
||||
v-model="filters.messageType"
|
||||
clearable
|
||||
placeholder="搜索消息类型"
|
||||
class="filter-field"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
v-if="filteredNotificationRows.length"
|
||||
:data="filteredNotificationRows"
|
||||
class="notification-table"
|
||||
>
|
||||
<el-table-column label="序号" width="80">
|
||||
<template #default="{ $index }">
|
||||
{{ $index + 1 }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="通知时间" prop="notificationTime" min-width="176" />
|
||||
<el-table-column label="发布人" prop="publisher" min-width="120" />
|
||||
<el-table-column label="消息类型" prop="messageType" min-width="140" />
|
||||
<el-table-column label="通知内容" min-width="360" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<div class="message-content">
|
||||
<span>{{ row.messageContent }}</span>
|
||||
<button type="button" class="message-content__link" @click="goToPlanModule()">
|
||||
去查看
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="消息状态" width="120">
|
||||
<template #default="{ row }">
|
||||
<button
|
||||
type="button"
|
||||
class="status-chip"
|
||||
:class="isRead(row.id) ? 'status-chip--read' : 'status-chip--unread'"
|
||||
@click="toggleReadStatus(row.id)"
|
||||
>
|
||||
{{ isRead(row.id) ? '已读' : '未读' }}
|
||||
</button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-empty v-else description="暂无通知" />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import storage from '@/utils/storage';
|
||||
|
||||
const PLAN_STORAGE_KEY = 'admin-pc:supervision-plans';
|
||||
const NOTICE_STATUS_STORAGE_KEY = 'admin-pc:notification-read-status';
|
||||
const MESSAGE_TYPE = '监管计划通知';
|
||||
const router = useRouter();
|
||||
const filters = reactive({
|
||||
messageType: '',
|
||||
});
|
||||
|
||||
const fallbackPlans = [
|
||||
{
|
||||
id: 'supervision_plan_1',
|
||||
startTime: '2026-04-01',
|
||||
creator: '王敏',
|
||||
planFileName: '一季度重点项目监管计划.pdf',
|
||||
},
|
||||
{
|
||||
id: 'supervision_plan_2',
|
||||
startTime: '2026-04-18',
|
||||
creator: '李峰',
|
||||
planFileName: '四月安全巡检监管安排.pdf',
|
||||
},
|
||||
];
|
||||
|
||||
const readStatusMap = ref(loadReadStatusMap());
|
||||
|
||||
const notificationRows = computed(() => {
|
||||
const storedPlans = storage.getLocalStorage(PLAN_STORAGE_KEY);
|
||||
const plans = Array.isArray(storedPlans) && storedPlans.length ? storedPlans : fallbackPlans;
|
||||
const today = formatCurrentDate();
|
||||
|
||||
return plans
|
||||
.filter((item) => item?.startTime && item.startTime <= today && item?.planFileName)
|
||||
.map((item) => {
|
||||
const id = `supervision-plan-notice-${item.id}`;
|
||||
|
||||
return {
|
||||
id,
|
||||
notificationTime: buildNotificationTime(item.startTime),
|
||||
publisher: '系统',
|
||||
messageType: MESSAGE_TYPE,
|
||||
messageContent: `${stripPdfExtension(item.planFileName)}已抵达预设起始时间`,
|
||||
};
|
||||
})
|
||||
.sort((first, second) => second.notificationTime.localeCompare(first.notificationTime));
|
||||
});
|
||||
|
||||
const filteredNotificationRows = computed(() => {
|
||||
const keyword = filters.messageType.trim();
|
||||
|
||||
if (!keyword) {
|
||||
return notificationRows.value;
|
||||
}
|
||||
|
||||
return notificationRows.value.filter((item) => item.messageType.includes(keyword));
|
||||
});
|
||||
|
||||
function loadReadStatusMap() {
|
||||
const storedValue = storage.getLocalStorage(NOTICE_STATUS_STORAGE_KEY);
|
||||
return storedValue && typeof storedValue === 'object' ? storedValue : {};
|
||||
}
|
||||
|
||||
function isRead(notificationId) {
|
||||
return Boolean(readStatusMap.value[notificationId]);
|
||||
}
|
||||
|
||||
function toggleReadStatus(notificationId) {
|
||||
readStatusMap.value = {
|
||||
...readStatusMap.value,
|
||||
[notificationId]: !readStatusMap.value[notificationId],
|
||||
};
|
||||
storage.setLocalStorage(NOTICE_STATUS_STORAGE_KEY, readStatusMap.value);
|
||||
}
|
||||
|
||||
function goToPlanModule() {
|
||||
router.push({ name: 'SupervisionPlanManagement' });
|
||||
}
|
||||
|
||||
function stripPdfExtension(fileName) {
|
||||
return String(fileName || '').replace(/\.pdf$/i, '');
|
||||
}
|
||||
|
||||
function buildNotificationTime(dateText) {
|
||||
return `${dateText} 09:00:00`;
|
||||
}
|
||||
|
||||
function formatCurrentDate() {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(now.getDate()).padStart(2, '0');
|
||||
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.notification-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.panel-card {
|
||||
padding: 20px;
|
||||
border: 1px solid rgba(112, 144, 196, 0.12);
|
||||
border-radius: 2px;
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.92),
|
||||
0 22px 40px rgba(26, 54, 93, 0.06);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.filter-field {
|
||||
width: 260px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.notification-table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:deep(.notification-table .el-table__inner-wrapper),
|
||||
:deep(.notification-table .el-table__cell),
|
||||
:deep(.notification-table::before) {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
:deep(.notification-table .el-table__inner-wrapper) {
|
||||
border: 1px solid #e4eaf3;
|
||||
border-radius: 2px;
|
||||
box-shadow: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:deep(.notification-table th.el-table__cell) {
|
||||
background: #f6f9fc;
|
||||
color: #24364d;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
:deep(.notification-table td.el-table__cell) {
|
||||
color: #1f2f46;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.message-content__link {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #1d4ed8;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.message-content__link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.status-chip {
|
||||
min-width: 64px;
|
||||
padding: 6px 12px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.status-chip--read {
|
||||
background: rgba(15, 118, 110, 0.12);
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.status-chip--unread {
|
||||
background: rgba(29, 78, 216, 0.12);
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.panel-card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.filter-field {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -11,6 +11,15 @@
|
||||
end-placeholder="结束时间"
|
||||
class="filter-field filter-field--range"
|
||||
/>
|
||||
<el-select
|
||||
v-model="filters.notifyStatus"
|
||||
clearable
|
||||
placeholder="通知状态"
|
||||
class="filter-field"
|
||||
>
|
||||
<el-option label="未开始" value="未开始" />
|
||||
<el-option label="已通知" value="已通知" />
|
||||
</el-select>
|
||||
<el-button @click="resetFilters">重置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -57,14 +66,30 @@
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="通知对象" min-width="220" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span>{{ formatNotifyReceiverNames(row.notifyReceiverIds) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="通知状态" width="120">
|
||||
<template #default="{ row }">
|
||||
<span
|
||||
class="notify-status-badge"
|
||||
:class="
|
||||
getNotifyStatus(row.startTime) === '已通知'
|
||||
? 'notify-status-badge--done'
|
||||
: 'notify-status-badge--pending'
|
||||
"
|
||||
>
|
||||
{{ getNotifyStatus(row.startTime) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建人" prop="creator" min-width="120" />
|
||||
<el-table-column label="更新时间" prop="updatedAt" min-width="176" />
|
||||
<el-table-column label="操作" fixed="right" width="180">
|
||||
<el-table-column label="操作" fixed="right" width="130">
|
||||
<template #default="{ row }">
|
||||
<div class="row-actions">
|
||||
<button type="button" class="row-action" @click="openDetailDialog(row)">
|
||||
查看
|
||||
</button>
|
||||
<button type="button" class="row-action" @click="openEditDialog(row)">
|
||||
编辑
|
||||
</button>
|
||||
@@ -120,6 +145,18 @@
|
||||
<div class="detail-item__label">更新时间</div>
|
||||
<div class="detail-item__value">{{ detailRecord.updatedAt || '-' }}</div>
|
||||
</div>
|
||||
<div class="detail-item detail-item--full">
|
||||
<div class="detail-item__label">通知对象</div>
|
||||
<div class="detail-item__value">
|
||||
{{ formatNotifyReceiverNames(detailRecord.notifyReceiverIds) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="detail-item__label">通知状态</div>
|
||||
<div class="detail-item__value">
|
||||
{{ getNotifyStatus(detailRecord.startTime) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-item detail-item--full">
|
||||
<div class="detail-item__label">监管计划内容</div>
|
||||
<div class="detail-item__value detail-item__value--file">
|
||||
@@ -173,28 +210,37 @@
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="通知对象" prop="notifyReceiverIds">
|
||||
<el-select
|
||||
v-model="formModel.notifyReceiverIds"
|
||||
multiple
|
||||
collapse-tags
|
||||
collapse-tags-tooltip
|
||||
filterable
|
||||
clearable
|
||||
placeholder="请选择通知对象"
|
||||
class="select-field"
|
||||
>
|
||||
<el-option
|
||||
v-for="user in notifyUserOptions"
|
||||
:key="user.value"
|
||||
:label="user.label"
|
||||
:value="user.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="监管计划内容" prop="planFileName" class="form-item--full">
|
||||
<div class="upload-panel">
|
||||
<div class="upload-simple">
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
accept=".pdf,application/pdf"
|
||||
class="upload-panel__input"
|
||||
class="upload-native-input"
|
||||
@change="handleFileChange"
|
||||
/>
|
||||
|
||||
<div class="upload-panel__content">
|
||||
<div class="upload-panel__copy">
|
||||
<strong class="upload-panel__title">上传 PDF 文件</strong>
|
||||
<span class="upload-panel__hint">
|
||||
仅支持 PDF,文件会以原型数据形式保存在本地浏览器。
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="upload-panel__actions">
|
||||
<el-button type="primary" plain @click="triggerFileSelect">
|
||||
选择文件
|
||||
</el-button>
|
||||
<div v-if="formModel.planFileName" class="upload-panel__actions">
|
||||
<el-button
|
||||
v-if="formModel.planFileUrl"
|
||||
@click="openCurrentFile"
|
||||
@@ -210,13 +256,17 @@
|
||||
移除
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="formModel.planFileName" class="upload-file">
|
||||
<button type="button" class="upload-file__main" @click="openCurrentFile">
|
||||
<span class="upload-file__name">{{ formModel.planFileName }}</span>
|
||||
<span v-if="formModel.planFileSizeText" class="upload-file__meta">
|
||||
{{ formModel.planFileSizeText }}
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" class="upload-file__delete" @click="clearSelectedFile">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
@@ -235,16 +285,27 @@
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import { Delete } from '@element-plus/icons-vue';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import storage from '@/utils/storage';
|
||||
|
||||
const STORAGE_KEY = 'admin-pc:supervision-plans';
|
||||
const notifyUserOptions = [
|
||||
{ label: '张敏', value: 'zhangmin' },
|
||||
{ label: '李峰', value: 'lifeng' },
|
||||
{ label: '陈宇', value: 'chenyu' },
|
||||
{ label: '赵晴', value: 'zhaoqing' },
|
||||
{ label: '顾晨', value: 'guchen' },
|
||||
{ label: '刘颖', value: 'liuying' },
|
||||
];
|
||||
const notifyUserNameMap = new Map(notifyUserOptions.map((item) => [item.value, item.label]));
|
||||
|
||||
const initialRecords = [
|
||||
{
|
||||
id: 'supervision_plan_1',
|
||||
startTime: '2026-04-01',
|
||||
endTime: '2026-04-15',
|
||||
notifyReceiverIds: ['zhangmin', 'chenyu'],
|
||||
creator: '王敏',
|
||||
updatedAt: '2026-04-01 09:20:00',
|
||||
planFileName: '一季度重点项目监管计划.pdf',
|
||||
@@ -256,6 +317,7 @@ const initialRecords = [
|
||||
id: 'supervision_plan_2',
|
||||
startTime: '2026-04-18',
|
||||
endTime: '2026-04-30',
|
||||
notifyReceiverIds: ['lifeng', 'zhaoqing'],
|
||||
creator: '李峰',
|
||||
updatedAt: '2026-04-18 14:35:00',
|
||||
planFileName: '四月安全巡检监管安排.pdf',
|
||||
@@ -267,6 +329,7 @@ const initialRecords = [
|
||||
id: 'supervision_plan_3',
|
||||
startTime: '2026-05-01',
|
||||
endTime: '2026-05-20',
|
||||
notifyReceiverIds: ['chenyu', 'guchen'],
|
||||
creator: '陈宇',
|
||||
updatedAt: '2026-04-26 18:10:00',
|
||||
planFileName: '五月监管执行计划.pdf',
|
||||
@@ -278,6 +341,7 @@ const initialRecords = [
|
||||
id: 'supervision_plan_4',
|
||||
startTime: '2026-05-08',
|
||||
endTime: '2026-05-22',
|
||||
notifyReceiverIds: ['zhaoqing', 'liuying'],
|
||||
creator: '赵晴',
|
||||
updatedAt: '2026-04-27 08:45:00',
|
||||
planFileName: '重点项目节点复核监管计划.pdf',
|
||||
@@ -289,6 +353,7 @@ const initialRecords = [
|
||||
id: 'supervision_plan_5',
|
||||
startTime: '2026-05-15',
|
||||
endTime: '2026-05-31',
|
||||
notifyReceiverIds: ['guchen', 'zhangmin'],
|
||||
creator: '顾晨',
|
||||
updatedAt: '2026-04-27 09:15:00',
|
||||
planFileName: '五月下旬施工安全监管清单.pdf',
|
||||
@@ -300,6 +365,7 @@ const initialRecords = [
|
||||
id: 'supervision_plan_6',
|
||||
startTime: '2026-06-01',
|
||||
endTime: '2026-06-12',
|
||||
notifyReceiverIds: ['liuying', 'lifeng'],
|
||||
creator: '刘颖',
|
||||
updatedAt: '2026-04-27 09:40:00',
|
||||
planFileName: '六月首轮质量检查监管安排.pdf',
|
||||
@@ -311,6 +377,7 @@ const initialRecords = [
|
||||
id: 'supervision_plan_7',
|
||||
startTime: '2026-06-10',
|
||||
endTime: '2026-06-25',
|
||||
notifyReceiverIds: ['zhangmin', 'guchen'],
|
||||
creator: '王敏',
|
||||
updatedAt: '2026-04-27 10:05:00',
|
||||
planFileName: '半年度现场监管抽查方案.pdf',
|
||||
@@ -322,6 +389,7 @@ const initialRecords = [
|
||||
id: 'supervision_plan_8',
|
||||
startTime: '2026-06-18',
|
||||
endTime: '2026-07-02',
|
||||
notifyReceiverIds: ['lifeng', 'liuying'],
|
||||
creator: '李峰',
|
||||
updatedAt: '2026-04-27 10:20:00',
|
||||
planFileName: '汛期重点工程风险监管预案.pdf',
|
||||
@@ -333,6 +401,7 @@ const initialRecords = [
|
||||
id: 'supervision_plan_9',
|
||||
startTime: '2026-07-01',
|
||||
endTime: '2026-07-15',
|
||||
notifyReceiverIds: ['chenyu', 'zhaoqing'],
|
||||
creator: '陈宇',
|
||||
updatedAt: '2026-04-27 10:55:00',
|
||||
planFileName: '七月月度监管执行计划.pdf',
|
||||
@@ -344,6 +413,7 @@ const initialRecords = [
|
||||
id: 'supervision_plan_10',
|
||||
startTime: '2026-07-12',
|
||||
endTime: '2026-07-30',
|
||||
notifyReceiverIds: ['zhaoqing', 'guchen'],
|
||||
creator: '赵晴',
|
||||
updatedAt: '2026-04-27 11:18:00',
|
||||
planFileName: '重点合同履约监管专项方案.pdf',
|
||||
@@ -355,6 +425,7 @@ const initialRecords = [
|
||||
id: 'supervision_plan_11',
|
||||
startTime: '2026-08-01',
|
||||
endTime: '2026-08-18',
|
||||
notifyReceiverIds: ['guchen', 'liuying'],
|
||||
creator: '顾晨',
|
||||
updatedAt: '2026-04-27 11:35:00',
|
||||
planFileName: '八月工程进度监管计划.pdf',
|
||||
@@ -366,6 +437,7 @@ const initialRecords = [
|
||||
id: 'supervision_plan_12',
|
||||
startTime: '2026-08-15',
|
||||
endTime: '2026-08-31',
|
||||
notifyReceiverIds: ['liuying', 'zhangmin'],
|
||||
creator: '刘颖',
|
||||
updatedAt: '2026-04-27 11:52:00',
|
||||
planFileName: '三季度开工项目监管部署.pdf',
|
||||
@@ -387,11 +459,13 @@ const detailRecord = ref(null);
|
||||
|
||||
const filters = reactive({
|
||||
dateRange: [],
|
||||
notifyStatus: '',
|
||||
});
|
||||
|
||||
const createEmptyForm = () => ({
|
||||
startTime: '',
|
||||
endTime: '',
|
||||
notifyReceiverIds: [],
|
||||
creator: '管理员',
|
||||
updatedAt: '',
|
||||
planFileName: '',
|
||||
@@ -399,17 +473,32 @@ const createEmptyForm = () => ({
|
||||
planFileSizeText: '',
|
||||
});
|
||||
|
||||
const normalizeRecord = (record) => ({
|
||||
const normalizeNotifyReceiverIds = (notifyReceiverIds) => {
|
||||
if (!Array.isArray(notifyReceiverIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return notifyReceiverIds.filter((item, index, list) => {
|
||||
return notifyUserNameMap.has(item) && list.indexOf(item) === index;
|
||||
});
|
||||
};
|
||||
|
||||
const normalizeRecord = (record, fallbackRecord = {}) => ({
|
||||
...createEmptyForm(),
|
||||
...fallbackRecord,
|
||||
...record,
|
||||
creator: record?.creator || '管理员',
|
||||
updatedAt: record?.updatedAt || buildFallbackUpdateTime(record),
|
||||
notifyReceiverIds: normalizeNotifyReceiverIds(
|
||||
record?.notifyReceiverIds ?? fallbackRecord?.notifyReceiverIds,
|
||||
),
|
||||
creator: record?.creator || fallbackRecord?.creator || '管理员',
|
||||
updatedAt: record?.updatedAt || fallbackRecord?.updatedAt || buildFallbackUpdateTime(record),
|
||||
});
|
||||
|
||||
const buildSeededRecords = () => {
|
||||
const storedRecords = storage.getLocalStorage(STORAGE_KEY);
|
||||
const seedRecordMap = new Map(initialRecords.map((record) => [record.id, record]));
|
||||
const normalizedStoredRecords = Array.isArray(storedRecords)
|
||||
? storedRecords.map(normalizeRecord)
|
||||
? storedRecords.map((record) => normalizeRecord(record, seedRecordMap.get(record.id)))
|
||||
: [];
|
||||
|
||||
if (!normalizedStoredRecords.length) {
|
||||
@@ -456,6 +545,14 @@ const formRules = {
|
||||
trigger: 'change',
|
||||
},
|
||||
],
|
||||
notifyReceiverIds: [
|
||||
{
|
||||
type: 'array',
|
||||
required: true,
|
||||
message: '请选择通知对象',
|
||||
trigger: 'change',
|
||||
},
|
||||
],
|
||||
planFileName: [
|
||||
{
|
||||
validator: (_, value, callback) => {
|
||||
@@ -473,11 +570,14 @@ const formRules = {
|
||||
|
||||
const filteredRecords = computed(() =>
|
||||
records.value.filter((record) => {
|
||||
if (!filters.dateRange?.length) {
|
||||
return true;
|
||||
}
|
||||
const matchesDateRange = filters.dateRange?.length
|
||||
? isDateRangeOverlapping(record.startTime, record.endTime, filters.dateRange)
|
||||
: true;
|
||||
const matchesNotifyStatus = filters.notifyStatus
|
||||
? getNotifyStatus(record.startTime) === filters.notifyStatus
|
||||
: true;
|
||||
|
||||
return isDateRangeOverlapping(record.startTime, record.endTime, filters.dateRange);
|
||||
return matchesDateRange && matchesNotifyStatus;
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -509,13 +609,10 @@ const resetForm = () => {
|
||||
|
||||
const resetFilters = () => {
|
||||
filters.dateRange = [];
|
||||
filters.notifyStatus = '';
|
||||
currentPage.value = 1;
|
||||
};
|
||||
|
||||
const triggerFileSelect = () => {
|
||||
fileInputRef.value?.click();
|
||||
};
|
||||
|
||||
const openCurrentFile = () => {
|
||||
if (!formModel.planFileUrl) {
|
||||
return;
|
||||
@@ -661,11 +758,10 @@ const deleteSelected = async () => {
|
||||
};
|
||||
|
||||
watch(
|
||||
() => filters.dateRange,
|
||||
() => [filters.notifyStatus, ...(filters.dateRange || [])],
|
||||
() => {
|
||||
currentPage.value = 1;
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
@@ -721,6 +817,31 @@ function formatFileSize(size) {
|
||||
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function formatNotifyReceiverNames(notifyReceiverIds) {
|
||||
const names = normalizeNotifyReceiverIds(notifyReceiverIds)
|
||||
.map((item) => notifyUserNameMap.get(item))
|
||||
.filter(Boolean);
|
||||
|
||||
return names.length ? names.join(',') : '-';
|
||||
}
|
||||
|
||||
function getNotifyStatus(startTime) {
|
||||
if (!startTime) {
|
||||
return '未开始';
|
||||
}
|
||||
|
||||
return startTime <= formatCurrentDate() ? '已通知' : '未开始';
|
||||
}
|
||||
|
||||
function formatCurrentDate() {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(now.getDate()).padStart(2, '0');
|
||||
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function buildFallbackUpdateTime(record) {
|
||||
const dateText = record?.endTime || record?.startTime || '2026-04-27';
|
||||
return `${dateText} 09:00:00`;
|
||||
@@ -861,6 +982,26 @@ function formatCurrentDateTime() {
|
||||
color: #da4f60;
|
||||
}
|
||||
|
||||
.notify-status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 64px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.notify-status-badge--pending {
|
||||
background: rgba(148, 163, 184, 0.16);
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.notify-status-badge--done {
|
||||
background: rgba(15, 118, 110, 0.12);
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.pagination-bar {
|
||||
margin-top: 18px;
|
||||
display: flex;
|
||||
@@ -932,71 +1073,83 @@ function formatCurrentDateTime() {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.upload-panel {
|
||||
.select-field {
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
border: 1px dashed rgba(84, 110, 151, 0.36);
|
||||
border-radius: 2px;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(246, 249, 255, 0.98), rgba(239, 245, 253, 0.92));
|
||||
}
|
||||
|
||||
.upload-panel__input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.upload-panel__content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.upload-panel__copy {
|
||||
.upload-simple {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.upload-panel__title {
|
||||
color: #15263d;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.upload-panel__hint {
|
||||
color: #60758f;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
.upload-native-input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
color: #1f2f46;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.upload-panel__actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.upload-file {
|
||||
margin-top: 14px;
|
||||
padding: 12px 14px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #dbe4f0;
|
||||
border-radius: 2px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
background: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.upload-file__main,
|
||||
.upload-file__delete {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.upload-file__main {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.upload-file__name {
|
||||
color: #1f2f46;
|
||||
color: #1d4ed8;
|
||||
font-size: 13px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.upload-file__main:hover .upload-file__name {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.upload-file__meta {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.upload-file__delete {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #da4f60;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.upload-file__delete:hover {
|
||||
color: #b42318;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
@@ -1020,8 +1173,7 @@ function formatCurrentDateTime() {
|
||||
}
|
||||
|
||||
.table-toolbar,
|
||||
.pagination-bar,
|
||||
.upload-panel__content {
|
||||
.pagination-bar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
@@ -60,15 +60,6 @@
|
||||
:value="option.value"
|
||||
/>
|
||||
</el-select>
|
||||
<el-select
|
||||
v-model="filters.status"
|
||||
clearable
|
||||
class="filter-field"
|
||||
placeholder="状态"
|
||||
>
|
||||
<el-option label="在线" value="在线" />
|
||||
<el-option label="离线" value="离线" />
|
||||
</el-select>
|
||||
<el-button @click="resetFilters">重置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -117,16 +108,6 @@
|
||||
<span class="link-text">{{ row.playUrl }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" prop="status" min-width="110">
|
||||
<template #default="{ row }">
|
||||
<span
|
||||
class="status-badge"
|
||||
:class="row.status === '在线' ? 'status-badge--online' : 'status-badge--offline'"
|
||||
>
|
||||
{{ row.status }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建人" prop="creator" min-width="120" />
|
||||
<el-table-column label="最后更新时间" prop="updatedAt" min-width="176" />
|
||||
<el-table-column label="操作" fixed="right" width="210">
|
||||
@@ -210,12 +191,6 @@
|
||||
<el-form-item label="播放链接" prop="playUrl" class="form-grid__span-2">
|
||||
<el-input v-model="formModel.playUrl" placeholder="请输入播放链接" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-select v-model="formModel.status" placeholder="请选择状态" class="select-field">
|
||||
<el-option label="在线" value="在线" />
|
||||
<el-option label="离线" value="离线" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="创建人" prop="creator">
|
||||
<el-input v-model="formModel.creator" placeholder="请输入创建人" />
|
||||
</el-form-item>
|
||||
@@ -349,7 +324,6 @@ const initialRecords = [
|
||||
nationalStandardCode: 'GB34020000001320000001',
|
||||
videoProtocol: 'webrtc',
|
||||
playUrl: DEFAULT_WEBRTC_STREAM_URL,
|
||||
status: '在线',
|
||||
creator: '王敏',
|
||||
updatedAt: '2026-04-27 09:12:00',
|
||||
},
|
||||
@@ -362,7 +336,6 @@ const initialRecords = [
|
||||
nationalStandardCode: 'GB34020000001320000002',
|
||||
videoProtocol: 'hls',
|
||||
playUrl: DEFAULT_HLS_STREAM_URL,
|
||||
status: '在线',
|
||||
creator: '李峰',
|
||||
updatedAt: '2026-04-27 09:18:00',
|
||||
},
|
||||
@@ -375,7 +348,6 @@ const initialRecords = [
|
||||
nationalStandardCode: 'GB34020000001320000003',
|
||||
videoProtocol: 'webrtc',
|
||||
playUrl: DEFAULT_WEBRTC_STREAM_URL,
|
||||
status: '离线',
|
||||
creator: '陈宇',
|
||||
updatedAt: '2026-04-27 09:26:00',
|
||||
},
|
||||
@@ -388,7 +360,6 @@ const initialRecords = [
|
||||
nationalStandardCode: 'GB34020000001320000004',
|
||||
videoProtocol: 'hls',
|
||||
playUrl: DEFAULT_HLS_STREAM_URL,
|
||||
status: '在线',
|
||||
creator: '赵晴',
|
||||
updatedAt: '2026-04-27 09:33:00',
|
||||
},
|
||||
@@ -401,7 +372,6 @@ const initialRecords = [
|
||||
nationalStandardCode: 'GB34020000001320000005',
|
||||
videoProtocol: 'webrtc',
|
||||
playUrl: DEFAULT_WEBRTC_STREAM_URL,
|
||||
status: '离线',
|
||||
creator: '顾晨',
|
||||
updatedAt: '2026-04-27 09:44:00',
|
||||
},
|
||||
@@ -414,7 +384,6 @@ const initialRecords = [
|
||||
nationalStandardCode: 'GB34020000001320000006',
|
||||
videoProtocol: 'hls',
|
||||
playUrl: DEFAULT_HLS_STREAM_URL,
|
||||
status: '在线',
|
||||
creator: '刘颖',
|
||||
updatedAt: '2026-04-27 09:55:00',
|
||||
},
|
||||
@@ -427,7 +396,6 @@ const initialRecords = [
|
||||
nationalStandardCode: 'GB34020000001320000007',
|
||||
videoProtocol: 'webrtc',
|
||||
playUrl: DEFAULT_WEBRTC_STREAM_URL,
|
||||
status: '在线',
|
||||
creator: '王敏',
|
||||
updatedAt: '2026-04-27 10:08:00',
|
||||
},
|
||||
@@ -440,7 +408,6 @@ const initialRecords = [
|
||||
nationalStandardCode: 'GB34020000001320000008',
|
||||
videoProtocol: 'hls',
|
||||
playUrl: DEFAULT_HLS_STREAM_URL,
|
||||
status: '离线',
|
||||
creator: '李峰',
|
||||
updatedAt: '2026-04-27 10:14:00',
|
||||
},
|
||||
@@ -453,7 +420,6 @@ const initialRecords = [
|
||||
nationalStandardCode: 'GB34020000001320000009',
|
||||
videoProtocol: 'webrtc',
|
||||
playUrl: DEFAULT_WEBRTC_STREAM_URL,
|
||||
status: '在线',
|
||||
creator: '陈宇',
|
||||
updatedAt: '2026-04-27 10:27:00',
|
||||
},
|
||||
@@ -466,7 +432,6 @@ const initialRecords = [
|
||||
nationalStandardCode: 'GB34020000001320000010',
|
||||
videoProtocol: 'hls',
|
||||
playUrl: DEFAULT_HLS_STREAM_URL,
|
||||
status: '离线',
|
||||
creator: '赵晴',
|
||||
updatedAt: '2026-04-27 10:36:00',
|
||||
},
|
||||
@@ -493,7 +458,6 @@ const filters = reactive({
|
||||
channelCode: '',
|
||||
nationalStandardCode: '',
|
||||
videoProtocol: '',
|
||||
status: '',
|
||||
});
|
||||
|
||||
const createEmptyForm = () => ({
|
||||
@@ -504,7 +468,6 @@ const createEmptyForm = () => ({
|
||||
nationalStandardCode: '',
|
||||
videoProtocol: 'webrtc',
|
||||
playUrl: DEFAULT_WEBRTC_STREAM_URL,
|
||||
status: '在线',
|
||||
creator: '管理员',
|
||||
updatedAt: '',
|
||||
});
|
||||
@@ -570,7 +533,6 @@ const formRules = {
|
||||
nationalStandardCode: [{ required: true, message: '请输入国标号', trigger: 'blur' }],
|
||||
videoProtocol: [{ required: true, message: '请选择视频协议', trigger: 'change' }],
|
||||
playUrl: [{ required: true, message: '请输入播放链接', trigger: 'blur' }],
|
||||
status: [{ required: true, message: '请选择状态', trigger: 'change' }],
|
||||
creator: [{ required: true, message: '请输入创建人', trigger: 'blur' }],
|
||||
};
|
||||
|
||||
@@ -594,7 +556,6 @@ const filteredRecords = computed(() =>
|
||||
const matchesProtocol = filters.videoProtocol
|
||||
? record.videoProtocol === filters.videoProtocol
|
||||
: true;
|
||||
const matchesStatus = filters.status ? record.status === filters.status : true;
|
||||
|
||||
return (
|
||||
matchesProjectName &&
|
||||
@@ -602,8 +563,7 @@ const filteredRecords = computed(() =>
|
||||
matchesCameraPoint &&
|
||||
matchesChannelCode &&
|
||||
matchesCode &&
|
||||
matchesProtocol &&
|
||||
matchesStatus
|
||||
matchesProtocol
|
||||
);
|
||||
}),
|
||||
);
|
||||
@@ -631,7 +591,6 @@ const resetFilters = () => {
|
||||
filters.channelCode = '';
|
||||
filters.nationalStandardCode = '';
|
||||
filters.videoProtocol = '';
|
||||
filters.status = '';
|
||||
currentPage.value = 1;
|
||||
};
|
||||
|
||||
@@ -742,11 +701,6 @@ const deleteSelected = async () => {
|
||||
};
|
||||
|
||||
const openLiveDialog = async (record) => {
|
||||
if (record.status !== '在线') {
|
||||
ElMessage.warning('当前视频点位离线,无法发起直播');
|
||||
return;
|
||||
}
|
||||
|
||||
liveRecord.value = normalizeRecord(record);
|
||||
liveDialogVisible.value = true;
|
||||
await startLiveStream();
|
||||
@@ -919,7 +873,6 @@ watch(
|
||||
filters.channelCode,
|
||||
filters.nationalStandardCode,
|
||||
filters.videoProtocol,
|
||||
filters.status,
|
||||
],
|
||||
() => {
|
||||
currentPage.value = 1;
|
||||
@@ -1200,25 +1153,6 @@ function extractAnswerSdp(payload, rawText) {
|
||||
color: #385372;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 58px;
|
||||
padding: 5px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.status-badge--online {
|
||||
color: #0b8e73;
|
||||
background: rgba(20, 195, 154, 0.12);
|
||||
}
|
||||
|
||||
.status-badge--offline {
|
||||
color: #c62828;
|
||||
background: rgba(229, 57, 53, 0.14);
|
||||
}
|
||||
|
||||
.row-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<ProjectCrudPanel
|
||||
title="中标项目管理"
|
||||
title="中标公告管理"
|
||||
storage-key="admin-pc:award-projects"
|
||||
date-range-prop="awardDate"
|
||||
amount-range-prop="awardAmount"
|
||||
|
||||
@@ -286,9 +286,9 @@
|
||||
|
||||
<el-form label-width="88px" class="notify-form">
|
||||
<el-form-item label="通知方式">
|
||||
<el-checkbox-group v-model="notifyForm.channels">
|
||||
<el-checkbox-button label="站内通知">站内通知</el-checkbox-button>
|
||||
<el-checkbox-button label="企业微信">企业微信</el-checkbox-button>
|
||||
<el-checkbox-group v-model="notifyForm.channels" class="notify-form__channels">
|
||||
<el-checkbox label="站内通知" border>站内通知</el-checkbox>
|
||||
<el-checkbox label="企业微信" border>企业微信</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</el-form-item>
|
||||
|
||||
@@ -1312,6 +1312,30 @@ const formatExportValue = (column, value) => {
|
||||
background: #fbfcfe;
|
||||
}
|
||||
|
||||
.notify-form__channels {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.notify-form__channels :deep(.el-checkbox) {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.notify-form__channels :deep(.el-checkbox.is-bordered) {
|
||||
min-width: 112px;
|
||||
height: 38px;
|
||||
margin-left: 0;
|
||||
border-radius: 2px;
|
||||
border-color: #d8e2ef;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.notify-form__channels :deep(.el-checkbox.is-bordered.is-checked) {
|
||||
border-color: #1d4ed8;
|
||||
background: rgba(29, 78, 216, 0.06);
|
||||
}
|
||||
|
||||
.notify-form__select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -1,441 +0,0 @@
|
||||
<template>
|
||||
<section class="sector-page">
|
||||
<div class="sector-card">
|
||||
<div class="sector-toolbar">
|
||||
<h1 class="sector-toolbar__title">板块管理</h1>
|
||||
<el-button type="primary" @click="openCreateRootDialog">新增一级板块</el-button>
|
||||
</div>
|
||||
|
||||
<el-empty
|
||||
v-if="!treeData.length"
|
||||
description="暂无板块数据"
|
||||
/>
|
||||
|
||||
<el-tree
|
||||
v-else
|
||||
:data="treeData"
|
||||
:props="treeProps"
|
||||
node-key="id"
|
||||
default-expand-all
|
||||
:expand-on-click-node="false"
|
||||
class="sector-tree"
|
||||
>
|
||||
<template #default="{ data }">
|
||||
<div class="sector-node">
|
||||
<div class="sector-node__main">
|
||||
<span class="sector-node__label">{{ data.label }}</span>
|
||||
</div>
|
||||
|
||||
<div class="sector-node__actions">
|
||||
<button
|
||||
type="button"
|
||||
class="sector-node__action"
|
||||
@click="openCreateChildDialog(data)"
|
||||
>
|
||||
新增子级
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="sector-node__action"
|
||||
@click="openEditDialog(data)"
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="sector-node__action sector-node__action--danger"
|
||||
@click="deleteNode(data)"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-tree>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogTitle"
|
||||
width="520px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form label-width="92px">
|
||||
<el-form-item
|
||||
v-if="currentAction.type === 'create-child'"
|
||||
label="上级板块"
|
||||
>
|
||||
<el-input :model-value="currentAction.parentLabel" disabled />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="板块名称" required>
|
||||
<el-input
|
||||
v-model="formModel.label"
|
||||
maxlength="30"
|
||||
placeholder="请输入板块名称"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitForm">保存</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import storage from '@/utils/storage';
|
||||
|
||||
const STORAGE_KEY = 'admin-pc:sector-tree';
|
||||
|
||||
const treeProps = {
|
||||
children: 'children',
|
||||
label: 'label',
|
||||
};
|
||||
|
||||
const defaultTreeData = [
|
||||
{
|
||||
id: 'infra',
|
||||
label: '基础设施',
|
||||
children: [
|
||||
{
|
||||
id: 'infra-road',
|
||||
label: '道路工程',
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
id: 'infra-mep',
|
||||
label: '机电安装',
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'smart-park',
|
||||
label: '智慧园区',
|
||||
children: [
|
||||
{
|
||||
id: 'smart-park-security',
|
||||
label: '智慧安防',
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
id: 'smart-park-operation',
|
||||
label: '智慧运维',
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'energy',
|
||||
label: '能源环保',
|
||||
children: [
|
||||
{
|
||||
id: 'energy-station',
|
||||
label: '能源站',
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
id: 'energy-consulting',
|
||||
label: '绿色咨询',
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const normalizeTree = (nodes) =>
|
||||
(Array.isArray(nodes) ? nodes : []).map((node) => ({
|
||||
id: node.id,
|
||||
label: node.label || '',
|
||||
children: normalizeTree(node.children),
|
||||
}));
|
||||
|
||||
const cloneTree = (nodes) =>
|
||||
nodes.map((node) => ({
|
||||
...node,
|
||||
children: cloneTree(node.children || []),
|
||||
}));
|
||||
|
||||
const treeData = ref(
|
||||
normalizeTree(storage.getLocalStorage(STORAGE_KEY) || defaultTreeData),
|
||||
);
|
||||
|
||||
const dialogVisible = ref(false);
|
||||
const formModel = reactive({
|
||||
label: '',
|
||||
});
|
||||
|
||||
const currentAction = reactive({
|
||||
type: 'create-root',
|
||||
targetId: '',
|
||||
parentLabel: '',
|
||||
});
|
||||
|
||||
const dialogTitle = computed(() => {
|
||||
if (currentAction.type === 'create-child') {
|
||||
return '新增子级板块';
|
||||
}
|
||||
|
||||
if (currentAction.type === 'edit') {
|
||||
return '编辑板块';
|
||||
}
|
||||
|
||||
return '新增一级板块';
|
||||
});
|
||||
|
||||
const persistTree = () => {
|
||||
storage.setLocalStorage(STORAGE_KEY, treeData.value);
|
||||
};
|
||||
|
||||
const generateId = () => `sector_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
const flattenTree = (nodes) =>
|
||||
nodes.flatMap((node) => [node, ...flattenTree(node.children || [])]);
|
||||
|
||||
const findNode = (nodes, id) => {
|
||||
for (const node of nodes) {
|
||||
if (node.id === id) {
|
||||
return node;
|
||||
}
|
||||
|
||||
const matched = findNode(node.children || [], id);
|
||||
if (matched) {
|
||||
return matched;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const removeNode = (nodes, id) =>
|
||||
nodes
|
||||
.filter((node) => node.id !== id)
|
||||
.map((node) => ({
|
||||
...node,
|
||||
children: removeNode(node.children || [], id),
|
||||
}));
|
||||
|
||||
const resetForm = () => {
|
||||
formModel.label = '';
|
||||
currentAction.type = 'create-root';
|
||||
currentAction.targetId = '';
|
||||
currentAction.parentLabel = '';
|
||||
};
|
||||
|
||||
const openCreateRootDialog = () => {
|
||||
resetForm();
|
||||
currentAction.type = 'create-root';
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
const openCreateChildDialog = (node) => {
|
||||
resetForm();
|
||||
currentAction.type = 'create-child';
|
||||
currentAction.targetId = node.id;
|
||||
currentAction.parentLabel = node.label;
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
const openEditDialog = (node) => {
|
||||
resetForm();
|
||||
currentAction.type = 'edit';
|
||||
currentAction.targetId = node.id;
|
||||
formModel.label = node.label;
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
const submitForm = () => {
|
||||
const label = formModel.label.trim();
|
||||
|
||||
if (!label) {
|
||||
ElMessage.warning('请输入板块名称');
|
||||
return;
|
||||
}
|
||||
|
||||
const duplicated = flattenTree(treeData.value).find(
|
||||
(node) => node.label === label && node.id !== currentAction.targetId,
|
||||
);
|
||||
|
||||
if (duplicated) {
|
||||
ElMessage.warning('板块名称已存在');
|
||||
return;
|
||||
}
|
||||
|
||||
const nextTree = cloneTree(treeData.value);
|
||||
|
||||
if (currentAction.type === 'create-root') {
|
||||
nextTree.push({
|
||||
id: generateId(),
|
||||
label,
|
||||
children: [],
|
||||
});
|
||||
} else if (currentAction.type === 'create-child') {
|
||||
const parentNode = findNode(nextTree, currentAction.targetId);
|
||||
if (!parentNode) {
|
||||
ElMessage.error('未找到上级板块');
|
||||
return;
|
||||
}
|
||||
|
||||
parentNode.children.push({
|
||||
id: generateId(),
|
||||
label,
|
||||
children: [],
|
||||
});
|
||||
} else if (currentAction.type === 'edit') {
|
||||
const targetNode = findNode(nextTree, currentAction.targetId);
|
||||
if (!targetNode) {
|
||||
ElMessage.error('未找到待编辑板块');
|
||||
return;
|
||||
}
|
||||
|
||||
targetNode.label = label;
|
||||
}
|
||||
|
||||
treeData.value = nextTree;
|
||||
persistTree();
|
||||
dialogVisible.value = false;
|
||||
|
||||
ElMessage.success(
|
||||
currentAction.type === 'edit' ? '板块已更新' : '板块已保存',
|
||||
);
|
||||
};
|
||||
|
||||
const deleteNode = async (node) => {
|
||||
const hasChildren = Boolean(node.children?.length);
|
||||
const message = hasChildren
|
||||
? `确认删除“${node.label}”及其下级板块吗?`
|
||||
: `确认删除“${node.label}”吗?`;
|
||||
|
||||
try {
|
||||
await ElMessageBox.confirm(message, '删除确认', {
|
||||
type: 'warning',
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
treeData.value = removeNode(treeData.value, node.id);
|
||||
persistTree();
|
||||
ElMessage.success('板块已删除');
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sector-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sector-card {
|
||||
padding: 20px;
|
||||
border: 1px solid rgba(112, 144, 196, 0.12);
|
||||
border-radius: 2px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.92),
|
||||
0 18px 36px rgba(26, 54, 93, 0.05);
|
||||
}
|
||||
|
||||
.sector-toolbar {
|
||||
margin-bottom: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.sector-toolbar__title {
|
||||
color: #142949;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.sector-tree {
|
||||
margin: -6px;
|
||||
border: 1px solid #d9e3f0;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.sector-node {
|
||||
width: 100%;
|
||||
min-height: 46px;
|
||||
padding: 8px 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sector-node__main {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sector-node__label {
|
||||
color: #1b2d44;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sector-node__actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.sector-node__action {
|
||||
color: #1d4ed8;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.sector-node__action--danger {
|
||||
color: #da4f60;
|
||||
}
|
||||
|
||||
:deep(.sector-tree .el-tree-node__content) {
|
||||
min-height: 52px;
|
||||
border-bottom: 1px solid #e5ebf3;
|
||||
border-radius: 2px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:deep(.sector-tree > .el-tree-node > .el-tree-node__content) {
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
:deep(.sector-tree .el-tree-node:last-child > .el-tree-node__content) {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
:deep(.sector-tree .el-tree-node__content:hover) {
|
||||
background: rgba(37, 99, 235, 0.06);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sector-toolbar {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sector-node {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sector-node__main,
|
||||
.sector-node__actions {
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -35,7 +35,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-card">
|
||||
<div class="panel-card panel-card--table">
|
||||
<div class="table-toolbar">
|
||||
<div class="table-toolbar__group">
|
||||
<el-button type="primary" @click="openCreateDialog">新增任务</el-button>
|
||||
@@ -46,10 +46,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="task-table-wrap">
|
||||
<el-table
|
||||
:data="paginatedRecords"
|
||||
row-key="id"
|
||||
class="task-table"
|
||||
height="100%"
|
||||
highlight-current-row
|
||||
@current-change="handleCurrentChange"
|
||||
@row-click="handleRowClick"
|
||||
@@ -95,6 +97,7 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<div class="pagination-bar">
|
||||
<div class="pagination-bar__summary">
|
||||
@@ -107,7 +110,7 @@
|
||||
v-model:page-size="pageSize"
|
||||
background
|
||||
layout="prev, pager, next, sizes"
|
||||
:page-sizes="[5, 10, 20, 50]"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
:total="filteredRecords.length"
|
||||
/>
|
||||
</div>
|
||||
@@ -115,32 +118,11 @@
|
||||
</div>
|
||||
|
||||
<div class="task-layout__side">
|
||||
<div class="config-card config-card--hero">
|
||||
<div class="config-card__eyebrow">TASK CONFIG</div>
|
||||
<template v-if="selectedRecord">
|
||||
<div class="config-hero">
|
||||
<div>
|
||||
<h2 class="config-hero__title">{{ selectedRecord.sectorType }}</h2>
|
||||
<p class="config-hero__meta">{{ selectedRecord.provinceCity }} / {{ selectedRecord.model }}</p>
|
||||
</div>
|
||||
<span
|
||||
class="status-badge"
|
||||
:class="selectedRecord.status === '启用' ? 'status-badge--online' : 'status-badge--offline'"
|
||||
>
|
||||
{{ selectedRecord.status }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="config-hero__prompt">{{ selectedRecord.prompt }}</p>
|
||||
</template>
|
||||
<el-empty v-else description="请选择左侧任务后再配置" />
|
||||
</div>
|
||||
|
||||
<template v-if="selectedRecord">
|
||||
<div class="config-card">
|
||||
<div class="config-card__header">
|
||||
<div>
|
||||
<h3 class="config-card__title">Agent 配置</h3>
|
||||
<p class="config-card__desc">控制任务调用的服务地址与轮询行为。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -173,7 +155,6 @@
|
||||
<div class="config-card__header">
|
||||
<div>
|
||||
<h3 class="config-card__title">定时任务配置</h3>
|
||||
<p class="config-card__desc">设置调度策略,并可立即执行当前任务。</p>
|
||||
</div>
|
||||
<el-button type="primary" plain @click="runScheduledTaskNow">
|
||||
立即执行定时任务
|
||||
@@ -235,13 +216,15 @@
|
||||
<strong class="schedule-summary__value">{{ scheduleSummary }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-actions">
|
||||
<el-button @click="resetConfigForm">重置配置</el-button>
|
||||
<div class="side-actions">
|
||||
<el-button type="primary" @click="saveConfig">保存配置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="config-card">
|
||||
<el-empty description="请选择左侧任务后再配置" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -610,7 +593,7 @@ const formRef = ref();
|
||||
const dialogVisible = ref(false);
|
||||
const editingRecordId = ref('');
|
||||
const currentPage = ref(1);
|
||||
const pageSize = ref(5);
|
||||
const pageSize = ref(10);
|
||||
const selectedRecordId = ref('');
|
||||
|
||||
const filters = reactive({
|
||||
@@ -722,14 +705,6 @@ const resetForm = () => {
|
||||
formRef.value?.clearValidate();
|
||||
};
|
||||
|
||||
const resetConfigForm = () => {
|
||||
if (!selectedRecord.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
Object.assign(configForm, pickConfigFields(selectedRecord.value));
|
||||
};
|
||||
|
||||
const openCreateDialog = () => {
|
||||
editingRecordId.value = '';
|
||||
resetForm();
|
||||
@@ -769,7 +744,7 @@ const submitForm = async () => {
|
||||
|
||||
if (selectedRecordId.value === payload.id || !selectedRecordId.value) {
|
||||
selectedRecordId.value = payload.id;
|
||||
resetConfigForm();
|
||||
Object.assign(configForm, pickConfigFields(payload));
|
||||
}
|
||||
|
||||
persistRecords();
|
||||
@@ -935,23 +910,29 @@ function formatCurrentDateTime() {
|
||||
|
||||
<style scoped>
|
||||
.task-page {
|
||||
height: calc(100vh - var(--shell-header-offset, 74px) - 34px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.task-layout {
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.35fr) minmax(360px, 0.85fr);
|
||||
gap: 18px;
|
||||
align-items: start;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.task-layout__main,
|
||||
.task-layout__side {
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel-card,
|
||||
@@ -966,67 +947,18 @@ function formatCurrentDateTime() {
|
||||
}
|
||||
|
||||
.panel-card--search {
|
||||
position: sticky;
|
||||
top: calc(var(--shell-header-offset, 74px) + 12px);
|
||||
z-index: 6;
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(35, 195, 172, 0.14), transparent 24%),
|
||||
radial-gradient(circle at left center, rgba(90, 129, 255, 0.16), transparent 28%),
|
||||
rgba(255, 255, 255, 0.92);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.config-card {
|
||||
position: sticky;
|
||||
top: calc(var(--shell-header-offset, 74px) + 12px);
|
||||
}
|
||||
|
||||
.config-card + .config-card {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.config-card--hero {
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(17, 193, 168, 0.14), transparent 32%),
|
||||
linear-gradient(145deg, rgba(250, 252, 255, 0.98), rgba(240, 246, 255, 0.94));
|
||||
}
|
||||
|
||||
.config-card__eyebrow {
|
||||
display: inline-flex;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
color: #0e846d;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.16em;
|
||||
background: rgba(14, 132, 109, 0.1);
|
||||
}
|
||||
|
||||
.config-hero {
|
||||
margin-top: 18px;
|
||||
.panel-card--table {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.config-hero__title {
|
||||
color: #142949;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.config-hero__meta {
|
||||
margin-top: 8px;
|
||||
color: #5f738f;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.config-hero__prompt {
|
||||
margin-top: 16px;
|
||||
color: #223755;
|
||||
font-size: 14px;
|
||||
line-height: 1.8;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.config-card__header {
|
||||
@@ -1083,11 +1015,17 @@ function formatCurrentDateTime() {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.config-actions {
|
||||
margin-top: 18px;
|
||||
.side-actions {
|
||||
margin-top: auto;
|
||||
padding: 20px;
|
||||
border: 1px solid rgba(112, 144, 196, 0.12);
|
||||
border-radius: 2px;
|
||||
background: #ffffff;
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.92),
|
||||
0 18px 36px rgba(26, 54, 93, 0.06);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
@@ -1125,8 +1063,15 @@ function formatCurrentDateTime() {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.task-table-wrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.task-table {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
:deep(.task-table .el-table__inner-wrapper),
|
||||
@@ -1239,13 +1184,20 @@ function formatCurrentDateTime() {
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.task-page {
|
||||
height: calc(100vh - var(--shell-header-offset, 74px) - 34px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.task-layout {
|
||||
height: 100%;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.config-card,
|
||||
.config-card + .config-card {
|
||||
position: static;
|
||||
.task-layout__main,
|
||||
.task-layout__side {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<ProjectCrudPanel
|
||||
title="招标项目管理"
|
||||
title="招标公告管理"
|
||||
storage-key="admin-pc:tender-projects"
|
||||
:enable-conversion-action="true"
|
||||
:record-defaults="{ conversionStatus: '未转化' }"
|
||||
|
||||
706
src/views/user/edit.vue
Normal file
706
src/views/user/edit.vue
Normal file
@@ -0,0 +1,706 @@
|
||||
<template>
|
||||
<section class="user-editor-page">
|
||||
<div class="editor-hero">
|
||||
<div class="editor-hero__copy">
|
||||
<button type="button" class="back-link" @click="goBack">返回用户管理</button>
|
||||
<h1 class="editor-hero__title">用户编辑设置</h1>
|
||||
<p class="editor-hero__subtitle">
|
||||
对用户基础资料、项目展示、审批流和授权范围进行统一配置。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="editor-hero__actions">
|
||||
<el-button @click="goBack">取消</el-button>
|
||||
<el-button type="primary" @click="submitForm">保存配置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!currentUser" class="panel-card panel-card--empty">
|
||||
<el-empty description="未找到该用户">
|
||||
<el-button type="primary" @click="goBack">返回列表</el-button>
|
||||
</el-empty>
|
||||
</div>
|
||||
|
||||
<el-form
|
||||
v-else
|
||||
ref="formRef"
|
||||
:model="formModel"
|
||||
:rules="formRules"
|
||||
label-width="96px"
|
||||
class="editor-stack"
|
||||
>
|
||||
<div class="panel-card">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<span class="section-heading__eyebrow">01</span>
|
||||
<h2 class="section-heading__title">用户基本信息</h2>
|
||||
</div>
|
||||
<span class="section-heading__meta">编辑用户身份、组织归属和当前状态</span>
|
||||
</div>
|
||||
|
||||
<div class="form-grid">
|
||||
<el-form-item label="账号" prop="account">
|
||||
<el-input v-model="formModel.account" placeholder="请输入账号" />
|
||||
</el-form-item>
|
||||
<el-form-item label="用户名" prop="username">
|
||||
<el-input v-model="formModel.username" placeholder="请输入用户名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="远程账号" prop="remoteAccount">
|
||||
<el-input v-model="formModel.remoteAccount" placeholder="请输入远程账号" />
|
||||
</el-form-item>
|
||||
<el-form-item label="微信账号" prop="wechatAccount">
|
||||
<el-input v-model="formModel.wechatAccount" placeholder="请输入微信账号" />
|
||||
</el-form-item>
|
||||
<el-form-item label="所属部门" prop="department">
|
||||
<el-select v-model="formModel.department" class="full-width" placeholder="请选择所属部门">
|
||||
<el-option
|
||||
v-for="option in departmentOptions"
|
||||
:key="option"
|
||||
:label="option"
|
||||
:value="option"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="所属岗位" prop="position">
|
||||
<el-select v-model="formModel.position" class="full-width" placeholder="请选择所属岗位">
|
||||
<el-option
|
||||
v-for="option in positionOptions"
|
||||
:key="option"
|
||||
:label="option"
|
||||
:value="option"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-select v-model="formModel.status" class="full-width" placeholder="请选择状态">
|
||||
<el-option
|
||||
v-for="option in statusOptions"
|
||||
:key="option"
|
||||
:label="option"
|
||||
:value="option"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="创建时间">
|
||||
<el-input :model-value="formModel.createdAt" disabled />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-card">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<span class="section-heading__eyebrow">02</span>
|
||||
<h2 class="section-heading__title">项目展示设置</h2>
|
||||
</div>
|
||||
<span class="section-heading__meta">按业务模块定义当前用户可见字段</span>
|
||||
</div>
|
||||
|
||||
<el-tabs v-model="activeDisplayTab" class="display-tabs">
|
||||
<el-tab-pane
|
||||
v-for="tab in projectDisplayTabs"
|
||||
:key="tab.key"
|
||||
:label="tab.label"
|
||||
:name="tab.key"
|
||||
>
|
||||
<el-checkbox-group v-model="formModel.config.projectDisplay[tab.key]" class="field-grid">
|
||||
<label
|
||||
v-for="field in tab.fields"
|
||||
:key="field.key"
|
||||
class="field-card"
|
||||
>
|
||||
<el-checkbox :label="field.key">
|
||||
{{ field.label }}
|
||||
</el-checkbox>
|
||||
</label>
|
||||
</el-checkbox-group>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
|
||||
<div class="module-grid">
|
||||
<div class="panel-card panel-card--split">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<span class="section-heading__eyebrow">03</span>
|
||||
<h2 class="section-heading__title">审批流设置</h2>
|
||||
</div>
|
||||
<el-button type="primary" plain @click="addApprovalNode">新增审批节点</el-button>
|
||||
</div>
|
||||
|
||||
<div class="approval-stack">
|
||||
<article
|
||||
v-for="(node, index) in formModel.config.approvalFlow"
|
||||
:key="node.id"
|
||||
class="approval-card"
|
||||
>
|
||||
<div class="approval-card__header">
|
||||
<div>
|
||||
<span class="approval-card__index">节点 {{ index + 1 }}</span>
|
||||
<strong class="approval-card__title">{{ node.description || '未命名节点' }}</strong>
|
||||
</div>
|
||||
<button
|
||||
v-if="formModel.config.approvalFlow.length > 1"
|
||||
type="button"
|
||||
class="text-action text-action--danger"
|
||||
@click="removeApprovalNode(node.id)"
|
||||
>
|
||||
删除节点
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="approval-grid">
|
||||
<el-form-item label="节点说明" class="approval-grid__item">
|
||||
<el-input v-model="node.description" placeholder="请输入节点说明" />
|
||||
</el-form-item>
|
||||
<el-form-item label="审批人设置" class="approval-grid__item">
|
||||
<el-select
|
||||
v-model="node.approverIds"
|
||||
multiple
|
||||
collapse-tags
|
||||
collapse-tags-tooltip
|
||||
class="full-width"
|
||||
placeholder="请选择审批人"
|
||||
>
|
||||
<el-option
|
||||
v-for="option in approverOptions"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-card panel-card--split">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<span class="section-heading__eyebrow">04</span>
|
||||
<h2 class="section-heading__title">授权省市设置</h2>
|
||||
</div>
|
||||
<span class="section-heading__meta">支持江苏省及 13 个地市多选授权</span>
|
||||
</div>
|
||||
|
||||
<el-checkbox-group v-model="formModel.config.authorizedRegions" class="region-grid">
|
||||
<label v-for="item in provinceCityOptions" :key="item" class="region-card">
|
||||
<el-checkbox :label="item">
|
||||
{{ item }}
|
||||
</el-checkbox>
|
||||
</label>
|
||||
</el-checkbox-group>
|
||||
</div>
|
||||
|
||||
<div class="panel-card panel-card--split">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<span class="section-heading__eyebrow">05</span>
|
||||
<h2 class="section-heading__title">授权项目设置</h2>
|
||||
</div>
|
||||
<span class="section-heading__meta">当前阶段先以布局模块承载,便于后续接入项目权限树</span>
|
||||
</div>
|
||||
|
||||
<div class="placeholder-board">
|
||||
<div class="placeholder-board__headline">
|
||||
<strong>项目授权模块预留区</strong>
|
||||
<span>后续可接入项目树、批量授权和快速搜索</span>
|
||||
</div>
|
||||
<div class="placeholder-board__grid">
|
||||
<div class="placeholder-tile">项目树区域</div>
|
||||
<div class="placeholder-tile">授权结果区域</div>
|
||||
<div class="placeholder-tile">批量操作工具栏</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-card panel-card--split">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<span class="section-heading__eyebrow">06</span>
|
||||
<h2 class="section-heading__title">授权对接记录</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="single-row-form">
|
||||
<el-form-item label="可见类型" label-width="96px">
|
||||
<el-select
|
||||
v-model="formModel.config.dockingVisibility"
|
||||
class="single-row-form__field"
|
||||
placeholder="请选择可见类型"
|
||||
>
|
||||
<el-option
|
||||
v-for="option in visibilityTypeOptions"
|
||||
:key="option"
|
||||
:label="option"
|
||||
:value="option"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-card panel-card--split">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<span class="section-heading__eyebrow">07</span>
|
||||
<h2 class="section-heading__title">授权项目条件</h2>
|
||||
</div>
|
||||
<span class="section-heading__meta">当前阶段先保留模块结构,后续接入条件组合和规则表达式</span>
|
||||
</div>
|
||||
|
||||
<div class="placeholder-board placeholder-board--soft">
|
||||
<div class="placeholder-board__headline">
|
||||
<strong>项目条件模块预留区</strong>
|
||||
<span>建议后续支持经营部、项目状态、金额区间、板块类型等组合筛选</span>
|
||||
</div>
|
||||
<div class="condition-tags">
|
||||
<span>经营部</span>
|
||||
<span>项目状态</span>
|
||||
<span>金额区间</span>
|
||||
<span>板块类型</span>
|
||||
<span>负责人</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-form>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import {
|
||||
buildApproverOptions,
|
||||
cloneUser,
|
||||
createApprovalNode,
|
||||
departmentOptions,
|
||||
getUserById,
|
||||
loadUsers,
|
||||
positionOptions,
|
||||
projectDisplayTabs,
|
||||
provinceCityOptions,
|
||||
saveUsers,
|
||||
statusOptions,
|
||||
visibilityTypeOptions,
|
||||
} from './user-data';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const formRef = ref();
|
||||
const activeDisplayTab = ref(projectDisplayTabs[0].key);
|
||||
const users = ref(loadUsers());
|
||||
const currentUser = ref(getUserById(String(route.params.id || '')));
|
||||
|
||||
const formModel = reactive(
|
||||
currentUser.value
|
||||
? cloneUser(currentUser.value)
|
||||
: {
|
||||
account: '',
|
||||
username: '',
|
||||
remoteAccount: '',
|
||||
wechatAccount: '',
|
||||
department: '',
|
||||
position: '',
|
||||
status: '启用',
|
||||
createdAt: '',
|
||||
config: {
|
||||
projectDisplay: {},
|
||||
approvalFlow: [],
|
||||
authorizedRegions: [],
|
||||
dockingVisibility: '仅自身',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const approverOptions = computed(() => buildApproverOptions(users.value));
|
||||
|
||||
const formRules = {
|
||||
account: [{ required: true, message: '请输入账号', trigger: 'blur' }],
|
||||
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||
remoteAccount: [{ required: true, message: '请输入远程账号', trigger: 'blur' }],
|
||||
wechatAccount: [{ required: true, message: '请输入微信账号', trigger: 'blur' }],
|
||||
department: [{ required: true, message: '请选择所属部门', trigger: 'change' }],
|
||||
position: [{ required: true, message: '请选择所属岗位', trigger: 'change' }],
|
||||
status: [{ required: true, message: '请选择状态', trigger: 'change' }],
|
||||
};
|
||||
|
||||
function goBack() {
|
||||
router.push({ name: 'UserManagement' });
|
||||
}
|
||||
|
||||
function addApprovalNode() {
|
||||
formModel.config.approvalFlow.push(createApprovalNode(formModel.config.approvalFlow.length + 1));
|
||||
}
|
||||
|
||||
function removeApprovalNode(nodeId) {
|
||||
formModel.config.approvalFlow = formModel.config.approvalFlow.filter((item) => item.id !== nodeId);
|
||||
}
|
||||
|
||||
async function submitForm() {
|
||||
if (!currentUser.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const valid = formRef.value ? await formRef.value.validate().catch(() => false) : false;
|
||||
if (!valid) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formModel.config.approvalFlow.length) {
|
||||
ElMessage.warning('请至少保留一个审批节点');
|
||||
return;
|
||||
}
|
||||
|
||||
users.value = users.value.map((item) =>
|
||||
item.id === currentUser.value.id
|
||||
? {
|
||||
...item,
|
||||
...cloneUser(formModel),
|
||||
}
|
||||
: item,
|
||||
);
|
||||
|
||||
saveUsers(users.value);
|
||||
currentUser.value = cloneUser(formModel);
|
||||
ElMessage.success('用户配置已保存');
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.user-editor-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.editor-hero {
|
||||
padding: 22px 24px;
|
||||
border: 1px solid rgba(112, 144, 196, 0.12);
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(14, 165, 233, 0.14), transparent 24%),
|
||||
radial-gradient(circle at left center, rgba(16, 185, 129, 0.12), transparent 28%),
|
||||
linear-gradient(135deg, rgba(255, 255, 255, 0.96), rgba(246, 250, 255, 0.92));
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.94),
|
||||
0 20px 44px rgba(24, 53, 92, 0.08);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.editor-hero__title {
|
||||
margin-top: 12px;
|
||||
color: #152740;
|
||||
font-size: 28px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.editor-hero__subtitle {
|
||||
margin-top: 10px;
|
||||
color: #60748d;
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.editor-hero__actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #0f766e;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.editor-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.module-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.panel-card {
|
||||
padding: 20px;
|
||||
border: 1px solid rgba(112, 144, 196, 0.12);
|
||||
border-radius: 2px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.94),
|
||||
0 20px 40px rgba(26, 54, 93, 0.06);
|
||||
}
|
||||
|
||||
.panel-card--empty {
|
||||
padding: 36px 20px;
|
||||
}
|
||||
|
||||
.panel-card--split {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
margin-bottom: 18px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.section-heading__eyebrow {
|
||||
display: inline-flex;
|
||||
padding: 4px 10px;
|
||||
color: #0f766e;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.16em;
|
||||
background: rgba(15, 118, 110, 0.1);
|
||||
}
|
||||
|
||||
.section-heading__title {
|
||||
margin-top: 10px;
|
||||
color: #162842;
|
||||
font-size: 22px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.section-heading__meta {
|
||||
color: #647892;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 4px 18px;
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.display-tabs :deep(.el-tabs__nav-wrap::after) {
|
||||
background-color: rgba(112, 144, 196, 0.16);
|
||||
}
|
||||
|
||||
.field-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.field-card {
|
||||
padding: 14px 16px;
|
||||
border: 1px solid rgba(112, 144, 196, 0.14);
|
||||
background: linear-gradient(135deg, rgba(250, 252, 255, 0.98), rgba(244, 248, 255, 0.92));
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
transform 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.field-card:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: rgba(37, 99, 235, 0.22);
|
||||
box-shadow: 0 10px 20px rgba(37, 99, 235, 0.06);
|
||||
}
|
||||
|
||||
.approval-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.approval-card {
|
||||
padding: 18px;
|
||||
border: 1px solid rgba(112, 144, 196, 0.14);
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(59, 130, 246, 0.1), transparent 22%),
|
||||
rgba(250, 252, 255, 0.96);
|
||||
}
|
||||
|
||||
.approval-card__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.approval-card__index {
|
||||
display: block;
|
||||
color: #6a7f98;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.approval-card__title {
|
||||
margin-top: 8px;
|
||||
display: block;
|
||||
color: #172942;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.approval-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 4px 18px;
|
||||
}
|
||||
|
||||
.approval-grid__item {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.text-action {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #2563eb;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.text-action--danger {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.region-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.region-card {
|
||||
padding: 14px 16px;
|
||||
border: 1px solid rgba(112, 144, 196, 0.14);
|
||||
background: rgba(251, 253, 255, 0.96);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.placeholder-board {
|
||||
padding: 20px;
|
||||
border: 1px dashed rgba(59, 130, 246, 0.28);
|
||||
background:
|
||||
linear-gradient(135deg, rgba(248, 252, 255, 0.98), rgba(241, 247, 255, 0.94));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.placeholder-board--soft {
|
||||
border-color: rgba(15, 118, 110, 0.24);
|
||||
background:
|
||||
linear-gradient(135deg, rgba(249, 253, 252, 0.98), rgba(242, 249, 247, 0.94));
|
||||
}
|
||||
|
||||
.placeholder-board__headline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.placeholder-board__headline strong {
|
||||
color: #182b44;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.placeholder-board__headline span {
|
||||
color: #61758f;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.placeholder-board__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.placeholder-tile {
|
||||
min-height: 96px;
|
||||
border: 1px dashed rgba(112, 144, 196, 0.24);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #5d738d;
|
||||
font-size: 13px;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.single-row-form {
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.single-row-form__field {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.condition-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.condition-tags span {
|
||||
padding: 8px 14px;
|
||||
color: #0f766e;
|
||||
font-size: 13px;
|
||||
background: rgba(15, 118, 110, 0.1);
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.field-grid,
|
||||
.region-grid,
|
||||
.placeholder-board__grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.editor-hero,
|
||||
.section-heading,
|
||||
.approval-card__header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.module-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.editor-hero__actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.panel-card,
|
||||
.editor-hero,
|
||||
.approval-card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.form-grid,
|
||||
.approval-grid,
|
||||
.field-grid,
|
||||
.region-grid,
|
||||
.placeholder-board__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,22 +1,600 @@
|
||||
<template>
|
||||
<div class="user-container">
|
||||
用户中心
|
||||
<section class="user-management-page">
|
||||
<div class="panel-card panel-card--search">
|
||||
<div class="filter-grid">
|
||||
<el-input v-model="filters.account" clearable class="filter-field" placeholder="搜索账号" />
|
||||
<el-input v-model="filters.username" clearable class="filter-field" placeholder="搜索用户名" />
|
||||
<el-input
|
||||
v-model="filters.remoteAccount"
|
||||
clearable
|
||||
class="filter-field"
|
||||
placeholder="搜索远程账号"
|
||||
/>
|
||||
<el-input
|
||||
v-model="filters.wechatAccount"
|
||||
clearable
|
||||
class="filter-field"
|
||||
placeholder="搜索微信账号"
|
||||
/>
|
||||
<el-select v-model="filters.department" clearable class="filter-field" placeholder="所属部门">
|
||||
<el-option
|
||||
v-for="option in departmentOptions"
|
||||
:key="option"
|
||||
:label="option"
|
||||
:value="option"
|
||||
/>
|
||||
</el-select>
|
||||
<el-select v-model="filters.position" clearable class="filter-field" placeholder="所属岗位">
|
||||
<el-option
|
||||
v-for="option in positionOptions"
|
||||
:key="option"
|
||||
:label="option"
|
||||
:value="option"
|
||||
/>
|
||||
</el-select>
|
||||
<el-select v-model="filters.status" clearable class="filter-field" placeholder="状态">
|
||||
<el-option
|
||||
v-for="option in statusOptions"
|
||||
:key="option"
|
||||
:label="option"
|
||||
:value="option"
|
||||
/>
|
||||
</el-select>
|
||||
<el-date-picker
|
||||
v-model="filters.createdRange"
|
||||
type="daterange"
|
||||
unlink-panels
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
value-format="YYYY-MM-DD"
|
||||
class="filter-field filter-field--date"
|
||||
/>
|
||||
<div class="filter-actions">
|
||||
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
||||
<el-button @click="resetFilters">重置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-card">
|
||||
<div class="table-toolbar">
|
||||
<div class="table-toolbar__title">
|
||||
<strong>用户列表</strong>
|
||||
<span>当前命中 {{ filteredUsers.length }} 条记录</span>
|
||||
</div>
|
||||
<div class="table-toolbar__summary">
|
||||
<span>启用 {{ enabledCount }} 人</span>
|
||||
<span>禁用 {{ disabledCount }} 人</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table v-if="filteredUsers.length" :data="paginatedUsers" class="user-table">
|
||||
<el-table-column label="账号" prop="account" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column label="用户名" prop="username" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column label="远程账号" prop="remoteAccount" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column label="微信账号" prop="wechatAccount" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column label="所属部门" prop="department" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column label="所属岗位" prop="position" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<span
|
||||
class="status-badge"
|
||||
:class="row.status === '启用' ? 'status-badge--enabled' : 'status-badge--disabled'"
|
||||
>
|
||||
{{ row.status }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建时间" prop="createdAt" min-width="176" />
|
||||
<el-table-column label="操作" fixed="right" width="160">
|
||||
<template #default="{ row }">
|
||||
<div class="row-actions">
|
||||
<button type="button" class="row-action" @click="openEditPage(row)">编辑</button>
|
||||
<el-dropdown @command="(command) => handleMoreCommand(command, row)">
|
||||
<button type="button" class="row-action row-action--more">更多</button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="detail">查看详情</el-dropdown-item>
|
||||
<el-dropdown-item command="toggle-status">
|
||||
{{ row.status === '启用' ? '禁用账号' : '启用账号' }}
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item command="reset-password" divided>重置密码</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-empty v-else description="未找到符合条件的用户" />
|
||||
|
||||
<div class="pagination-bar">
|
||||
<div class="pagination-bar__summary">
|
||||
<span>第 {{ currentPage }} / {{ totalPages }} 页</span>
|
||||
<span>共 {{ filteredUsers.length }} 条</span>
|
||||
</div>
|
||||
|
||||
<el-pagination
|
||||
v-model:current-page="currentPage"
|
||||
v-model:page-size="pageSize"
|
||||
background
|
||||
layout="prev, pager, next, sizes"
|
||||
:page-sizes="[5, 10, 20, 50]"
|
||||
:total="filteredUsers.length"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-drawer v-model="detailVisible" title="用户详情" size="420px" destroy-on-close>
|
||||
<div v-if="detailUser" class="detail-panel">
|
||||
<div class="detail-panel__hero">
|
||||
<span class="detail-panel__avatar">{{ detailUser.username.slice(0, 1) }}</span>
|
||||
<div>
|
||||
<h3 class="detail-panel__name">{{ detailUser.username }}</h3>
|
||||
<p class="detail-panel__account">{{ detailUser.account }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl class="detail-list">
|
||||
<div class="detail-list__item">
|
||||
<dt>远程账号</dt>
|
||||
<dd>{{ detailUser.remoteAccount }}</dd>
|
||||
</div>
|
||||
<div class="detail-list__item">
|
||||
<dt>微信账号</dt>
|
||||
<dd>{{ detailUser.wechatAccount }}</dd>
|
||||
</div>
|
||||
<div class="detail-list__item">
|
||||
<dt>所属部门</dt>
|
||||
<dd>{{ detailUser.department }}</dd>
|
||||
</div>
|
||||
<div class="detail-list__item">
|
||||
<dt>所属岗位</dt>
|
||||
<dd>{{ detailUser.position }}</dd>
|
||||
</div>
|
||||
<div class="detail-list__item">
|
||||
<dt>状态</dt>
|
||||
<dd>{{ detailUser.status }}</dd>
|
||||
</div>
|
||||
<div class="detail-list__item">
|
||||
<dt>创建时间</dt>
|
||||
<dd>{{ detailUser.createdAt }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { useRouter } from 'vue-router';
|
||||
import {
|
||||
cloneUser,
|
||||
departmentOptions,
|
||||
loadUsers,
|
||||
positionOptions,
|
||||
saveUsers,
|
||||
statusOptions,
|
||||
} from './user-data';
|
||||
|
||||
const store = useStore();
|
||||
const router = useRouter();
|
||||
|
||||
onMounted(() => {
|
||||
console.log('用户中心页面加载完成');
|
||||
const createFilterState = () => ({
|
||||
account: '',
|
||||
username: '',
|
||||
remoteAccount: '',
|
||||
wechatAccount: '',
|
||||
department: '',
|
||||
position: '',
|
||||
status: '',
|
||||
createdRange: [],
|
||||
});
|
||||
|
||||
const currentPage = ref(1);
|
||||
const pageSize = ref(10);
|
||||
const detailVisible = ref(false);
|
||||
const detailUser = ref(null);
|
||||
const filters = reactive(createFilterState());
|
||||
const appliedFilters = ref(createFilterState());
|
||||
const users = ref(loadUsers());
|
||||
|
||||
const filteredUsers = computed(() => {
|
||||
const currentFilters = appliedFilters.value;
|
||||
|
||||
return users.value.filter((user) => {
|
||||
const matchesAccount = includesKeyword(user.account, currentFilters.account);
|
||||
const matchesUsername = includesKeyword(user.username, currentFilters.username);
|
||||
const matchesRemote = includesKeyword(user.remoteAccount, currentFilters.remoteAccount);
|
||||
const matchesWechat = includesKeyword(user.wechatAccount, currentFilters.wechatAccount);
|
||||
const matchesDepartment = currentFilters.department
|
||||
? user.department === currentFilters.department
|
||||
: true;
|
||||
const matchesPosition = currentFilters.position
|
||||
? user.position === currentFilters.position
|
||||
: true;
|
||||
const matchesStatus = currentFilters.status ? user.status === currentFilters.status : true;
|
||||
const matchesCreatedRange = matchCreatedRange(user.createdAt, currentFilters.createdRange);
|
||||
|
||||
return (
|
||||
matchesAccount &&
|
||||
matchesUsername &&
|
||||
matchesRemote &&
|
||||
matchesWechat &&
|
||||
matchesDepartment &&
|
||||
matchesPosition &&
|
||||
matchesStatus &&
|
||||
matchesCreatedRange
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const paginatedUsers = computed(() => {
|
||||
const startIndex = (currentPage.value - 1) * pageSize.value;
|
||||
return filteredUsers.value.slice(startIndex, startIndex + pageSize.value);
|
||||
});
|
||||
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(filteredUsers.value.length / pageSize.value)));
|
||||
const enabledCount = computed(() => users.value.filter((item) => item.status === '启用').length);
|
||||
const disabledCount = computed(() => users.value.filter((item) => item.status === '禁用').length);
|
||||
|
||||
watch(
|
||||
[filteredUsers, pageSize],
|
||||
() => {
|
||||
if (currentPage.value > totalPages.value) {
|
||||
currentPage.value = totalPages.value;
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function cloneFilters(source) {
|
||||
return {
|
||||
account: source.account,
|
||||
username: source.username,
|
||||
remoteAccount: source.remoteAccount,
|
||||
wechatAccount: source.wechatAccount,
|
||||
department: source.department,
|
||||
position: source.position,
|
||||
status: source.status,
|
||||
createdRange: Array.isArray(source.createdRange) ? [...source.createdRange] : [],
|
||||
};
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
users.value = loadUsers();
|
||||
appliedFilters.value = cloneFilters(filters);
|
||||
currentPage.value = 1;
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
Object.assign(filters, createFilterState());
|
||||
appliedFilters.value = createFilterState();
|
||||
users.value = loadUsers();
|
||||
currentPage.value = 1;
|
||||
}
|
||||
|
||||
function openEditPage(user) {
|
||||
router.push({ name: 'UserManagementEdit', params: { id: user.id } });
|
||||
}
|
||||
|
||||
function handleMoreCommand(command, user) {
|
||||
if (command === 'detail') {
|
||||
detailUser.value = cloneUser(user);
|
||||
detailVisible.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === 'toggle-status') {
|
||||
toggleStatus(user);
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === 'reset-password') {
|
||||
ElMessage.success(`已为账号 ${user.account} 重置密码`);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleStatus(user) {
|
||||
const nextStatus = user.status === '启用' ? '禁用' : '启用';
|
||||
|
||||
users.value = users.value.map((item) =>
|
||||
item.id === user.id
|
||||
? {
|
||||
...item,
|
||||
status: nextStatus,
|
||||
}
|
||||
: item,
|
||||
);
|
||||
|
||||
saveUsers(users.value);
|
||||
|
||||
if (detailUser.value?.id === user.id) {
|
||||
detailUser.value = {
|
||||
...detailUser.value,
|
||||
status: nextStatus,
|
||||
};
|
||||
}
|
||||
|
||||
ElMessage.success(`账号 ${user.account} 已${nextStatus === '启用' ? '启用' : '禁用'}`);
|
||||
}
|
||||
|
||||
function includesKeyword(value, keyword) {
|
||||
const normalizedKeyword = String(keyword || '').trim().toLowerCase();
|
||||
if (!normalizedKeyword) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return String(value || '').toLowerCase().includes(normalizedKeyword);
|
||||
}
|
||||
|
||||
function matchCreatedRange(createdAt, range) {
|
||||
if (!Array.isArray(range) || range.length !== 2) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const [startDate, endDate] = range;
|
||||
const currentDate = String(createdAt || '').slice(0, 10);
|
||||
return currentDate >= startDate && currentDate <= endDate;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.user-container {
|
||||
.user-management-page {
|
||||
--search-sticky-gap: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.panel-card {
|
||||
padding: 20px;
|
||||
border: 1px solid rgba(112, 144, 196, 0.12);
|
||||
border-radius: 2px;
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.92),
|
||||
0 22px 40px rgba(26, 54, 93, 0.06);
|
||||
}
|
||||
|
||||
.panel-card--search {
|
||||
position: sticky;
|
||||
top: calc(var(--shell-header-offset, 74px) + var(--search-sticky-gap));
|
||||
z-index: 6;
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(15, 118, 110, 0.12), transparent 26%),
|
||||
radial-gradient(circle at left center, rgba(59, 130, 246, 0.16), transparent 32%),
|
||||
rgba(255, 255, 255, 0.92);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.filter-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.filter-field {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.filter-field--date {
|
||||
min-width: 0;
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.filter-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
grid-column: 5 / 7;
|
||||
}
|
||||
|
||||
.table-toolbar,
|
||||
.pagination-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.table-toolbar {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.table-toolbar__title,
|
||||
.table-toolbar__summary,
|
||||
.pagination-bar__summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.table-toolbar__title strong {
|
||||
color: #1b2e49;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.table-toolbar__title span,
|
||||
.table-toolbar__summary,
|
||||
.pagination-bar__summary {
|
||||
color: #5f738f;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.user-table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:deep(.user-table .el-table__inner-wrapper),
|
||||
:deep(.user-table .el-table__cell),
|
||||
:deep(.user-table::before) {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
:deep(.user-table .el-table__inner-wrapper) {
|
||||
border: 1px solid #e4eaf3;
|
||||
border-radius: 2px;
|
||||
box-shadow: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:deep(.user-table th.el-table__cell) {
|
||||
background: #f6f9fc;
|
||||
color: #24364d;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
:deep(.user-table td.el-table__cell) {
|
||||
color: #1f2f46;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 56px;
|
||||
padding: 5px 12px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.status-badge--enabled {
|
||||
color: #0f766e;
|
||||
background: rgba(15, 118, 110, 0.12);
|
||||
}
|
||||
|
||||
.status-badge--disabled {
|
||||
color: #b45309;
|
||||
background: rgba(245, 158, 11, 0.16);
|
||||
}
|
||||
|
||||
.row-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.row-action {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
color: #1d4ed8;
|
||||
font-size: 13px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.row-action:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.row-action--more::after {
|
||||
content: ' ▾';
|
||||
}
|
||||
|
||||
.pagination-bar {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.detail-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.detail-panel__hero {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 18px;
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(59, 130, 246, 0.16), transparent 24%),
|
||||
linear-gradient(135deg, rgba(246, 250, 255, 0.96), rgba(238, 245, 255, 0.92));
|
||||
}
|
||||
|
||||
.detail-panel__avatar {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #ffffff;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, #2563eb, #0f766e);
|
||||
}
|
||||
|
||||
.detail-panel__name {
|
||||
color: #172942;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.detail-panel__account {
|
||||
margin-top: 4px;
|
||||
color: #5f738f;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.detail-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.detail-list__item {
|
||||
padding: 14px 16px;
|
||||
border: 1px solid rgba(112, 144, 196, 0.12);
|
||||
background: #fbfdff;
|
||||
}
|
||||
|
||||
.detail-list__item dt {
|
||||
color: #6a7f98;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.detail-list__item dd {
|
||||
margin-top: 8px;
|
||||
color: #1b2d47;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.filter-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.filter-actions {
|
||||
grid-column: span 3;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.filter-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.panel-card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.table-toolbar,
|
||||
.pagination-bar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.filter-actions,
|
||||
.filter-field--date {
|
||||
grid-column: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
382
src/views/user/user-data.js
Normal file
382
src/views/user/user-data.js
Normal file
@@ -0,0 +1,382 @@
|
||||
import storage from '@/utils/storage';
|
||||
|
||||
export const USER_STORAGE_KEY = 'admin-pc:user-management';
|
||||
|
||||
export const departmentOptions = [
|
||||
'南京经营部',
|
||||
'扬淮经营部',
|
||||
'徐宿经营部',
|
||||
'连盐经营部',
|
||||
'总部经营部',
|
||||
'工程部',
|
||||
'综合部',
|
||||
'总部领导',
|
||||
'苏通经营部',
|
||||
'锡泰经营部',
|
||||
'常镇经营部',
|
||||
'省属平台',
|
||||
];
|
||||
|
||||
export const positionOptions = [
|
||||
'管理员',
|
||||
'总经理',
|
||||
'副总经理',
|
||||
'总部经营部部长',
|
||||
'省市经营部部长',
|
||||
'省市经营部副部长',
|
||||
'办事处主任',
|
||||
'部员',
|
||||
'总部工程部部长',
|
||||
'省市经营部总经理',
|
||||
'总部综合部长',
|
||||
'总部财务部副部长',
|
||||
'专家',
|
||||
];
|
||||
|
||||
export const statusOptions = ['启用', '禁用'];
|
||||
|
||||
export const provinceCityOptions = [
|
||||
'江苏省',
|
||||
'南京市',
|
||||
'无锡市',
|
||||
'徐州市',
|
||||
'常州市',
|
||||
'苏州市',
|
||||
'南通市',
|
||||
'连云港市',
|
||||
'淮安市',
|
||||
'盐城市',
|
||||
'扬州市',
|
||||
'镇江市',
|
||||
'泰州市',
|
||||
'宿迁市',
|
||||
];
|
||||
|
||||
export const visibilityTypeOptions = [
|
||||
'仅自身',
|
||||
'经营部',
|
||||
'全部',
|
||||
'项目授权',
|
||||
'经营部 + 项目授权',
|
||||
];
|
||||
|
||||
export const projectDisplayTabs = [
|
||||
{
|
||||
key: 'basicInfo',
|
||||
label: '基本信息',
|
||||
fields: [
|
||||
{ key: 'projectCode', label: '项目编号' },
|
||||
{ key: 'projectName', label: '项目名称' },
|
||||
{ key: 'projectOwner', label: '建设单位' },
|
||||
{ key: 'projectLocation', label: '项目地点' },
|
||||
{ key: 'sectorType', label: '板块类型' },
|
||||
{ key: 'manager', label: '项目负责人' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'trackingInfo',
|
||||
label: '跟踪信息',
|
||||
fields: [
|
||||
{ key: 'trackingStage', label: '跟踪阶段' },
|
||||
{ key: 'trackingOwner', label: '跟踪负责人' },
|
||||
{ key: 'opponentSituation', label: '竞争对手情况' },
|
||||
{ key: 'contactProgress', label: '对接进展' },
|
||||
{ key: 'expectedBidTime', label: '预计招标时间' },
|
||||
{ key: 'riskLevel', label: '风险等级' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'qualificationInfo',
|
||||
label: '资格预审信息',
|
||||
fields: [
|
||||
{ key: 'prequalificationNotice', label: '资格预审公告' },
|
||||
{ key: 'prequalificationDeadline', label: '报名截止时间' },
|
||||
{ key: 'qualificationRequirement', label: '资质要求' },
|
||||
{ key: 'qualificationResult', label: '预审结果' },
|
||||
{ key: 'filePurchase', label: '文件购买情况' },
|
||||
{ key: 'reviewComment', label: '评审备注' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'tenderInfo',
|
||||
label: '投标信息',
|
||||
fields: [
|
||||
{ key: 'tenderAmount', label: '投标金额' },
|
||||
{ key: 'tenderDeadline', label: '投标截止时间' },
|
||||
{ key: 'tenderLeader', label: '投标负责人' },
|
||||
{ key: 'bidBond', label: '投标保证金' },
|
||||
{ key: 'winningProbability', label: '中标概率' },
|
||||
{ key: 'tenderComment', label: '投标备注' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'constructionInfo',
|
||||
label: '在建信息',
|
||||
fields: [
|
||||
{ key: 'contractAmount', label: '合同金额' },
|
||||
{ key: 'constructionManager', label: '项目经理' },
|
||||
{ key: 'constructionProgress', label: '工程进度' },
|
||||
{ key: 'paymentProgress', label: '回款进度' },
|
||||
{ key: 'safetyStatus', label: '安全状态' },
|
||||
{ key: 'siteVideo', label: '现场视频' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const seedUsers = [
|
||||
{
|
||||
id: 'user_1',
|
||||
account: 'admin',
|
||||
username: '平台管理员',
|
||||
remoteAccount: 'remote_admin',
|
||||
wechatAccount: 'wx_admin',
|
||||
department: '总部领导',
|
||||
position: '管理员',
|
||||
status: '启用',
|
||||
createdAt: '2026-01-03 09:15:00',
|
||||
},
|
||||
{
|
||||
id: 'user_2',
|
||||
account: 'nj_zhangming',
|
||||
username: '张明',
|
||||
remoteAccount: 'oa_zhangming',
|
||||
wechatAccount: 'wx_zhangming',
|
||||
department: '南京经营部',
|
||||
position: '总经理',
|
||||
status: '启用',
|
||||
createdAt: '2026-01-08 10:20:00',
|
||||
},
|
||||
{
|
||||
id: 'user_3',
|
||||
account: 'yh_lina',
|
||||
username: '李娜',
|
||||
remoteAccount: 'oa_lina',
|
||||
wechatAccount: 'wx_lina',
|
||||
department: '扬淮经营部',
|
||||
position: '副总经理',
|
||||
status: '启用',
|
||||
createdAt: '2026-01-12 14:06:00',
|
||||
},
|
||||
{
|
||||
id: 'user_4',
|
||||
account: 'xs_chenwei',
|
||||
username: '陈伟',
|
||||
remoteAccount: 'oa_chenwei',
|
||||
wechatAccount: 'wx_chenwei',
|
||||
department: '徐宿经营部',
|
||||
position: '省市经营部部长',
|
||||
status: '禁用',
|
||||
createdAt: '2026-01-16 08:45:00',
|
||||
},
|
||||
{
|
||||
id: 'user_5',
|
||||
account: 'ly_wangjie',
|
||||
username: '王杰',
|
||||
remoteAccount: 'oa_wangjie',
|
||||
wechatAccount: 'wx_wangjie',
|
||||
department: '连盐经营部',
|
||||
position: '省市经营部副部长',
|
||||
status: '启用',
|
||||
createdAt: '2026-01-21 11:38:00',
|
||||
},
|
||||
{
|
||||
id: 'user_6',
|
||||
account: 'hq_sunli',
|
||||
username: '孙丽',
|
||||
remoteAccount: 'oa_sunli',
|
||||
wechatAccount: 'wx_sunli',
|
||||
department: '总部经营部',
|
||||
position: '总部经营部部长',
|
||||
status: '启用',
|
||||
createdAt: '2026-02-03 09:52:00',
|
||||
},
|
||||
{
|
||||
id: 'user_7',
|
||||
account: 'gc_zhaokai',
|
||||
username: '赵凯',
|
||||
remoteAccount: 'oa_zhaokai',
|
||||
wechatAccount: 'wx_zhaokai',
|
||||
department: '工程部',
|
||||
position: '总部工程部部长',
|
||||
status: '启用',
|
||||
createdAt: '2026-02-11 16:22:00',
|
||||
},
|
||||
{
|
||||
id: 'user_8',
|
||||
account: 'zhb_liuqi',
|
||||
username: '刘琪',
|
||||
remoteAccount: 'oa_liuqi',
|
||||
wechatAccount: 'wx_liuqi',
|
||||
department: '综合部',
|
||||
position: '总部综合部长',
|
||||
status: '禁用',
|
||||
createdAt: '2026-02-18 13:11:00',
|
||||
},
|
||||
{
|
||||
id: 'user_9',
|
||||
account: 'st_hujun',
|
||||
username: '胡军',
|
||||
remoteAccount: 'oa_hujun',
|
||||
wechatAccount: 'wx_hujun',
|
||||
department: '苏通经营部',
|
||||
position: '办事处主任',
|
||||
status: '启用',
|
||||
createdAt: '2026-03-02 09:05:00',
|
||||
},
|
||||
{
|
||||
id: 'user_10',
|
||||
account: 'xt_yangfan',
|
||||
username: '杨帆',
|
||||
remoteAccount: 'oa_yangfan',
|
||||
wechatAccount: 'wx_yangfan',
|
||||
department: '锡泰经营部',
|
||||
position: '省市经营部总经理',
|
||||
status: '启用',
|
||||
createdAt: '2026-03-15 15:16:00',
|
||||
},
|
||||
{
|
||||
id: 'user_11',
|
||||
account: 'cz_fangyu',
|
||||
username: '方宇',
|
||||
remoteAccount: 'oa_fangyu',
|
||||
wechatAccount: 'wx_fangyu',
|
||||
department: '常镇经营部',
|
||||
position: '部员',
|
||||
status: '禁用',
|
||||
createdAt: '2026-03-24 10:42:00',
|
||||
},
|
||||
{
|
||||
id: 'user_12',
|
||||
account: 'pt_xujie',
|
||||
username: '许杰',
|
||||
remoteAccount: 'oa_xujie',
|
||||
wechatAccount: 'wx_xujie',
|
||||
department: '省属平台',
|
||||
position: '专家',
|
||||
status: '启用',
|
||||
createdAt: '2026-04-06 17:28:00',
|
||||
},
|
||||
];
|
||||
|
||||
function createId(prefix) {
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
function cloneValue(value) {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function createDefaultProjectDisplay() {
|
||||
return projectDisplayTabs.reduce((result, tab) => {
|
||||
result[tab.key] = tab.fields.map((field) => field.key);
|
||||
return result;
|
||||
}, {});
|
||||
}
|
||||
|
||||
export function createApprovalNode(index = 1) {
|
||||
return {
|
||||
id: createId('approval'),
|
||||
description: `第 ${index} 审批节点`,
|
||||
approverIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function createDefaultUserConfig() {
|
||||
return {
|
||||
projectDisplay: createDefaultProjectDisplay(),
|
||||
approvalFlow: [createApprovalNode(1)],
|
||||
authorizedRegions: ['江苏省'],
|
||||
dockingVisibility: '仅自身',
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeApprovalNode(node, index) {
|
||||
return {
|
||||
id: node?.id || createId('approval'),
|
||||
description: String(node?.description || `第 ${index + 1} 审批节点`),
|
||||
approverIds: Array.isArray(node?.approverIds) ? [...new Set(node.approverIds.map(String))] : [],
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeUserConfig(config) {
|
||||
const defaultConfig = createDefaultUserConfig();
|
||||
const normalizedProjectDisplay = projectDisplayTabs.reduce((result, tab) => {
|
||||
const availableKeys = tab.fields.map((field) => field.key);
|
||||
const selectedKeys = Array.isArray(config?.projectDisplay?.[tab.key])
|
||||
? config.projectDisplay[tab.key].filter((item) => availableKeys.includes(item))
|
||||
: [];
|
||||
|
||||
result[tab.key] = selectedKeys.length ? selectedKeys : [...defaultConfig.projectDisplay[tab.key]];
|
||||
return result;
|
||||
}, {});
|
||||
|
||||
const approvalFlowSource =
|
||||
Array.isArray(config?.approvalFlow) && config.approvalFlow.length
|
||||
? config.approvalFlow
|
||||
: defaultConfig.approvalFlow;
|
||||
|
||||
const authorizedRegions = Array.isArray(config?.authorizedRegions)
|
||||
? config.authorizedRegions.filter((item) => provinceCityOptions.includes(item))
|
||||
: [];
|
||||
|
||||
return {
|
||||
projectDisplay: normalizedProjectDisplay,
|
||||
approvalFlow: approvalFlowSource.map(normalizeApprovalNode),
|
||||
authorizedRegions: authorizedRegions.length ? [...new Set(authorizedRegions)] : ['江苏省'],
|
||||
dockingVisibility: visibilityTypeOptions.includes(config?.dockingVisibility)
|
||||
? config.dockingVisibility
|
||||
: defaultConfig.dockingVisibility,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeUser(user) {
|
||||
return {
|
||||
id: String(user?.id || createId('user')),
|
||||
account: String(user?.account || ''),
|
||||
username: String(user?.username || ''),
|
||||
remoteAccount: String(user?.remoteAccount || ''),
|
||||
wechatAccount: String(user?.wechatAccount || ''),
|
||||
department: String(user?.department || ''),
|
||||
position: String(user?.position || ''),
|
||||
status: user?.status === '禁用' ? '禁用' : '启用',
|
||||
createdAt: String(user?.createdAt || formatCurrentDateTime()),
|
||||
config: normalizeUserConfig(user?.config),
|
||||
};
|
||||
}
|
||||
|
||||
export function loadUsers() {
|
||||
const storedUsers = storage.getLocalStorage(USER_STORAGE_KEY);
|
||||
const source = Array.isArray(storedUsers) && storedUsers.length ? storedUsers : seedUsers;
|
||||
return source.map(normalizeUser);
|
||||
}
|
||||
|
||||
export function saveUsers(users) {
|
||||
storage.setLocalStorage(USER_STORAGE_KEY, users.map(normalizeUser));
|
||||
}
|
||||
|
||||
export function getUserById(userId) {
|
||||
return loadUsers().find((item) => item.id === userId) || null;
|
||||
}
|
||||
|
||||
export function buildApproverOptions(users) {
|
||||
return users.map((user) => ({
|
||||
label: `${user.username} / ${user.position}`,
|
||||
value: user.id,
|
||||
}));
|
||||
}
|
||||
|
||||
export function cloneUser(user) {
|
||||
return cloneValue(normalizeUser(user));
|
||||
}
|
||||
|
||||
function formatCurrentDateTime() {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(now.getDate()).padStart(2, '0');
|
||||
const hours = String(now.getHours()).padStart(2, '0');
|
||||
const minutes = String(now.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(now.getSeconds()).padStart(2, '0');
|
||||
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||
}
|
||||
Reference in New Issue
Block a user