新增用户管理模块

This commit is contained in:
2026-05-09 11:47:36 +08:00
parent 0ade339435
commit bf00397621
16 changed files with 2353 additions and 787 deletions

View File

@@ -55,7 +55,7 @@
type="button" type="button"
class="icon-button" class="icon-button"
aria-label="通知中心" aria-label="通知中心"
@click="handlePrototypeNotice" @click="router.push({ name: 'NotificationCenter' })"
> >
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true"> <svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path <path
@@ -116,7 +116,7 @@ const menuItems = [
label: '上级来访/督导', label: '上级来访/督导',
}, },
{ {
label: '红黄牌处', label: '红黄牌处',
}, },
{ {
label: '监控视频', label: '监控视频',
@@ -125,7 +125,7 @@ const menuItems = [
], ],
}, },
{ {
label: '招投标信息管理', label: '公共资源平台',
routeName: 'TenderProjectManagement', routeName: 'TenderProjectManagement',
activeNames: [ activeNames: [
'TenderProjectManagement', 'TenderProjectManagement',
@@ -135,26 +135,32 @@ const menuItems = [
], ],
children: [ children: [
{ {
label: '招标项目管理', label: '招标公告管理',
note: '管理招标金额、发布日期与详情链接', note: '管理招标金额、发布日期与详情链接',
routeName: 'TenderProjectManagement', routeName: 'TenderProjectManagement',
}, },
{ {
label: '中标项目管理', label: '中标公告管理',
note: '管理中标金额与中标单位信息', note: '管理中标金额与中标单位信息',
routeName: 'AwardProjectManagement', routeName: 'AwardProjectManagement',
}, },
{
label: '板块管理',
routeName: 'SectorManagement',
},
{ {
label: '任务配置', label: '任务配置',
routeName: 'TenderTaskConfig', routeName: 'TenderTaskConfig',
}, },
], ],
}, },
{ label: '系统管理' }, {
label: '系统管理',
routeName: 'UserManagement',
activeNames: ['UserManagement', 'UserManagementEdit'],
children: [
{
label: '用户管理',
routeName: 'UserManagement',
},
],
},
]; ];
const handlePrototypeNotice = () => { const handlePrototypeNotice = () => {

View File

@@ -5,12 +5,14 @@ import homeRoutes from './modules/home';
import userRoutes from './modules/user'; import userRoutes from './modules/user';
import supervisionRoutes from './modules/supervision'; import supervisionRoutes from './modules/supervision';
import tenderingRoutes from './modules/tendering'; import tenderingRoutes from './modules/tendering';
import notificationRoutes from './modules/notification';
const routes = [ const routes = [
...homeRoutes, ...homeRoutes,
...userRoutes, ...userRoutes,
...supervisionRoutes, ...supervisionRoutes,
...tenderingRoutes, ...tenderingRoutes,
...notificationRoutes,
]; ];
const router = createRouter({ const router = createRouter({

View File

@@ -0,0 +1,10 @@
export default [
{
path: '/notifications',
name: 'NotificationCenter',
component: () => import('@/views/notification/index.vue'),
meta: {
title: '通知中心',
},
},
];

View File

@@ -4,7 +4,7 @@ export default [
name: 'TenderProjectManagement', name: 'TenderProjectManagement',
component: () => import('@/views/tendering/tender/index.vue'), component: () => import('@/views/tendering/tender/index.vue'),
meta: { meta: {
title: '招标项目管理', title: '招标公告管理',
}, },
}, },
{ {
@@ -12,15 +12,7 @@ export default [
name: 'AwardProjectManagement', name: 'AwardProjectManagement',
component: () => import('@/views/tendering/award/index.vue'), component: () => import('@/views/tendering/award/index.vue'),
meta: { meta: {
title: '中标项目管理', title: '中标公告管理',
},
},
{
path: '/tendering/sector-management',
name: 'SectorManagement',
component: () => import('@/views/tendering/sector/index.vue'),
meta: {
title: '板块管理',
}, },
}, },
{ {

View File

@@ -1,14 +1,23 @@
/** /**
* 用户相关路由配置 * 用户管理路由配置
*/ */
export default [ export default [
{ {
path: '/user', path: '/system/user-management',
name: 'User', alias: '/user',
component: () => import('@/views/user/index.vue'), name: 'UserManagement',
meta: { component: () => import('@/views/user/index.vue'),
title: '用户中心', meta: {
}, title: '用户管理',
}, },
},
{
path: '/system/user-management/:id/edit',
name: 'UserManagementEdit',
component: () => import('@/views/user/edit.vue'),
meta: {
title: '用户编辑',
},
},
]; ];

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

View File

@@ -11,6 +11,15 @@
end-placeholder="结束时间" end-placeholder="结束时间"
class="filter-field filter-field--range" 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> <el-button @click="resetFilters">重置</el-button>
</div> </div>
</div> </div>
@@ -57,14 +66,30 @@
<span v-else>-</span> <span v-else>-</span>
</template> </template>
</el-table-column> </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="creator" min-width="120" />
<el-table-column label="更新时间" prop="updatedAt" min-width="176" /> <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 }"> <template #default="{ row }">
<div class="row-actions"> <div class="row-actions">
<button type="button" class="row-action" @click="openDetailDialog(row)">
查看
</button>
<button type="button" class="row-action" @click="openEditDialog(row)"> <button type="button" class="row-action" @click="openEditDialog(row)">
编辑 编辑
</button> </button>
@@ -120,6 +145,18 @@
<div class="detail-item__label">更新时间</div> <div class="detail-item__label">更新时间</div>
<div class="detail-item__value">{{ detailRecord.updatedAt || '-' }}</div> <div class="detail-item__value">{{ detailRecord.updatedAt || '-' }}</div>
</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 detail-item--full">
<div class="detail-item__label">监管计划内容</div> <div class="detail-item__label">监管计划内容</div>
<div class="detail-item__value detail-item__value--file"> <div class="detail-item__value detail-item__value--file">
@@ -173,50 +210,63 @@
/> />
</el-form-item> </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"> <el-form-item label="监管计划内容" prop="planFileName" class="form-item--full">
<div class="upload-panel"> <div class="upload-simple">
<input <input
ref="fileInputRef" ref="fileInputRef"
type="file" type="file"
accept=".pdf,application/pdf" accept=".pdf,application/pdf"
class="upload-panel__input" class="upload-native-input"
@change="handleFileChange" @change="handleFileChange"
/> />
<div class="upload-panel__content"> <div v-if="formModel.planFileName" class="upload-panel__actions">
<div class="upload-panel__copy"> <el-button
<strong class="upload-panel__title">上传 PDF 文件</strong> v-if="formModel.planFileUrl"
<span class="upload-panel__hint"> @click="openCurrentFile"
仅支持 PDF文件会以原型数据形式保存在本地浏览器 >
</span> 查看文件
</div> </el-button>
<el-button
<div class="upload-panel__actions"> v-if="formModel.planFileName"
<el-button type="primary" plain @click="triggerFileSelect"> text
选择文件 type="danger"
</el-button> @click="clearSelectedFile"
<el-button >
v-if="formModel.planFileUrl" 移除
@click="openCurrentFile" </el-button>
>
查看文件
</el-button>
<el-button
v-if="formModel.planFileName"
text
type="danger"
@click="clearSelectedFile"
>
移除
</el-button>
</div>
</div> </div>
<div v-if="formModel.planFileName" class="upload-file"> <div v-if="formModel.planFileName" class="upload-file">
<span class="upload-file__name">{{ formModel.planFileName }}</span> <button type="button" class="upload-file__main" @click="openCurrentFile">
<span v-if="formModel.planFileSizeText" class="upload-file__meta"> <span class="upload-file__name">{{ formModel.planFileName }}</span>
{{ formModel.planFileSizeText }} <span v-if="formModel.planFileSizeText" class="upload-file__meta">
</span> {{ formModel.planFileSizeText }}
</span>
</button>
<button type="button" class="upload-file__delete" @click="clearSelectedFile">
<el-icon><Delete /></el-icon>
</button>
</div> </div>
</div> </div>
</el-form-item> </el-form-item>
@@ -235,16 +285,27 @@
<script setup> <script setup>
import { computed, reactive, ref, watch } from 'vue'; import { computed, reactive, ref, watch } from 'vue';
import { Delete } from '@element-plus/icons-vue';
import { ElMessage, ElMessageBox } from 'element-plus'; import { ElMessage, ElMessageBox } from 'element-plus';
import storage from '@/utils/storage'; import storage from '@/utils/storage';
const STORAGE_KEY = 'admin-pc:supervision-plans'; 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 = [ const initialRecords = [
{ {
id: 'supervision_plan_1', id: 'supervision_plan_1',
startTime: '2026-04-01', startTime: '2026-04-01',
endTime: '2026-04-15', endTime: '2026-04-15',
notifyReceiverIds: ['zhangmin', 'chenyu'],
creator: '王敏', creator: '王敏',
updatedAt: '2026-04-01 09:20:00', updatedAt: '2026-04-01 09:20:00',
planFileName: '一季度重点项目监管计划.pdf', planFileName: '一季度重点项目监管计划.pdf',
@@ -256,6 +317,7 @@ const initialRecords = [
id: 'supervision_plan_2', id: 'supervision_plan_2',
startTime: '2026-04-18', startTime: '2026-04-18',
endTime: '2026-04-30', endTime: '2026-04-30',
notifyReceiverIds: ['lifeng', 'zhaoqing'],
creator: '李峰', creator: '李峰',
updatedAt: '2026-04-18 14:35:00', updatedAt: '2026-04-18 14:35:00',
planFileName: '四月安全巡检监管安排.pdf', planFileName: '四月安全巡检监管安排.pdf',
@@ -267,6 +329,7 @@ const initialRecords = [
id: 'supervision_plan_3', id: 'supervision_plan_3',
startTime: '2026-05-01', startTime: '2026-05-01',
endTime: '2026-05-20', endTime: '2026-05-20',
notifyReceiverIds: ['chenyu', 'guchen'],
creator: '陈宇', creator: '陈宇',
updatedAt: '2026-04-26 18:10:00', updatedAt: '2026-04-26 18:10:00',
planFileName: '五月监管执行计划.pdf', planFileName: '五月监管执行计划.pdf',
@@ -278,6 +341,7 @@ const initialRecords = [
id: 'supervision_plan_4', id: 'supervision_plan_4',
startTime: '2026-05-08', startTime: '2026-05-08',
endTime: '2026-05-22', endTime: '2026-05-22',
notifyReceiverIds: ['zhaoqing', 'liuying'],
creator: '赵晴', creator: '赵晴',
updatedAt: '2026-04-27 08:45:00', updatedAt: '2026-04-27 08:45:00',
planFileName: '重点项目节点复核监管计划.pdf', planFileName: '重点项目节点复核监管计划.pdf',
@@ -289,6 +353,7 @@ const initialRecords = [
id: 'supervision_plan_5', id: 'supervision_plan_5',
startTime: '2026-05-15', startTime: '2026-05-15',
endTime: '2026-05-31', endTime: '2026-05-31',
notifyReceiverIds: ['guchen', 'zhangmin'],
creator: '顾晨', creator: '顾晨',
updatedAt: '2026-04-27 09:15:00', updatedAt: '2026-04-27 09:15:00',
planFileName: '五月下旬施工安全监管清单.pdf', planFileName: '五月下旬施工安全监管清单.pdf',
@@ -300,6 +365,7 @@ const initialRecords = [
id: 'supervision_plan_6', id: 'supervision_plan_6',
startTime: '2026-06-01', startTime: '2026-06-01',
endTime: '2026-06-12', endTime: '2026-06-12',
notifyReceiverIds: ['liuying', 'lifeng'],
creator: '刘颖', creator: '刘颖',
updatedAt: '2026-04-27 09:40:00', updatedAt: '2026-04-27 09:40:00',
planFileName: '六月首轮质量检查监管安排.pdf', planFileName: '六月首轮质量检查监管安排.pdf',
@@ -311,6 +377,7 @@ const initialRecords = [
id: 'supervision_plan_7', id: 'supervision_plan_7',
startTime: '2026-06-10', startTime: '2026-06-10',
endTime: '2026-06-25', endTime: '2026-06-25',
notifyReceiverIds: ['zhangmin', 'guchen'],
creator: '王敏', creator: '王敏',
updatedAt: '2026-04-27 10:05:00', updatedAt: '2026-04-27 10:05:00',
planFileName: '半年度现场监管抽查方案.pdf', planFileName: '半年度现场监管抽查方案.pdf',
@@ -322,6 +389,7 @@ const initialRecords = [
id: 'supervision_plan_8', id: 'supervision_plan_8',
startTime: '2026-06-18', startTime: '2026-06-18',
endTime: '2026-07-02', endTime: '2026-07-02',
notifyReceiverIds: ['lifeng', 'liuying'],
creator: '李峰', creator: '李峰',
updatedAt: '2026-04-27 10:20:00', updatedAt: '2026-04-27 10:20:00',
planFileName: '汛期重点工程风险监管预案.pdf', planFileName: '汛期重点工程风险监管预案.pdf',
@@ -333,6 +401,7 @@ const initialRecords = [
id: 'supervision_plan_9', id: 'supervision_plan_9',
startTime: '2026-07-01', startTime: '2026-07-01',
endTime: '2026-07-15', endTime: '2026-07-15',
notifyReceiverIds: ['chenyu', 'zhaoqing'],
creator: '陈宇', creator: '陈宇',
updatedAt: '2026-04-27 10:55:00', updatedAt: '2026-04-27 10:55:00',
planFileName: '七月月度监管执行计划.pdf', planFileName: '七月月度监管执行计划.pdf',
@@ -344,6 +413,7 @@ const initialRecords = [
id: 'supervision_plan_10', id: 'supervision_plan_10',
startTime: '2026-07-12', startTime: '2026-07-12',
endTime: '2026-07-30', endTime: '2026-07-30',
notifyReceiverIds: ['zhaoqing', 'guchen'],
creator: '赵晴', creator: '赵晴',
updatedAt: '2026-04-27 11:18:00', updatedAt: '2026-04-27 11:18:00',
planFileName: '重点合同履约监管专项方案.pdf', planFileName: '重点合同履约监管专项方案.pdf',
@@ -355,6 +425,7 @@ const initialRecords = [
id: 'supervision_plan_11', id: 'supervision_plan_11',
startTime: '2026-08-01', startTime: '2026-08-01',
endTime: '2026-08-18', endTime: '2026-08-18',
notifyReceiverIds: ['guchen', 'liuying'],
creator: '顾晨', creator: '顾晨',
updatedAt: '2026-04-27 11:35:00', updatedAt: '2026-04-27 11:35:00',
planFileName: '八月工程进度监管计划.pdf', planFileName: '八月工程进度监管计划.pdf',
@@ -366,6 +437,7 @@ const initialRecords = [
id: 'supervision_plan_12', id: 'supervision_plan_12',
startTime: '2026-08-15', startTime: '2026-08-15',
endTime: '2026-08-31', endTime: '2026-08-31',
notifyReceiverIds: ['liuying', 'zhangmin'],
creator: '刘颖', creator: '刘颖',
updatedAt: '2026-04-27 11:52:00', updatedAt: '2026-04-27 11:52:00',
planFileName: '三季度开工项目监管部署.pdf', planFileName: '三季度开工项目监管部署.pdf',
@@ -387,11 +459,13 @@ const detailRecord = ref(null);
const filters = reactive({ const filters = reactive({
dateRange: [], dateRange: [],
notifyStatus: '',
}); });
const createEmptyForm = () => ({ const createEmptyForm = () => ({
startTime: '', startTime: '',
endTime: '', endTime: '',
notifyReceiverIds: [],
creator: '管理员', creator: '管理员',
updatedAt: '', updatedAt: '',
planFileName: '', planFileName: '',
@@ -399,17 +473,32 @@ const createEmptyForm = () => ({
planFileSizeText: '', 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(), ...createEmptyForm(),
...fallbackRecord,
...record, ...record,
creator: record?.creator || '管理员', notifyReceiverIds: normalizeNotifyReceiverIds(
updatedAt: record?.updatedAt || buildFallbackUpdateTime(record), record?.notifyReceiverIds ?? fallbackRecord?.notifyReceiverIds,
),
creator: record?.creator || fallbackRecord?.creator || '管理员',
updatedAt: record?.updatedAt || fallbackRecord?.updatedAt || buildFallbackUpdateTime(record),
}); });
const buildSeededRecords = () => { const buildSeededRecords = () => {
const storedRecords = storage.getLocalStorage(STORAGE_KEY); const storedRecords = storage.getLocalStorage(STORAGE_KEY);
const seedRecordMap = new Map(initialRecords.map((record) => [record.id, record]));
const normalizedStoredRecords = Array.isArray(storedRecords) const normalizedStoredRecords = Array.isArray(storedRecords)
? storedRecords.map(normalizeRecord) ? storedRecords.map((record) => normalizeRecord(record, seedRecordMap.get(record.id)))
: []; : [];
if (!normalizedStoredRecords.length) { if (!normalizedStoredRecords.length) {
@@ -456,6 +545,14 @@ const formRules = {
trigger: 'change', trigger: 'change',
}, },
], ],
notifyReceiverIds: [
{
type: 'array',
required: true,
message: '请选择通知对象',
trigger: 'change',
},
],
planFileName: [ planFileName: [
{ {
validator: (_, value, callback) => { validator: (_, value, callback) => {
@@ -473,11 +570,14 @@ const formRules = {
const filteredRecords = computed(() => const filteredRecords = computed(() =>
records.value.filter((record) => { records.value.filter((record) => {
if (!filters.dateRange?.length) { const matchesDateRange = filters.dateRange?.length
return true; ? 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 = () => { const resetFilters = () => {
filters.dateRange = []; filters.dateRange = [];
filters.notifyStatus = '';
currentPage.value = 1; currentPage.value = 1;
}; };
const triggerFileSelect = () => {
fileInputRef.value?.click();
};
const openCurrentFile = () => { const openCurrentFile = () => {
if (!formModel.planFileUrl) { if (!formModel.planFileUrl) {
return; return;
@@ -661,11 +758,10 @@ const deleteSelected = async () => {
}; };
watch( watch(
() => filters.dateRange, () => [filters.notifyStatus, ...(filters.dateRange || [])],
() => { () => {
currentPage.value = 1; currentPage.value = 1;
}, },
{ deep: true },
); );
watch( watch(
@@ -721,6 +817,31 @@ function formatFileSize(size) {
return `${(size / (1024 * 1024)).toFixed(1)} MB`; 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) { function buildFallbackUpdateTime(record) {
const dateText = record?.endTime || record?.startTime || '2026-04-27'; const dateText = record?.endTime || record?.startTime || '2026-04-27';
return `${dateText} 09:00:00`; return `${dateText} 09:00:00`;
@@ -861,6 +982,26 @@ function formatCurrentDateTime() {
color: #da4f60; 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 { .pagination-bar {
margin-top: 18px; margin-top: 18px;
display: flex; display: flex;
@@ -932,71 +1073,83 @@ function formatCurrentDateTime() {
width: 100%; width: 100%;
} }
.upload-panel { .select-field {
width: 100%; 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 { .upload-simple {
display: none; width: 100%;
}
.upload-panel__content {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
}
.upload-panel__copy {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 6px; gap: 12px;
} }
.upload-panel__title { .upload-native-input {
color: #15263d; display: block;
font-size: 14px; width: 100%;
} color: #1f2f46;
font-size: 13px;
.upload-panel__hint {
color: #60758f;
font-size: 12px;
line-height: 1.6;
} }
.upload-panel__actions { .upload-panel__actions {
display: inline-flex; display: none;
align-items: center;
gap: 8px;
flex-wrap: wrap;
} }
.upload-file { .upload-file {
margin-top: 14px; padding: 10px 12px;
padding: 12px 14px; border: 1px solid #dbe4f0;
border-radius: 2px; border-radius: 2px;
background: rgba(255, 255, 255, 0.92); background: #fff;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 12px; 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 { .upload-file__name {
color: #1f2f46; color: #1d4ed8;
font-size: 13px; font-size: 13px;
word-break: break-all; word-break: break-all;
} }
.upload-file__main:hover .upload-file__name {
text-decoration: underline;
}
.upload-file__meta { .upload-file__meta {
color: #64748b; color: #64748b;
font-size: 12px; 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 { .dialog-footer {
@@ -1020,8 +1173,7 @@ function formatCurrentDateTime() {
} }
.table-toolbar, .table-toolbar,
.pagination-bar, .pagination-bar {
.upload-panel__content {
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;
} }

View File

@@ -60,15 +60,6 @@
:value="option.value" :value="option.value"
/> />
</el-select> </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> <el-button @click="resetFilters">重置</el-button>
</div> </div>
</div> </div>
@@ -117,16 +108,6 @@
<span class="link-text">{{ row.playUrl }}</span> <span class="link-text">{{ row.playUrl }}</span>
</template> </template>
</el-table-column> </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="creator" min-width="120" />
<el-table-column label="最后更新时间" prop="updatedAt" min-width="176" /> <el-table-column label="最后更新时间" prop="updatedAt" min-width="176" />
<el-table-column label="操作" fixed="right" width="210"> <el-table-column label="操作" fixed="right" width="210">
@@ -210,12 +191,6 @@
<el-form-item label="播放链接" prop="playUrl" class="form-grid__span-2"> <el-form-item label="播放链接" prop="playUrl" class="form-grid__span-2">
<el-input v-model="formModel.playUrl" placeholder="请输入播放链接" /> <el-input v-model="formModel.playUrl" placeholder="请输入播放链接" />
</el-form-item> </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-form-item label="创建人" prop="creator">
<el-input v-model="formModel.creator" placeholder="请输入创建人" /> <el-input v-model="formModel.creator" placeholder="请输入创建人" />
</el-form-item> </el-form-item>
@@ -349,7 +324,6 @@ const initialRecords = [
nationalStandardCode: 'GB34020000001320000001', nationalStandardCode: 'GB34020000001320000001',
videoProtocol: 'webrtc', videoProtocol: 'webrtc',
playUrl: DEFAULT_WEBRTC_STREAM_URL, playUrl: DEFAULT_WEBRTC_STREAM_URL,
status: '在线',
creator: '王敏', creator: '王敏',
updatedAt: '2026-04-27 09:12:00', updatedAt: '2026-04-27 09:12:00',
}, },
@@ -362,7 +336,6 @@ const initialRecords = [
nationalStandardCode: 'GB34020000001320000002', nationalStandardCode: 'GB34020000001320000002',
videoProtocol: 'hls', videoProtocol: 'hls',
playUrl: DEFAULT_HLS_STREAM_URL, playUrl: DEFAULT_HLS_STREAM_URL,
status: '在线',
creator: '李峰', creator: '李峰',
updatedAt: '2026-04-27 09:18:00', updatedAt: '2026-04-27 09:18:00',
}, },
@@ -375,7 +348,6 @@ const initialRecords = [
nationalStandardCode: 'GB34020000001320000003', nationalStandardCode: 'GB34020000001320000003',
videoProtocol: 'webrtc', videoProtocol: 'webrtc',
playUrl: DEFAULT_WEBRTC_STREAM_URL, playUrl: DEFAULT_WEBRTC_STREAM_URL,
status: '离线',
creator: '陈宇', creator: '陈宇',
updatedAt: '2026-04-27 09:26:00', updatedAt: '2026-04-27 09:26:00',
}, },
@@ -388,7 +360,6 @@ const initialRecords = [
nationalStandardCode: 'GB34020000001320000004', nationalStandardCode: 'GB34020000001320000004',
videoProtocol: 'hls', videoProtocol: 'hls',
playUrl: DEFAULT_HLS_STREAM_URL, playUrl: DEFAULT_HLS_STREAM_URL,
status: '在线',
creator: '赵晴', creator: '赵晴',
updatedAt: '2026-04-27 09:33:00', updatedAt: '2026-04-27 09:33:00',
}, },
@@ -401,7 +372,6 @@ const initialRecords = [
nationalStandardCode: 'GB34020000001320000005', nationalStandardCode: 'GB34020000001320000005',
videoProtocol: 'webrtc', videoProtocol: 'webrtc',
playUrl: DEFAULT_WEBRTC_STREAM_URL, playUrl: DEFAULT_WEBRTC_STREAM_URL,
status: '离线',
creator: '顾晨', creator: '顾晨',
updatedAt: '2026-04-27 09:44:00', updatedAt: '2026-04-27 09:44:00',
}, },
@@ -414,7 +384,6 @@ const initialRecords = [
nationalStandardCode: 'GB34020000001320000006', nationalStandardCode: 'GB34020000001320000006',
videoProtocol: 'hls', videoProtocol: 'hls',
playUrl: DEFAULT_HLS_STREAM_URL, playUrl: DEFAULT_HLS_STREAM_URL,
status: '在线',
creator: '刘颖', creator: '刘颖',
updatedAt: '2026-04-27 09:55:00', updatedAt: '2026-04-27 09:55:00',
}, },
@@ -427,7 +396,6 @@ const initialRecords = [
nationalStandardCode: 'GB34020000001320000007', nationalStandardCode: 'GB34020000001320000007',
videoProtocol: 'webrtc', videoProtocol: 'webrtc',
playUrl: DEFAULT_WEBRTC_STREAM_URL, playUrl: DEFAULT_WEBRTC_STREAM_URL,
status: '在线',
creator: '王敏', creator: '王敏',
updatedAt: '2026-04-27 10:08:00', updatedAt: '2026-04-27 10:08:00',
}, },
@@ -440,7 +408,6 @@ const initialRecords = [
nationalStandardCode: 'GB34020000001320000008', nationalStandardCode: 'GB34020000001320000008',
videoProtocol: 'hls', videoProtocol: 'hls',
playUrl: DEFAULT_HLS_STREAM_URL, playUrl: DEFAULT_HLS_STREAM_URL,
status: '离线',
creator: '李峰', creator: '李峰',
updatedAt: '2026-04-27 10:14:00', updatedAt: '2026-04-27 10:14:00',
}, },
@@ -453,7 +420,6 @@ const initialRecords = [
nationalStandardCode: 'GB34020000001320000009', nationalStandardCode: 'GB34020000001320000009',
videoProtocol: 'webrtc', videoProtocol: 'webrtc',
playUrl: DEFAULT_WEBRTC_STREAM_URL, playUrl: DEFAULT_WEBRTC_STREAM_URL,
status: '在线',
creator: '陈宇', creator: '陈宇',
updatedAt: '2026-04-27 10:27:00', updatedAt: '2026-04-27 10:27:00',
}, },
@@ -466,7 +432,6 @@ const initialRecords = [
nationalStandardCode: 'GB34020000001320000010', nationalStandardCode: 'GB34020000001320000010',
videoProtocol: 'hls', videoProtocol: 'hls',
playUrl: DEFAULT_HLS_STREAM_URL, playUrl: DEFAULT_HLS_STREAM_URL,
status: '离线',
creator: '赵晴', creator: '赵晴',
updatedAt: '2026-04-27 10:36:00', updatedAt: '2026-04-27 10:36:00',
}, },
@@ -493,7 +458,6 @@ const filters = reactive({
channelCode: '', channelCode: '',
nationalStandardCode: '', nationalStandardCode: '',
videoProtocol: '', videoProtocol: '',
status: '',
}); });
const createEmptyForm = () => ({ const createEmptyForm = () => ({
@@ -504,7 +468,6 @@ const createEmptyForm = () => ({
nationalStandardCode: '', nationalStandardCode: '',
videoProtocol: 'webrtc', videoProtocol: 'webrtc',
playUrl: DEFAULT_WEBRTC_STREAM_URL, playUrl: DEFAULT_WEBRTC_STREAM_URL,
status: '在线',
creator: '管理员', creator: '管理员',
updatedAt: '', updatedAt: '',
}); });
@@ -570,7 +533,6 @@ const formRules = {
nationalStandardCode: [{ required: true, message: '请输入国标号', trigger: 'blur' }], nationalStandardCode: [{ required: true, message: '请输入国标号', trigger: 'blur' }],
videoProtocol: [{ required: true, message: '请选择视频协议', trigger: 'change' }], videoProtocol: [{ required: true, message: '请选择视频协议', trigger: 'change' }],
playUrl: [{ required: true, message: '请输入播放链接', trigger: 'blur' }], playUrl: [{ required: true, message: '请输入播放链接', trigger: 'blur' }],
status: [{ required: true, message: '请选择状态', trigger: 'change' }],
creator: [{ required: true, message: '请输入创建人', trigger: 'blur' }], creator: [{ required: true, message: '请输入创建人', trigger: 'blur' }],
}; };
@@ -594,7 +556,6 @@ const filteredRecords = computed(() =>
const matchesProtocol = filters.videoProtocol const matchesProtocol = filters.videoProtocol
? record.videoProtocol === filters.videoProtocol ? record.videoProtocol === filters.videoProtocol
: true; : true;
const matchesStatus = filters.status ? record.status === filters.status : true;
return ( return (
matchesProjectName && matchesProjectName &&
@@ -602,8 +563,7 @@ const filteredRecords = computed(() =>
matchesCameraPoint && matchesCameraPoint &&
matchesChannelCode && matchesChannelCode &&
matchesCode && matchesCode &&
matchesProtocol && matchesProtocol
matchesStatus
); );
}), }),
); );
@@ -631,7 +591,6 @@ const resetFilters = () => {
filters.channelCode = ''; filters.channelCode = '';
filters.nationalStandardCode = ''; filters.nationalStandardCode = '';
filters.videoProtocol = ''; filters.videoProtocol = '';
filters.status = '';
currentPage.value = 1; currentPage.value = 1;
}; };
@@ -742,11 +701,6 @@ const deleteSelected = async () => {
}; };
const openLiveDialog = async (record) => { const openLiveDialog = async (record) => {
if (record.status !== '在线') {
ElMessage.warning('当前视频点位离线,无法发起直播');
return;
}
liveRecord.value = normalizeRecord(record); liveRecord.value = normalizeRecord(record);
liveDialogVisible.value = true; liveDialogVisible.value = true;
await startLiveStream(); await startLiveStream();
@@ -919,7 +873,6 @@ watch(
filters.channelCode, filters.channelCode,
filters.nationalStandardCode, filters.nationalStandardCode,
filters.videoProtocol, filters.videoProtocol,
filters.status,
], ],
() => { () => {
currentPage.value = 1; currentPage.value = 1;
@@ -1200,25 +1153,6 @@ function extractAnswerSdp(payload, rawText) {
color: #385372; 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 { .row-actions {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;

View File

@@ -1,6 +1,6 @@
<template> <template>
<ProjectCrudPanel <ProjectCrudPanel
title="中标项目管理" title="中标公告管理"
storage-key="admin-pc:award-projects" storage-key="admin-pc:award-projects"
date-range-prop="awardDate" date-range-prop="awardDate"
amount-range-prop="awardAmount" amount-range-prop="awardAmount"

View File

@@ -286,9 +286,9 @@
<el-form label-width="88px" class="notify-form"> <el-form label-width="88px" class="notify-form">
<el-form-item label="通知方式"> <el-form-item label="通知方式">
<el-checkbox-group v-model="notifyForm.channels"> <el-checkbox-group v-model="notifyForm.channels" class="notify-form__channels">
<el-checkbox-button label="站内通知">站内通知</el-checkbox-button> <el-checkbox label="站内通知" border>站内通知</el-checkbox>
<el-checkbox-button label="企业微信">企业微信</el-checkbox-button> <el-checkbox label="企业微信" border>企业微信</el-checkbox>
</el-checkbox-group> </el-checkbox-group>
</el-form-item> </el-form-item>
@@ -1312,6 +1312,30 @@ const formatExportValue = (column, value) => {
background: #fbfcfe; 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 { .notify-form__select {
width: 100%; width: 100%;
} }

View File

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

View File

@@ -35,7 +35,7 @@
</div> </div>
</div> </div>
<div class="panel-card"> <div class="panel-card panel-card--table">
<div class="table-toolbar"> <div class="table-toolbar">
<div class="table-toolbar__group"> <div class="table-toolbar__group">
<el-button type="primary" @click="openCreateDialog">新增任务</el-button> <el-button type="primary" @click="openCreateDialog">新增任务</el-button>
@@ -46,55 +46,58 @@
</div> </div>
</div> </div>
<el-table <div class="task-table-wrap">
:data="paginatedRecords" <el-table
row-key="id" :data="paginatedRecords"
class="task-table" row-key="id"
highlight-current-row class="task-table"
@current-change="handleCurrentChange" height="100%"
@row-click="handleRowClick" highlight-current-row
> @current-change="handleCurrentChange"
<el-table-column label="所属省市" prop="provinceCity" min-width="150" show-overflow-tooltip /> @row-click="handleRowClick"
<el-table-column label="板块类型" prop="sectorType" min-width="120" show-overflow-tooltip /> >
<el-table-column label="提示词" prop="prompt" min-width="240" show-overflow-tooltip> <el-table-column label="所属省市" prop="provinceCity" min-width="150" show-overflow-tooltip />
<template #default="{ row }"> <el-table-column label="板块类型" prop="sectorType" min-width="120" show-overflow-tooltip />
<div class="prompt-cell"> <el-table-column label="提示词" prop="prompt" min-width="240" show-overflow-tooltip>
<span class="prompt-cell__text">{{ row.prompt }}</span> <template #default="{ row }">
</div> <div class="prompt-cell">
</template> <span class="prompt-cell__text">{{ row.prompt }}</span>
</el-table-column> </div>
<el-table-column label="调用模型" prop="model" min-width="130" /> </template>
<el-table-column label="状态" prop="status" min-width="96"> </el-table-column>
<template #default="{ row }"> <el-table-column label="调用模型" prop="model" min-width="130" />
<span <el-table-column label="状态" prop="status" min-width="96">
class="status-badge" <template #default="{ row }">
:class="row.status === '启用' ? 'status-badge--online' : 'status-badge--offline'" <span
> class="status-badge"
{{ row.status }} :class="row.status === '启用' ? 'status-badge--online' : 'status-badge--offline'"
</span>
</template>
</el-table-column>
<el-table-column label="操作" fixed="right" width="206">
<template #default="{ row }">
<div class="row-actions">
<button type="button" class="row-action" @click.stop="openEditDialog(row)">编辑</button>
<button type="button" class="row-action row-action--run" @click.stop="runTask(row)">
运行
</button>
<button type="button" class="row-action" @click.stop="toggleStatus(row)">
{{ row.status === '启用' ? '禁用' : '启用' }}
</button>
<button
type="button"
class="row-action row-action--danger"
@click.stop="deleteRecord(row)"
> >
删除 {{ row.status }}
</button> </span>
</div> </template>
</template> </el-table-column>
</el-table-column> <el-table-column label="操作" fixed="right" width="206">
</el-table> <template #default="{ row }">
<div class="row-actions">
<button type="button" class="row-action" @click.stop="openEditDialog(row)">编辑</button>
<button type="button" class="row-action row-action--run" @click.stop="runTask(row)">
运行
</button>
<button type="button" class="row-action" @click.stop="toggleStatus(row)">
{{ row.status === '启用' ? '禁用' : '启用' }}
</button>
<button
type="button"
class="row-action row-action--danger"
@click.stop="deleteRecord(row)"
>
删除
</button>
</div>
</template>
</el-table-column>
</el-table>
</div>
<div class="pagination-bar"> <div class="pagination-bar">
<div class="pagination-bar__summary"> <div class="pagination-bar__summary">
@@ -107,7 +110,7 @@
v-model:page-size="pageSize" v-model:page-size="pageSize"
background background
layout="prev, pager, next, sizes" layout="prev, pager, next, sizes"
:page-sizes="[5, 10, 20, 50]" :page-sizes="[10, 20, 50]"
:total="filteredRecords.length" :total="filteredRecords.length"
/> />
</div> </div>
@@ -115,32 +118,11 @@
</div> </div>
<div class="task-layout__side"> <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"> <template v-if="selectedRecord">
<div class="config-card"> <div class="config-card">
<div class="config-card__header"> <div class="config-card__header">
<div> <div>
<h3 class="config-card__title">Agent 配置</h3> <h3 class="config-card__title">Agent 配置</h3>
<p class="config-card__desc">控制任务调用的服务地址与轮询行为</p>
</div> </div>
</div> </div>
@@ -173,7 +155,6 @@
<div class="config-card__header"> <div class="config-card__header">
<div> <div>
<h3 class="config-card__title">定时任务配置</h3> <h3 class="config-card__title">定时任务配置</h3>
<p class="config-card__desc">设置调度策略并可立即执行当前任务</p>
</div> </div>
<el-button type="primary" plain @click="runScheduledTaskNow"> <el-button type="primary" plain @click="runScheduledTaskNow">
立即执行定时任务 立即执行定时任务
@@ -235,13 +216,15 @@
<strong class="schedule-summary__value">{{ scheduleSummary }}</strong> <strong class="schedule-summary__value">{{ scheduleSummary }}</strong>
</div> </div>
</div> </div>
</div>
<div class="config-actions"> <div class="side-actions">
<el-button @click="resetConfigForm">重置配置</el-button> <el-button type="primary" @click="saveConfig">保存配置</el-button>
<el-button type="primary" @click="saveConfig">保存配置</el-button>
</div>
</div> </div>
</template> </template>
<div v-else class="config-card">
<el-empty description="请选择左侧任务后再配置" />
</div>
</div> </div>
</div> </div>
@@ -610,7 +593,7 @@ const formRef = ref();
const dialogVisible = ref(false); const dialogVisible = ref(false);
const editingRecordId = ref(''); const editingRecordId = ref('');
const currentPage = ref(1); const currentPage = ref(1);
const pageSize = ref(5); const pageSize = ref(10);
const selectedRecordId = ref(''); const selectedRecordId = ref('');
const filters = reactive({ const filters = reactive({
@@ -722,14 +705,6 @@ const resetForm = () => {
formRef.value?.clearValidate(); formRef.value?.clearValidate();
}; };
const resetConfigForm = () => {
if (!selectedRecord.value) {
return;
}
Object.assign(configForm, pickConfigFields(selectedRecord.value));
};
const openCreateDialog = () => { const openCreateDialog = () => {
editingRecordId.value = ''; editingRecordId.value = '';
resetForm(); resetForm();
@@ -769,7 +744,7 @@ const submitForm = async () => {
if (selectedRecordId.value === payload.id || !selectedRecordId.value) { if (selectedRecordId.value === payload.id || !selectedRecordId.value) {
selectedRecordId.value = payload.id; selectedRecordId.value = payload.id;
resetConfigForm(); Object.assign(configForm, pickConfigFields(payload));
} }
persistRecords(); persistRecords();
@@ -935,23 +910,29 @@ function formatCurrentDateTime() {
<style scoped> <style scoped>
.task-page { .task-page {
height: calc(100vh - var(--shell-header-offset, 74px) - 34px);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden;
} }
.task-layout { .task-layout {
height: 100%;
display: grid; display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(360px, 0.85fr); grid-template-columns: minmax(0, 1.35fr) minmax(360px, 0.85fr);
gap: 18px; gap: 18px;
align-items: start; align-items: start;
overflow: hidden;
} }
.task-layout__main, .task-layout__main,
.task-layout__side { .task-layout__side {
height: 100%;
min-width: 0; min-width: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 18px; gap: 18px;
overflow: hidden;
} }
.panel-card, .panel-card,
@@ -966,67 +947,18 @@ function formatCurrentDateTime() {
} }
.panel-card--search { .panel-card--search {
position: sticky;
top: calc(var(--shell-header-offset, 74px) + 12px);
z-index: 6;
background: background:
radial-gradient(circle at top right, rgba(35, 195, 172, 0.14), transparent 24%), 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%), radial-gradient(circle at left center, rgba(90, 129, 255, 0.16), transparent 28%),
rgba(255, 255, 255, 0.92); rgba(255, 255, 255, 0.92);
backdrop-filter: blur(14px);
} }
.config-card { .panel-card--table {
position: sticky; flex: 1;
top: calc(var(--shell-header-offset, 74px) + 12px); min-height: 0;
}
.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;
display: flex; display: flex;
align-items: flex-start; flex-direction: column;
justify-content: space-between; overflow: hidden;
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;
} }
.config-card__header { .config-card__header {
@@ -1083,11 +1015,17 @@ function formatCurrentDateTime() {
line-height: 1.6; line-height: 1.6;
} }
.config-actions { .side-actions {
margin-top: 18px; 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; display: flex;
justify-content: flex-end; justify-content: flex-end;
gap: 10px;
} }
.filter-bar { .filter-bar {
@@ -1125,8 +1063,15 @@ function formatCurrentDateTime() {
font-size: 13px; font-size: 13px;
} }
.task-table-wrap {
flex: 1;
min-height: 0;
overflow: hidden;
}
.task-table { .task-table {
width: 100%; width: 100%;
height: 100%;
} }
:deep(.task-table .el-table__inner-wrapper), :deep(.task-table .el-table__inner-wrapper),
@@ -1239,13 +1184,20 @@ function formatCurrentDateTime() {
} }
@media (max-width: 1280px) { @media (max-width: 1280px) {
.task-page {
height: calc(100vh - var(--shell-header-offset, 74px) - 34px);
overflow: hidden;
}
.task-layout { .task-layout {
height: 100%;
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.config-card, .task-layout__main,
.config-card + .config-card { .task-layout__side {
position: static; height: 100%;
overflow: hidden;
} }
} }

View File

@@ -1,6 +1,6 @@
<template> <template>
<ProjectCrudPanel <ProjectCrudPanel
title="招标项目管理" title="招标公告管理"
storage-key="admin-pc:tender-projects" storage-key="admin-pc:tender-projects"
:enable-conversion-action="true" :enable-conversion-action="true"
:record-defaults="{ conversionStatus: '未转化' }" :record-defaults="{ conversionStatus: '未转化' }"

706
src/views/user/edit.vue Normal file
View 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>

View File

@@ -1,22 +1,600 @@
<template> <template>
<div class="user-container"> <section class="user-management-page">
用户中心 <div class="panel-card panel-card--search">
</div> <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> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue'; import { computed, reactive, ref, watch } from 'vue';
import { useStore } from 'vuex'; 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(() => { const createFilterState = () => ({
console.log('用户中心页面加载完成'); 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> </script>
<style scoped> <style scoped>
.user-container { .user-management-page {
--search-sticky-gap: 12px;
display: flex;
flex-direction: column;
gap: 18px;
}
.panel-card {
padding: 20px; 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> </style>

382
src/views/user/user-data.js Normal file
View 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}`;
}