kangwenjing 2026-03-17 15:18:56 +08:00
parent b75150dca0
commit f718ee4834
96 changed files with 6204 additions and 1653 deletions

View File

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,70 @@
-- 页面菜单缺失清单 SQL
-- 用途:
-- 1. 检查前端当前已接入的页面路由,哪些还没有对应的 sys_menu 页面菜单
-- 2. 只查询,不做插入
--
-- 说明:
-- 1. 这里按前端当前实际路由整理了一份页面菜单基线
-- 2. 仅检查 menu_type in ('M','C') 的目录/页面菜单
-- 3. 管理员无需在角色管理中额外勾选页面;前端当前已按 admin 角色默认放行
DROP TEMPORARY TABLE IF EXISTS tmp_page_menu_specs;
CREATE TEMPORARY TABLE tmp_page_menu_specs (
path VARCHAR(128) NOT NULL,
component_like VARCHAR(255) NULL,
menu_name VARCHAR(128) NOT NULL,
remark VARCHAR(255) NULL
);
INSERT INTO tmp_page_menu_specs (path, component_like, menu_name, remark) VALUES
('index', '%worklog%', '工作日志', 'WorkLogPage 首页'),
('dashboard/project-execution', '%dashboard/project-execution%', '项目进度', 'ProjectExecutionPage'),
('projectBank/projectProgress', '%dashboard/project-execution%', '项目进度(兼容)', 'ProjectExecutionPage 别名路由'),
('projectBank/projectUser', '%projectBank/projectUser%', '项目成员', 'ProjectUserPage'),
('projectBank/userProject', '%projectBank/userProject%', '人员项目', 'UserProjectPage'),
('projectBank/userScore', '%projectBank/userScore%', '我的绩效', 'UserScorePage'),
('projectBank/userScoreDetail', '%projectBank/userScoreDetail%', '绩效详情', 'UserScoreDetailPage'),
('user/profile', '%Profile%', '个人中心', 'ProfilePage'),
('monitor/cache', '%monitor/cache%', '缓存监控', 'CacheMonitorPage'),
('monitor/job', '%monitor/job%', '定时任务', 'JobMonitorPage'),
('monitor/logininfor', '%monitor/login%', '登录日志', 'LoginLogPage'),
('monitor/online', '%monitor/online%', '在线用户', 'OnlineUserPage'),
('monitor/operlog', '%monitor/operation%', '操作日志', 'OperationLogPage'),
('monitor/server', '%monitor/server%', '服务监控', 'ServerMonitorPage'),
('monitor/cacheList', '%monitor/cache-list%', '缓存列表', 'CacheListPage'),
('system/user', '%system/user%', '用户管理', 'UserPage'),
('system/role', '%system/role%', '角色管理', 'RolePage'),
('system/menu', '%system/menu%', '菜单管理', 'MenuPage'),
('system/dept', '%system/dept%', '部门管理', 'DeptPage'),
('system/dict', '%system/dict%', '字典管理', 'DictPage'),
('system/config', '%system/config%', '参数配置', 'ConfigPage'),
('project', '%project/list%', '项目管理', 'ProjectPage 后端标准路径'),
('project/list', '%project/list%', '项目管理', 'ProjectPage 前端兼容路径'),
('project/detail', '%project/detail%', '项目详情', 'ProjectDetailPage'),
('project/demandManage', '%project/%DemandManage%', '需求管理', 'DemandManagePage 后端路径'),
('demandManage', '%project/%DemandManage%', '需求管理', 'DemandManagePage 前端兼容路径'),
('workAppraisal/manager', '%workAppraisal/ManagerPage%', '经理评分', 'ManagerPage'),
('workAppraisal/normalWorker', '%workAppraisal/NormalWorkerPage%', '员工评分', 'NormalWorkerPage'),
('workAppraisal/managerUser', '%workAppraisal/ManagerUserPage%', '经理评分用户', 'ManagerUserPage'),
('workAppraisal/taskSet', '%workAppraisal/TaskSetPage%', '任务设置', 'TaskSetPage'),
('workAppraisal/detail', '%workAppraisal/AppraisalDetailPage%', '评分详情', 'AppraisalDetailPage'),
('workAppraisal/taskModule', '%workAppraisal/AppraisalDashboardPage%', '绩效看板', 'AppraisalDashboardPage'),
('workAppraisal/dashboard', '%workAppraisal/AppraisalDashboardPage%', '绩效看板(兼容)', 'AppraisalDashboardPage 别名路由'),
('workAppraisal/moduleDetail', '%workAppraisal/AppraisalModuleDetailPage%', '模块详情', 'AppraisalModuleDetailPage'),
('workAppraisal/myPerformance', '%projectBank/userScore%', '我的绩效(兼容)', 'UserScorePage 前端映射');
SELECT
s.path,
s.menu_name,
s.remark
FROM tmp_page_menu_specs s
WHERE NOT EXISTS (
SELECT 1
FROM sys_menu m
WHERE m.menu_type IN ('M', 'C')
AND (
m.path = s.path
OR (s.component_like IS NOT NULL AND s.component_like <> '' AND m.component LIKE s.component_like)
)
)
ORDER BY s.path;

View File

@ -0,0 +1,256 @@
-- 权限按钮对账与补齐脚本
-- 用途:
-- 1. 根据前端当前实际使用到的权限码,对 sys_menu 中的按钮菜单做补齐
-- 2. 仅插入缺失按钮,不修改已有按钮,不重复插入
-- 3. 执行前会先输出“未找到父页面菜单”的按钮清单,便于先补页面再补按钮
--
-- 说明:
-- 1. 当前环境无法直接连库校验,这份脚本基于前端代码中的权限使用点生成
-- 2. 父页面使用 sys_menu.path 定位,个别页面补充 component 模糊匹配兜底
-- 3. 同一权限码如果在多个页面中复用,会按页面分别补按钮节点
START TRANSACTION;
DROP TEMPORARY TABLE IF EXISTS tmp_permission_button_specs;
CREATE TEMPORARY TABLE tmp_permission_button_specs (
parent_path VARCHAR(128) NOT NULL,
parent_component_like VARCHAR(255) NULL,
menu_name VARCHAR(128) NOT NULL,
perms VARCHAR(128) NOT NULL,
order_num INT NOT NULL DEFAULT 1,
remark VARCHAR(255) NULL
);
INSERT INTO tmp_permission_button_specs (parent_path, parent_component_like, menu_name, perms, order_num, remark) VALUES
-- 工作日志
('index', '%worklog%', '新增日志', 'business:work:hour:add', 1, 'WorkLogPage 添加日志'),
('index', '%worklog%', '编辑日志', 'business:work:hour:edit', 2, 'WorkLogPage 编辑/确认'),
('index', '%worklog%', '更新日志', 'business:work:hour:update', 3, 'WorkLogPage 兼容旧权限码'),
('index', '%worklog%', '删除日志', 'business:work:hour:remove', 4, 'WorkLogPage 删除/取消'),
-- 项目管理
('project', '%project/list%', '新增项目', 'business:project:add', 1, 'ProjectPage'),
('project', '%project/list%', '编辑项目', 'business:project:edit', 2, 'ProjectPage/ProjectDetailPage'),
('project', '%project/list%', '删除项目', 'business:project:remove', 3, 'ProjectPage'),
('project', '%project/list%', '查看需求', 'project:demand:list', 4, 'ProjectPage'),
-- 需求管理
('demandManage', NULL, '新增需求', 'project:demand:add', 1, 'DemandManagePage'),
('demandManage', NULL, '编辑需求', 'project:demand:edit', 2, 'DemandManagePage'),
('demandManage', NULL, '删除需求', 'project:demand:remove', 3, 'DemandManagePage'),
('demandManage', NULL, '新增版本', 'project:version:add', 4, 'DemandManagePage'),
('demandManage', NULL, '编辑版本', 'project:version:edit', 5, 'DemandManagePage'),
('demandManage', NULL, '删除版本', 'project:version:remove', 6, 'DemandManagePage'),
-- 任务监控
('job', NULL, '新增任务', 'monitor:job:add', 1, 'JobMonitorPage'),
('job', NULL, '编辑任务', 'monitor:job:edit', 2, 'JobMonitorPage'),
('job', NULL, '删除任务', 'monitor:job:remove', 3, 'JobMonitorPage'),
('job', NULL, '导出任务', 'monitor:job:export', 4, 'JobMonitorPage'),
('job', NULL, '查询任务', 'monitor:job:query', 5, 'JobMonitorPage'),
('job', NULL, '状态修改', 'monitor:job:changeStatus', 6, 'JobMonitorPage'),
-- 缓存列表
('cacheList', NULL, '清理缓存', 'monitor:cache:clear', 1, 'CacheListPage'),
-- 在线用户
('online', NULL, '强退用户', 'monitor:online:forceLogout', 1, 'OnlineUserPage'),
-- 登录日志
('logininfor', NULL, '删除日志', 'monitor:logininfor:remove', 1, 'LoginLogPage'),
('logininfor', NULL, '清空日志', 'monitor:logininfor:clean', 2, 'LoginLogPage'),
('logininfor', NULL, '账户解锁', 'monitor:logininfor:unlock', 3, 'LoginLogPage'),
('logininfor', NULL, '导出日志', 'monitor:logininfor:export', 4, 'LoginLogPage'),
-- 操作日志
('operlog', NULL, '删除日志', 'monitor:operlog:remove', 1, 'OperationLogPage'),
('operlog', NULL, '清空日志', 'monitor:operlog:clean', 2, 'OperationLogPage'),
('operlog', NULL, '导出日志', 'monitor:operlog:export', 3, 'OperationLogPage'),
-- 用户管理
('user', NULL, '新增用户', 'system:user:add', 1, 'UserPage'),
('user', NULL, '修改用户', 'system:user:edit', 2, 'UserPage'),
('user', NULL, '删除用户', 'system:user:remove', 3, 'UserPage'),
('user', NULL, '重置密码', 'system:user:resetPwd', 4, 'UserPage'),
-- 角色管理
('role', NULL, '新增角色', 'system:role:add', 1, 'RolePage'),
('role', NULL, '修改角色', 'system:role:edit', 2, 'RolePage'),
('role', NULL, '删除角色', 'system:role:remove', 3, 'RolePage'),
('role', NULL, '导出角色', 'system:role:export', 4, 'RolePage'),
('role', NULL, '数据权限', 'system:role:dataScope', 5, 'RolePage'),
('role', NULL, '分配用户', 'system:role:authUser', 6, 'RolePage'),
-- 菜单管理
('menu', NULL, '新增菜单', 'system:menu:add', 1, 'MenuPage'),
('menu', NULL, '修改菜单', 'system:menu:edit', 2, 'MenuPage'),
('menu', NULL, '删除菜单', 'system:menu:remove', 3, 'MenuPage'),
-- 部门管理
('dept', NULL, '新增部门', 'system:dept:add', 1, 'DeptPage'),
('dept', NULL, '修改部门', 'system:dept:edit', 2, 'DeptPage'),
('dept', NULL, '删除部门', 'system:dept:remove', 3, 'DeptPage'),
-- 字典管理
('dict', NULL, '新增字典', 'system:dict:add', 1, 'DictPage'),
('dict', NULL, '修改字典', 'system:dict:edit', 2, 'DictPage'),
('dict', NULL, '删除字典', 'system:dict:remove', 3, 'DictPage'),
('dict', NULL, '导出字典', 'system:dict:export', 4, 'DictPage'),
('dict', NULL, '刷新缓存', 'system:dict:refreshCache', 5, 'DictPage'),
-- 参数配置
('config', NULL, '新增参数', 'system:config:add', 1, 'ConfigPage'),
('config', NULL, '修改参数', 'system:config:edit', 2, 'ConfigPage'),
('config', NULL, '删除参数', 'system:config:remove', 3, 'ConfigPage'),
('config', NULL, '导出参数', 'system:config:export', 4, 'ConfigPage'),
('config', NULL, '刷新缓存', 'system:config:refreshCache', 5, 'ConfigPage'),
-- 绩效任务设置
('taskSet', NULL, '新增任务', 'task:add', 1, 'TaskSetPage'),
('taskSet', NULL, '创建任务', 'task:create', 2, 'TaskSetPage 兼容旧权限码'),
('taskSet', NULL, '编辑任务', 'task:edit', 3, 'TaskSetPage'),
('taskSet', NULL, '更新任务', 'task:update', 4, 'TaskSetPage 兼容旧权限码'),
('taskSet', NULL, '删除任务', 'task:remove', 5, 'TaskSetPage'),
('taskSet', NULL, '删除任务兼容', 'task:delete', 6, 'TaskSetPage 兼容旧权限码'),
('taskSet', NULL, '指标配置', 'task:config', 7, 'TaskSetPage'),
('taskSet', NULL, '设置指标', 'task:set', 8, 'TaskSetPage 兼容旧权限码'),
-- 绩效看板
('taskModule', NULL, '删除模板', 'examine:template:remove', 1, 'AppraisalDashboardPage'),
('taskModule', NULL, '删除模板兼容', 'examine:template:delete', 2, 'AppraisalDashboardPage 兼容旧权限码'),
-- 绩效打分相关页面
('manager', NULL, '新增评分', 'examine:detail:add', 1, 'ManagerPage'),
('manager', NULL, '编辑评分', 'examine:detail:edit', 2, 'ManagerPage'),
('manager', NULL, '提交评分', 'examine:detail:submit', 3, 'ManagerPage'),
('managerUser', NULL, '新增评分', 'examine:detail:add', 1, 'ManagerUserPage'),
('managerUser', NULL, '编辑评分', 'examine:detail:edit', 2, 'ManagerUserPage'),
('managerUser', NULL, '提交评分', 'examine:detail:submit', 3, 'ManagerUserPage'),
('normalWorker', NULL, '新增评分', 'examine:detail:add', 1, 'NormalWorkerPage'),
('normalWorker', NULL, '编辑评分', 'examine:detail:edit', 2, 'NormalWorkerPage'),
('normalWorker', NULL, '提交评分', 'examine:detail:submit', 3, 'NormalWorkerPage'),
('detail', NULL, '新增评分', 'examine:detail:add', 1, 'AppraisalDetailPage'),
('detail', NULL, '编辑评分', 'examine:detail:edit', 2, 'AppraisalDetailPage'),
('detail', NULL, '提交评分', 'examine:detail:submit', 3, 'AppraisalDetailPage');
-- 先看哪些按钮找不到父页面
SELECT
s.parent_path,
s.menu_name,
s.perms,
s.remark
FROM tmp_permission_button_specs s
WHERE (
SELECT m.menu_id
FROM sys_menu m
WHERE m.menu_type IN ('M', 'C')
AND (
m.path = s.parent_path
OR (s.parent_component_like IS NOT NULL AND s.parent_component_like <> '' AND m.component LIKE s.parent_component_like)
)
ORDER BY
CASE WHEN m.path = s.parent_path THEN 0 ELSE 1 END,
m.menu_id
LIMIT 1
) IS NULL
ORDER BY s.parent_path, s.order_num;
-- 补齐缺失按钮
INSERT INTO sys_menu (
menu_name,
parent_id,
order_num,
path,
component,
query,
is_frame,
is_cache,
menu_type,
visible,
status,
perms,
icon,
create_by,
create_time,
remark
)
SELECT
s.menu_name,
(
SELECT m.menu_id
FROM sys_menu m
WHERE m.menu_type IN ('M', 'C')
AND (
m.path = s.parent_path
OR (s.parent_component_like IS NOT NULL AND s.parent_component_like <> '' AND m.component LIKE s.parent_component_like)
)
ORDER BY
CASE WHEN m.path = s.parent_path THEN 0 ELSE 1 END,
m.menu_id
LIMIT 1
) AS parent_id,
s.order_num,
'#',
'',
'',
'1',
'0',
'F',
'0',
'0',
s.perms,
'#',
'codex',
NOW(),
s.remark
FROM tmp_permission_button_specs s
WHERE (
SELECT m.menu_id
FROM sys_menu m
WHERE m.menu_type IN ('M', 'C')
AND (
m.path = s.parent_path
OR (s.parent_component_like IS NOT NULL AND s.parent_component_like <> '' AND m.component LIKE s.parent_component_like)
)
ORDER BY
CASE WHEN m.path = s.parent_path THEN 0 ELSE 1 END,
m.menu_id
LIMIT 1
) IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM sys_menu f
WHERE f.menu_type = 'F'
AND f.perms = s.perms
AND f.parent_id = (
SELECT m.menu_id
FROM sys_menu m
WHERE m.menu_type IN ('M', 'C')
AND (
m.path = s.parent_path
OR (s.parent_component_like IS NOT NULL AND s.parent_component_like <> '' AND m.component LIKE s.parent_component_like)
)
ORDER BY
CASE WHEN m.path = s.parent_path THEN 0 ELSE 1 END,
m.menu_id
LIMIT 1
)
);
-- 补齐后可查看这一批按钮
SELECT
p.menu_name AS parent_menu_name,
p.path AS parent_path,
c.menu_name,
c.perms,
c.order_num
FROM sys_menu c
INNER JOIN sys_menu p ON p.menu_id = c.parent_id
WHERE c.menu_type = 'F'
AND c.create_by = 'codex'
ORDER BY p.path, c.order_num, c.menu_id;
COMMIT;

View File

@ -0,0 +1,220 @@
-- 工作日志菜单与按钮补录
-- 目标:
-- 1. 确保 sys_menu 中存在工作日志页面菜单(/index
-- 2. 为工作日志页面补齐按钮权限项
-- 3. 所有插入都做不存在判断,避免重复
--
-- 对应前端逻辑:
-- - 页面路由:/index
-- - 页面组件component 包含 worklog 即可被前端识别为工作日志页
-- - 按钮权限:
-- business:work:hour:add
-- business:work:hour:edit
-- business:work:hour:remove
START TRANSACTION;
-- 1. 如果工作日志页面菜单不存在,则补一条页面菜单
INSERT INTO sys_menu (
menu_name,
parent_id,
order_num,
path,
component,
query,
is_frame,
is_cache,
menu_type,
visible,
status,
perms,
icon,
create_by,
create_time,
remark
)
SELECT
'工作日志',
0,
1,
'index',
'worklog/index',
'',
'1',
'0',
'C',
'0',
'0',
'business:work:hour:list',
'dashboard',
'codex',
NOW(),
'工作日志首页菜单'
FROM DUAL
WHERE NOT EXISTS (
SELECT 1
FROM sys_menu
WHERE menu_type IN ('M', 'C')
AND (
path IN ('index', '/index')
OR component LIKE '%worklog%'
)
);
-- 2. 定位工作日志页面菜单
SET @worklog_menu_id := (
SELECT menu_id
FROM sys_menu
WHERE menu_type IN ('M', 'C')
AND (
path IN ('index', '/index')
OR component LIKE '%worklog%'
)
ORDER BY
CASE
WHEN path IN ('index', '/index') THEN 0
ELSE 1
END,
menu_id
LIMIT 1
);
-- 3. 补录按钮:新增日志
INSERT INTO sys_menu (
menu_name,
parent_id,
order_num,
path,
component,
query,
is_frame,
is_cache,
menu_type,
visible,
status,
perms,
icon,
create_by,
create_time,
remark
)
SELECT
'新增日志',
@worklog_menu_id,
1,
'#',
'',
'',
'1',
'0',
'F',
'0',
'0',
'business:work:hour:add',
'#',
'codex',
NOW(),
'工作日志页按钮:添加日志'
FROM DUAL
WHERE @worklog_menu_id IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM sys_menu
WHERE parent_id = @worklog_menu_id
AND menu_type = 'F'
AND perms = 'business:work:hour:add'
);
-- 4. 补录按钮:编辑日志
INSERT INTO sys_menu (
menu_name,
parent_id,
order_num,
path,
component,
query,
is_frame,
is_cache,
menu_type,
visible,
status,
perms,
icon,
create_by,
create_time,
remark
)
SELECT
'编辑日志',
@worklog_menu_id,
2,
'#',
'',
'',
'1',
'0',
'F',
'0',
'0',
'business:work:hour:edit',
'#',
'codex',
NOW(),
'工作日志页按钮:编辑/确认'
FROM DUAL
WHERE @worklog_menu_id IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM sys_menu
WHERE parent_id = @worklog_menu_id
AND menu_type = 'F'
AND perms = 'business:work:hour:edit'
);
-- 5. 补录按钮:删除日志
INSERT INTO sys_menu (
menu_name,
parent_id,
order_num,
path,
component,
query,
is_frame,
is_cache,
menu_type,
visible,
status,
perms,
icon,
create_by,
create_time,
remark
)
SELECT
'删除日志',
@worklog_menu_id,
3,
'#',
'',
'',
'1',
'0',
'F',
'0',
'0',
'business:work:hour:remove',
'#',
'codex',
NOW(),
'工作日志页按钮:删除/取消'
FROM DUAL
WHERE @worklog_menu_id IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM sys_menu
WHERE parent_id = @worklog_menu_id
AND menu_type = 'F'
AND perms = 'business:work:hour:remove'
);
COMMIT;

View File

@ -0,0 +1,233 @@
-- 页面菜单自动补齐 SQL
-- 目标:
-- 1. 按若依常见模型补齐目录(M)与页面(C)菜单
-- 2. 管理员无需依赖 sys_role_menu 勾选;前端已按 admin 角色默认放行
-- 3. 仅插入缺失菜单,不重复插入
--
-- 说明:
-- 1. 本脚本不删除已有数据,只补齐缺失目录/页面
-- 2. 页面 path 采用“目录 + 子页面”的常见结构,贴近若依模型
-- 3. 一些详情类页面设为隐藏 visible = '1',保留访问能力但不在侧边栏展示
START TRANSACTION;
DROP TEMPORARY TABLE IF EXISTS tmp_menu_dir_specs;
CREATE TEMPORARY TABLE tmp_menu_dir_specs (
path VARCHAR(128) NOT NULL,
menu_name VARCHAR(128) NOT NULL,
icon VARCHAR(64) NULL,
order_num INT NOT NULL DEFAULT 1,
remark VARCHAR(255) NULL
);
DROP TEMPORARY TABLE IF EXISTS tmp_menu_page_specs;
CREATE TEMPORARY TABLE tmp_menu_page_specs (
parent_path VARCHAR(128) NULL,
menu_name VARCHAR(128) NOT NULL,
path VARCHAR(128) NOT NULL,
component VARCHAR(255) NOT NULL,
perms VARCHAR(128) NULL,
icon VARCHAR(64) NULL,
order_num INT NOT NULL DEFAULT 1,
visible CHAR(1) NOT NULL DEFAULT '0',
is_cache CHAR(1) NOT NULL DEFAULT '0',
remark VARCHAR(255) NULL
);
INSERT INTO tmp_menu_dir_specs (path, menu_name, icon, order_num, remark) VALUES
('dashboard', '看板统计', 'dashboard', 1, '首页看板目录'),
('projectBank', '项目库', 'table', 2, '项目库目录'),
('monitor', '系统监控', 'monitor', 3, '监控目录'),
('system', '系统管理', 'system', 4, '系统管理目录'),
('project', '项目管理', 'table', 5, '项目目录'),
('workAppraisal', '绩效管理', 'chart', 6, '绩效目录'),
('user', '用户中心', 'user', 7, '个人中心目录');
INSERT INTO tmp_menu_page_specs (parent_path, menu_name, path, component, perms, icon, order_num, visible, is_cache, remark) VALUES
-- 根页面
(NULL, '工作日志', 'index', 'worklog/index', 'business:work:hour:list', 'dashboard', 1, '0', '0', '工作日志首页'),
-- 看板统计
('dashboard', '项目进度', 'project-execution', 'dashboard/project-execution', NULL, 'table', 1, '0', '0', '项目进度页面'),
-- 项目库
('projectBank', '项目进度(兼容)', 'projectProgress', 'dashboard/project-execution', NULL, 'table', 1, '1', '0', '兼容旧路径'),
('projectBank', '项目成员', 'projectUser', 'projectBank/projectUser', NULL, 'peoples', 2, '0', '0', '项目成员页面'),
('projectBank', '人员项目', 'userProject', 'projectBank/userProject', NULL, 'table', 3, '0', '0', '人员项目页面'),
('projectBank', '我的绩效', 'userScore', 'projectBank/userScore', NULL, 'chart', 4, '0', '0', '我的绩效页面'),
('projectBank', '绩效详情', 'userScoreDetail', 'projectBank/userScoreDetail', NULL, 'form', 5, '1', '0', '绩效详情页'),
-- 用户中心
('user', '个人中心', 'profile', 'profile/index', NULL, 'user', 1, '1', '0', '个人中心页面'),
-- 系统监控
('monitor', '缓存监控', 'cache', 'monitor/cache/index', NULL, 'cache', 1, '0', '0', '缓存监控'),
('monitor', '定时任务', 'job', 'monitor/job/index', NULL, 'job', 2, '0', '0', '定时任务'),
('monitor', '登录日志', 'logininfor', 'monitor/logininfor/index', NULL, 'user', 3, '0', '0', '登录日志'),
('monitor', '在线用户', 'online', 'monitor/online/index', NULL, 'online', 4, '0', '0', '在线用户'),
('monitor', '操作日志', 'operlog', 'monitor/operlog/index', NULL, 'form', 5, '0', '0', '操作日志'),
('monitor', '服务监控', 'server', 'monitor/server/index', NULL, 'server', 6, '0', '0', '服务监控'),
('monitor', '缓存列表', 'cacheList', 'monitor/cache-list/index', NULL, 'cache', 7, '0', '0', '缓存列表'),
-- 系统管理
('system', '用户管理', 'user', 'system/user/index', NULL, 'user', 1, '0', '0', '用户管理'),
('system', '角色管理', 'role', 'system/role/index', NULL, 'peoples', 2, '0', '0', '角色管理'),
('system', '菜单管理', 'menu', 'system/menu/index', NULL, 'tree-table', 3, '0', '0', '菜单管理'),
('system', '部门管理', 'dept', 'system/dept/index', NULL, 'tree', 4, '0', '0', '部门管理'),
('system', '字典管理', 'dict', 'system/dict/index', NULL, 'dict', 5, '0', '0', '字典管理'),
('system', '参数配置', 'config', 'system/config/index', NULL, 'edit', 6, '0', '0', '参数配置'),
-- 项目管理
('project', '项目列表', 'list', 'project/list', NULL, 'table', 1, '0', '0', '项目列表'),
('project', '项目详情', 'detail', 'project/detail', NULL, 'form', 2, '1', '0', '项目详情页'),
('project', '需求管理', 'demandManage', 'project/DemandManagePage', NULL, 'edit', 3, '0', '0', '需求管理'),
-- 绩效管理
('workAppraisal', '经理评分', 'manager', 'workAppraisal/manager', NULL, 'build', 1, '0', '0', '经理评分页'),
('workAppraisal', '员工评分', 'normalWorker', 'workAppraisal/normalWorker', NULL, 'chart', 2, '0', '0', '员工评分页'),
('workAppraisal', '经理评分用户', 'managerUser', 'workAppraisal/managerUser', NULL, 'peoples', 3, '1', '0', '经理评分用户页'),
('workAppraisal', '任务设置', 'taskSet', 'workAppraisal/taskSet', NULL, 'tool', 4, '0', '0', '任务设置页'),
('workAppraisal', '评分详情', 'detail', 'workAppraisal/detail', NULL, 'form', 5, '1', '0', '评分详情页'),
('workAppraisal', '绩效看板', 'taskModule', 'workAppraisal/taskModule', NULL, 'chart', 6, '0', '0', '绩效看板页'),
('workAppraisal', '绩效看板(兼容)', 'dashboard', 'workAppraisal/taskModule', NULL, 'chart', 7, '1', '0', '兼容旧路径'),
('workAppraisal', '模块详情', 'moduleDetail', 'workAppraisal/moduleDetail', NULL, 'form', 8, '1', '0', '模块详情页'),
('workAppraisal', '我的绩效(兼容)', 'myPerformance', 'projectBank/userScore', NULL, 'chart', 9, '1', '0', '兼容旧路径');
-- 1. 先补目录
INSERT INTO sys_menu (
menu_name,
parent_id,
order_num,
path,
component,
query,
is_frame,
is_cache,
menu_type,
visible,
status,
perms,
icon,
create_by,
create_time,
remark
)
SELECT
d.menu_name,
0,
d.order_num,
d.path,
'',
'',
'1',
'0',
'M',
'0',
'0',
'',
d.icon,
'codex',
NOW(),
d.remark
FROM tmp_menu_dir_specs d
WHERE NOT EXISTS (
SELECT 1
FROM sys_menu m
WHERE m.menu_type IN ('M', 'C')
AND m.parent_id = 0
AND m.path = d.path
);
-- 2. 再补页面
INSERT INTO sys_menu (
menu_name,
parent_id,
order_num,
path,
component,
query,
is_frame,
is_cache,
menu_type,
visible,
status,
perms,
icon,
create_by,
create_time,
remark
)
SELECT
p.menu_name,
CASE
WHEN p.parent_path IS NULL OR p.parent_path = '' THEN 0
ELSE (
SELECT m.menu_id
FROM sys_menu m
WHERE m.menu_type = 'M'
AND m.path = p.parent_path
ORDER BY m.menu_id
LIMIT 1
)
END AS parent_id,
p.order_num,
p.path,
p.component,
'',
'1',
p.is_cache,
'C',
p.visible,
'0',
COALESCE(p.perms, ''),
p.icon,
'codex',
NOW(),
p.remark
FROM tmp_menu_page_specs p
WHERE (
p.parent_path IS NULL
OR EXISTS (
SELECT 1
FROM sys_menu m
WHERE m.menu_type = 'M'
AND m.path = p.parent_path
)
)
AND NOT EXISTS (
SELECT 1
FROM sys_menu c
WHERE c.menu_type IN ('M', 'C')
AND c.path = p.path
AND (
c.component = p.component
OR c.menu_name = p.menu_name
)
AND c.parent_id = CASE
WHEN p.parent_path IS NULL OR p.parent_path = '' THEN 0
ELSE (
SELECT m.menu_id
FROM sys_menu m
WHERE m.menu_type = 'M'
AND m.path = p.parent_path
ORDER BY m.menu_id
LIMIT 1
)
END
);
-- 3. 查看本次补齐结果
SELECT
parent.menu_name AS parent_menu_name,
child.menu_name,
child.path,
child.component,
child.menu_type,
child.visible
FROM sys_menu child
LEFT JOIN sys_menu parent ON parent.menu_id = child.parent_id
WHERE child.create_by = 'codex'
AND child.menu_type IN ('M', 'C')
ORDER BY child.parent_id, child.order_num, child.menu_id;
COMMIT;

View File

@ -1 +1 @@
/* Remove default styles */
/* Remove default styles */

View File

@ -1,45 +1,53 @@
import { Suspense, lazy } from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate, Outlet, useLocation } from 'react-router-dom';
import { Spin } from 'antd';
import LoginPage from './pages/Login';
import JobMonitorPage from './pages/monitor/JobMonitorPage';
import CacheMonitorPage from './pages/monitor/CacheMonitorPage';
import LoginLogPage from './pages/monitor/LoginLogPage'; // Import LoginLogPage
import OnlineUserPage from './pages/monitor/OnlineUserPage'; // Import OnlineUserPage
import OperationLogPage from './pages/monitor/OperationLogPage'; // Import OperationLogPage
import ServerMonitorPage from './pages/monitor/ServerMonitorPage'; // Import ServerMonitorPage
import CacheListPage from './pages/monitor/CacheListPage'; // Import CacheListPage
import UserPage from './pages/system/UserPage'; // Import UserPage
import RolePage from './pages/system/RolePage'; // Import RolePage
import MenuPage from './pages/system/MenuPage'; // Import MenuPage
import DeptPage from './pages/system/DeptPage'; // Import DeptPage
import DictPage from './pages/system/DictPage'; // Import DictPage
import ConfigPage from './pages/system/ConfigPage'; // Import ConfigPage
import ProjectPage from './pages/project/ProjectPage'; // Import ProjectPage
import ProjectDetailPage from './pages/project/ProjectDetailPage'; // Import ProjectDetailPage
import DemandManagePage from './pages/project/DemandManagePage'; // Import DemandManagePage
import TaskSetPage from './pages/workAppraisal/TaskSetPage'; // Import TaskSetPage
import ManagerPage from './pages/workAppraisal/ManagerPage';
import ManagerUserPage from './pages/workAppraisal/ManagerUserPage';
import AppraisalDetailPage from './pages/workAppraisal/AppraisalDetailPage'; // Import AppraisalDetailPage
import NormalWorkerPage from './pages/workAppraisal/NormalWorkerPage';
import AppraisalDashboardPage from './pages/workAppraisal/AppraisalDashboardPage';
import AppraisalModuleDetailPage from './pages/workAppraisal/AppraisalModuleDetailPage';
import WorkLogPage from './pages/worklog/WorkLogPage'; // Import WorkLogPage
import ProjectExecutionPage from './pages/dashboard/ProjectExecutionPage';
import ProjectUserPage from './pages/projectBank/ProjectUserPage';
import UserProjectPage from './pages/projectBank/UserProjectPage';
import UserScorePage from './pages/projectBank/UserScorePage';
import UserScoreDetailPage from './pages/projectBank/UserScoreDetailPage';
import ProfilePage from './pages/Profile';
import MainLayout from './layout/MainLayout';
import './App.css';
import { getToken } from './utils/auth';
import { PermissionProvider, usePermission } from './contexts/PermissionContext';
const LoginPage = lazy(() => import('./pages/Login'));
const MainLayout = lazy(() => import('./layout/MainLayout'));
const JobMonitorPage = lazy(() => import('./pages/monitor/JobMonitorPage'));
const CacheMonitorPage = lazy(() => import('./pages/monitor/CacheMonitorPage'));
const LoginLogPage = lazy(() => import('./pages/monitor/LoginLogPage'));
const OnlineUserPage = lazy(() => import('./pages/monitor/OnlineUserPage'));
const OperationLogPage = lazy(() => import('./pages/monitor/OperationLogPage'));
const ServerMonitorPage = lazy(() => import('./pages/monitor/ServerMonitorPage'));
const CacheListPage = lazy(() => import('./pages/monitor/CacheListPage'));
const UserPage = lazy(() => import('./pages/system/UserPage'));
const RolePage = lazy(() => import('./pages/system/RolePage'));
const MenuPage = lazy(() => import('./pages/system/MenuPage'));
const DeptPage = lazy(() => import('./pages/system/DeptPage'));
const DictPage = lazy(() => import('./pages/system/DictPage'));
const ConfigPage = lazy(() => import('./pages/system/ConfigPage'));
const ProjectPage = lazy(() => import('./pages/project/ProjectPage'));
const ProjectDetailPage = lazy(() => import('./pages/project/ProjectDetailPage'));
const DemandManagePage = lazy(() => import('./pages/project/DemandManagePage'));
const TaskSetPage = lazy(() => import('./pages/workAppraisal/TaskSetPage'));
const ManagerPage = lazy(() => import('./pages/workAppraisal/ManagerPage'));
const ManagerUserPage = lazy(() => import('./pages/workAppraisal/ManagerUserPage'));
const AppraisalDetailPage = lazy(() => import('./pages/workAppraisal/AppraisalDetailPage'));
const NormalWorkerPage = lazy(() => import('./pages/workAppraisal/NormalWorkerPage'));
const AppraisalDashboardPage = lazy(() => import('./pages/workAppraisal/AppraisalDashboardPage'));
const AppraisalModuleDetailPage = lazy(() => import('./pages/workAppraisal/AppraisalModuleDetailPage'));
const WorkLogPage = lazy(() => import('./pages/worklog/WorkLogPage'));
const ProjectExecutionPage = lazy(() => import('./pages/dashboard/ProjectExecutionPage'));
const ProjectUserPage = lazy(() => import('./pages/projectBank/ProjectUserPage'));
const UserProjectPage = lazy(() => import('./pages/projectBank/UserProjectPage'));
const UserScorePage = lazy(() => import('./pages/projectBank/UserScorePage'));
const UserScoreDetailPage = lazy(() => import('./pages/projectBank/UserScoreDetailPage'));
const ProfilePage = lazy(() => import('./pages/Profile'));
const RouteLoading = () => (
<div style={{ width: '100%', minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Spin size="large" />
</div>
);
const PrivateRoute = () => {
const location = useLocation();
const token = getToken();
const { ready, loading, canAccessPath } = usePermission();
const { ready, loading, canAccessPath, defaultRoutePath } = usePermission();
if (!token) {
return <Navigate to="/login" replace />;
@ -53,8 +61,26 @@ const PrivateRoute = () => {
);
}
if (location.pathname === '/') {
if (defaultRoutePath && defaultRoutePath !== '/') {
return <Navigate to={defaultRoutePath} replace />;
}
return (
<div style={{ width: '100%', minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
访
</div>
);
}
if (!canAccessPath(location.pathname)) {
return <Navigate to="/index" replace />;
if (defaultRoutePath && defaultRoutePath !== location.pathname) {
return <Navigate to={defaultRoutePath} replace />;
}
return (
<div style={{ width: '100%', minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
访
</div>
);
}
return <Outlet />;
@ -64,48 +90,53 @@ const PrivateRoute = () => {
function App() {
return (
<Router>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route element={<PermissionProvider><PrivateRoute /></PermissionProvider>}>
<Route path="/" element={<MainLayout />}>
<Route index element={<WorkLogPage />} />
<Route path="/index" element={<WorkLogPage />} />
<Route path="/dashboard/project-execution" element={<ProjectExecutionPage />} />
<Route path="/projectBank/projectProgress" element={<ProjectExecutionPage />} />
<Route path="/projectBank/projectUser" element={<ProjectUserPage />} />
<Route path="/projectBank/userProject" element={<UserProjectPage />} />
<Route path="/projectBank/userScore" element={<UserScorePage />} />
<Route path="/projectBank/userScoreDetail" element={<UserScoreDetailPage />} />
<Route path="/profile" element={<ProfilePage />} />
<Route path="/monitor/cache" element={<CacheMonitorPage />} />
<Route path="/monitor/job" element={<JobMonitorPage />} />
<Route path="/monitor/logininfor" element={<LoginLogPage />} />
<Route path="/monitor/online" element={<OnlineUserPage />} />
<Route path="/monitor/operlog" element={<OperationLogPage />} />
<Route path="/monitor/server" element={<ServerMonitorPage />} />
<Route path="/monitor/cacheList" element={<CacheListPage />} />
<Route path="/system/user" element={<UserPage />} />
<Route path="/system/role" element={<RolePage />} />
<Route path="/system/menu" element={<MenuPage />} />
<Route path="/system/dept" element={<DeptPage />} />
<Route path="/system/dict" element={<DictPage />} />
<Route path="/system/config" element={<ConfigPage />} />
<Route path="/project/list" element={<ProjectPage />} />
<Route path="/project/detail" element={<ProjectDetailPage />} />
<Route path="/project/demandManage" element={<DemandManagePage />} />
<Route path="/demandManage" element={<DemandManagePage />} />
<Route path="/workAppraisal/manager" element={<ManagerPage />} />
<Route path="/workAppraisal/normalWorker" element={<NormalWorkerPage />} />
<Route path="/workAppraisal/managerUser" element={<ManagerUserPage />} />
<Route path="/workAppraisal/taskSet" element={<TaskSetPage />} />
<Route path="/workAppraisal/detail" element={<AppraisalDetailPage />} />
<Route path="/workAppraisal/taskModule" element={<AppraisalDashboardPage />} />
<Route path="/workAppraisal/dashboard" element={<AppraisalDashboardPage />} />
<Route path="/workAppraisal/moduleDetail" element={<AppraisalModuleDetailPage />} />
<Route path="/workAppraisal/myPerformance" element={<UserScorePage />} />
<Suspense fallback={<RouteLoading />}>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route element={<PermissionProvider><PrivateRoute /></PermissionProvider>}>
<Route path="/" element={<MainLayout />}>
<Route index element={<WorkLogPage />} />
<Route path="/index" element={<WorkLogPage />} />
<Route path="/dashboard/project-execution" element={<ProjectExecutionPage />} />
<Route path="/projectBank/projectProgress" element={<ProjectExecutionPage />} />
<Route path="/projectBank/projectUser" element={<ProjectUserPage />} />
<Route path="/projectBank/userProject" element={<UserProjectPage />} />
<Route path="/projectBank/userScore" element={<UserScorePage />} />
<Route path="/projectBank/userScoreDetail" element={<UserScoreDetailPage />} />
<Route path="/user/profile" element={<ProfilePage />} />
<Route path="/profile" element={<ProfilePage />} />
<Route path="/monitor/cache" element={<CacheMonitorPage />} />
<Route path="/monitor/job" element={<JobMonitorPage />} />
<Route path="/monitor/logininfor" element={<LoginLogPage />} />
<Route path="/system/logininfor" element={<LoginLogPage />} />
<Route path="/monitor/online" element={<OnlineUserPage />} />
<Route path="/monitor/operlog" element={<OperationLogPage />} />
<Route path="/system/operlog" element={<OperationLogPage />} />
<Route path="/monitor/server" element={<ServerMonitorPage />} />
<Route path="/monitor/cacheList" element={<CacheListPage />} />
<Route path="/system/user" element={<UserPage />} />
<Route path="/system/role" element={<RolePage />} />
<Route path="/system/menu" element={<MenuPage />} />
<Route path="/system/dept" element={<DeptPage />} />
<Route path="/system/dict" element={<DictPage />} />
<Route path="/system/config" element={<ConfigPage />} />
<Route path="/project/list" element={<ProjectPage />} />
<Route path="/project/detail" element={<ProjectDetailPage />} />
<Route path="/project/demandManage" element={<DemandManagePage />} />
<Route path="/demandManage" element={<DemandManagePage />} />
<Route path="/workAppraisal/manager" element={<ManagerPage />} />
<Route path="/workAppraisal/normalWorker" element={<NormalWorkerPage />} />
<Route path="/workAppraisal/managerUser" element={<ManagerUserPage />} />
<Route path="/workAppraisal/taskSet" element={<TaskSetPage />} />
<Route path="/workAppraisal/detail" element={<AppraisalDetailPage />} />
<Route path="/workAppraisal/taskModule" element={<AppraisalDashboardPage />} />
<Route path="/workAppraisal/dashboard" element={<AppraisalDashboardPage />} />
<Route path="/workAppraisal/moduleDetail" element={<AppraisalModuleDetailPage />} />
<Route path="/workAppraisal/myPerformance" element={<UserScorePage />} />
</Route>
</Route>
</Route>
</Routes>
</Routes>
</Suspense>
</Router>
);
}

View File

@ -30,3 +30,11 @@ export function updateUserPwd(oldPassword: string, newPassword: string) {
params: data,
});
}
export function uploadUserAvatar(data: FormData) {
return request<{ imgUrl?: string }, FormData>({
url: '/system/user/profile/avatar',
method: 'post',
data,
});
}

File diff suppressed because one or more lines are too long

View File

@ -1 +1 @@
<base64_decode_as_bytes>g/9j/4AAQSkZJRgABAQEASABIAAD/4QAiRXhpZgAASUkqAAgAAAABABIBAwABAAAABgASAAAAAAD/7QAsUGhvdG9zaG9wIDMuMAA4QklNA+0AAAAAABAASAAAAAEAAQBIAAAAAQABOEJJTQQGAAAAAAAEAAAAAjhCSU0EAgAAAAAABAAAAAD/4Q/gaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wLwA8P3hwYWNrZXQgYmVnaW49Iu+7vyIgaWQ9Ilc1TTBNcENlaGlIenJlU3pOVGN6a2M5ZCI/PiA8eDp4bXBtZXRhIHhtbG5zOng9ImFkb2JlOm5zOm1ldGEvIiB4OnhtcHRrPSJBZG9iZSBYTVAgQ29yZSA1LjYtYzE0MCA3OS4xNjA0NTEsIDIwMTcvMDUvMDYtMDE6MDg6MjEgICAgICAgICI+IDxyZGY6UkRGIHhtbG5zOnJkZj0iaHR0cDovL3d3dy53My5vcmcvMTk5OS8wMi8yMi1yZGYtc3ludGF4LW5zIyI+IDxyZGY6RGVzY3JpcHRpb24gcmRmOmFib3V0PSIiIHhtbG5zOnhtcD0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wLyIgeG1sbnM6cGRmPSJodHRwOi8vbnMuYWRvYmUuY29tL3BkZi8xLjMvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTggKFdpbmRvd3MpIiBwZGY6UHJvZHVjZXI9IkNvbnZlcnQgZnJvbSBBcHBsZSBCb29rIHRvIFBERiAoQXBwbGUgSW5jLikgLSBRdWFydHogMi4wIiB4bXBNTTpEb2N1bWVudElEPSJ4bXAuZGlkOkZBNjczQjlCQjNGNDExRTk4NjY0QzZERjhDMzYxNTMyIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOkZBNjczQjlBQjNGNDExRTk4NjY0QzZERjhDMzYxNTMyIj4gPHhtcE1NOkRlcml2ZWRGcm9tIHN0UmVmOmluc3RhbmNlSUQ9InhtcC5paWQ6NzIyQjE1NTM4RjM4MTFFOUExM0Q4RjhBRjE3RTBDRDQiIHN0UmVmOmRvY3VtZW50SUQ9InhtcC5kaWQ6NzIyQjE1NTQ4RjM4MTFFOUExM0Q4RjhBRjE3RTBDRDQiLz4gPC9yZGY6RGVzY3JpcHRpb24+IDwvcmRmOlJERj4gPC94OnhtcG1ldGE+IDw/eHBhY2tldCBlbmQ9InIiPz7/4gxYSUNDX1BST0ZJTEUAAQEAAAxITGlubwIQAABtbnRyUkdCIFhZWiAOGAAEABgAGgAfABdhY3NwTVNGVAAAAABJRUMgc1JHQgAAAAAAAAAAAAAAAAAA9tYAAQAAAADTLUhQICAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABFjcHJ0AAABUAAAADNkZXNjAAABhAAAAGZ3dHB0AAABwAAAABRia3B0AAAB1AAAABRyWFlaAAAB7AAAABRnWFlaAAACAAAAABRiWFlaAAACFAAAABRyVFJDAAACGAAQAMBjaHJtAAACNAAAACRkbW5kAAACWAAAACRkbWRkAAACfAAAACRsdW1pAAACjAAAAA埬</base64_decode_as_bytes>
<base64_decode_as_bytes>g/9j/4AAQSkZJRgABAQEASABIAAD/4QAiRXhpZgAASUkqAAgAAAABABIBAwABAAAABgASAAAAAAD/7QAsUGhvdG9zaG9wIDMuMAA4QklNA+0AAAAAABAASAAAAAEAAQBIAAAAAQABOEJJTQQGAAAAAAAEAAAAAjhCSU0EAgAAAAAABAAAAAD/4Q/gaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wLwA8P3hwYWNrZXQgYmVnaW49Iu+7vyIgaWQ9Ilc1TTBNcENlaGlIenJlU3pOVGN6a2M5ZCI/PiA8eDp4bXBtZXRhIHhtbG5zOng9ImFkb2JlOm5zOm1ldGEvIiB4OnhtcHRrPSJBZG9iZSBYTVAgQ29yZSA1LjYtYzE0MCA3OS4xNjA0NTEsIDIwMTcvMDUvMDYtMDE6MDg6MjEgICAgICAgICI+IDxyZGY6UkRGIHhtbG5zOnJkZj0iaHR0cDovL3d3dy53My5vcmcvMTk5OS8wMi8yMi1yZGYtc3ludGF4LW5zIyI+IDxyZGY6RGVzY3JpcHRpb24gcmRmOmFib3V0PSIiIHhtbG5zOnhtcD0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wLyIgeG1sbnM6cGRmPSJodHRwOi8vbnMuYWRvYmUuY29tL3BkZi8xLjMvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTggKFdpbmRvd3MpIiBwZGY6UHJvZHVjZXI9IkNvbnZlcnQgZnJvbSBBcHBsZSBCb29rIHRvIFBERiAoQXBwbGUgSW5jLikgLSBRdWFydHogMi4wIiB4bXBNTTpEb2N1bWVudElEPSJ4bXAuZGlkOkZBNjczQjlCQjNGNDExRTk4NjY0QzZERjhDMzYxNTMyIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOkZBNjczQjlBQjNGNDExRTk4NjY0QzZERjhDMzYxNTMyIj4gPHhtcE1NOkRlcml2ZWRGcm9tIHN0UmVmOmluc3RhbmNlSUQ9InhtcC5paWQ6NzIyQjE1NTM4RjM4MTFFOUExM0Q4RjhBRjE3RTBDRDQiIHN0UmVmOmRvY3VtZW50SUQ9InhtcC5kaWQ6NzIyQjE1NTQ4RjM4MTFFOUExM0Q4RjhBRjE3RTBDRDQiLz4gPC9yZGY6RGVzY3JpcHRpb24+IDwvcmRmOlJERj4gPC94OnhtcG1ldGE+IDw/eHBhY2tldCBlbmQ9InIiPz7/4gxYSUNDX1BST0ZJTEUAAQEAAAxITGlubwIQAABtbnRyUkdCIFhZWiAOGAAEABgAGgAfABdhY3NwTVNGVAAAAABJRUMgc1JHQgAAAAAAAAAAAAAAAAAA9tYAAQAAAADTLUhQICAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABFjcHJ0AAABUAAAADNkZXNjAAABhAAAAGZ3dHB0AAABwAAAABRia3B0AAAB1AAAABRyWFlaAAAB7AAAABRnWFlaAAACAAAAABRiWFlaAAACFAAAABRyVFJDAAACGAAQAMBjaHJtAAACNAAAACRkbW5kAAACWAAAACRkbWRkAAACfAAAACRsdW1pAAACjAAAAA埬</base64_decode_as_bytes>

File diff suppressed because one or more lines are too long

View File

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -1,9 +1,11 @@
import React from 'react';
import { Button, Space } from 'antd';
import { MenuFoldOutlined, MenuUnfoldOutlined, UserOutlined } from '@ant-design/icons';
import { Avatar, Button, Dropdown, Space } from 'antd';
import type { MenuProps } from 'antd';
import { DownOutlined, LogoutOutlined, MenuFoldOutlined, MenuUnfoldOutlined, UserOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { removeToken } from '../../utils/auth';
import { usePermission } from '@/contexts/PermissionContext';
import './navbar.css';
interface AppNavbarProps {
collapsed: boolean;
@ -18,26 +20,51 @@ const AppNavbar: React.FC<AppNavbarProps> = ({ collapsed, onToggle }) => {
navigate('/login');
};
const menuItems: MenuProps['items'] = [
{
key: 'profile',
icon: <UserOutlined />,
label: '个人中心',
},
{
type: 'divider' as const,
},
{
key: 'logout',
icon: <LogoutOutlined />,
label: '退出登录',
},
];
const handleMenuClick: MenuProps['onClick'] = ({ key }) => {
if (key === 'profile') {
navigate('/user/profile');
return;
}
if (key === 'logout') {
handleLogout();
}
};
return (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', paddingRight: '20px' }}>
<div className="app-navbar">
<Button
type="text"
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
onClick={onToggle}
style={{
fontSize: '16px',
width: 64,
height: 64,
}}
className="app-navbar-toggle"
/>
<Space style={{ marginLeft: 'auto' }}>
<Button type="text" icon={<UserOutlined />} onClick={() => navigate('/profile')}>
{userName || '用户'}
</Button>
<Button type="link" onClick={handleLogout}>
退
</Button>
</Space>
<div className="app-navbar-meta">
<Dropdown menu={{ items: menuItems, onClick: handleMenuClick }} trigger={['click']}>
<Button type="text" className="app-navbar-user">
<Space size={10}>
<Avatar size={36} className="app-navbar-avatar" icon={<UserOutlined />} />
<span className="app-navbar-user-name">{userName || '用户'}</span>
<DownOutlined className="app-navbar-chevron" />
</Space>
</Button>
</Dropdown>
</div>
</div>
);
};

View File

@ -0,0 +1,91 @@
.app-navbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-height: 74px;
padding: 10px 16px;
border: 1px solid rgba(219, 228, 243, 0.82);
border-radius: 22px;
background: rgba(255, 255, 255, 0.82);
box-shadow: 0 14px 36px rgba(22, 32, 51, 0.05);
backdrop-filter: blur(12px);
}
.app-navbar-toggle.ant-btn {
display: grid;
place-items: center;
width: 46px;
min-width: 46px;
height: 46px;
border-radius: 14px;
color: #384866;
background: linear-gradient(180deg, rgba(247, 249, 255, 0.96), rgba(255, 255, 255, 0.96));
border: 1px solid rgba(219, 228, 243, 0.9);
}
.app-navbar-meta {
display: flex;
align-items: center;
gap: 16px;
margin-left: auto;
}
.app-navbar-copy {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
}
.app-navbar-kicker {
color: #7d8cab;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.app-navbar-title {
color: #1a2439;
font-size: 14px;
font-weight: 700;
}
.app-navbar-user.ant-btn {
height: 48px;
padding: 6px 10px 6px 8px;
border-radius: 16px;
border: 1px solid rgba(219, 228, 243, 0.9);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(246, 249, 255, 0.96));
}
.app-navbar-avatar {
background: linear-gradient(135deg, rgba(99, 91, 255, 0.18), rgba(25, 180, 184, 0.18));
color: #4f46e5;
}
.app-navbar-user-name {
color: #162033;
font-weight: 700;
}
.app-navbar-chevron {
font-size: 12px;
color: #7d8cab;
}
@media (max-width: 768px) {
.app-navbar {
min-height: 64px;
padding: 8px 10px;
}
.app-navbar-copy {
display: none;
}
.app-navbar-user-name {
display: none;
}
}

View File

@ -0,0 +1,29 @@
import React from 'react';
import '@/styles/permission-link.css';
interface ReadonlyActionProps {
icon?: React.ReactNode;
children: React.ReactNode;
danger?: boolean;
className?: string;
}
const ReadonlyAction: React.FC<ReadonlyActionProps> = ({
icon,
children,
danger = false,
className = '',
}) => {
const classes = ['permission-link-disabled', danger ? 'is-danger' : '', className]
.filter(Boolean)
.join(' ');
return (
<span className={classes}>
{icon}
<span>{children}</span>
</span>
);
};
export default ReadonlyAction;

View File

@ -30,17 +30,23 @@ import {
import { useLocation, useNavigate } from 'react-router-dom';
import { usePermission } from '@/contexts/PermissionContext';
import type { RouterNode } from '@/api/permission';
import './sidebar.css';
type MenuItem = Required<MenuProps>['items'][number];
type MenuItemWithChildren = MenuItem & { children?: MenuItem[] };
const BACKEND_PATH_TO_APP_PATH: Record<string, string> = {
'/project': '/project/list',
'/projectBank/userScore': '/workAppraisal/myPerformance',
'/system/logininfor': '/monitor/logininfor',
'/system/operlog': '/monitor/operlog',
};
const MENU_ROUTE_ALIASES: Record<string, string[]> = {
'/projectBank/projectProgress': ['/dashboard/project-execution'],
'/workAppraisal/myPerformance': ['/projectBank/userScore'],
'/monitor/logininfor': ['/system/logininfor'],
'/monitor/operlog': ['/system/operlog'],
};
const ICON_MAP: Record<string, React.ReactNode> = {
@ -185,6 +191,8 @@ const buildMenuItems = (
};
const isRouteKey = (key: React.Key): key is string => typeof key === 'string' && key.startsWith('/');
const getMenuItemChildren = (item: MenuItem): MenuItem[] | undefined =>
(item as MenuItemWithChildren).children;
const findMatchedPath = (menuItems: MenuItem[], pathname: string): string | null => {
for (const item of menuItems) {
@ -192,7 +200,7 @@ const findMatchedPath = (menuItems: MenuItem[], pathname: string): string | null
continue;
}
const children = item.children as MenuItem[] | undefined;
const children = getMenuItemChildren(item);
if (Array.isArray(children) && children.length > 0) {
const childHit = findMatchedPath(children, pathname);
if (childHit) {
@ -200,14 +208,15 @@ const findMatchedPath = (menuItems: MenuItem[], pathname: string): string | null
}
}
if (isRouteKey(item.key)) {
const aliases = MENU_ROUTE_ALIASES[item.key] ?? [];
const itemKey = item.key;
if (itemKey !== undefined && isRouteKey(itemKey)) {
const aliases = MENU_ROUTE_ALIASES[itemKey] ?? [];
if (
item.key === pathname ||
pathname.startsWith(`${item.key}/`) ||
itemKey === pathname ||
pathname.startsWith(`${itemKey}/`) ||
aliases.some((alias) => alias === pathname || pathname.startsWith(`${alias}/`))
) {
return item.key;
return itemKey;
}
}
}
@ -229,7 +238,7 @@ const findParentKeys = (
return parentKeys;
}
const children = item.children as MenuItem[] | undefined;
const children = getMenuItemChildren(item);
if (Array.isArray(children) && children.length > 0) {
const found = findParentKeys(children, targetKey, [...parentKeys, key]);
if (found) {
@ -269,7 +278,7 @@ const AppSidebar: React.FC = () => {
return (
<Menu
theme="dark"
className="app-sidebar-menu"
mode="inline"
selectedKeys={[selectedKey]}
openKeys={manualOpenKeys}

View File

@ -0,0 +1,74 @@
.app-sidebar-menu.ant-menu {
padding: 6px;
background: transparent;
border-inline-end: none;
color: #5f6f92;
}
.app-sidebar-menu .ant-menu-item,
.app-sidebar-menu .ant-menu-submenu-title {
height: 46px;
margin: 4px 0;
line-height: 46px;
border-radius: 14px;
font-weight: 600;
transition: background-color 180ms ease, color 180ms ease, transform 180ms ease;
}
.app-sidebar-menu .ant-menu-item .ant-menu-title-content,
.app-sidebar-menu .ant-menu-submenu-title .ant-menu-title-content {
font-size: 14px;
}
.app-sidebar-menu .ant-menu-item .anticon,
.app-sidebar-menu .ant-menu-submenu-title .anticon {
color: #7d8cab;
transition: color 180ms ease;
}
.app-sidebar-menu .ant-menu-item:hover,
.app-sidebar-menu .ant-menu-submenu-title:hover {
background: rgba(79, 124, 255, 0.08);
color: #172138;
}
.app-sidebar-menu .ant-menu-item:hover .anticon,
.app-sidebar-menu .ant-menu-submenu-title:hover .anticon {
color: #4f46e5;
}
.app-sidebar-menu .ant-menu-item-selected {
background: linear-gradient(135deg, rgba(99, 91, 255, 0.14), rgba(79, 124, 255, 0.08)) !important;
color: #4f46e5 !important;
box-shadow: inset 0 0 0 1px rgba(99, 91, 255, 0.12);
}
.app-sidebar-menu .ant-menu-item-selected .anticon,
.app-sidebar-menu .ant-menu-submenu-selected > .ant-menu-submenu-title .anticon {
color: #4f46e5;
}
.app-sidebar-menu .ant-menu-sub.ant-menu-inline {
margin: 4px 0 8px;
padding: 6px;
border: 1px solid rgba(219, 228, 243, 0.72);
border-radius: 18px;
background: rgba(246, 249, 255, 0.7);
}
.app-sidebar-menu .ant-menu-sub.ant-menu-inline > .ant-menu-item {
margin: 2px 0;
}
.app-sidebar-menu .ant-menu-submenu-arrow {
color: #98a6c2;
}
.app-sidebar-menu.ant-menu-inline-collapsed {
width: auto;
}
.app-sidebar-menu.ant-menu-inline-collapsed .ant-menu-item,
.app-sidebar-menu.ant-menu-inline-collapsed .ant-menu-submenu-title {
padding-inline: calc(50% - 16px) !important;
}

View File

@ -1,16 +1,18 @@
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { message } from 'antd';
import { getInfo, getRouters, type RouterNode } from '@/api/permission';
import { getUserProfile } from '@/api/user';
import { getToken } from '@/utils/auth';
import { notify } from '@/utils/notify';
interface PermissionContextValue {
loading: boolean;
ready: boolean;
isAdmin: boolean;
userName: string;
roles: string[];
permissions: string[];
routers: RouterNode[];
defaultRoutePath: string;
refreshPermissions: () => Promise<void>;
hasRole: (roles: string | string[]) => boolean;
hasPermi: (permissions: string | string[]) => boolean;
@ -19,12 +21,16 @@ interface PermissionContextValue {
const PermissionContext = createContext<PermissionContextValue | null>(null);
const ALWAYS_ALLOW_PATHS = new Set([
'/',
'/index',
'/profile',
]);
const ALWAYS_ALLOW_PATHS = new Set(['/', '/profile', '/user/profile']);
const BACKEND_PATH_TO_APP_PATH: Record<string, string> = {
'/project': '/project/list',
'/projectBank/userScore': '/workAppraisal/myPerformance',
'/system/logininfor': '/monitor/logininfor',
'/system/operlog': '/monitor/operlog',
};
const ROUTE_ALIASES: Record<string, string[]> = {
'/profile': ['/user/profile'],
'/user/profile': ['/profile'],
'/demandManage': ['/project/demandManage'],
'/workAppraisal/dashboard': ['/workAppraisal/taskModule'],
'/dashboard/project-execution': ['/projectBank/projectProgress'],
@ -33,10 +39,11 @@ const ROUTE_ALIASES: Record<string, string[]> = {
'/workAppraisal/myPerformance': ['/projectBank/userScore'],
'/projectBank/userScore': ['/workAppraisal/myPerformance'],
'/projectBank/userScoreDetail': ['/workAppraisal/myPerformance'],
'/monitor/logininfor': ['/system/logininfor'],
'/monitor/operlog': ['/system/operlog'],
};
const SUPER_PERMI = '*:*:*';
const ADMIN_ROLE = 'admin';
const ADMIN_ROLE_ALIASES = ['admin', '超级管理员', 'superadmin'];
const normalizePath = (rawPath: string) => {
const path = rawPath.split('?')[0]?.split('#')[0] ?? '';
@ -65,6 +72,17 @@ const joinPath = (base: string, child: string) => {
return normalizePath(`${normalizedBase}/${child}`);
};
const resolveAppPath = (fullBackendPath: string, node?: Partial<RouterNode>) => {
const normalized = normalizePath(fullBackendPath);
if (normalized === '/index' || String(node?.component ?? '').includes('worklog')) {
return '/index';
}
if (normalized === '/project' || String(node?.component ?? '') === 'project/list') {
return '/project/list';
}
return BACKEND_PATH_TO_APP_PATH[normalized] ?? normalized;
};
const parseStringList = (input: unknown) => {
if (!Array.isArray(input)) {
return [] as string[];
@ -109,22 +127,21 @@ const extractRouteNodes = (payload: unknown): RouterNode[] => {
return [];
};
const isAdminRoleName = (role: string) => {
const roleText = role.trim().toLowerCase();
return ADMIN_ROLE_ALIASES.some((alias) => roleText === alias.toLowerCase());
};
const flattenRouterPaths = (routes: RouterNode[]) => {
const pathSet = new Set<string>();
const walk = (nodes: RouterNode[], parentPath: string) => {
nodes.forEach((node) => {
const currentPath = joinPath(parentPath, String(node.path ?? ''));
if (currentPath && currentPath !== '/') {
pathSet.add(currentPath);
const backendPath = joinPath(parentPath, String(node.path ?? ''));
const appPath = resolveAppPath(backendPath, node);
if (backendPath && backendPath !== '/') {
pathSet.add(backendPath);
}
if (appPath && appPath !== '/') {
pathSet.add(appPath);
}
if (Array.isArray(node.children) && node.children.length > 0) {
walk(node.children, currentPath);
walk(node.children, backendPath);
}
});
};
@ -133,6 +150,33 @@ const flattenRouterPaths = (routes: RouterNode[]) => {
return pathSet;
};
const findFirstVisibleRoutePath = (routes: RouterNode[], parentPath = '/'): string => {
for (const node of routes) {
if (node.hidden) {
continue;
}
const backendPath = joinPath(parentPath, String(node.path ?? ''));
const children = Array.isArray(node.children) ? node.children : [];
if (children.length > 0) {
const childPath = findFirstVisibleRoutePath(children, backendPath);
if (childPath) {
return childPath;
}
}
const title = String(node.meta?.title ?? node.name ?? '').trim();
if (title) {
const appPath = resolveAppPath(backendPath, node);
if (appPath && appPath !== '/') {
return appPath;
}
}
}
return '';
};
const matchPathPattern = (allowedPath: string, actualPath: string) => {
if (allowedPath === actualPath) {
return true;
@ -157,6 +201,7 @@ export const PermissionProvider: React.FC<{ children: React.ReactNode }> = ({ ch
const [roles, setRoles] = useState<string[]>([]);
const [permissions, setPermissions] = useState<string[]>([]);
const [routers, setRouters] = useState<RouterNode[]>([]);
const [defaultRoutePath, setDefaultRoutePath] = useState('');
const [allowedPaths, setAllowedPaths] = useState<Set<string>>(new Set());
const [routeGuardEnabled, setRouteGuardEnabled] = useState(false);
@ -165,6 +210,7 @@ export const PermissionProvider: React.FC<{ children: React.ReactNode }> = ({ ch
setRoles([]);
setPermissions([]);
setRouters([]);
setDefaultRoutePath('');
setAllowedPaths(new Set());
setRouteGuardEnabled(false);
}, []);
@ -185,8 +231,10 @@ export const PermissionProvider: React.FC<{ children: React.ReactNode }> = ({ ch
let nextRoles: string[] = [];
let nextPermissions: string[] = [];
let nextRouters: RouterNode[] = [];
let nextAllowedPaths = new Set<string>();
let nextRouteGuardEnabled = false;
let nextDefaultRoutePath = '/index';
let nextAllowedPaths = new Set<string>(['/index', '/profile', '/user/profile']);
let nextRouteGuardEnabled = true;
let bootstrapFailed = false;
try {
try {
@ -195,37 +243,48 @@ export const PermissionProvider: React.FC<{ children: React.ReactNode }> = ({ ch
nextPermissions = parseStringList(info.permissions);
nextUserName = String((info.user as Record<string, unknown> | undefined)?.userName ?? '');
} catch (error) {
// Fallback for environments where /getInfo is not available yet.
const profile = await getUserProfile();
nextUserName = String(profile.user?.userName ?? '');
const roleGroup = String((profile as Record<string, unknown>).roleGroup ?? '');
nextRoles = roleGroup
.split(',')
.map((role) => role.trim())
.filter(Boolean);
console.error('Failed to load /getInfo, fallback to profile:', error);
try {
const profile = await getUserProfile();
nextUserName = String(profile.user?.userName ?? '');
const roleGroup = String((profile as Record<string, unknown>).roleGroup ?? '');
nextRoles = roleGroup
.split(',')
.map((role) => role.trim())
.filter(Boolean);
} catch (profileError) {
console.error('Failed to load profile fallback:', profileError);
bootstrapFailed = true;
}
}
try {
const routersRaw = await getRouters();
const routes = extractRouteNodes(routersRaw);
nextRouters = routes;
nextAllowedPaths = flattenRouterPaths(routes);
nextRouteGuardEnabled = true;
if (routes.length > 0) {
nextRouters = routes;
nextAllowedPaths = flattenRouterPaths(routes);
nextAllowedPaths.add('/profile');
nextAllowedPaths.add('/user/profile');
nextDefaultRoutePath = findFirstVisibleRoutePath(routes, '/') || '/index';
nextRouteGuardEnabled = true;
}
} catch (routerError) {
console.error('Failed to load router permission data:', routerError);
const isAdminUser = nextRoles.some((role) => isAdminRoleName(role));
nextAllowedPaths = new Set();
nextRouteGuardEnabled = !isAdminUser;
bootstrapFailed = true;
}
} catch (error) {
console.error('Failed to load permission data:', error);
message.error('加载权限信息失败');
clearPermissionState();
bootstrapFailed = true;
} finally {
if (bootstrapFailed && nextUserName === '' && nextRoles.length === 0 && nextRouters.length === 0) {
notify.warning('权限信息加载超时,已使用基础访问模式');
}
setUserName(nextUserName);
setRoles(nextRoles);
setPermissions(nextPermissions);
setRouters(nextRouters);
setDefaultRoutePath(nextDefaultRoutePath);
setAllowedPaths(nextAllowedPaths);
setRouteGuardEnabled(nextRouteGuardEnabled);
setLoading(false);
@ -241,7 +300,7 @@ export const PermissionProvider: React.FC<{ children: React.ReactNode }> = ({ ch
const permissionSet = useMemo(() => new Set(permissions), [permissions]);
const isAdmin = useCallback(
() => roleSet.has(ADMIN_ROLE) || Array.from(roleSet).some((role) => isAdminRoleName(role)),
() => roleSet.has(ADMIN_ROLE),
[roleSet],
);
@ -258,11 +317,11 @@ export const PermissionProvider: React.FC<{ children: React.ReactNode }> = ({ ch
const hasPermi = useCallback(
(required: string | string[]) => {
if (isAdmin() || permissionSet.has(SUPER_PERMI)) {
if (isAdmin()) {
return true;
}
const targets = Array.isArray(required) ? required : [required];
return targets.some((item) => permissionSet.has(item));
return targets.some((item) => permissionSet.has(item) || permissionSet.has(SUPER_PERMI));
},
[isAdmin, permissionSet],
);
@ -310,16 +369,18 @@ export const PermissionProvider: React.FC<{ children: React.ReactNode }> = ({ ch
() => ({
loading,
ready,
isAdmin: isAdmin(),
userName,
roles,
permissions,
routers,
defaultRoutePath,
refreshPermissions,
hasRole,
hasPermi,
canAccessPath,
}),
[loading, ready, userName, roles, permissions, routers, refreshPermissions, hasRole, hasPermi, canAccessPath],
[loading, ready, isAdmin, userName, roles, permissions, routers, defaultRoutePath, refreshPermissions, hasRole, hasPermi, canAccessPath],
);
return <PermissionContext.Provider value={value}>{children}</PermissionContext.Provider>;

View File

@ -1,9 +1,462 @@
:root {
--font-sans: "Plus Jakarta Sans", "PingFang SC", "Microsoft YaHei", sans-serif;
--color-bg: #f6f8fc;
--color-bg-soft: #f8faff;
--color-surface: rgba(255, 255, 255, 0.92);
--color-surface-strong: #ffffff;
--color-panel: #f4f7ff;
--color-text: #162033;
--color-text-soft: #5f6f92;
--color-text-mute: #7d8cab;
--color-border: #dbe4f3;
--color-border-soft: #e9eef7;
--color-primary: #635bff;
--color-primary-strong: #4f46e5;
--color-primary-soft: rgba(99, 91, 255, 0.1);
--color-accent: #19b4b8;
--color-success-soft: rgba(16, 185, 129, 0.12);
--color-warning-soft: rgba(245, 158, 11, 0.14);
--color-danger-soft: rgba(239, 68, 68, 0.12);
--shadow-sm: 0 8px 18px rgba(22, 32, 51, 0.06);
--shadow-md: 0 16px 40px rgba(22, 32, 51, 0.08);
--shadow-lg: 0 28px 60px rgba(72, 84, 159, 0.12);
--radius-sm: 10px;
--radius-md: 16px;
--radius-lg: 22px;
--radius-pill: 999px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--motion-base: 180ms ease;
}
html,
body,
#root {
height: 100%;
width: 100%;
min-height: 100%;
margin: 0;
padding: 0;
}
body {
font-family: var(--font-sans);
color: var(--color-text);
background:
radial-gradient(circle at top left, rgba(99, 91, 255, 0.12), transparent 28%),
radial-gradient(circle at top right, rgba(25, 180, 184, 0.08), transparent 24%),
linear-gradient(180deg, #f8faff 0%, #f4f7fc 100%);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
background-image:
linear-gradient(rgba(255, 255, 255, 0.7), rgba(255, 255, 255, 0.7)),
linear-gradient(90deg, rgba(219, 228, 243, 0.18) 1px, transparent 1px),
linear-gradient(rgba(219, 228, 243, 0.12) 1px, transparent 1px);
background-size: auto, 36px 36px, 36px 36px;
mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.34), transparent 80%);
z-index: -1;
}
a {
color: inherit;
}
* {
box-sizing: border-box;
}
::selection {
background: rgba(99, 91, 255, 0.18);
}
.app-container {
position: relative;
display: flex;
flex-direction: column;
gap: var(--space-5);
min-height: 100%;
}
.app-container > .ant-card,
.app-container > .ant-table-wrapper,
.app-container > .ant-tabs,
.app-container > .ant-spin-nested-loading,
.app-container > .ant-row {
animation: fade-up var(--motion-base);
}
.search-form {
display: flex;
flex-wrap: wrap;
gap: 12px 14px;
align-items: flex-start;
padding: 18px 20px 4px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(246, 249, 255, 0.96));
border: 1px solid rgba(219, 228, 243, 0.9);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
backdrop-filter: blur(12px);
}
.search-form .ant-form-item {
margin-bottom: 14px;
}
.search-form .ant-form-item:last-child .ant-form-item-control-input-content,
.dashboard-search .ant-form-item:last-child .ant-form-item-control-input-content,
.task-set-search .ant-form-item:last-child .ant-form-item-control-input-content,
.demand-search-form .ant-form-item:last-child .ant-form-item-control-input-content {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.search-form .ant-form-item:last-child .ant-btn,
.dashboard-search .ant-form-item:last-child .ant-btn,
.task-set-search .ant-form-item:last-child .ant-btn,
.demand-search-form .ant-form-item:last-child .ant-btn {
margin-left: 0 !important;
}
.search-form .ant-form-item-label > label,
.search-form .ant-form-item .ant-form-item-label > label {
color: var(--color-text-soft);
font-weight: 600;
}
.mb8 {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
padding: 0 2px;
}
.mb8 .ant-btn,
.search-form .ant-btn {
min-width: 92px;
}
.ant-table-thead > tr > th,
.ant-table-tbody > tr > td {
padding: 14px 16px !important;
}
.ant-table-pagination-right {
padding-inline: 4px;
}
.ant-layout {
background: transparent;
}
.ant-card {
border: 1px solid rgba(219, 228, 243, 0.86);
box-shadow: var(--shadow-sm);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(250, 251, 255, 0.98));
transition: transform var(--motion-base), box-shadow var(--motion-base), border-color var(--motion-base);
}
.ant-card:hover {
border-color: rgba(99, 91, 255, 0.18);
box-shadow: var(--shadow-md);
}
.ant-card .ant-card-head {
min-height: 64px;
border-bottom: 1px solid var(--color-border-soft);
}
.ant-card .ant-card-head-title {
font-weight: 700;
color: var(--color-text);
}
.ant-btn {
font-weight: 600;
transition: transform var(--motion-base), box-shadow var(--motion-base), border-color var(--motion-base), background-color var(--motion-base);
}
.ant-btn:hover {
transform: translateY(-1px);
}
.ant-btn:disabled,
.ant-btn[disabled],
.ant-btn.ant-btn-disabled,
.ant-btn-primary:disabled,
.ant-btn-primary[disabled],
.ant-btn-primary.ant-btn-disabled {
color: #7b88a6 !important;
background: #eef3fb !important;
border-color: #d7e1f0 !important;
box-shadow: none !important;
opacity: 1 !important;
text-shadow: none !important;
}
.ant-btn:disabled:hover,
.ant-btn[disabled]:hover,
.ant-btn.ant-btn-disabled:hover {
transform: none;
}
.ant-btn-primary {
background: linear-gradient(135deg, #635bff 0%, #4f46e5 100%);
border-color: transparent;
color: #ffffff !important;
text-shadow: none;
}
.ant-btn-primary .anticon,
.ant-btn-primary > span {
color: inherit;
}
.ant-btn-default,
.ant-btn-color-default {
border-color: var(--color-border);
background: rgba(255, 255, 255, 0.86);
color: var(--color-text) !important;
}
.ant-btn-dangerous.ant-btn-variant-solid,
.ant-btn-color-dangerous.ant-btn-variant-solid {
box-shadow: 0 10px 24px rgba(239, 68, 68, 0.18);
color: #ffffff !important;
}
.ant-btn-dangerous.ant-btn-variant-solid .anticon,
.ant-btn-dangerous.ant-btn-variant-solid > span,
.ant-btn-color-dangerous.ant-btn-variant-solid .anticon,
.ant-btn-color-dangerous.ant-btn-variant-solid > span {
color: inherit;
}
.ant-input,
.ant-input-affix-wrapper,
.ant-picker,
.ant-select-selector,
.ant-input-number,
.ant-input-number-affix-wrapper {
border-color: var(--color-border) !important;
background: rgba(255, 255, 255, 0.9) !important;
box-shadow: none !important;
transition: border-color var(--motion-base), box-shadow var(--motion-base), background-color var(--motion-base);
}
.ant-input:hover,
.ant-input-affix-wrapper:hover,
.ant-picker:hover,
.ant-select-selector:hover,
.ant-input-number:hover,
.ant-input-number-affix-wrapper:hover {
border-color: rgba(99, 91, 255, 0.38) !important;
}
.ant-input:focus,
.ant-input-focused,
.ant-input-affix-wrapper-focused,
.ant-picker-focused,
.ant-select-focused .ant-select-selector,
.ant-input-number-focused,
.ant-input-number-affix-wrapper-focused {
border-color: rgba(99, 91, 255, 0.55) !important;
box-shadow: 0 0 0 4px rgba(99, 91, 255, 0.12) !important;
background: #ffffff !important;
}
.ant-form-item-explain,
.ant-form-item-extra {
color: var(--color-text-mute);
}
.ant-table-wrapper {
background: rgba(255, 255, 255, 0.92);
border: 1px solid rgba(219, 228, 243, 0.86);
border-radius: var(--radius-lg);
overflow: hidden;
box-shadow: var(--shadow-sm);
}
.ant-table {
background: transparent;
}
.ant-table-thead > tr > th {
font-weight: 700;
color: #34425f;
background: #f7f9fe !important;
border-bottom: 1px solid var(--color-border-soft);
}
.ant-table-tbody > tr > td {
border-bottom: 1px solid #eef2f9;
}
.ant-table-tbody > tr.ant-table-row:hover > td {
background: #f8faff !important;
}
.ant-pagination {
margin: 18px 0 4px;
}
.ant-pagination .ant-pagination-item,
.ant-pagination .ant-pagination-prev,
.ant-pagination .ant-pagination-next {
border-color: var(--color-border);
border-radius: 12px;
background: rgba(255, 255, 255, 0.92);
}
.ant-pagination .ant-pagination-item-active {
border-color: rgba(99, 91, 255, 0.2);
background: rgba(99, 91, 255, 0.08);
}
.ant-tag {
padding-inline: 10px;
font-weight: 600;
border: none;
}
.ant-modal .ant-modal-content,
.ant-drawer .ant-drawer-content {
background: linear-gradient(180deg, #ffffff 0%, #fbfcff 100%);
border: 1px solid rgba(219, 228, 243, 0.92);
box-shadow: var(--shadow-lg);
}
.ant-modal .ant-modal-header,
.ant-drawer .ant-drawer-header {
background: transparent;
border-bottom: 1px solid var(--color-border-soft);
}
.ant-modal .ant-modal-title,
.ant-drawer .ant-drawer-title {
font-weight: 800;
}
.ant-modal .ant-form-horizontal .ant-form-item-label > label,
.ant-drawer .ant-form-horizontal .ant-form-item-label > label {
color: var(--color-text-soft);
font-weight: 700;
}
.ant-dropdown .ant-dropdown-menu {
border: 1px solid rgba(219, 228, 243, 0.9);
border-radius: 16px;
box-shadow: var(--shadow-md);
padding: 8px;
}
.ant-dropdown .ant-dropdown-menu-item,
.ant-dropdown .ant-dropdown-menu-submenu-title {
border-radius: 10px;
font-weight: 500;
}
.ant-tabs .ant-tabs-tab {
border-radius: 12px;
padding: 10px 14px;
}
.ant-tabs .ant-tabs-tab-active {
background: rgba(99, 91, 255, 0.08);
}
.ant-alert {
border-radius: 16px;
}
@media (max-width: 992px) {
.search-form {
padding: 16px 16px 2px;
}
.search-form .ant-form-item {
width: 100%;
margin-right: 0;
}
.search-form .ant-form-item-control,
.search-form .ant-form-item-control-input,
.search-form .ant-form-item-control-input-content,
.search-form .ant-select,
.search-form .ant-picker,
.search-form .ant-input-affix-wrapper,
.search-form .ant-input {
width: 100% !important;
}
.ant-modal {
max-width: calc(100vw - 24px);
margin: 0 auto;
}
.ant-modal .ant-modal-content {
padding: 20px 16px;
border-radius: 20px;
}
.ant-modal .ant-form-horizontal .ant-form-item {
margin-bottom: 16px;
}
.ant-modal .ant-form-horizontal .ant-form-item .ant-row {
display: block;
}
.ant-modal .ant-form-horizontal .ant-form-item-label,
.ant-modal .ant-form-horizontal .ant-form-item-control,
.ant-modal .ant-form-horizontal .ant-col {
max-width: 100%;
flex: 0 0 100%;
width: 100%;
text-align: left;
}
.ant-modal .ant-form-horizontal .ant-form-item-label {
padding-bottom: 6px;
}
.ant-modal .ant-form-horizontal .ant-form-item-control {
margin-inline-start: 0 !important;
}
}
@media (max-width: 768px) {
.app-container {
gap: var(--space-4);
}
.ant-card .ant-card-head {
min-height: 58px;
}
.mb8 {
gap: 10px;
}
}
@keyframes fade-up {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

View File

@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { Layout, theme } from 'antd';
import { Layout } from 'antd';
import { Outlet } from 'react-router-dom';
import AppSidebar from '../components/Sidebar/index';
import AppNavbar from '../components/Navbar';
@ -9,30 +9,29 @@ const { Header, Sider, Content } = Layout;
const MainLayout: React.FC = () => {
const [collapsed, setCollapsed] = useState(false);
const {
token: { colorBgContainer, borderRadiusLG },
} = theme.useToken();
return (
<Layout style={{ minHeight: '100vh' }}>
<Sider trigger={null} collapsible collapsed={collapsed}>
<div className="logo" />
<Layout className="main-layout">
<Sider className="main-sider" trigger={null} collapsible collapsed={collapsed} width={260}>
<div className="logo">
<div className="logo-mark">pms</div>
{!collapsed ? (
<div className="logo-copy">
<span className="logo-kicker">UNISSENSE PMS</span>
<strong className="logo-title">线</strong>
</div>
) : null}
</div>
<AppSidebar />
</Sider>
<Layout>
<Header style={{ padding: 0, background: colorBgContainer }}>
<Layout className="main-layout-body">
<Header className="main-header">
<AppNavbar collapsed={collapsed} onToggle={() => setCollapsed(!collapsed)} />
</Header>
<Content
style={{
margin: '24px 16px',
padding: 24,
minHeight: 280,
background: colorBgContainer,
borderRadius: borderRadiusLG,
}}
>
<Outlet />
<Content className="main-content">
<div className="main-content-panel">
<Outlet />
</div>
</Content>
</Layout>
</Layout>

View File

@ -1,10 +1,121 @@
.logo {
height: 32px;
margin: 16px;
background: rgba(255, 255, 255, 0.2);
border-radius: 6px;
color: white;
text-align: center;
line-height: 32px;
font-weight: bold;
.main-layout {
min-height: 100vh;
background: transparent;
}
.main-sider.ant-layout-sider {
position: sticky;
top: 0;
height: 100vh;
overflow: auto;
padding: 18px 14px 18px 18px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(247, 249, 255, 0.98)) !important;
border-right: 1px solid rgba(219, 228, 243, 0.82);
box-shadow: 18px 0 44px rgba(22, 32, 51, 0.04);
}
.main-layout-body {
background: transparent;
}
.main-header.ant-layout-header {
position: sticky;
top: 0;
z-index: 10;
height: auto;
line-height: 1;
padding: 18px 20px 0;
background: transparent !important;
}
.main-content.ant-layout-content {
margin: 0;
padding: 18px 20px 20px;
background: transparent;
}
.main-content-panel {
min-height: calc(100vh - 104px);
padding: 24px;
border: 1px solid rgba(219, 228, 243, 0.78);
border-radius: 28px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.82), rgba(248, 250, 255, 0.96));
box-shadow: 0 20px 48px rgba(22, 32, 51, 0.06);
backdrop-filter: blur(14px);
}
.logo {
display: flex;
align-items: center;
gap: 12px;
margin: 0 0 18px;
padding: 14px 12px;
border: 1px solid rgba(219, 228, 243, 0.7);
border-radius: 20px;
background: linear-gradient(135deg, rgba(99, 91, 255, 0.08), rgba(25, 180, 184, 0.08));
box-shadow: 0 12px 28px rgba(22, 32, 51, 0.05);
}
.logo-mark {
display: grid;
place-items: center;
width: 42px;
height: 42px;
border-radius: 14px;
background: linear-gradient(135deg, #635bff, #4f46e5);
box-shadow: 0 14px 28px rgba(99, 91, 255, 0.28);
color: #ffffff;
font-size: 14px;
font-weight: 800;
text-transform: lowercase;
letter-spacing: 0.02em;
}
.logo-copy {
display: flex;
flex-direction: column;
min-width: 0;
}
.logo-kicker {
color: #6f7ea0;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.18em;
}
.logo-title {
color: #182238;
font-size: 16px;
font-weight: 800;
line-height: 1.25;
}
@media (max-width: 992px) {
.main-sider.ant-layout-sider {
position: fixed;
z-index: 12;
}
.main-header.ant-layout-header {
padding: 14px 14px 0;
}
.main-content.ant-layout-content {
padding: 14px;
}
.main-content-panel {
min-height: calc(100vh - 84px);
padding: 18px;
border-radius: 22px;
}
}
@media (max-width: 768px) {
.main-content-panel {
padding: 16px;
}
}

View File

@ -1,11 +1,120 @@
import { StrictMode } from 'react'
import { StrictMode, useEffect } from 'react'
import { createRoot } from 'react-dom/client'
import { App as AntdApp, ConfigProvider, theme } from 'antd'
import zhCN from 'antd/locale/zh_CN'
import dayjs from 'dayjs'
import 'dayjs/locale/zh-cn'
import 'antd/dist/reset.css';
import './index.css'
import App from './App.tsx'
import { bindMessageApi } from './utils/notify'
dayjs.locale('zh-cn')
const MessageBinder = () => {
const { message } = AntdApp.useApp();
useEffect(() => {
bindMessageApi(message);
}, [message]);
return null;
};
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
<ConfigProvider
locale={zhCN}
theme={{
algorithm: theme.defaultAlgorithm,
token: {
colorPrimary: '#635bff',
colorSuccess: '#10b981',
colorWarning: '#f59e0b',
colorError: '#ef4444',
colorInfo: '#4f7cff',
colorBgBase: '#f6f8fc',
colorBgLayout: '#f6f8fc',
colorBgContainer: '#ffffff',
colorText: '#162033',
colorTextSecondary: '#5f6f92',
colorTextTertiary: '#7d8cab',
colorBorder: '#dbe4f3',
colorBorderSecondary: '#e9eef7',
borderRadius: 14,
borderRadiusLG: 20,
borderRadiusSM: 10,
fontFamily: '"Plus Jakarta Sans", "PingFang SC", "Microsoft YaHei", sans-serif',
fontSize: 14,
fontSizeLG: 16,
controlHeight: 40,
controlHeightLG: 46,
lineWidth: 1,
boxShadow: '0 18px 45px rgba(22, 32, 51, 0.08)',
boxShadowSecondary: '0 10px 24px rgba(99, 91, 255, 0.10)',
},
components: {
Layout: {
headerBg: 'rgba(255,255,255,0.86)',
siderBg: '#ffffff',
bodyBg: '#f6f8fc',
triggerBg: '#ffffff',
triggerColor: '#162033',
},
Menu: {
itemBg: 'transparent',
itemColor: '#5f6f92',
itemHoverColor: '#162033',
itemSelectedColor: '#4f46e5',
itemSelectedBg: 'rgba(99, 91, 255, 0.12)',
subMenuItemBg: 'transparent',
iconSize: 16,
collapsedIconSize: 16,
itemBorderRadius: 12,
},
Button: {
borderRadius: 12,
controlHeight: 40,
controlHeightLG: 44,
fontWeight: 600,
primaryShadow: '0 12px 24px rgba(99, 91, 255, 0.24)',
},
Card: {
borderRadiusLG: 20,
boxShadowTertiary: '0 12px 32px rgba(22, 32, 51, 0.06)',
},
Input: {
borderRadius: 12,
controlHeight: 40,
activeShadow: '0 0 0 4px rgba(99, 91, 255, 0.12)',
},
Select: {
borderRadius: 12,
controlHeight: 40,
optionSelectedBg: 'rgba(99, 91, 255, 0.1)',
},
Table: {
borderColor: '#e9eef7',
headerBg: '#f7f9fe',
headerColor: '#34425f',
rowHoverBg: '#f8faff',
},
Modal: {
borderRadiusLG: 22,
},
Drawer: {
borderRadiusLG: 22,
},
Tag: {
borderRadiusSM: 999,
},
},
}}
>
<AntdApp>
<MessageBinder />
<App />
</AntdApp>
</ConfigProvider>
</StrictMode>,
)

View File

@ -1,37 +1,213 @@
.home-container {
font-family: "open sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
font-size: 13px;
color: #676a6c;
overflow-x: hidden;
display: flex;
flex-direction: column;
gap: 20px;
color: #162033;
}
.home-container .notification {
padding: 10px 20px;
margin: 0 0 20px;
font-size: 14px;
border-left: 5px solid #eee;
.home-hero {
display: grid;
grid-template-columns: minmax(0, 1.5fr) minmax(280px, 0.9fr);
gap: 22px;
padding: 30px;
border: 1px solid rgba(219, 228, 243, 0.84);
border-radius: 28px;
background:
radial-gradient(circle at top right, rgba(25, 180, 184, 0.16), transparent 28%),
radial-gradient(circle at left center, rgba(99, 91, 255, 0.18), transparent 32%),
linear-gradient(135deg, rgba(255, 255, 255, 0.98), rgba(245, 248, 255, 0.96));
box-shadow: 0 18px 44px rgba(22, 32, 51, 0.08);
}
.home-container ul {
padding: 0;
margin: 0;
list-style-type: none;
.home-kicker {
display: inline-flex;
margin-bottom: 16px;
color: #5f57f5;
font-size: 12px;
font-weight: 800;
letter-spacing: 0.18em;
}
.home-container h4 {
margin-top: 0px;
.home-hero .ant-typography {
margin-bottom: 0;
}
.home-container h2 {
margin-top: 10px;
font-size: 26px;
font-weight: 100;
.home-hero h1.ant-typography {
margin-bottom: 12px;
color: #162033;
font-size: clamp(30px, 4vw, 44px);
line-height: 1.08;
}
.home-container p {
margin-top: 10px;
.home-hero-text {
display: inline-block;
max-width: 680px;
color: #5f6f92;
font-size: 16px;
line-height: 1.8;
}
.home-container .ant-card {
margin-bottom: 20px;
.home-hero-actions {
margin-top: 24px;
}
.home-hero-panels {
display: grid;
gap: 14px;
align-content: center;
}
.home-highlight {
display: flex;
flex-direction: column;
gap: 8px;
padding: 18px 18px 20px;
border-radius: 20px;
border: 1px solid rgba(219, 228, 243, 0.84);
background: rgba(255, 255, 255, 0.82);
box-shadow: 0 12px 28px rgba(22, 32, 51, 0.05);
}
.home-highlight span {
color: #7d8cab;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.home-highlight strong {
color: #162033;
font-size: 24px;
font-weight: 800;
}
.home-highlight-purple {
background: linear-gradient(135deg, rgba(99, 91, 255, 0.12), rgba(255, 255, 255, 0.88));
}
.home-highlight-cyan {
background: linear-gradient(135deg, rgba(25, 180, 184, 0.12), rgba(255, 255, 255, 0.88));
}
.home-highlight-slate {
background: linear-gradient(135deg, rgba(79, 124, 255, 0.1), rgba(255, 255, 255, 0.88));
}
.home-section-card {
height: 100%;
}
.home-feature-grid {
display: grid;
gap: 16px;
}
.home-feature {
display: grid;
grid-template-columns: 48px minmax(0, 1fr);
gap: 16px;
align-items: start;
padding: 18px;
border: 1px solid rgba(219, 228, 243, 0.76);
border-radius: 18px;
background: rgba(247, 249, 255, 0.7);
}
.home-feature-icon {
display: grid;
place-items: center;
width: 48px;
height: 48px;
border-radius: 16px;
background: linear-gradient(135deg, rgba(99, 91, 255, 0.16), rgba(79, 124, 255, 0.16));
color: #4f46e5;
font-size: 22px;
}
.home-feature h3 {
margin: 0 0 6px;
font-size: 16px;
font-weight: 700;
}
.home-feature p,
.home-notification p,
.home-support p {
margin: 0;
color: #5f6f92;
line-height: 1.8;
}
.home-stack-grid {
display: grid;
gap: 16px;
}
.home-stack-group {
padding: 18px;
border: 1px solid rgba(219, 228, 243, 0.76);
border-radius: 18px;
background: rgba(247, 249, 255, 0.7);
}
.home-stack-head {
display: inline-flex;
align-items: center;
gap: 10px;
margin-bottom: 14px;
color: #162033;
font-weight: 700;
}
.home-stack-group ul {
display: grid;
gap: 10px;
padding: 0;
margin: 0;
list-style: none;
}
.home-stack-group li {
color: #5f6f92;
}
.home-notification {
display: grid;
gap: 10px;
}
.home-notification .ant-typography,
.home-notification .ant-tag {
width: fit-content;
}
.home-support {
display: grid;
gap: 18px;
}
.home-support img {
width: 100%;
max-width: 220px;
border-radius: 18px;
border: 1px solid rgba(219, 228, 243, 0.82);
}
@media (max-width: 992px) {
.home-hero {
grid-template-columns: 1fr;
padding: 22px;
}
}
@media (max-width: 768px) {
.home-hero {
padding: 18px;
border-radius: 22px;
}
.home-highlight strong {
font-size: 20px;
}
}

View File

@ -1,80 +1,146 @@
import { Row, Col, Typography, Button, Tag, Divider, Card, Collapse } from 'antd';
import { Row, Col, Typography, Button, Tag, Card, Collapse, Space } from 'antd';
import {
ArrowRightOutlined,
CloudServerOutlined,
CodeOutlined,
RocketOutlined,
SafetyCertificateOutlined,
} from '@ant-design/icons';
import './home.css';
import payImg from '../../assets/pay.png';
const { Title, Text, Link } = Typography;
const HomePage = () => {
const version = "3.8.8"; // From original file
const version = "3.8.8";
const goTarget = (href: string) => {
window.open(href, "_blank");
};
const highlights = [
{ label: '当前版本', value: `v${version}`, tone: 'purple' },
{ label: '产品定位', value: '企业协作平台', tone: 'cyan' },
{ label: '部署形态', value: '内网 / 云端', tone: 'slate' },
];
const productValues = [
{
icon: <RocketOutlined />,
title: '统一工作入口',
description: '围绕项目、日志、绩效和系统管理组织工作流,减少页面跳转和重复确认。',
},
{
icon: <SafetyCertificateOutlined />,
title: '权限清晰可控',
description: '页面、按钮、角色数据分层管理,既适合企业管控,也兼顾一线使用效率。',
},
{
icon: <CloudServerOutlined />,
title: '稳定交付视角',
description: '保留后台业务效率,同时升级成更专业、可长期演进的企业产品界面语言。',
},
];
return (
<div className="home-container">
<Row gutter={[20, 20]}>
<Col span={24}>
<div className="notification">
1888<br />
<Link href="https://www.aliyun.com/minisite/goods?userCode=brki8iof" target="_blank">
https://www.aliyun.com/minisite/goods?userCode=brki8iof
</Link>
<br />
2860<br />
<Link href="https://cloud.tencent.com/redirect.php?redirect=1025&cps_key=198c8df2ed259157187173bc7f4f32fd&from=console" target="_blank">
https://cloud.tencent.com/redirect.php?redirect=1025&cps_key=198c8df2ed259157187173bc7f4f32fd&from=console
</Link>
<br />
<Link href="http://aly.ruoyi.vip" target="_blank"> &gt;</Link>
&nbsp;&nbsp;&nbsp;
<Link href="http://txy.ruoyi.vip" target="_blank"> &gt;</Link>
<br />
<Text type="danger" strong>
使()
</Text>
</div>
<section className="home-hero">
<div className="home-hero-copy">
<span className="home-kicker">UNISSENSE PRODUCT CONSOLE</span>
<Title level={1}></Title>
<Text className="home-hero-text">
AI SaaS
</Text>
<Space wrap className="home-hero-actions">
<Button type="primary" size="large" onClick={() => goTarget('https://gitee.com/y_project/RuoYi-Vue')}>
</Button>
<Button size="large" onClick={() => goTarget('http://ruoyi.vip')}>
<ArrowRightOutlined />
</Button>
</Space>
</div>
<div className="home-hero-panels">
{highlights.map((item) => (
<div key={item.label} className={`home-highlight home-highlight-${item.tone}`}>
<span>{item.label}</span>
<strong>{item.value}</strong>
</div>
))}
</div>
</section>
</Col>
</Row>
<Divider />
<Row gutter={[20, 20]}>
<Col xs={24} sm={24} md={12}>
<Title level={2}></Title>
<p>
...
</p>
<p><Text strong>:</Text> <span>v{version}</span></p>
<p><Tag color="red">¥</Tag></p>
<p>
<Button onClick={() => goTarget('https://gitee.com/y_project/RuoYi-Vue')}>访</Button>
<Button style={{ marginLeft: 8 }} onClick={() => goTarget('http://ruoyi.vip')}>访</Button>
</p>
<Col xs={24} lg={16}>
<Card className="home-section-card" title="产品特性">
<div className="home-feature-grid">
{productValues.map((item) => (
<div key={item.title} className="home-feature">
<div className="home-feature-icon">{item.icon}</div>
<div>
<h3>{item.title}</h3>
<p>{item.description}</p>
</div>
</div>
))}
</div>
</Card>
</Col>
<Col xs={24} sm={24} md={12}>
<Title level={2}></Title>
<Row>
<Col span={12}>
<h4></h4>
<ul><li>SpringBoot</li><li>Spring Security</li><li>JWT</li><li>MyBatis</li><li>...</li></ul>
</Col>
<Col span={12}>
<h4></h4>
<ul><li>Vue</li><li>Vuex</li><li>Element-ui</li><li>Axios</li><li>...</li></ul>
</Col>
</Row>
<Col xs={24} lg={8}>
<Card className="home-section-card" title="技术选型">
<div className="home-stack-grid">
<div className="home-stack-group">
<div className="home-stack-head">
<CodeOutlined />
<span></span>
</div>
<ul>
<li>Spring Boot</li>
<li>Spring Security</li>
<li>JWT</li>
<li>MyBatis</li>
</ul>
</div>
<div className="home-stack-group">
<div className="home-stack-head">
<CodeOutlined />
<span></span>
</div>
<ul>
<li>React</li>
<li>Ant Design</li>
<li>Vite</li>
<li>Axios</li>
</ul>
</div>
</div>
</Card>
</Col>
</Row>
<Divider />
<Row gutter={[20, 20]}>
<Col xs={24} sm={24} md={12} lg={8}>
<Card title="联系信息">
<p><Link href="http://www.ruoyi.vip" target="_blank">http://www.ruoyi.vip</Link></p>
<p>QQ<Link href="#" target="_blank">151450850</Link></p>
<Card className="home-section-card" title="云资源提示">
<div className="home-notification">
<p></p>
<Link href="https://www.aliyun.com/minisite/goods?userCode=brki8iof" target="_blank">
aliyun.com/minisite/goods?userCode=brki8iof
</Link>
<p></p>
<Link href="https://cloud.tencent.com/redirect.php?redirect=1025&cps_key=198c8df2ed259157187173bc7f4f32fd&from=console" target="_blank">
cloud.tencent.com/redirect.php?redirect=1025
</Link>
<Tag color="error">使</Tag>
</div>
</Card>
</Col>
<Col xs={24} sm={24} md={12} lg={8}>
<Card title="更新日志">
<Card className="home-section-card" title="更新日志">
<Collapse accordion items={[
{
key: '1',
@ -90,9 +156,15 @@ const HomePage = () => {
</Card>
</Col>
<Col xs={24} sm={24} md={12} lg={8}>
<Card title="捐赠支持">
<img src={payImg} alt="donate" style={{ width: '100%' }}/>
<Text></Text>
<Card className="home-section-card" title="联系与支持">
<div className="home-support">
<div className="home-support-copy">
<p><Link href="http://www.ruoyi.vip" target="_blank">ruoyi.vip</Link></p>
<p>QQ151450850</p>
<Text></Text>
</div>
<img src={payImg} alt="donate" />
</div>
</Card>
</Col>
</Row>

View File

@ -1,11 +1,12 @@
import { useState, useEffect } from 'react';
import { Form, Input, Button, Checkbox, message } from 'antd';
import { Form, Input, Button, Checkbox } from 'antd';
import { UserOutlined, LockOutlined, SafetyOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import Cookies from 'js-cookie';
import { login, getCodeImg } from '../../api/login';
import { TokenKey } from '../../utils/auth';
import type { LoginRequest } from '@/types/api';
import { notify } from '@/utils/notify';
import './login.css';
interface LoginFormValues {
@ -15,6 +16,22 @@ interface LoginFormValues {
rememberMe?: boolean;
}
const REMEMBER_USERNAME_KEY = 'remember_username';
const REMEMBER_PASSWORD_KEY = 'remember_password';
const REMEMBER_ME_KEY = 'remember_me';
const encodeRememberValue = (value: string) => window.btoa(encodeURIComponent(value));
const decodeRememberValue = (value?: string) => {
if (!value) {
return '';
}
try {
return decodeURIComponent(window.atob(value));
} catch {
return '';
}
};
const LoginPage = () => {
const [form] = Form.useForm<LoginFormValues>();
const navigate = useNavigate();
@ -39,7 +56,7 @@ const LoginPage = () => {
})
.catch((error: unknown) => {
console.error('Failed to load captcha image:', error);
message.error('Failed to load captcha image, please refresh.');
notify.error('Failed to load captcha image, please refresh.');
});
};
@ -47,19 +64,40 @@ const LoginPage = () => {
void fetchCode();
}, []);
useEffect(() => {
const remembered = Cookies.get(REMEMBER_ME_KEY) === 'true';
const username = decodeRememberValue(Cookies.get(REMEMBER_USERNAME_KEY));
const password = decodeRememberValue(Cookies.get(REMEMBER_PASSWORD_KEY));
form.setFieldsValue({
rememberMe: remembered,
username,
password,
});
}, [form]);
const handleLogin = (values: LoginFormValues) => {
setLoading(true);
const data: LoginRequest = { ...values, uuid };
login(data)
.then((res) => {
message.success('Login successful!');
notify.success('Login successful!');
const tokenToSet = res.token ?? 'mock_token';
Cookies.set(TokenKey, tokenToSet);
if (values.rememberMe) {
Cookies.set(REMEMBER_ME_KEY, 'true', { expires: 30 });
Cookies.set(REMEMBER_USERNAME_KEY, encodeRememberValue(values.username), { expires: 30 });
Cookies.set(REMEMBER_PASSWORD_KEY, encodeRememberValue(values.password), { expires: 30 });
} else {
Cookies.remove(REMEMBER_ME_KEY);
Cookies.remove(REMEMBER_USERNAME_KEY);
Cookies.remove(REMEMBER_PASSWORD_KEY);
}
navigate('/');
})
.catch((error: unknown) => {
const errorMessage = error instanceof Error ? error.message : 'Login failed.';
message.error(errorMessage);
notify.error(errorMessage);
if (captchaEnabled) {
void fetchCode();
}
@ -71,56 +109,83 @@ const LoginPage = () => {
return (
<div className="login">
<Form
form={form}
name="loginForm"
className="login-form"
initialValues={{ rememberMe: true }}
onFinish={handleLogin}
>
<h3 className="title">线</h3>
<Form.Item
name="username"
rules={[{ required: true, message: '请输入您的账号!' }]}
>
<Input prefix={<UserOutlined />} placeholder="账号" />
</Form.Item>
<Form.Item
name="password"
rules={[{ required: true, message: '请输入您的密码!' }]}
>
<Input.Password prefix={<LockOutlined />} placeholder="密码" />
</Form.Item>
{captchaEnabled && (
<Form.Item>
<Form.Item
name="code"
rules={[{ required: true, message: '请输入验证码!' }]}
style={{ display: 'inline-block', width: 'calc(67% - 8px)', marginRight: '8px' }}
>
<Input prefix={<SafetyOutlined />} placeholder="验证码" />
</Form.Item>
<div className="login-code">
{codeUrl ? <img src={codeUrl} onClick={() => void fetchCode()} alt="Captcha" /> : null}
<div className="login-shell">
<div className="login-brand-panel">
<div className="login-brand-badge">UNISSENSE PMS</div>
<h1>线</h1>
<p>
</p>
<div className="login-brand-metrics">
<div className="login-metric">
<strong>AI-ready</strong>
<span></span>
</div>
</Form.Item>
)}
<div className="login-metric">
<strong>Role-based</strong>
<span></span>
</div>
<div className="login-metric">
<strong>Responsive</strong>
<span>访</span>
</div>
</div>
</div>
<Form
form={form}
name="loginForm"
className="login-form"
initialValues={{ rememberMe: true }}
onFinish={handleLogin}
>
<div className="login-form-head">
<span className="login-kicker">Sign in</span>
<h3 className="title"></h3>
<p className="login-subtitle">使</p>
</div>
<Form.Item>
<Form.Item name="rememberMe" valuePropName="checked" noStyle>
<Checkbox></Checkbox>
<Form.Item
name="username"
rules={[{ required: true, message: '请输入您的账号!' }]}
>
<Input prefix={<UserOutlined />} placeholder="账号" />
</Form.Item>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" className="login-form-button" loading={loading} style={{ width: '100%' }}>
{loading ? '登 录 中...' : '登 录'}
</Button>
</Form.Item>
</Form>
<Form.Item
name="password"
rules={[{ required: true, message: '请输入您的密码!' }]}
>
<Input.Password prefix={<LockOutlined />} placeholder="密码" />
</Form.Item>
{captchaEnabled && (
<div className="login-code-row">
<Form.Item
name="code"
rules={[{ required: true, message: '请输入验证码!' }]}
className="login-code-input"
>
<Input prefix={<SafetyOutlined />} placeholder="验证码" />
</Form.Item>
<div className="login-code">
{codeUrl ? <img src={codeUrl} onClick={() => void fetchCode()} alt="Captcha" /> : null}
</div>
</div>
)}
<Form.Item className="login-remember">
<Form.Item name="rememberMe" valuePropName="checked" noStyle>
<Checkbox></Checkbox>
</Form.Item>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" className="login-form-button" loading={loading}>
{loading ? '登 录 中...' : '登 录'}
</Button>
</Form.Item>
</Form>
</div>
<div className="el-login-footer">
<span>unissense.tech</span>
</div>

View File

@ -1,63 +1,269 @@
.login {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
width: 100vw;
background-image: url("../../assets/login-background.jpg");
background-size: cover;
position: relative;
display: grid;
place-items: center;
min-height: 100vh;
padding: 28px;
background:
linear-gradient(135deg, rgba(244, 247, 255, 0.84), rgba(246, 250, 255, 0.92)),
url("../../assets/login-background.jpg") center/cover no-repeat;
}
.title {
margin: 0px auto 30px auto;
text-align: center;
color: #707070;
font-size: 24px;
font-weight: bold;
.login::before,
.login::after {
content: "";
position: absolute;
border-radius: 50%;
filter: blur(8px);
opacity: 0.8;
}
.login::before {
top: 10%;
left: 8%;
width: 320px;
height: 320px;
background: radial-gradient(circle, rgba(99, 91, 255, 0.24), transparent 66%);
}
.login::after {
right: 8%;
bottom: 12%;
width: 260px;
height: 260px;
background: radial-gradient(circle, rgba(25, 180, 184, 0.18), transparent 66%);
}
.login-shell {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: minmax(320px, 1.05fr) minmax(360px, 420px);
gap: 22px;
width: min(1120px, 100%);
align-items: stretch;
}
.login-brand-panel,
.login-form {
border: 1px solid rgba(219, 228, 243, 0.84);
border-radius: 28px;
backdrop-filter: blur(16px);
}
.login-brand-panel {
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 620px;
padding: 34px;
background:
radial-gradient(circle at top right, rgba(25, 180, 184, 0.14), transparent 28%),
radial-gradient(circle at left center, rgba(99, 91, 255, 0.16), transparent 36%),
linear-gradient(135deg, rgba(255, 255, 255, 0.82), rgba(244, 247, 255, 0.78));
box-shadow: 0 24px 60px rgba(22, 32, 51, 0.12);
}
.login-brand-badge {
display: inline-flex;
width: fit-content;
padding: 8px 12px;
border-radius: 999px;
background: rgba(99, 91, 255, 0.1);
color: #564fee;
font-size: 12px;
font-weight: 800;
letter-spacing: 0.12em;
}
.login-brand-panel h1 {
max-width: 520px;
margin: 22px 0 14px;
color: #162033;
font-size: clamp(34px, 4vw, 50px);
line-height: 1.08;
}
.login-brand-panel p {
max-width: 560px;
margin: 0;
color: #5f6f92;
font-size: 16px;
line-height: 1.8;
}
.login-brand-metrics {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
margin-top: 34px;
}
.login-metric {
padding: 18px;
border: 1px solid rgba(219, 228, 243, 0.72);
border-radius: 20px;
background: rgba(255, 255, 255, 0.72);
}
.login-metric strong {
display: block;
margin-bottom: 8px;
color: #162033;
font-size: 15px;
}
.login-metric span {
color: #6a7898;
font-size: 13px;
line-height: 1.7;
}
.login-form {
border-radius: 6px;
background: #ffffff;
width: 400px;
padding: 25px 25px 5px 25px;
align-self: center;
width: 100%;
padding: 32px 30px 22px;
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 20px 50px rgba(22, 32, 51, 0.1);
}
.login-form-head {
margin-bottom: 24px;
}
.login-kicker {
color: #635bff;
font-size: 12px;
font-weight: 800;
letter-spacing: 0.16em;
text-transform: uppercase;
}
.title {
margin: 10px 0 8px;
color: #162033;
font-size: 28px;
font-weight: 800;
}
.login-subtitle {
margin: 0;
color: #6a7898;
line-height: 1.8;
}
.login-form .ant-form-item {
margin-bottom: 18px;
}
.login-form .ant-input-affix-wrapper,
.login-form .ant-input,
.login-form .ant-btn {
height: 46px;
}
.login-form .ant-input-affix-wrapper {
height: 40px;
display: flex;
align-items: center;
padding-inline: 14px;
}
.login-form .ant-input-affix-wrapper > input.ant-input,
.login-form .ant-input {
height: 100%;
line-height: 46px;
}
.login-form .ant-input-prefix {
margin-right: 8px;
margin-right: 10px;
color: #7d8cab;
}
.login-tip {
font-size: 13px;
text-align: center;
color: #bfbfbf;
.login-code-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 132px;
gap: 12px;
align-items: stretch;
}
.login-code-input.ant-form-item {
margin-bottom: 18px;
}
.login-code-input .ant-form-item-control,
.login-code-input .ant-form-item-control-input,
.login-code-input .ant-form-item-control-input-content {
height: 100%;
}
.login-code {
width: 33%;
height: 40px;
float: right;
display: grid;
place-items: center;
height: 46px;
overflow: hidden;
border: 1px solid rgba(219, 228, 243, 0.9);
border-radius: 14px;
background: rgba(247, 249, 255, 0.84);
}
.login-code img {
width: 100%;
height: 100%;
object-fit: contain;
cursor: pointer;
vertical-align: middle;
height: 40px;
}
.login-remember .ant-checkbox-wrapper {
color: #6a7898;
}
.login-form-button {
width: 100%;
}
.el-login-footer {
height: 40px;
line-height: 40px;
position: fixed;
bottom: 0;
bottom: 18px;
z-index: 1;
width: 100%;
text-align: center;
color: #fff;
font-family: Arial, sans-serif;
color: #74839f;
font-size: 12px;
letter-spacing: 1px;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
}
@media (max-width: 992px) {
.login-shell {
grid-template-columns: 1fr;
}
.login-brand-panel {
min-height: auto;
padding: 24px;
}
.login-brand-metrics {
grid-template-columns: 1fr;
}
}
@media (max-width: 576px) {
.login {
padding: 14px;
}
.login-form,
.login-brand-panel {
border-radius: 22px;
}
.login-form {
padding: 24px 18px 16px;
}
.login-code-row {
grid-template-columns: 1fr;
}
}

View File

@ -0,0 +1,77 @@
import { CameraOutlined, UserOutlined } from '@ant-design/icons';
import { Avatar, Upload, message } from 'antd';
import type { UploadProps } from 'antd';
import { uploadUserAvatar } from '@/api/user';
interface AvatarUploaderProps {
avatarUrl?: string;
displayName?: string;
onUploaded: (nextAvatar: string) => void;
}
const buildAvatarUrl = (value?: string) => {
const raw = String(value ?? '').trim();
if (!raw) {
return '';
}
if (raw.startsWith('http://') || raw.startsWith('https://') || raw.startsWith('data:')) {
return raw;
}
return raw.startsWith('/api/') ? raw : `/api${raw.startsWith('/') ? raw : `/${raw}`}`;
};
const AvatarUploader = ({ avatarUrl, displayName, onUploaded }: AvatarUploaderProps) => {
const uploadProps: UploadProps = {
accept: 'image/*',
showUploadList: false,
customRequest: async ({ file, onSuccess, onError }) => {
try {
const formData = new FormData();
formData.append('avatarfile', file as Blob);
const response = await uploadUserAvatar(formData);
const nextAvatar = String(response.imgUrl ?? '');
onUploaded(nextAvatar);
message.success('修改成功');
onSuccess?.(response);
} catch (error) {
message.error('头像上传失败');
onError?.(error as Error);
}
},
beforeUpload: (file) => {
if (!file.type.startsWith('image/')) {
message.error('请上传图片文件');
return Upload.LIST_IGNORE;
}
const isLt5M = file.size / 1024 / 1024 < 5;
if (!isLt5M) {
message.error('头像大小不能超过 5MB');
return Upload.LIST_IGNORE;
}
return true;
},
};
return (
<div className="profile-avatar-block">
<Upload {...uploadProps}>
<button type="button" className="profile-avatar-trigger">
<Avatar
size={108}
src={buildAvatarUrl(avatarUrl) || undefined}
icon={avatarUrl ? undefined : <UserOutlined />}
className="profile-avatar-image"
/>
<span className="profile-avatar-mask">
<CameraOutlined />
</span>
</button>
</Upload>
<div className="profile-avatar-name">{displayName || '未命名用户'}</div>
<div className="profile-avatar-tip"> JPGPNG</div>
</div>
);
};
export default AvatarUploader;

View File

@ -1,5 +1,7 @@
import { Form, Input, Button, message } from 'antd';
import { updateUserPwd } from '../../api/user';
import { Button, Form, Input, Space, message } from 'antd';
import { useNavigate } from 'react-router-dom';
import { updateUserPwd } from '@/api/user';
import { usePermission } from '@/contexts/PermissionContext';
interface ResetPasswordValues {
oldPassword: string;
@ -8,62 +10,78 @@ interface ResetPasswordValues {
}
const ResetPassword = () => {
const [form] = Form.useForm<ResetPasswordValues>();
const [form] = Form.useForm<ResetPasswordValues>();
const navigate = useNavigate();
const { defaultRoutePath } = usePermission();
const onFinish = (values: ResetPasswordValues) => {
updateUserPwd(values.oldPassword, values.newPassword).then(() => {
message.success('修改成功,请重新登录');
form.resetFields();
}).catch(() => {
message.error('修改失败');
});
};
const handleClose = () => {
if (window.history.length > 1) {
navigate(-1);
return;
}
navigate(defaultRoutePath || '/index');
};
return (
<Form form={form} onFinish={onFinish} labelCol={{ span: 4 }} wrapperCol={{ span: 16 }}>
<Form.Item
label="旧密码"
name="oldPassword"
rules={[{ required: true, message: '旧密码不能为空' }]}
>
<Input.Password />
</Form.Item>
<Form.Item
label="新密码"
name="newPassword"
rules={[
{ required: true, message: '新密码不能为空' },
{ min: 6, message: '长度不能小于6位' },
]}
>
<Input.Password />
</Form.Item>
<Form.Item
label="确认密码"
name="confirmPassword"
dependencies={['newPassword']}
hasFeedback
rules={[
{ required: true, message: '请确认新密码' },
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue('newPassword') === value) {
return Promise.resolve();
}
return Promise.reject(new Error('两次输入的密码不一致!'));
},
}),
]}
>
<Input.Password />
</Form.Item>
<Form.Item wrapperCol={{ offset: 4, span: 16 }}>
<Button type="primary" htmlType="submit">
</Button>
</Form.Item>
</Form>
);
const onFinish = async (values: ResetPasswordValues) => {
try {
await updateUserPwd(values.oldPassword, values.newPassword);
message.success('修改成功');
form.resetFields();
} catch (error) {
console.error('Failed to update password:', error);
}
};
return (
<Form
form={form}
onFinish={onFinish}
labelCol={{ flex: '80px' }}
wrapperCol={{ flex: 'auto' }}
className="profile-form"
>
<Form.Item label="旧密码" name="oldPassword" rules={[{ required: true, message: '旧密码不能为空' }]}>
<Input.Password placeholder="请输入旧密码" />
</Form.Item>
<Form.Item
label="新密码"
name="newPassword"
rules={[
{ required: true, message: '新密码不能为空' },
{ min: 6, max: 20, message: '长度在 6 到 20 个字符' },
{ pattern: /^[^<>"'|\\]+$/, message: '不能包含非法字符:< > \" \' \\ |' },
]}
>
<Input.Password placeholder="请输入新密码" />
</Form.Item>
<Form.Item
label="确认密码"
name="confirmPassword"
dependencies={['newPassword']}
rules={[
{ required: true, message: '确认密码不能为空' },
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue('newPassword') === value) {
return Promise.resolve();
}
return Promise.reject(new Error('两次输入的密码不一致'));
},
}),
]}
>
<Input.Password placeholder="请确认新密码" />
</Form.Item>
<Form.Item className="profile-form-actions">
<Space size={12}>
<Button type="primary" htmlType="submit">
</Button>
<Button onClick={handleClose}></Button>
</Space>
</Form.Item>
</Form>
);
};
export default ResetPassword;

View File

@ -1,42 +1,56 @@
import { useEffect } from 'react';
import { Form, Input, Button, Radio, message } from 'antd';
import { updateUserProfile } from '../../api/user';
import { Button, Form, Input, Radio, Space, message } from 'antd';
import { useNavigate } from 'react-router-dom';
import { updateUserProfile } from '@/api/user';
import { usePermission } from '@/contexts/PermissionContext';
import type { UpdateUserProfilePayload, UserProfileUser } from '@/types/api';
interface UserInfoProps {
user: UserProfileUser;
onUpdated: (values: Partial<UserProfileUser>) => void;
}
const UserInfo = ({ user }: UserInfoProps) => {
const UserInfo = ({ user, onUpdated }: UserInfoProps) => {
const [form] = Form.useForm<UpdateUserProfilePayload>();
const navigate = useNavigate();
const { defaultRoutePath } = usePermission();
useEffect(() => {
// Set form fields when user data is available
form.setFieldsValue({
nickName: user.nickName,
phonenumber: user.phonenumber,
email: user.email,
sex: user.sex,
nickName: user.nickName ?? '',
phonenumber: user.phonenumber ?? '',
email: user.email ?? '',
sex: user.sex ?? '0',
});
}, [user, form]);
}, [form, user]);
const onFinish = (values: UpdateUserProfilePayload) => {
updateUserProfile(values)
.then(() => {
message.success('修改成功');
})
.catch(() => {
message.error('修改失败');
});
const handleClose = () => {
if (window.history.length > 1) {
navigate(-1);
return;
}
navigate(defaultRoutePath || '/index');
};
const onFinish = async (values: UpdateUserProfilePayload) => {
try {
await updateUserProfile(values);
message.success('修改成功');
onUpdated(values);
} catch (error) {
console.error('Failed to update profile:', error);
}
};
return (
<Form form={form} onFinish={onFinish} labelCol={{ span: 4 }} wrapperCol={{ span: 16 }}>
<Form.Item
label="用户昵称"
name="nickName"
rules={[{ required: true, message: '用户昵称不能为空' }]}
>
<Form
form={form}
onFinish={onFinish}
labelCol={{ flex: '80px' }}
wrapperCol={{ flex: 'auto' }}
className="profile-form"
>
<Form.Item label="用户昵称" name="nickName" rules={[{ required: true, message: '用户昵称不能为空' }]}>
<Input maxLength={30} />
</Form.Item>
<Form.Item
@ -65,10 +79,13 @@ const UserInfo = ({ user }: UserInfoProps) => {
<Radio value="1"></Radio>
</Radio.Group>
</Form.Item>
<Form.Item wrapperCol={{ offset: 4, span: 16 }}>
<Button type="primary" htmlType="submit">
</Button>
<Form.Item className="profile-form-actions">
<Space size={12}>
<Button type="primary" htmlType="submit">
</Button>
<Button onClick={handleClose}></Button>
</Space>
</Form.Item>
</Form>
);

View File

@ -1,84 +1,172 @@
import { useState, useEffect } from 'react';
import { Row, Col, Card, Tabs, Spin, List, message } from 'antd';
import { UserOutlined, PhoneOutlined, MailOutlined, HomeOutlined, TeamOutlined, CalendarOutlined } from '@ant-design/icons';
import { getUserProfile } from '../../api/user';
import UserInfo from './UserInfo';
import ResetPassword from './ResetPassword';
import { useEffect, useMemo, useState } from 'react';
import { Card, Spin, Tabs, message } from 'antd';
import type { TabsProps } from 'antd';
import {
ApartmentOutlined,
CalendarOutlined,
MailOutlined,
PhoneOutlined,
SafetyCertificateOutlined,
UserOutlined,
} from '@ant-design/icons';
import { useSearchParams } from 'react-router-dom';
import { getUserProfile } from '@/api/user';
import type { UserProfileUser } from '@/types/api';
import AvatarUploader from './AvatarUploader';
import ResetPassword from './ResetPassword';
import UserInfo from './UserInfo';
import './profile.css';
const { TabPane } = Tabs;
const tabItems: TabsProps['items'] = [
{
key: 'userinfo',
label: '基本资料',
},
{
key: 'resetPwd',
label: '修改密码',
},
];
const ProfilePage = () => {
const [searchParams, setSearchParams] = useSearchParams();
const [user, setUser] = useState<UserProfileUser | null>(null);
const [roleGroup, setRoleGroup] = useState('');
const [postGroup, setPostGroup] = useState('');
const [loading, setLoading] = useState(true);
const activeTab = searchParams.get('tab') === 'resetPwd' ? 'resetPwd' : 'userinfo';
useEffect(() => {
let active = true;
getUserProfile()
.then((response) => {
if (!active) {
return;
}
setUser(response.user);
setRoleGroup(response.roleGroup);
setPostGroup(response.postGroup);
setLoading(false);
setRoleGroup(response.roleGroup ?? '');
setPostGroup(response.postGroup ?? '');
})
.catch((error: unknown) => {
console.error('Failed to load user profile:', error);
message.error('Failed to load user profile.');
setLoading(false);
message.error('获取个人中心信息失败');
})
.finally(() => {
if (active) {
setLoading(false);
}
});
return () => {
active = false;
};
}, []);
if (loading || !user) {
return <Spin tip="Loading..." style={{ display: 'block', marginTop: '50px' }} />;
const profileItems = useMemo(() => {
if (!user) {
return [];
}
return [
{
key: 'userName',
label: '用户名称',
icon: <UserOutlined />,
value: user.userName ?? '-',
},
{
key: 'phone',
label: '手机号码',
icon: <PhoneOutlined />,
value: user.phonenumber ?? '-',
},
{
key: 'email',
label: '用户邮箱',
icon: <MailOutlined />,
value: user.email ?? '-',
},
{
key: 'dept',
label: '所属部门',
icon: <ApartmentOutlined />,
value: user.dept?.deptName ? `${user.dept.deptName} / ${postGroup || '-'}` : '-',
},
{
key: 'role',
label: '所属角色',
icon: <SafetyCertificateOutlined />,
value: roleGroup || '-',
},
{
key: 'createTime',
label: '创建日期',
icon: <CalendarOutlined />,
value: user.createTime ?? '-',
},
];
}, [postGroup, roleGroup, user]);
if (loading) {
return (
<div className="profile-loading">
<Spin size="large" />
</div>
);
}
const profileItems = [
{ icon: <UserOutlined />, label: '用户名称', value: user.userName ?? '' },
{ icon: <PhoneOutlined />, label: '手机号码', value: user.phonenumber ?? '' },
{ icon: <MailOutlined />, label: '用户邮箱', value: user.email ?? '' },
{ icon: <HomeOutlined />, label: '所属部门', value: user.dept ? `${user.dept.deptName ?? ''} / ${postGroup}` : '' },
{ icon: <TeamOutlined />, label: '所属角色', value: roleGroup },
{ icon: <CalendarOutlined />, label: '创建日期', value: user.createTime ?? '' },
];
if (!user) {
return null;
}
return (
<div className="profile-app-container">
<Row gutter={20}>
<Col span={6} xs={24}>
<Card title="个人信息">
<div style={{ textAlign: 'center', marginBottom: '20px' }}>
<img src={user.avatar || ''} alt="avatar" style={{width: 120, height: 120, borderRadius: '50%'}}/>
</div>
<List
itemLayout="horizontal"
dataSource={profileItems}
renderItem={item => (
<List.Item>
<List.Item.Meta
avatar={item.icon}
title={item.label}
description={<div className="pull-right">{item.value}</div>}
/>
</List.Item>
)}
<div className="profile-page app-container">
<div className="profile-grid">
<div className="profile-grid__aside">
<Card className="profile-card profile-card--summary" title="个人信息" variant="borderless">
<AvatarUploader
avatarUrl={user.avatar as string | undefined}
displayName={user.nickName || user.userName || '未命名用户'}
onUploaded={(nextAvatar) => setUser((prev) => (prev ? { ...prev, avatar: nextAvatar } : prev))}
/>
<ul className="profile-info-list">
{profileItems.map((item) => (
<li key={item.key} className="profile-info-item">
<span className="profile-info-label">
{item.icon}
{item.label}
</span>
<span className="profile-info-value">{item.value}</span>
</li>
))}
</ul>
</Card>
</Col>
<Col span={18} xs={24}>
<Card>
<Tabs defaultActiveKey="userinfo">
<TabPane tab="基本资料" key="userinfo">
<UserInfo user={user} />
</TabPane>
<TabPane tab="修改密码" key="resetPwd">
</div>
<div className="profile-grid__main">
<Card className="profile-card" title="基本资料" variant="borderless">
<Tabs
activeKey={activeTab}
items={tabItems}
onChange={(key) => {
const params = new URLSearchParams(searchParams);
params.set('tab', key);
setSearchParams(params);
}}
/>
<div className="profile-tab-panel">
{activeTab === 'userinfo' ? (
<UserInfo user={user} onUpdated={(values) => setUser((prev) => (prev ? { ...prev, ...values } : prev))} />
) : (
<ResetPassword />
</TabPane>
</Tabs>
)}
</div>
</Card>
</Col>
</Row>
</div>
</div>
</div>
);
};

View File

@ -1,8 +1,202 @@
.profile-app-container .pull-right {
float: right;
color: #999;
.profile-loading {
min-height: 360px;
display: flex;
align-items: center;
justify-content: center;
}
.profile-app-container .ant-list-item-meta-description {
width: 100%;
.profile-page {
min-height: calc(100vh - 220px);
}
.profile-grid {
display: grid;
grid-template-columns: 340px minmax(0, 1fr);
gap: 20px;
}
.profile-grid__aside,
.profile-grid__main {
min-width: 0;
}
.profile-card {
height: 100%;
border-radius: 22px;
border: 1px solid rgba(219, 228, 243, 0.84);
box-shadow: 0 14px 36px rgba(22, 32, 51, 0.06);
}
.profile-card .ant-card-head {
min-height: 56px;
border-bottom: 1px solid #edf2fa;
}
.profile-card .ant-card-head-title {
font-size: 16px;
font-weight: 700;
color: #1a2940;
}
.profile-card .ant-card-body {
padding: 20px 22px 22px;
}
.profile-avatar-block {
display: flex;
flex-direction: column;
align-items: center;
padding-bottom: 18px;
border-bottom: 1px solid #edf2fa;
margin-bottom: 18px;
}
.profile-avatar-trigger {
position: relative;
padding: 0;
border: 0;
background: transparent;
cursor: pointer;
}
.profile-avatar-image {
border: 4px solid #f2f5ff;
box-shadow: 0 12px 26px rgba(22, 32, 51, 0.08);
}
.profile-avatar-mask {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
border-radius: 999px;
background: rgba(16, 24, 40, 0.48);
color: #fff;
font-size: 12px;
font-weight: 700;
opacity: 0;
transition: opacity 0.2s ease;
}
.profile-avatar-trigger:hover .profile-avatar-mask {
opacity: 1;
}
.profile-avatar-name {
margin-top: 14px;
font-size: 22px;
font-weight: 700;
color: #18263d;
}
.profile-avatar-tip {
margin-top: 6px;
color: #6f819a;
font-size: 13px;
}
.profile-info-list {
list-style: none;
margin: 0;
padding: 0;
}
.profile-info-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 13px 0;
border-bottom: 1px solid #f1f4fa;
}
.profile-info-item:last-child {
border-bottom: 0;
padding-bottom: 0;
}
.profile-info-label {
display: inline-flex;
align-items: center;
gap: 8px;
color: #60748f;
font-weight: 700;
}
.profile-info-value {
color: #1f314b;
text-align: right;
}
.profile-tab-panel {
padding-top: 8px;
}
.profile-tab-panel .ant-tabs-nav {
margin-bottom: 20px;
}
.profile-form .ant-form-item-label > label {
color: #4c5f7b;
font-weight: 600;
}
.profile-form .ant-input,
.profile-form .ant-input-password,
.profile-form .ant-radio-group {
border-radius: 10px;
}
.profile-form .ant-input,
.profile-form .ant-input-password {
min-height: 40px;
}
.profile-form-actions {
margin-bottom: 0;
padding-top: 4px;
}
.profile-form .ant-btn-primary {
min-width: 108px;
border-radius: 12px;
}
@media (max-width: 991px) {
.profile-grid {
grid-template-columns: 1fr;
}
.profile-grid__aside {
margin-bottom: 20px;
}
.profile-form .ant-form-item .ant-row {
display: block;
}
.profile-form .ant-form-item-label,
.profile-form .ant-form-item-control {
max-width: 100%;
flex: 0 0 100%;
width: 100%;
text-align: left;
}
.profile-form .ant-form-item-control {
margin-inline-start: 0 !important;
}
.profile-info-item {
align-items: flex-start;
flex-direction: column;
gap: 6px;
}
.profile-info-value {
text-align: left;
}
}

View File

@ -17,6 +17,8 @@ import { parseTime } from '@/utils/ruoyi';
import { listProject } from '@/api/project';
import { listProjectExecution } from '@/api/projectExecution';
import { getDicts } from '@/api/system/dict';
import { usePermission } from '@/contexts/PermissionContext';
import '@/styles/permission-link.css';
import './project-execution.css';
const { RangePicker } = DatePicker;
@ -309,6 +311,7 @@ const formatPercent = (value: number) => `${clampPercent(value)}%`;
const ProjectExecutionPage = () => {
const [queryForm] = Form.useForm();
const { canAccessPath } = usePermission();
const navigate = useNavigate();
const defaultRange = useMemo(() => getDefaultRange(), []);
const [loading, setLoading] = useState(false);
@ -335,6 +338,7 @@ const ProjectExecutionPage = () => {
[defaultRange, queryParams.endTime],
);
const periods = useMemo(() => buildPeriods(rangeStart, rangeEnd), [rangeEnd, rangeStart]);
const canViewProjectUser = canAccessPath('/projectBank/projectUser');
const statusMap = useMemo(
() => new Map(statusOptions.map((item) => [String(item.dictValue), item.dictLabel])),
[statusOptions],
@ -415,6 +419,9 @@ const ProjectExecutionPage = () => {
const openProject = useCallback(
(row: BoardRow) => {
if (!canViewProjectUser) {
return;
}
const params = new URLSearchParams();
params.set('projectId', String(row.projectId ?? ''));
params.set('projectName', row.projectName ?? '');
@ -430,7 +437,7 @@ const ProjectExecutionPage = () => {
navigate(`/projectBank/projectUser?${params.toString()}`);
},
[navigate],
[canViewProjectUser, navigate],
);
const handleQuery = () => {
@ -472,9 +479,13 @@ const ProjectExecutionPage = () => {
fixed: 'left',
render: (value: unknown, row) => (
<div className="project-name-cell">
<Button type="link" className="project-name-link" onClick={() => openProject(row)}>
{String(value ?? '-') || '-'}
</Button>
{canViewProjectUser ? (
<Button type="link" className="project-name-link" onClick={() => openProject(row)}>
{String(value ?? '-') || '-'}
</Button>
) : (
<span className="project-name-link is-disabled permission-link-disabled">{String(value ?? '-') || '-'}</span>
)}
<div className="project-name-meta">
<span>{row.projectCode || '未配置项目编号'}</span>
<span>
@ -581,7 +592,7 @@ const ProjectExecutionPage = () => {
}));
return [...baseColumns, ...periodColumns];
}, [openProject, periods, statusMap]);
}, [canViewProjectUser, openProject, periods, statusMap]);
const stats = useMemo(
() => [

View File

@ -283,6 +283,19 @@
color: #0958d9;
}
.project-name-link.is-disabled {
display: inline-flex;
align-items: center;
padding: 0;
font-size: 14px;
line-height: 1.4;
font-weight: 700;
color: #98a2b3;
cursor: not-allowed;
text-decoration: none;
user-select: none;
}
.project-name-meta {
display: flex;
flex-direction: column;

View File

@ -16,6 +16,9 @@ import type {
CacheNamePayload,
CacheNameRecord,
} from '@/types/api';
import Permission from '@/components/Permission';
import { usePermission } from '@/contexts/PermissionContext';
import ReadonlyAction from '@/components/Permission/ReadonlyAction';
import './cache-list.css';
interface CacheForm {
@ -91,6 +94,7 @@ const extractCacheKeys = (payload: CacheKeyPayload): string[] => {
};
const CacheListPage: React.FC = () => {
const { hasPermi } = usePermission();
const [cacheNames, setCacheNames] = useState<CacheNameRecord[]>([]);
const [cacheKeys, setCacheKeys] = useState<string[]>([]);
const [cacheForm, setCacheForm] = useState<CacheForm>(defaultForm);
@ -98,6 +102,7 @@ const CacheListPage: React.FC = () => {
const [subLoading, setSubLoading] = useState(false);
const [nowCacheName, setNowCacheName] = useState('');
const [tableHeight, setTableHeight] = useState(window.innerHeight - 200);
const canClearCache = hasPermi('monitor:cache:list');
const getCacheKeys = useCallback(
async (cacheName?: string) => {
@ -192,6 +197,9 @@ const CacheListPage: React.FC = () => {
};
const handleClearCacheName = async (row: CacheNameRecord) => {
if (!canClearCache) {
return;
}
try {
await clearCacheName(row.cacheName);
message.success(`清理缓存名称[${row.cacheName}]成功`);
@ -207,6 +215,9 @@ const CacheListPage: React.FC = () => {
};
const handleClearCacheKey = async (fullCacheKey: string) => {
if (!canClearCache) {
return;
}
if (!nowCacheName) {
return;
}
@ -222,6 +233,9 @@ const CacheListPage: React.FC = () => {
};
const handleClearCacheAll = async () => {
if (!canClearCache) {
return;
}
try {
await clearCacheAll();
message.success('清理全部缓存成功');
@ -259,7 +273,12 @@ const CacheListPage: React.FC = () => {
key: 'operation',
width: 60,
render: (_text, record) => (
<Button type="link" danger icon={<DeleteOutlined />} onClick={() => void handleClearCacheName(record)} />
<Permission
permissions="monitor:cache:list"
fallback={<ReadonlyAction icon={<DeleteOutlined />} danger></ReadonlyAction>}
>
<Button type="link" danger icon={<DeleteOutlined />} onClick={() => void handleClearCacheName(record)} />
</Permission>
),
},
];
@ -282,12 +301,17 @@ const CacheListPage: React.FC = () => {
key: 'operation',
width: 60,
render: (_text, record) => (
<Button
type="link"
danger
icon={<DeleteOutlined />}
onClick={() => void handleClearCacheKey(record.cacheKey)}
/>
<Permission
permissions="monitor:cache:list"
fallback={<ReadonlyAction icon={<DeleteOutlined />} danger></ReadonlyAction>}
>
<Button
type="link"
danger
icon={<DeleteOutlined />}
onClick={() => void handleClearCacheKey(record.cacheKey)}
/>
</Permission>
),
},
];
@ -347,7 +371,11 @@ const CacheListPage: React.FC = () => {
<Col span={8}>
<Card
title={<Space><FileTextOutlined /> </Space>}
extra={<Button type="text" danger onClick={() => void handleClearCacheAll()}></Button>}
extra={
<Permission permissions="monitor:cache:list">
<Button type="text" danger onClick={() => void handleClearCacheAll()}></Button>
</Permission>
}
style={{ height: tableHeight + 50, overflow: 'auto' }}
>
<Form layout="vertical" initialValues={cacheForm}>

View File

@ -1,7 +1,7 @@
import { useState, useEffect } from 'react';
import { Row, Col, Card, Descriptions, Spin, message, Typography } from 'antd';
import ReactECharts from 'echarts-for-react';
import * as echarts from 'echarts/core';
import ReactEChartsCore from 'echarts-for-react/lib/core';
import { echarts } from '@/utils/echarts';
import { macarons } from '../../themes/macarons';
import { getCache } from '../../api/monitor/cache';
import type { CacheMonitorResponse } from '@/types/api';
@ -95,7 +95,7 @@ const CacheMonitorPage = () => {
};
return (
<div className="cache-monitor-container">
<div className="app-container cache-monitor-container">
<Row gutter={[16, 16]}>
<Col span={24} className="card-box">
<Card title="基本信息">
@ -129,12 +129,12 @@ const CacheMonitorPage = () => {
</Col>
<Col span={12} className="card-box">
<Card title="命令统计">
<ReactECharts theme="macarons" option={commandStatsOptions} style={{ height: '420px' }} />
<ReactEChartsCore echarts={echarts} theme="macarons" option={commandStatsOptions} style={{ height: '420px' }} />
</Card>
</Col>
<Col span={12} className="card-box">
<Card title="内存信息">
<ReactECharts theme="macarons" option={usedMemoryOptions} style={{ height: '420px' }} />
<ReactEChartsCore echarts={echarts} theme="macarons" option={usedMemoryOptions} style={{ height: '420px' }} />
</Card>
</Col>
</Row>

View File

@ -40,6 +40,8 @@ import { saveAs } from 'file-saver';
import dayjs from 'dayjs';
import type { JobQueryParams, JobRecord } from '@/types/api';
import './job-monitor.css';
import Permission from '@/components/Permission';
import ReadonlyAction from '@/components/Permission/ReadonlyAction';
const sysJobGroupDict = [
{ value: 'DEFAULT', label: '默认' },
@ -309,12 +311,24 @@ const JobMonitorPage = () => {
dataIndex: 'status',
align: 'center',
render: (_value, record) => (
<Switch
checked={String(record.status ?? '') === '0'}
checkedChildren="正常"
unCheckedChildren="暂停"
onChange={() => void handleStatusChange(record)}
/>
<Permission
permissions="monitor:job:changeStatus"
fallback={
<Switch
checked={String(record.status ?? '') === '0'}
checkedChildren="正常"
unCheckedChildren="暂停"
disabled
/>
}
>
<Switch
checked={String(record.status ?? '') === '0'}
checkedChildren="正常"
unCheckedChildren="暂停"
onChange={() => void handleStatusChange(record)}
/>
</Permission>
),
},
{
@ -345,17 +359,27 @@ const JobMonitorPage = () => {
return (
<Space size="small">
<Button type="link" icon={<EditOutlined />} onClick={() => void handleUpdate(record)}>
</Button>
<Button type="link" icon={<DeleteOutlined />} danger onClick={() => void handleDelete(record)}>
</Button>
<Dropdown menu={{ items: moreItems, onClick: handleMoreMenuClick }} trigger={['click']}>
<Button type="link">
<DownOutlined />
<Permission permissions="monitor:job:edit" fallback={<ReadonlyAction icon={<EditOutlined />}></ReadonlyAction>}>
<Button type="link" icon={<EditOutlined />} onClick={() => void handleUpdate(record)}>
</Button>
</Dropdown>
</Permission>
<Permission permissions="monitor:job:remove" fallback={<ReadonlyAction icon={<DeleteOutlined />} danger></ReadonlyAction>}>
<Button type="link" icon={<DeleteOutlined />} danger onClick={() => void handleDelete(record)}>
</Button>
</Permission>
<Permission
permissions={['monitor:job:changeStatus', 'monitor:job:query']}
mode="or"
fallback={<ReadonlyAction></ReadonlyAction>}
>
<Dropdown menu={{ items: moreItems, onClick: handleMoreMenuClick }} trigger={['click']}>
<Button type="link">
<DownOutlined />
</Button>
</Dropdown>
</Permission>
</Space>
);
},
@ -397,21 +421,31 @@ const JobMonitorPage = () => {
</Form>
<Space className="mb8">
<Button type="primary" ghost icon={<PlusOutlined />} onClick={handleAdd}>
</Button>
<Button type="primary" ghost icon={<EditOutlined />} disabled={selectedRowKeys.length !== 1} onClick={() => void handleUpdate()}>
</Button>
<Button type="primary" danger ghost icon={<DeleteOutlined />} disabled={selectedRowKeys.length === 0} onClick={() => void handleDelete()}>
</Button>
<Button type="default" ghost icon={<DownloadOutlined />} onClick={() => void handleExport()}>
</Button>
<Button type="default" ghost icon={<SyncOutlined />} onClick={() => handleJobLog()}>
</Button>
<Permission permissions="monitor:job:add">
<Button type="primary" ghost icon={<PlusOutlined />} onClick={handleAdd}>
</Button>
</Permission>
<Permission permissions="monitor:job:edit">
<Button type="primary" ghost icon={<EditOutlined />} disabled={selectedRowKeys.length !== 1} onClick={() => void handleUpdate()}>
</Button>
</Permission>
<Permission permissions="monitor:job:remove">
<Button type="primary" danger ghost icon={<DeleteOutlined />} disabled={selectedRowKeys.length === 0} onClick={() => void handleDelete()}>
</Button>
</Permission>
<Permission permissions="monitor:job:export">
<Button type="default" ghost icon={<DownloadOutlined />} onClick={() => void handleExport()}>
</Button>
</Permission>
<Permission permissions="monitor:job:query">
<Button type="default" ghost icon={<SyncOutlined />} onClick={() => handleJobLog()}>
</Button>
</Permission>
</Space>
<Table

View File

@ -1,13 +1,13 @@
import { useCallback, useEffect, useState } from 'react';
import type { Key } from 'react';
import {
App,
Table,
Form,
Input,
Select,
Button,
Modal,
message,
Space,
Tag,
DatePicker,
@ -29,6 +29,8 @@ import {
import { saveAs } from 'file-saver';
import dayjs from 'dayjs';
import type { LogininforQueryParams, LogininforRecord } from '@/types/api';
import Permission from '@/components/Permission';
import './login-log.css';
const { RangePicker } = DatePicker;
@ -61,6 +63,7 @@ const escapeCsvCell = (value: unknown): string => {
};
const LoginLogPage = () => {
const { message, modal } = App.useApp();
const [queryForm] = Form.useForm<LogininforQueryParams>();
const [loading, setLoading] = useState(false);
const [list, setList] = useState<LogininforRecord[]>([]);
@ -120,7 +123,7 @@ const LoginLogPage = () => {
return;
}
Modal.confirm({
modal.confirm({
title: '确认删除',
content: `是否确认删除访问编号为"${infoIds}"的数据项?`,
onOk: async () => {
@ -138,7 +141,7 @@ const LoginLogPage = () => {
};
const handleClean = async () => {
Modal.confirm({
modal.confirm({
title: '确认清空',
content: '是否确认清空所有登录日志数据项?',
onOk: async () => {
@ -166,7 +169,7 @@ const LoginLogPage = () => {
}
const targetUserName = userNames[0];
Modal.confirm({
modal.confirm({
title: '确认解锁',
content: `是否确认解锁用户"${targetUserName}"的登录状态?`,
onOk: async () => {
@ -277,7 +280,7 @@ const LoginLogPage = () => {
];
return (
<div className="app-container">
<div className="app-container login-log-container">
<Form form={queryForm} layout="inline" className="search-form" onFinish={handleQuery} initialValues={queryParams}>
<Form.Item label="登录地址" name="ipaddr">
<Input placeholder="请输入登录地址" allowClear onPressEnter={handleQuery} style={{ width: 240 }} />
@ -316,31 +319,39 @@ const LoginLogPage = () => {
</Form>
<Space className="mb8">
<Button
type="primary"
danger
ghost
icon={<DeleteOutlined />}
disabled={selectedRowKeys.length === 0}
onClick={() => void handleDelete()}
>
</Button>
<Button type="primary" danger ghost icon={<DeleteOutlined />} onClick={() => void handleClean()}>
</Button>
<Button
type="primary"
ghost
icon={<UnlockOutlined />}
disabled={selectedRowKeys.length !== 1}
onClick={() => void handleUnlock()}
>
</Button>
<Button type="default" ghost icon={<DownloadOutlined />} onClick={() => void handleExport()}>
</Button>
<Permission permissions="monitor:logininfor:remove">
<Button
type="primary"
danger
ghost
icon={<DeleteOutlined />}
disabled={selectedRowKeys.length === 0}
onClick={() => void handleDelete()}
>
</Button>
</Permission>
<Permission permissions="monitor:logininfor:remove">
<Button type="primary" danger ghost icon={<DeleteOutlined />} onClick={() => void handleClean()}>
</Button>
</Permission>
<Permission permissions="monitor:logininfor:unlock">
<Button
type="primary"
ghost
icon={<UnlockOutlined />}
disabled={selectedRowKeys.length !== 1}
onClick={() => void handleUnlock()}
>
</Button>
</Permission>
<Permission permissions="monitor:logininfor:export">
<Button type="default" ghost icon={<DownloadOutlined />} onClick={() => void handleExport()}>
</Button>
</Permission>
</Space>
<Table

View File

@ -5,6 +5,10 @@ import { SearchOutlined, ReloadOutlined, DeleteOutlined } from '@ant-design/icon
import { listOnline, forceLogout } from '../../api/monitor/online';
import dayjs from 'dayjs';
import type { OnlineQueryParams, OnlineRecord } from '@/types/api';
import Permission from '@/components/Permission';
import { usePermission } from '@/contexts/PermissionContext';
import ReadonlyAction from '@/components/Permission/ReadonlyAction';
import './online-user.css';
const defaultQueryParams: OnlineQueryParams = {
pageNum: 1,
@ -28,12 +32,14 @@ const normalizeDateValue = (value: unknown): string | number | Date | undefined
};
const OnlineUserPage = () => {
const { hasPermi } = usePermission();
const [queryForm] = Form.useForm<OnlineQueryParams>();
const [loading, setLoading] = useState(false);
const [list, setList] = useState<OnlineRecord[]>([]);
const [total, setTotal] = useState(0);
const [queryParams, setQueryParams] = useState<OnlineQueryParams>(defaultQueryParams);
const canForceLogout = hasPermi('monitor:online:forceLogout');
const getList = useCallback(async () => {
setLoading(true);
@ -64,6 +70,9 @@ const OnlineUserPage = () => {
};
const handleForceLogout = (row: OnlineRecord) => {
if (!canForceLogout) {
return;
}
if (!row.tokenId) {
message.warning('当前会话缺少 tokenId无法强退');
return;
@ -117,15 +126,20 @@ const OnlineUserPage = () => {
align: 'center',
width: 80,
render: (_value, record) => (
<Button type="link" danger icon={<DeleteOutlined />} onClick={() => handleForceLogout(record)}>
退
</Button>
<Permission
permissions="monitor:online:forceLogout"
fallback={<ReadonlyAction icon={<DeleteOutlined />} danger>退</ReadonlyAction>}
>
<Button type="link" danger icon={<DeleteOutlined />} onClick={() => handleForceLogout(record)}>
退
</Button>
</Permission>
),
},
];
return (
<div className="app-container">
<div className="app-container online-user-container">
<Form form={queryForm} layout="inline" className="search-form" onFinish={handleQuery} initialValues={queryParams}>
<Form.Item label="登录地址" name="ipaddr">
<Input placeholder="请输入登录地址" allowClear onPressEnter={handleQuery} />

View File

@ -25,6 +25,8 @@ import { listOperlog, delOperlog, cleanOperlog } from '../../api/monitor/operlog
import { saveAs } from 'file-saver';
import dayjs from 'dayjs';
import type { OperlogQueryParams, OperlogRecord } from '@/types/api';
import Permission from '@/components/Permission';
import './operation-log.css';
const { RangePicker } = DatePicker;
@ -316,7 +318,7 @@ const OperationLogPage = () => {
];
return (
<div className="app-container">
<div className="app-container operation-log-container">
<Form form={queryForm} layout="inline" className="search-form" onFinish={handleQuery} initialValues={queryParams}>
<Form.Item label="操作地址" name="operIp">
<Input placeholder="请输入操作地址" allowClear onPressEnter={handleQuery} style={{ width: 240 }} />
@ -367,22 +369,28 @@ const OperationLogPage = () => {
</Form>
<Space className="mb8">
<Button
type="primary"
danger
ghost
icon={<DeleteOutlined />}
disabled={selectedRowKeys.length === 0}
onClick={() => void handleDelete()}
>
</Button>
<Button type="primary" danger ghost icon={<DeleteOutlined />} onClick={() => void handleClean()}>
</Button>
<Button type="default" ghost icon={<DownloadOutlined />} onClick={() => void handleExport()}>
</Button>
<Permission permissions="monitor:operlog:remove">
<Button
type="primary"
danger
ghost
icon={<DeleteOutlined />}
disabled={selectedRowKeys.length === 0}
onClick={() => void handleDelete()}
>
</Button>
</Permission>
<Permission permissions="monitor:operlog:remove">
<Button type="primary" danger ghost icon={<DeleteOutlined />} onClick={() => void handleClean()}>
</Button>
</Permission>
<Permission permissions="monitor:operlog:export">
<Button type="default" ghost icon={<DownloadOutlined />} onClick={() => void handleExport()}>
</Button>
</Permission>
</Space>
<Table

View File

@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { Card, Col, Row, message, Typography, Descriptions, Progress, Table, Space } from 'antd';
import { useEffect, useRef, useState } from 'react';
import { App, Card, Col, Row, Typography, Descriptions, Progress, Table, Space } from 'antd';
import type { TableColumnsType } from 'antd';
import {
SettingOutlined,
@ -50,10 +50,17 @@ const renderUsageProgress = (usage: unknown) => {
};
const ServerMonitorPage = () => {
const { message } = App.useApp();
const [serverInfo, setServerInfo] = useState<ServerInfoResponse>(defaultServerInfo);
const [loading, setLoading] = useState(true);
const requestedRef = useRef(false);
useEffect(() => {
if (requestedRef.current) {
return;
}
requestedRef.current = true;
const getList = async () => {
setLoading(true);
const hide = message.loading('正在加载服务监控数据,请稍候!', 0);

View File

@ -1,3 +1,5 @@
@import "./monitor-shared.css";
.cache-list-container .ant-card-body {
padding-top: 8px;
}

View File

@ -1,3 +1,5 @@
@import "./monitor-shared.css";
.card-box {
padding-right: 15px;
padding-left: 15px;

View File

@ -1,3 +1,5 @@
@import "./monitor-shared.css";
.job-monitor-container .ant-form-item {
margin-bottom: 0px; /* Reduce vertical space in search form */
}

View File

@ -1,3 +1,5 @@
@import "./monitor-shared.css";
.login-log-container .ant-form-item {
margin-bottom: 0px; /* Reduce vertical space in search form */
}

View File

@ -0,0 +1,74 @@
.login-log-container,
.online-user-container,
.operation-log-container,
.job-monitor-container,
.cache-list-container,
.server-monitor-container,
.cache-monitor-container {
gap: 18px;
}
.login-log-container .search-form .ant-form-item,
.online-user-container .search-form .ant-form-item,
.operation-log-container .search-form .ant-form-item,
.job-monitor-container .search-form .ant-form-item {
margin-bottom: 14px;
}
.login-log-container .mb8,
.operation-log-container .mb8,
.job-monitor-container .mb8 {
margin-bottom: 2px;
}
.login-log-container .ant-table-wrapper .ant-btn-link,
.online-user-container .ant-table-wrapper .ant-btn-link,
.operation-log-container .ant-table-wrapper .ant-btn-link,
.job-monitor-container .ant-table-wrapper .ant-btn-link,
.cache-list-container .ant-table-wrapper .ant-btn-link {
padding-inline: 6px;
font-weight: 700;
}
.server-monitor-container .card-box,
.cache-monitor-container .card-box {
margin-bottom: 0;
}
.server-monitor-container .ant-card,
.cache-monitor-container .ant-card,
.cache-list-container .ant-card {
height: 100%;
}
.server-monitor-container .ant-card-head,
.cache-monitor-container .ant-card-head,
.cache-list-container .ant-card-head {
background: linear-gradient(180deg, rgba(248, 250, 255, 0.92), rgba(255, 255, 255, 0.98));
}
.server-monitor-container .ant-descriptions,
.cache-monitor-container .ant-descriptions {
overflow: hidden;
border-radius: 18px;
}
.server-monitor-container .ant-descriptions-bordered .ant-descriptions-item-label,
.server-monitor-container .ant-descriptions-bordered .ant-descriptions-item-content,
.cache-monitor-container .ant-descriptions-bordered .ant-descriptions-item-label,
.cache-monitor-container .ant-descriptions-bordered .ant-descriptions-item-content {
background: rgba(255, 255, 255, 0.88);
}
.server-monitor-container .ant-progress .ant-progress-bg,
.cache-monitor-container .ant-progress .ant-progress-bg {
border-radius: 999px;
}
.cache-list-container .ant-card-body {
padding-top: 10px;
}
.cache-list-container .ant-table-row {
cursor: pointer;
}

View File

@ -1,3 +1,5 @@
@import "./monitor-shared.css";
.online-user-container .ant-form-item {
margin-bottom: 0px; /* Reduce vertical space in search form */
}

View File

@ -1,3 +1,5 @@
@import "./monitor-shared.css";
.operation-log-container .ant-form-item {
margin-bottom: 0px; /* Reduce vertical space in search form */
}

View File

@ -1,3 +1,5 @@
@import "./monitor-shared.css";
.server-monitor-container .card-box {
margin-bottom: 16px;
}

View File

@ -45,6 +45,10 @@ import {
} from '@/api/project';
import { getDicts } from '@/api/system/dict';
import { listUser } from '@/api/system/user';
import Permission from '@/components/Permission';
import { usePermission } from '@/contexts/PermissionContext';
import ReadonlyAction from '@/components/Permission/ReadonlyAction';
import './demand-manage.css';
const { Text } = Typography;
@ -266,6 +270,7 @@ const DemandManagePage: React.FC = () => {
const [queryForm] = Form.useForm();
const [demandForm] = Form.useForm<DemandFormValues>();
const [versionForm] = Form.useForm<VersionFormValues>();
const { hasPermi } = usePermission();
const projectId = searchParams.get('id') ?? '';
const projectName = searchParams.get('projectName') ?? '未命名项目';
@ -336,6 +341,9 @@ const DemandManagePage: React.FC = () => {
);
const versionRows = useMemo(() => versionTree.filter((item) => item.type === 0), [versionTree]);
const canCreateDemand = hasPermi('project:list:demand');
const canEditVersion = hasPermi('project:list:demand');
const canDeleteVersion = hasPermi('project:list:demand');
const versionChildNodeIds = useMemo(() => {
const idSet = new Set<string>();
versionRows.forEach((item) => {
@ -904,17 +912,22 @@ const DemandManagePage: React.FC = () => {
render: (_value, record) => (
<Space size={6}>
<Badge color={STATUS_COLOR_MAP[record.demandStatus] ?? '#8c8c8c'} />
<Select
size="small"
style={{ width: 110 }}
value={record.demandStatus}
options={statusSelectOptions}
onChange={(value) => {
if (value !== record.demandStatus) {
handleQuickUpdate(record, { demandStatus: value });
}
}}
/>
<Permission
permissions="project:list:demand"
fallback={<Text>{record.demandStatusLabel}</Text>}
>
<Select
size="small"
style={{ width: 110 }}
value={record.demandStatus}
options={statusSelectOptions}
onChange={(value) => {
if (value !== record.demandStatus) {
handleQuickUpdate(record, { demandStatus: value });
}
}}
/>
</Permission>
</Space>
),
},
@ -928,17 +941,22 @@ const DemandManagePage: React.FC = () => {
key: 'priority',
width: 130,
render: (_value, record) => (
<Select
size="small"
style={{ width: 90 }}
value={record.priority}
options={priorityOptions}
onChange={(value) => {
if (value !== record.priority) {
handleQuickUpdate(record, { priority: value });
}
}}
/>
<Permission
permissions="project:list:demand"
fallback={<Text>{record.priority}</Text>}
>
<Select
size="small"
style={{ width: 90 }}
value={record.priority}
options={priorityOptions}
onChange={(value) => {
if (value !== record.priority) {
handleQuickUpdate(record, { priority: value });
}
}}
/>
</Permission>
),
},
{
@ -947,19 +965,23 @@ const DemandManagePage: React.FC = () => {
width: 150,
render: (_value, record) => (
<Space size={4}>
<Button type="link" onClick={() => openEditDemand(record)}>
</Button>
<Popconfirm
title="此操作将永久删除该需求,是否继续?"
okText="确定"
cancelText="取消"
onConfirm={() => handleDeleteOne(record)}
>
<Button type="link" danger>
<Permission permissions="project:list:demand" fallback={<ReadonlyAction></ReadonlyAction>}>
<Button type="link" onClick={() => openEditDemand(record)}>
</Button>
</Popconfirm>
</Permission>
<Permission permissions="project:list:demand" fallback={<ReadonlyAction danger></ReadonlyAction>}>
<Popconfirm
title="此操作将永久删除该需求,是否继续?"
okText="确定"
cancelText="取消"
onConfirm={() => handleDeleteOne(record)}
>
<Button type="link" danger>
</Button>
</Popconfirm>
</Permission>
</Space>
),
},
@ -980,7 +1002,24 @@ const DemandManagePage: React.FC = () => {
};
return (
<div className="app-container">
<div className="app-container demand-manage-page">
<div className="demand-page-hero">
<div>
<div className="demand-page-kicker">DEMAND WORKSPACE</div>
<div className="demand-page-title"></div>
<div className="demand-page-subtitle"></div>
</div>
<div className="demand-page-hero-meta">
<div className="demand-page-meta-card">
<span></span>
<strong>{projectName}</strong>
</div>
<div className="demand-page-meta-card">
<span></span>
<strong>{total}</strong>
</div>
</div>
</div>
<Row gutter={16} align="top">
<Col xs={24} lg={versionSidebarCollapsed ? 1 : 7} xl={versionSidebarCollapsed ? 1 : 6}>
{versionSidebarCollapsed ? (
@ -994,6 +1033,7 @@ const DemandManagePage: React.FC = () => {
</div>
) : (
<Card
className="demand-version-card"
size="small"
title={
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
@ -1039,7 +1079,9 @@ const DemandManagePage: React.FC = () => {
</Text>
</Space>
<Space size={4}>
<Button type="text" icon={<PlusOutlined />} onClick={openCreateVersion} />
<Permission permissions="project:list:demand">
<Button type="text" icon={<PlusOutlined />} onClick={openCreateVersion} />
</Permission>
</Space>
</div>
@ -1113,27 +1155,39 @@ const DemandManagePage: React.FC = () => {
<Space size={6}>
<Text style={{ color: selected ? '#1677ff' : '#8c8c8c' }}>{count}</Text>
{hoverVersionNodeId === item.nodeId ? (
<Dropdown
trigger={['click']}
menu={{
items: [
{ key: 'add', label: '新建需求' },
{ key: 'edit', label: '编辑' },
{ key: 'delete', label: <span style={{ color: '#ff4d4f' }}></span> },
],
onClick: ({ key, domEvent }) => {
domEvent.stopPropagation();
handleVersionMenuAction(String(key), item);
},
}}
>
<Button
type="text"
size="small"
icon={<MoreOutlined />}
onClick={(event) => event.stopPropagation()}
/>
</Dropdown>
(() => {
const versionMenuItems = [
canCreateDemand ? { key: 'add', label: '新建需求' } : null,
canEditVersion ? { key: 'edit', label: '编辑' } : null,
canDeleteVersion
? { key: 'delete', label: <span style={{ color: '#ff4d4f' }}></span> }
: null,
].filter(Boolean);
if (versionMenuItems.length === 0) {
return null;
}
return (
<Dropdown
trigger={['click']}
menu={{
items: versionMenuItems,
onClick: ({ key, domEvent }) => {
domEvent.stopPropagation();
handleVersionMenuAction(String(key), item);
},
}}
>
<Button
type="text"
size="small"
icon={<MoreOutlined />}
onClick={(event) => event.stopPropagation()}
/>
</Dropdown>
);
})()
) : null}
</Space>
</div>
@ -1233,6 +1287,7 @@ const DemandManagePage: React.FC = () => {
<Col xs={24} lg={versionSidebarCollapsed ? 23 : 17} xl={versionSidebarCollapsed ? 23 : 18}>
<Card
className="demand-list-card"
size="small"
title="需求列表"
extra={
@ -1247,7 +1302,7 @@ const DemandManagePage: React.FC = () => {
<Text type="secondary">{projectStartText} ~ {projectEndText}</Text>
</Space>
<Form form={queryForm} layout="inline" onFinish={handleSearch}>
<Form form={queryForm} layout="inline" className="demand-search-form" onFinish={handleSearch}>
<Form.Item name="title">
<Input placeholder="标题名称" allowClear style={{ width: 220 }} />
</Form.Item>
@ -1291,18 +1346,22 @@ const DemandManagePage: React.FC = () => {
</Form.Item>
</Form>
<Space style={{ margin: '12px 0' }}>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreateDemand}>
</Button>
<Popconfirm
title="此操作将永久删除选中需求,是否继续?"
okText="确定"
cancelText="取消"
onConfirm={handleDeleteBatch}
>
<Button disabled={selectedRowKeys.length === 0}></Button>
</Popconfirm>
<Space className="demand-toolbar">
<Permission permissions="project:list:demand">
<Button type="primary" icon={<PlusOutlined />} onClick={openCreateDemand}>
</Button>
</Permission>
<Permission permissions="project:list:demand">
<Popconfirm
title="此操作将永久删除选中需求,是否继续?"
okText="确定"
cancelText="取消"
onConfirm={handleDeleteBatch}
>
<Button disabled={selectedRowKeys.length === 0}></Button>
</Popconfirm>
</Permission>
</Space>
<div ref={tableContainerRef}>
@ -1320,7 +1379,7 @@ const DemandManagePage: React.FC = () => {
/>
</div>
<div style={{ marginTop: 12, display: 'flex', justifyContent: 'space-between' }}>
<div className="demand-pagination">
<Text type="secondary"> {total} </Text>
<Pagination
size="small"
@ -1337,6 +1396,7 @@ const DemandManagePage: React.FC = () => {
</Row>
<Modal
className="demand-edit-modal"
title={editingDemand ? '修改需求' : '新建需求'}
open={demandModalOpen}
onOk={submitDemand}
@ -1448,6 +1508,7 @@ const DemandManagePage: React.FC = () => {
</Modal>
<Modal
className="demand-version-modal"
title={editingVersion ? '编辑版本号' : '新增版本号'}
open={versionModalOpen}
onOk={submitVersion}

View File

@ -5,17 +5,22 @@ import { getProjectDetail, addProject, updateProject, getProjectCode } from '../
import dayjs from 'dayjs';
import { UserOutlined } from '@ant-design/icons';
import PageBackButton from '@/components/PageBackButton';
import { usePermission } from '@/contexts/PermissionContext';
import './project-detail.css';
const { TextArea } = Input;
const ProjectDetailPage: React.FC = () => {
const [form] = Form.useForm();
const projectCode = Form.useWatch('projectCode', form);
const { hasPermi } = usePermission();
const { id: pathId } = useParams<{ id: string }>();
const [searchParams] = useSearchParams();
const id = pathId ?? searchParams.get('id') ?? undefined;
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const isEdit = !!id;
const canSubmitProject = hasPermi('project:detail:save');
useEffect(() => {
const fetchProjectData = async () => {
@ -50,6 +55,9 @@ const ProjectDetailPage: React.FC = () => {
}, [id, isEdit, form]);
const onFinish = async (values: any) => {
if (!canSubmitProject) {
return;
}
setLoading(true);
try {
const payload = {
@ -73,11 +81,22 @@ const ProjectDetailPage: React.FC = () => {
};
return (
<div className="app-container">
<div style={{ marginBottom: 12 }}>
<div className="app-container project-detail-page">
<div className="project-detail-back">
<PageBackButton text="返回项目列表" fallbackPath="/project/list" />
</div>
<Card title={isEdit ? '编辑项目' : '新建项目'}>
<div className="project-detail-hero">
<div>
<div className="project-detail-kicker">PROJECT DETAIL</div>
<div className="project-detail-title">{isEdit ? '编辑项目' : '新建项目'}</div>
<div className="project-detail-subtitle"></div>
</div>
<div className="project-detail-summary">
<span>{isEdit ? '当前模式:编辑' : '当前模式:新建'}</span>
<strong>{projectCode || '待生成编号'}</strong>
</div>
</div>
<Card className="project-detail-card" title={isEdit ? '编辑项目' : '新建项目'}>
<Form form={form} layout="vertical" onFinish={onFinish}>
<Row gutter={16}>
<Col span={12}>
@ -119,7 +138,7 @@ const ProjectDetailPage: React.FC = () => {
<TextArea rows={4} placeholder="请输入项目描述" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" loading={loading}>
<Button type="primary" htmlType="submit" loading={loading} disabled={!canSubmitProject}>
{isEdit ? '保 存' : '创 建'}
</Button>
<Button style={{ marginLeft: 8 }} onClick={() => navigate('/project/list')}>

View File

@ -12,6 +12,9 @@ import {
import { getDicts } from '../../api/system/dict'; // To get dict data for project status
import { parseTime } from '../../utils/ruoyi';
import { useNavigate } from 'react-router-dom';
import ReadonlyAction from '@/components/Permission/ReadonlyAction';
import { usePermission } from '@/contexts/PermissionContext';
import './project.css';
const ProjectPage: React.FC = () => {
const [queryForm] = Form.useForm();
@ -20,6 +23,7 @@ const ProjectPage: React.FC = () => {
const [total, setTotal] = useState(0);
const [statusList, setStatusList] = useState<any[]>([]);
const navigate = useNavigate();
const { hasPermi } = usePermission();
const [queryParams, setQueryParams] = useState({
pageNum: 1,
@ -169,23 +173,35 @@ const ProjectPage: React.FC = () => {
title: '操作', key: 'operation', width: 250, fixed: 'right',
render: (_, record) => (
<Space size="small">
<Button type="link" icon={<UnorderedListOutlined />} onClick={() => handleDemand(record)}></Button>
<Button type="link" icon={<EditOutlined />} onClick={() => handleEdit(record)}></Button>
<Popconfirm
title="是否确认删除该项目?"
onConfirm={() => handleDelete(record)}
okText="是"
cancelText="否"
>
<Button type="link" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
{hasPermi('project:list:demand') ? (
<Button type="link" icon={<UnorderedListOutlined />} onClick={() => handleDemand(record)}></Button>
) : (
<ReadonlyAction icon={<UnorderedListOutlined />}></ReadonlyAction>
)}
{hasPermi('project:list:eidt') ? (
<Button type="link" icon={<EditOutlined />} onClick={() => handleEdit(record)}></Button>
) : (
<ReadonlyAction icon={<EditOutlined />}></ReadonlyAction>
)}
{hasPermi('project:list:delete') ? (
<Popconfirm
title="是否确认删除该项目?"
onConfirm={() => handleDelete(record)}
okText="是"
cancelText="否"
>
<Button type="link" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
) : (
<ReadonlyAction icon={<DeleteOutlined />} danger></ReadonlyAction>
)}
</Space>
),
},
];
return (
<div className="app-container">
<div className="app-container project-page-container">
<Form form={queryForm} layout="inline" className="search-form" onFinish={handleQuery}>
<Form.Item label="项目名称" name="projectName">
<Input placeholder="项目名称" allowClear />
@ -207,7 +223,9 @@ const ProjectPage: React.FC = () => {
</Form>
<Space className="mb8" style={{ width: '100%', justifyContent: 'flex-end' }}>
<Button type="primary" icon={<PlusOutlined />} onClick={handleAddProject}></Button>
{hasPermi('project:list:add') ? (
<Button type="primary" icon={<PlusOutlined />} onClick={handleAddProject}></Button>
) : null}
</Space>
<Table

View File

@ -0,0 +1,120 @@
.demand-manage-page {
gap: 18px;
}
.demand-page-hero {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
padding: 24px 26px;
border: 1px solid rgba(219, 228, 243, 0.84);
border-radius: 24px;
background:
radial-gradient(circle at top right, rgba(25, 180, 184, 0.12), transparent 26%),
radial-gradient(circle at left center, rgba(99, 91, 255, 0.12), transparent 34%),
linear-gradient(135deg, rgba(255, 255, 255, 0.98), rgba(246, 249, 255, 0.96));
box-shadow: 0 18px 42px rgba(22, 32, 51, 0.06);
}
.demand-page-kicker {
margin-bottom: 10px;
color: #6258f6;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.18em;
text-transform: uppercase;
}
.demand-page-title {
color: #162033;
font-size: 28px;
font-weight: 800;
line-height: 1.2;
}
.demand-page-subtitle {
margin-top: 8px;
color: #6c7b98;
font-size: 14px;
}
.demand-page-hero-meta {
display: flex;
gap: 12px;
}
.demand-page-meta-card {
min-width: 140px;
padding: 16px 18px;
border: 1px solid rgba(219, 228, 243, 0.78);
border-radius: 18px;
background: rgba(255, 255, 255, 0.82);
}
.demand-page-meta-card span {
display: block;
margin-bottom: 8px;
color: #7d8cab;
font-size: 12px;
font-weight: 700;
}
.demand-page-meta-card strong {
color: #162033;
font-size: 18px;
font-weight: 800;
}
.demand-version-card,
.demand-list-card {
height: 100%;
}
.demand-version-card .ant-card-head,
.demand-list-card .ant-card-head {
background: linear-gradient(180deg, rgba(248, 250, 255, 0.92), rgba(255, 255, 255, 0.98));
}
.demand-list-card .ant-card-body {
display: flex;
flex-direction: column;
gap: 14px;
}
.demand-search-form {
padding: 0;
border: none;
box-shadow: none;
background: transparent;
}
.demand-toolbar {
margin: 0;
}
.demand-pagination {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-top: 12px;
}
.demand-edit-modal .ant-modal-body,
.demand-version-modal .ant-modal-body {
padding-top: 10px;
}
@media (max-width: 992px) {
.demand-page-hero {
flex-direction: column;
align-items: flex-start;
padding: 20px;
}
.demand-page-hero-meta {
width: 100%;
flex-direction: column;
}
}

View File

@ -0,0 +1,78 @@
.project-detail-page {
gap: 18px;
}
.project-detail-back {
margin-bottom: -2px;
}
.project-detail-hero {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
padding: 24px 26px;
border: 1px solid rgba(219, 228, 243, 0.84);
border-radius: 24px;
background:
radial-gradient(circle at top right, rgba(25, 180, 184, 0.12), transparent 26%),
radial-gradient(circle at left center, rgba(99, 91, 255, 0.12), transparent 34%),
linear-gradient(135deg, rgba(255, 255, 255, 0.98), rgba(246, 249, 255, 0.96));
box-shadow: 0 18px 42px rgba(22, 32, 51, 0.06);
}
.project-detail-kicker {
margin-bottom: 10px;
color: #6258f6;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.18em;
text-transform: uppercase;
}
.project-detail-title {
color: #162033;
font-size: 28px;
font-weight: 800;
line-height: 1.2;
}
.project-detail-subtitle {
margin-top: 8px;
color: #6c7b98;
font-size: 14px;
}
.project-detail-summary {
min-width: 180px;
padding: 16px 18px;
border: 1px solid rgba(219, 228, 243, 0.78);
border-radius: 18px;
background: rgba(255, 255, 255, 0.82);
}
.project-detail-summary span {
display: block;
margin-bottom: 8px;
color: #7d8cab;
font-size: 12px;
font-weight: 700;
}
.project-detail-summary strong {
color: #162033;
font-size: 18px;
font-weight: 800;
}
.project-detail-card .ant-form-item:last-child {
margin-bottom: 4px;
}
@media (max-width: 992px) {
.project-detail-hero {
flex-direction: column;
align-items: flex-start;
padding: 20px;
}
}

View File

@ -1,7 +1,27 @@
.project-page-container {
gap: 18px;
}
.project-page-container .search-form .ant-form-item {
margin-bottom: 20px;
margin-bottom: 14px;
}
.project-page-container .search-form .ant-input,
.project-page-container .search-form .ant-select {
min-width: 220px;
}
.project-page-container .mb8 {
margin-bottom: 8px;
justify-content: flex-end;
margin-bottom: 2px;
}
.project-page-container .ant-table-wrapper .ant-tag {
min-width: 74px;
text-align: center;
}
.project-page-container .ant-table-wrapper .ant-btn-link {
padding-inline: 6px;
font-weight: 700;
}

View File

@ -9,6 +9,8 @@ import zhCN from 'antd/es/date-picker/locale/zh_CN';
import PageBackButton from '@/components/PageBackButton';
import { getProjectDetail, listProject } from '@/api/project';
import { getProjectExecutionInfo, getProjectWorkInfo } from '@/api/projectExecution';
import { usePermission } from '@/contexts/PermissionContext';
import '@/styles/permission-link.css';
import { parseTime } from '@/utils/ruoyi';
import './project-user.css';
@ -115,6 +117,7 @@ const normalizeDateParam = (value: string | null) => {
const ProjectUserPage = () => {
const navigate = useNavigate();
const { canAccessPath } = usePermission();
const [searchParams] = useSearchParams();
const projectId = searchParams.get('projectId') ?? searchParams.get('id') ?? '';
const queryStartDate = normalizeDateParam(searchParams.get('startDate'));
@ -132,8 +135,12 @@ const ProjectUserPage = () => {
dayjs(defaultEndDate, 'YYYY-MM-DD'),
]);
const [detailList, setDetailList] = useState<ProjectUserItem[][]>([]);
const canViewWorkLog = canAccessPath('/index');
const openUserLog = (row: ProjectUserItem, queryDate?: string) => {
if (!canViewWorkLog) {
return;
}
const params = new URLSearchParams();
params.set('projectId', String(selectedProjectId ?? ''));
params.set('userId', String(row.userId ?? ''));
@ -298,15 +305,24 @@ const ProjectUserPage = () => {
return (
<div className="project-user-button-group">
{users.map((item, itemIndex) => (
<Button
key={`${String(item.userId ?? item.userName ?? itemIndex)}-${columnIndex}`}
type="link"
size="small"
className="project-user-log-button"
onClick={() => openUserLog(item, queryDate)}
>
{`${String(item.userName ?? '-') }(${toNumber(item.workTime, 0)}天)`}
</Button>
canViewWorkLog ? (
<Button
key={`${String(item.userId ?? item.userName ?? itemIndex)}-${columnIndex}`}
type="link"
size="small"
className="project-user-log-button"
onClick={() => openUserLog(item, queryDate)}
>
{`${String(item.userName ?? '-') }(${toNumber(item.workTime, 0)}天)`}
</Button>
) : (
<span
key={`${String(item.userId ?? item.userName ?? itemIndex)}-${columnIndex}`}
className="project-user-log-button is-disabled permission-link-disabled"
>
{`${String(item.userName ?? '-') }(${toNumber(item.workTime, 0)}天)`}
</span>
)
))}
</div>
);
@ -317,7 +333,7 @@ const ProjectUserPage = () => {
}
return list;
})();
}, [dateRange, detailList]);
}, [canViewWorkLog, dateRange, detailList]);
const selectedProjectName = useMemo(() => {
const hit = projectList.find((item) => String(item.projectId) === String(selectedProjectId ?? ''));

View File

@ -9,6 +9,8 @@ import { useNavigate } from 'react-router-dom';
import PageBackButton from '@/components/PageBackButton';
import { getProjectExecutionInfo } from '@/api/projectExecution';
import { deptTreeSelect, getUserProfile, listUser } from '@/api/system/user';
import { usePermission } from '@/contexts/PermissionContext';
import '@/styles/permission-link.css';
import './user-project.css';
const { RangePicker } = DatePicker;
@ -129,6 +131,7 @@ const matchesUserKeyword = (user: UserRow, keyword: string) => {
const UserProjectPage = () => {
const navigate = useNavigate();
const { canAccessPath } = usePermission();
const [loading, setLoading] = useState(false);
const [userLoading, setUserLoading] = useState(false);
const [deptLoading, setDeptLoading] = useState(false);
@ -148,6 +151,7 @@ const UserProjectPage = () => {
const [userPageSize, setUserPageSize] = useState(10);
const [userTotal, setUserTotal] = useState(0);
const deferredUserKeyword = useDeferredValue(userKeyword);
const canViewProjectDetail = canAccessPath('/project/detail');
useEffect(() => {
const fetchCurrentUser = async () => {
@ -280,13 +284,17 @@ const UserProjectPage = () => {
fixed: 'left',
width: 180,
render: (value: unknown, row) => (
<Button
type="link"
className="user-project-link"
onClick={() => navigate(`/project/detail?id=${String(row.projectId ?? '')}`)}
>
{String(value ?? '-')}
</Button>
canViewProjectDetail ? (
<Button
type="link"
className="user-project-link"
onClick={() => navigate(`/project/detail?id=${String(row.projectId ?? '')}`)}
>
{String(value ?? '-')}
</Button>
) : (
<span className="user-project-link is-disabled permission-link-disabled">{String(value ?? '-')}</span>
)
),
},
{
@ -320,7 +328,7 @@ const UserProjectPage = () => {
}
return columns;
}, [dateRange, navigate]);
}, [canViewProjectDetail, dateRange, navigate]);
const summaryValues = useMemo(() => {
const dayCount = dateRange[1].startOf('day').diff(dateRange[0].startOf('day'), 'day') + 1;

View File

@ -223,7 +223,7 @@ const UserScoreDetailPage = () => {
dataIndex: 'score',
key: 'score',
width: 360,
render: (value: unknown, row) => (
render: (value: unknown, row: DetailItem) => (
row.reviewCategory === '发展与协作'
? <span className="user-score-detail-empty"></span>
: <ScoreBar value={toNumber(value, 0)} />

View File

@ -5,6 +5,8 @@ import { useNavigate, useSearchParams } from 'react-router-dom';
import PageBackButton from '@/components/PageBackButton';
import { getTaskList, getTaskUserList } from '@/api/appraisal';
import { deptTreeSelect, listUser } from '@/api/system/user';
import { usePermission } from '@/contexts/PermissionContext';
import '@/styles/permission-link.css';
import './user-score.css';
interface DeptTreeNode {
@ -142,6 +144,7 @@ const getScoreValue = (row: UserScoreRow) => row.score ?? row.manageScore ?? row
const UserScorePage = () => {
const navigate = useNavigate();
const { canAccessPath } = usePermission();
const [searchParams] = useSearchParams();
const routeTaskId = searchParams.get('taskId') ?? '';
const routeDeptId = searchParams.get('deptId') ?? '';
@ -160,6 +163,7 @@ const UserScorePage = () => {
const [deptUserIds, setDeptUserIds] = useState<Set<string>>(new Set());
const [pageNum, setPageNum] = useState(routePageNum);
const [pageSize, setPageSize] = useState(routePageSize);
const canViewScoreDetail = canAccessPath('/projectBank/userScoreDetail');
useEffect(() => {
const loadBaseData = async () => {
@ -274,6 +278,9 @@ const UserScorePage = () => {
};
const openDetail = (row: UserScoreRow) => {
if (!canViewScoreDetail) {
return;
}
const params = new URLSearchParams();
params.set('userId', String(row.userId ?? ''));
params.set('userName', String(row.userName ?? ''));
@ -323,9 +330,13 @@ const UserScorePage = () => {
key: 'action',
width: 160,
render: (_value, row) => (
<Button type="link" onClick={() => openDetail(row)}>
</Button>
canViewScoreDetail ? (
<Button type="link" onClick={() => openDetail(row)}>
</Button>
) : (
<span className="user-score-link is-disabled permission-link-disabled"></span>
)
),
},
];

View File

@ -129,6 +129,20 @@
line-height: 1.4;
}
.project-user-log-button.is-disabled {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 22px;
padding: 0;
font-weight: 600;
white-space: normal;
line-height: 1.4;
color: #98a2b3;
cursor: not-allowed;
user-select: none;
}
.project-user-empty-cell {
color: #94a3b8;
}

View File

@ -79,6 +79,17 @@
font-weight: 600;
}
.user-project-link.is-disabled {
display: inline-flex;
align-items: center;
padding: 0;
font-weight: 600;
color: #98a2b3;
cursor: not-allowed;
text-decoration: none;
user-select: none;
}
.user-select-modal {
display: flex;

View File

@ -193,6 +193,15 @@
font-weight: 600;
}
.user-score-link.is-disabled {
display: inline-flex;
align-items: center;
color: #98a2b3;
font-weight: 500;
cursor: not-allowed;
user-select: none;
}
.user-score-total-value {
color: #f97316;
font-weight: 700;

View File

@ -12,6 +12,9 @@ import {
import { saveAs } from 'file-saver';
import dayjs from 'dayjs';
import { parseTime } from '../../utils/ruoyi';
import Permission from '@/components/Permission';
import ReadonlyAction from '@/components/Permission/ReadonlyAction';
import { usePermission } from '@/contexts/PermissionContext';
const { RangePicker } = DatePicker;
@ -21,20 +24,43 @@ const sysYesNoDict = [
{ value: 'N', label: '否' },
];
type QueryParams = {
pageNum?: number;
pageSize?: number;
configName?: string;
configKey?: string;
configType?: string;
beginTime?: string;
endTime?: string;
};
type ConfigRecord = {
configId?: string | number;
configName?: string;
configKey?: string;
configValue?: string;
configType?: string;
remark?: string;
createTime?: string | number | Date;
[key: string]: unknown;
};
const ConfigPage: React.FC = () => {
const { canAccessPath } = usePermission();
const canOperateConfig = canAccessPath('/system/config');
const [queryForm] = Form.useForm();
const [configForm] = Form.useForm();
const [loading, setLoading] = useState(false);
const [configList, setConfigList] = useState<any[]>([]);
const [configList, setConfigList] = useState<ConfigRecord[]>([]);
const [total, setTotal] = useState(0);
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
const [dateRange, setDateRange] = useState<[dayjs.Dayjs, dayjs.Dayjs] | null>(null);
const [dateRange, setDateRange] = useState<[dayjs.Dayjs | null, dayjs.Dayjs | null] | null>(null);
const [modalVisible, setModalVisible] = useState(false);
const [modalTitle, setModalTitle] = useState('');
const [currentConfig, setCurrentConfig] = useState<any>({});
const [currentConfig, setCurrentConfig] = useState<ConfigRecord>({});
const [queryParams, setQueryParams] = useState({
const [queryParams, setQueryParams] = useState<QueryParams>({
pageNum: 1,
pageSize: 10,
configName: undefined,
@ -45,17 +71,17 @@ const ConfigPage: React.FC = () => {
const getList = useCallback(async () => {
setLoading(true);
try {
const formattedQueryParams = { ...queryParams };
if (dateRange && dateRange.length === 2) {
formattedQueryParams['beginTime'] = dateRange[0].format('YYYY-MM-DD');
formattedQueryParams['endTime'] = dateRange[1].format('YYYY-MM-DD');
const formattedQueryParams: QueryParams = { ...queryParams };
if (dateRange && dateRange[0] && dateRange[1]) {
formattedQueryParams.beginTime = dateRange[0].format('YYYY-MM-DD');
formattedQueryParams.endTime = dateRange[1].format('YYYY-MM-DD');
} else {
formattedQueryParams['beginTime'] = undefined;
formattedQueryParams['endTime'] = undefined;
formattedQueryParams.beginTime = undefined;
formattedQueryParams.endTime = undefined;
}
const response = await listConfig(formattedQueryParams);
setConfigList(response.rows);
setTotal(response.total);
const response = await listConfig(formattedQueryParams) as { rows?: ConfigRecord[]; total?: number };
setConfigList(response.rows ?? []);
setTotal(response.total ?? 0);
} catch (error) {
console.error('Failed to fetch config list:', error);
message.error('获取参数列表失败');
@ -96,14 +122,19 @@ const ConfigPage: React.FC = () => {
setModalVisible(true);
};
const handleUpdate = async (row: any) => {
const handleUpdate = async (row: Partial<ConfigRecord> | React.Key[]) => {
resetConfigForm();
try {
const configId = row.configId || selectedRowKeys[0];
const response = await getConfig(configId);
const configId = Array.isArray(row) ? row[0] : row.configId;
if (configId === undefined || configId === null) {
message.warning('请选择要修改的参数');
return;
}
const normalizedConfigId = typeof configId === 'bigint' ? Number(configId) : configId;
const response = await getConfig(normalizedConfigId) as unknown;
const detail = (response && typeof response === 'object' && 'data' in response)
? response.data ?? {}
: response ?? {};
? (response.data as ConfigRecord | undefined) ?? {}
: (response as ConfigRecord | undefined) ?? {};
setCurrentConfig(detail);
configForm.setFieldsValue({
...detail,
@ -141,16 +172,16 @@ const ConfigPage: React.FC = () => {
const handleExport = async () => {
const hide = message.loading('正在导出数据...', 0);
try {
const response = await listConfig({ ...queryParams, pageNum: undefined, pageSize: undefined });
const response = await listConfig({ ...queryParams, pageNum: undefined, pageSize: undefined }) as { rows?: ConfigRecord[] };
const header = ['参数主键', '参数名称', '参数键名', '参数键值', '系统内置', '备注', '创建时间'];
const data = response.rows.map((config: any) => [
const data = (response.rows ?? []).map((config) => [
config.configId, config.configName, config.configKey, config.configValue,
sysYesNoDict.find((d) => d.value === config.configType)?.label ?? '',
config.remark, parseTime(config.createTime),
]);
const csvContent = [header, ...data]
.map((row) => row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(','))
.map((row) => row.map((cell: unknown) => `"${String(cell).replace(/"/g, '""')}"`).join(','))
.join('\n');
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
@ -205,22 +236,26 @@ const ConfigPage: React.FC = () => {
title: '操作', key: 'operation', align: 'center', width: 180,
render: (_, record) => (
<Space size="small">
<Button type="link" icon={<EditOutlined />} onClick={() => handleUpdate(record)}></Button>
<Popconfirm
title="是否确认删除该参数?"
onConfirm={() => handleDelete(record)}
okText="是"
cancelText="否"
>
<Button type="link" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
<Permission permissions={canOperateConfig ? undefined : '__forbidden__'} fallback={<ReadonlyAction icon={<EditOutlined />}></ReadonlyAction>}>
<Button type="link" icon={<EditOutlined />} onClick={() => handleUpdate(record)}></Button>
</Permission>
<Permission permissions={canOperateConfig ? undefined : '__forbidden__'} fallback={<ReadonlyAction icon={<DeleteOutlined />} danger></ReadonlyAction>}>
<Popconfirm
title="是否确认删除该参数?"
onConfirm={() => handleDelete(record)}
okText="是"
cancelText="否"
>
<Button type="link" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
</Permission>
</Space>
),
},
];
return (
<div className="app-container">
<div className="app-container config-page-container">
<Form form={queryForm} layout="inline" className="search-form" onFinish={handleQuery} initialValues={queryParams}>
<Form.Item label="参数名称" name="configName">
<Input placeholder="请输入参数名称" allowClear onPressEnter={handleQuery} style={{ width: 240 }} />
@ -250,11 +285,21 @@ const ConfigPage: React.FC = () => {
</Form>
<Space className="mb8">
<Button type="primary" ghost icon={<PlusOutlined />} onClick={handleAdd}></Button>
<Button type="primary" ghost icon={<EditOutlined />} disabled={selectedRowKeys.length !== 1} onClick={() => handleUpdate(selectedRowKeys)}></Button>
<Button type="danger" ghost icon={<DeleteOutlined />} disabled={selectedRowKeys.length === 0} onClick={() => handleDelete()}></Button>
<Button type="warning" ghost icon={<DownloadOutlined />} onClick={handleExport}></Button>
<Button type="danger" ghost icon={<SyncOutlined />} onClick={handleRefreshCache}></Button>
<Permission permissions={canOperateConfig ? undefined : '__forbidden__'}>
<Button type="primary" ghost icon={<PlusOutlined />} onClick={handleAdd}></Button>
</Permission>
<Permission permissions={canOperateConfig ? undefined : '__forbidden__'}>
<Button type="primary" ghost icon={<EditOutlined />} disabled={selectedRowKeys.length !== 1} onClick={() => handleUpdate(selectedRowKeys)}></Button>
</Permission>
<Permission permissions={canOperateConfig ? undefined : '__forbidden__'}>
<Button danger ghost icon={<DeleteOutlined />} disabled={selectedRowKeys.length === 0} onClick={() => handleDelete()}></Button>
</Permission>
<Permission permissions={canOperateConfig ? undefined : '__forbidden__'}>
<Button ghost icon={<DownloadOutlined />} onClick={handleExport}></Button>
</Permission>
<Permission permissions={canOperateConfig ? undefined : '__forbidden__'}>
<Button danger ghost icon={<SyncOutlined />} onClick={handleRefreshCache}></Button>
</Permission>
</Space>
<Table
@ -281,6 +326,7 @@ const ConfigPage: React.FC = () => {
{/* Add/Edit Config Modal */}
<Modal
className="system-admin-modal"
title={modalTitle}
open={modalVisible}
onOk={configForm.submit}

View File

@ -10,6 +10,8 @@ import {
listDept, getDept, addDept, updateDept, delDept, listDeptExcludeChild
} from '../../api/system/dept';
import { parseTime, handleTree } from '../../utils/ruoyi'; // Using handleTree utility
import Permission from '@/components/Permission';
import ReadonlyAction from '@/components/Permission/ReadonlyAction';
// Mock Dictionaries
const sysNormalDisableDict = [
@ -226,17 +228,23 @@ const DeptPage: React.FC = () => {
title: '操作', key: 'operation', align: 'center', width: 200,
render: (_, record) => (
<Space size="small">
<Button type="link" icon={<EditOutlined />} onClick={() => handleUpdate(record)}></Button>
<Button type="link" icon={<PlusOutlined />} onClick={() => handleAdd(record)}></Button>
<Permission permissions="system:dept:edit" fallback={<ReadonlyAction icon={<EditOutlined />}></ReadonlyAction>}>
<Button type="link" icon={<EditOutlined />} onClick={() => handleUpdate(record)}></Button>
</Permission>
<Permission permissions="system:dept:add" fallback={<ReadonlyAction icon={<PlusOutlined />}></ReadonlyAction>}>
<Button type="link" icon={<PlusOutlined />} onClick={() => handleAdd(record)}></Button>
</Permission>
{record.parentId !== 0 && (
<Popconfirm
title="是否确认删除该部门?"
onConfirm={() => handleDelete(record)}
okText="是"
cancelText="否"
>
<Button type="link" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
<Permission permissions="system:dept:remove" fallback={<ReadonlyAction icon={<DeleteOutlined />} danger></ReadonlyAction>}>
<Popconfirm
title="是否确认删除该部门?"
onConfirm={() => handleDelete(record)}
okText="是"
cancelText="否"
>
<Button type="link" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
</Permission>
)}
</Space>
),
@ -244,7 +252,7 @@ const DeptPage: React.FC = () => {
];
return (
<div className="app-container">
<div className="app-container dept-page-container">
<Form form={queryForm} layout="inline" className="search-form" onFinish={handleQuery} initialValues={queryParams}>
<Form.Item label="部门名称" name="deptName">
<Input placeholder="请输入部门名称" allowClear onPressEnter={handleQuery} style={{ width: 240 }} />
@ -263,8 +271,10 @@ const DeptPage: React.FC = () => {
</Form>
<Space className="mb8">
<Button type="primary" ghost icon={<PlusOutlined />} onClick={() => handleAdd()}></Button>
<Button type="info" ghost icon={<SortAscendingOutlined />} onClick={toggleExpandAll}>/</Button>
<Permission permissions="system:dept:add">
<Button type="primary" ghost icon={<PlusOutlined />} onClick={() => handleAdd()}></Button>
</Permission>
<Button ghost icon={<SortAscendingOutlined />} onClick={toggleExpandAll}>/</Button>
</Space>
{refreshTable && (
@ -284,6 +294,7 @@ const DeptPage: React.FC = () => {
{/* Add/Edit Department Modal */}
<Modal
className="system-admin-modal"
title={modalTitle}
open={modalVisible}
onOk={deptForm.submit}

View File

@ -15,6 +15,9 @@ import 'dayjs/locale/zh-cn';
import zhCN from 'antd/es/date-picker/locale/zh_CN';
import { parseTime } from '../../utils/ruoyi';
import { Link } from 'react-router-dom';
import Permission from '@/components/Permission';
import ReadonlyAction from '@/components/Permission/ReadonlyAction';
import { usePermission } from '@/contexts/PermissionContext';
const { RangePicker } = DatePicker;
dayjs.locale('zh-cn');
@ -25,20 +28,41 @@ const sysNormalDisableDict = [
{ value: '1', label: '停用' },
];
type DictQueryParams = {
pageNum?: number;
pageSize?: number;
dictName?: string;
dictType?: string;
status?: string;
beginTime?: string;
endTime?: string;
};
type DictTypeRecord = {
dictId?: string | number;
dictName?: string;
dictType?: string;
status?: string | number;
remark?: string;
createTime?: string | number | Date;
[key: string]: unknown;
};
const DictPage: React.FC = () => {
const { canAccessPath } = usePermission();
const [queryForm] = Form.useForm();
const [dictForm] = Form.useForm();
const [loading, setLoading] = useState(false);
const [typeList, setTypeList] = useState<any[]>([]);
const [typeList, setTypeList] = useState<DictTypeRecord[]>([]);
const [total, setTotal] = useState(0);
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
const [dateRange, setDateRange] = useState<[dayjs.Dayjs, dayjs.Dayjs] | null>(null);
const [dateRange, setDateRange] = useState<[dayjs.Dayjs | null, dayjs.Dayjs | null] | null>(null);
const [modalVisible, setModalVisible] = useState(false);
const [modalTitle, setModalTitle] = useState('');
const [currentDict, setCurrentDict] = useState<any>({});
const [currentDict, setCurrentDict] = useState<DictTypeRecord>({});
const [queryParams, setQueryParams] = useState({
const [queryParams, setQueryParams] = useState<DictQueryParams>({
pageNum: 1,
pageSize: 10,
dictName: undefined,
@ -49,17 +73,17 @@ const DictPage: React.FC = () => {
const getList = useCallback(async () => {
setLoading(true);
try {
const formattedQueryParams = { ...queryParams };
if (dateRange && dateRange.length === 2) {
formattedQueryParams['beginTime'] = dateRange[0].format('YYYY-MM-DD');
formattedQueryParams['endTime'] = dateRange[1].format('YYYY-MM-DD');
const formattedQueryParams: DictQueryParams = { ...queryParams };
if (dateRange && dateRange[0] && dateRange[1]) {
formattedQueryParams.beginTime = dateRange[0].format('YYYY-MM-DD');
formattedQueryParams.endTime = dateRange[1].format('YYYY-MM-DD');
} else {
formattedQueryParams['beginTime'] = undefined;
formattedQueryParams['endTime'] = undefined;
formattedQueryParams.beginTime = undefined;
formattedQueryParams.endTime = undefined;
}
const response = await listDictType(formattedQueryParams);
setTypeList(response.rows);
setTotal(response.total);
const response = await listDictType(formattedQueryParams) as { rows?: DictTypeRecord[]; total?: number };
setTypeList(response.rows ?? []);
setTotal(response.total ?? 0);
} catch (error) {
console.error('Failed to fetch dictionary type list:', error);
message.error('获取字典类型列表失败');
@ -100,14 +124,19 @@ const DictPage: React.FC = () => {
setModalVisible(true);
};
const handleUpdate = async (row: any) => {
const handleUpdate = async (row: Partial<DictTypeRecord> | React.Key[]) => {
resetDictForm();
try {
const dictId = row.dictId || selectedRowKeys[0];
const response = await getDictType(dictId);
const dictId = Array.isArray(row) ? row[0] : row.dictId;
if (dictId === undefined || dictId === null) {
message.warning('请选择要修改的字典类型');
return;
}
const normalizedDictId = typeof dictId === 'bigint' ? Number(dictId) : dictId;
const response = await getDictType(normalizedDictId) as unknown;
const detail = (response && typeof response === 'object' && 'data' in response)
? response.data ?? {}
: response ?? {};
? (response.data as DictTypeRecord | undefined) ?? {}
: (response as DictTypeRecord | undefined) ?? {};
setCurrentDict(detail);
dictForm.setFieldsValue({
...detail,
@ -145,16 +174,16 @@ const DictPage: React.FC = () => {
const handleExport = async () => {
const hide = message.loading('正在导出数据...', 0);
try {
const response = await listDictType({ ...queryParams, pageNum: undefined, pageSize: undefined });
const response = await listDictType({ ...queryParams, pageNum: undefined, pageSize: undefined }) as { rows?: DictTypeRecord[] };
const header = ['字典编号', '字典名称', '字典类型', '状态', '备注', '创建时间'];
const data = response.rows.map((dict: any) => [
const data = (response.rows ?? []).map((dict) => [
dict.dictId, dict.dictName, dict.dictType,
sysNormalDisableDict.find((d) => d.value === String(dict.status ?? ''))?.label ?? '',
dict.remark, parseTime(dict.createTime),
]);
const csvContent = [header, ...data]
.map((row) => row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(','))
.map((row) => row.map((cell: unknown) => `"${String(cell).replace(/"/g, '""')}"`).join(','))
.join('\n');
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
@ -213,22 +242,26 @@ const DictPage: React.FC = () => {
title: '操作', key: 'operation', align: 'center', width: 180,
render: (_, record) => (
<Space size="small">
<Button type="link" icon={<EditOutlined />} onClick={() => handleUpdate(record)}></Button>
<Popconfirm
title="是否确认删除该字典类型?"
onConfirm={() => handleDelete(record)}
okText="是"
cancelText="否"
>
<Button type="link" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
<Permission permissions="system:dict:edit" fallback={<ReadonlyAction icon={<EditOutlined />}></ReadonlyAction>}>
<Button type="link" icon={<EditOutlined />} onClick={() => handleUpdate(record)}></Button>
</Permission>
<Permission permissions="system:dict:remove" fallback={<ReadonlyAction icon={<DeleteOutlined />} danger></ReadonlyAction>}>
<Popconfirm
title="是否确认删除该字典类型?"
onConfirm={() => handleDelete(record)}
okText="是"
cancelText="否"
>
<Button type="link" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
</Permission>
</Space>
),
},
];
return (
<div className="app-container">
<div className="app-container dict-page-container">
<Form form={queryForm} layout="inline" className="search-form" onFinish={handleQuery} initialValues={queryParams}>
<Form.Item label="字典名称" name="dictName">
<Input placeholder="请输入字典名称" allowClear onPressEnter={handleQuery} style={{ width: 240 }} />
@ -260,11 +293,21 @@ const DictPage: React.FC = () => {
</Form>
<Space className="mb8">
<Button type="primary" ghost icon={<PlusOutlined />} onClick={handleAdd}></Button>
<Button type="primary" ghost icon={<EditOutlined />} disabled={selectedRowKeys.length !== 1} onClick={() => handleUpdate(selectedRowKeys)}></Button>
<Button type="danger" ghost icon={<DeleteOutlined />} disabled={selectedRowKeys.length === 0} onClick={() => handleDelete()}></Button>
<Button type="warning" ghost icon={<DownloadOutlined />} onClick={handleExport}></Button>
<Button type="danger" ghost icon={<SyncOutlined />} onClick={handleRefreshCache}></Button>
<Permission permissions="system:dict:add">
<Button type="primary" ghost icon={<PlusOutlined />} onClick={handleAdd}></Button>
</Permission>
<Permission permissions="system:dict:edit">
<Button type="primary" ghost icon={<EditOutlined />} disabled={selectedRowKeys.length !== 1} onClick={() => handleUpdate(selectedRowKeys)}></Button>
</Permission>
<Permission permissions="system:dict:remove">
<Button danger ghost icon={<DeleteOutlined />} disabled={selectedRowKeys.length === 0} onClick={() => handleDelete()}></Button>
</Permission>
<Permission permissions="system:dict:export">
<Button ghost icon={<DownloadOutlined />} onClick={handleExport}></Button>
</Permission>
<Permission permissions={canAccessPath('/system/dict') ? undefined : '__forbidden__'}>
<Button danger ghost icon={<SyncOutlined />} onClick={handleRefreshCache}></Button>
</Permission>
</Space>
<Table
@ -291,6 +334,7 @@ const DictPage: React.FC = () => {
{/* Add/Edit Dictionary Type Modal */}
<Modal
className="system-admin-modal"
title={modalTitle}
open={modalVisible}
onOk={dictForm.submit}

View File

@ -1,7 +1,9 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
Card,
Button,
Col,
Empty,
Form,
Input,
InputNumber,
@ -14,6 +16,7 @@ import {
Table,
Tag,
Tooltip,
Tree,
TreeSelect,
message,
} from 'antd';
@ -35,7 +38,6 @@ import {
ClockCircleOutlined,
CodeSandboxOutlined,
ControlOutlined,
DashboardOutlined,
DesktopOutlined,
EditOutlined as EditMenuIcon,
FileTextOutlined,
@ -61,8 +63,10 @@ import {
treeselect,
updateMenu,
} from '../../api/system/menu';
import { handleTree, parseTime } from '../../utils/ruoyi';
import { handleTree } from '../../utils/ruoyi';
import './menu.css';
import Permission from '@/components/Permission';
import ReadonlyAction from '@/components/Permission/ReadonlyAction';
interface MenuRecord {
menuId?: number;
@ -90,6 +94,17 @@ interface MenuTreeOption {
children?: MenuTreeOption[];
}
interface PageTreeRecord extends MenuRecord {
children?: PageTreeRecord[];
buttonChildren?: MenuRecord[];
}
interface PageTreeNode {
key: string;
title: React.ReactNode;
children?: PageTreeNode[];
}
const sysNormalDisableDict = [
{ value: '0', label: '正常', color: 'success' },
{ value: '1', label: '停用', color: 'error' },
@ -185,26 +200,131 @@ const extractDetailData = (response: unknown): MenuRecord => {
};
const normalizeTreeSelectNodes = (nodes: MenuTreeOption[]): MenuTreeOption[] =>
(nodes ?? [])
.map((node) => {
const id = node?.id;
if (id === undefined || id === null) {
return null;
}
return {
id,
label: String(node.label ?? id),
children: normalizeTreeSelectNodes(node.children ?? []),
};
})
.filter((node): node is MenuTreeOption => Boolean(node));
(nodes ?? []).reduce<MenuTreeOption[]>((acc, node) => {
const id = node?.id;
if (id === undefined || id === null) {
return acc;
}
acc.push({
id,
label: String(node.label ?? id),
children: normalizeTreeSelectNodes(node.children ?? []),
});
return acc;
}, []);
const collectExpandedKeys = (nodes: MenuRecord[]): React.Key[] =>
nodes.flatMap((node) => [
node.menuId ?? '',
String(node.menuId ?? ''),
...(Array.isArray(node.children) ? collectExpandedKeys(node.children) : []),
]).filter(Boolean);
const countMenuNodes = (nodes: MenuRecord[]): number =>
nodes.reduce((total, node) => total + 1 + countMenuNodes(Array.isArray(node.children) ? node.children : []), 0);
const countByMenuType = (nodes: MenuRecord[], menuType: string): number =>
nodes.reduce((total, node) => {
const self = String(node.menuType ?? '') === menuType ? 1 : 0;
const children = countByMenuType(Array.isArray(node.children) ? node.children : [], menuType);
return total + self + children;
}, 0);
const sortMenusByOrder = <T extends MenuRecord>(nodes: T[]): T[] =>
[...nodes].sort((a, b) => {
const orderDiff = Number(a.orderNum ?? 0) - Number(b.orderNum ?? 0);
if (orderDiff !== 0) {
return orderDiff;
}
return Number(a.menuId ?? 0) - Number(b.menuId ?? 0);
});
const buildPageTreeRecords = (nodes: MenuRecord[]): PageTreeRecord[] =>
sortMenusByOrder(nodes).reduce<PageTreeRecord[]>((acc, node) => {
if (String(node.menuType ?? '') === 'F') {
return acc;
}
const children = sortMenusByOrder(Array.isArray(node.children) ? node.children : []);
const buttonChildren = children
.filter((child) => String(child.menuType ?? '') === 'F')
.sort((a, b) => {
const orderDiff = Number(a.orderNum ?? 0) - Number(b.orderNum ?? 0);
if (orderDiff !== 0) {
return orderDiff;
}
return Number(a.menuId ?? 0) - Number(b.menuId ?? 0);
});
const pageChildren = buildPageTreeRecords(children);
acc.push({
...node,
children: pageChildren,
buttonChildren,
});
return acc;
}, []);
const flattenPageTree = (nodes: PageTreeRecord[]): PageTreeRecord[] =>
nodes.flatMap((node) => [node, ...flattenPageTree(Array.isArray(node.children) ? node.children : [])]);
const findPageNodeById = (nodes: PageTreeRecord[], menuId?: React.Key): PageTreeRecord | undefined => {
for (const node of nodes) {
if (String(node.menuId ?? '') === String(menuId ?? '')) {
return node;
}
const childHit = findPageNodeById(node.children ?? [], menuId);
if (childHit) {
return childHit;
}
}
return undefined;
};
const buildTreeNodeData = (
nodes: PageTreeRecord[],
expandedKeys: React.Key[] = [],
selectedKey?: React.Key,
onNodeClick?: (node: PageTreeRecord) => void,
): PageTreeNode[] =>
nodes.map((node) => ({
key: String(node.menuId ?? ''),
title: (
<div
className={`menu-tree-node${String(selectedKey ?? '') === String(node.menuId ?? '') ? ' menu-tree-node--active' : ''}`}
onClick={(event) => {
event.stopPropagation();
onNodeClick?.(node);
}}
>
<div className="menu-tree-node-main">
{node.children?.length ? (
<span className="menu-tree-node-toggle">
{expandedKeys.some((item) => String(item) === String(node.menuId ?? '')) ? (
<CaretDownFilled />
) : (
<CaretRightFilled />
)}
</span>
) : null}
<span className="menu-tree-node-title">{String(node.menuName ?? '-')}</span>
</div>
<Space size={6} wrap>
<Tag color={findDict(menuTypeDict, String(node.menuType ?? ''))?.color}>
{findDict(menuTypeDict, String(node.menuType ?? ''))?.label ?? '页面'}
</Tag>
{node.buttonChildren?.length ? (
<Tag variant="filled" className="menu-tree-node-badge">
{node.buttonChildren.length}
</Tag>
) : null}
</Space>
</div>
),
children: buildTreeNodeData(node.children ?? [], expandedKeys, selectedKey, onNodeClick),
}));
const findDict = (dict: Array<{ value: string; label: string; color?: string }>, value?: string) =>
dict.find((item) => item.value === String(value ?? ''));
@ -223,8 +343,8 @@ const MenuPage: React.FC = () => {
const [loading, setLoading] = useState(false);
const [menuList, setMenuList] = useState<MenuRecord[]>([]);
const [menuOptions, setMenuOptions] = useState<MenuTreeOption[]>([]);
const [expandedRowKeys, setExpandedRowKeys] = useState<React.Key[]>([]);
const [isExpandAll, setIsExpandAll] = useState(true);
const [expandedKeys, setExpandedKeys] = useState<React.Key[]>([]);
const [selectedPageId, setSelectedPageId] = useState<React.Key>();
const [modalVisible, setModalVisible] = useState(false);
const [modalTitle, setModalTitle] = useState('');
@ -241,14 +361,13 @@ const MenuPage: React.FC = () => {
const response = await listMenu(queryParams);
const list = handleTree(extractListData(response), 'menuId') as MenuRecord[];
setMenuList(list);
setExpandedRowKeys(isExpandAll ? collectExpandedKeys(list) : []);
} catch (error) {
console.error('Failed to fetch menu list:', error);
message.error('获取菜单列表失败');
} finally {
setLoading(false);
}
}, [isExpandAll, queryParams]);
}, [queryParams]);
const getTreeselect = useCallback(async () => {
try {
@ -293,14 +412,15 @@ const MenuPage: React.FC = () => {
});
};
const handleAdd = async (row?: MenuRecord) => {
const handleAdd = async (row?: MenuRecord, preset?: Partial<MenuRecord>, title?: string) => {
resetMenuForm();
await getTreeselect();
menuForm.setFieldsValue({
...defaultMenuValues,
...preset,
parentId: row?.menuId ?? 0,
});
setModalTitle(row?.menuId ? '新增下级菜单' : '新增菜单');
setModalTitle(title ?? (row?.menuId ? '新增下级菜单' : '新增菜单'));
setModalVisible(true);
};
@ -335,17 +455,6 @@ const MenuPage: React.FC = () => {
}
};
const toggleExpandAll = () => {
if (isExpandAll) {
setExpandedRowKeys([]);
setIsExpandAll(false);
return;
}
const keys = collectExpandedKeys(menuList);
setExpandedRowKeys(keys);
setIsExpandAll(true);
};
const submitMenuForm = async (values: MenuRecord) => {
try {
const payload = { ...currentMenu, ...values };
@ -365,27 +474,77 @@ const MenuPage: React.FC = () => {
}
};
const columns: TableColumnsType<MenuRecord> = useMemo(() => [
const pageTreeData = useMemo(() => buildPageTreeRecords(menuList), [menuList]);
const allExpandableKeys = useMemo(() => collectExpandedKeys(pageTreeData), [pageTreeData]);
const isAllExpanded = useMemo(
() => allExpandableKeys.length > 0 && allExpandableKeys.every((key) => expandedKeys.some((item) => String(item) === String(key))),
[allExpandableKeys, expandedKeys],
);
const selectedPage = useMemo(
() => findPageNodeById(pageTreeData, selectedPageId),
[pageTreeData, selectedPageId],
);
useEffect(() => {
if (!pageTreeData.length) {
setSelectedPageId(undefined);
return;
}
if (selectedPageId !== undefined && findPageNodeById(pageTreeData, selectedPageId)) {
return;
}
setSelectedPageId(pageTreeData[0]?.menuId !== undefined ? String(pageTreeData[0].menuId) : undefined);
}, [pageTreeData, selectedPageId]);
const pageNodes = useMemo(() => flattenPageTree(pageTreeData), [pageTreeData]);
const handlePageNodeClick = useCallback((node: PageTreeRecord) => {
const nextSelectedKey = String(node.menuId ?? '');
setSelectedPageId(nextSelectedKey);
if (!node.children?.length || !nextSelectedKey) {
return;
}
setExpandedKeys((prev) => {
const exists = prev.some((item) => String(item) === nextSelectedKey);
return exists
? prev.filter((item) => String(item) !== nextSelectedKey)
: [...prev, nextSelectedKey];
});
}, []);
const toggleExpandAll = useCallback(() => {
setExpandedKeys(isAllExpanded ? [] : allExpandableKeys);
}, [allExpandableKeys, isAllExpanded]);
const pageTreeNodes = useMemo(
() => buildTreeNodeData(pageTreeData, expandedKeys, selectedPageId, handlePageNodeClick),
[expandedKeys, handlePageNodeClick, pageTreeData, selectedPageId],
);
const buttonColumns: TableColumnsType<MenuRecord> = useMemo(() => [
{
title: '菜单名称',
title: '按钮名称',
dataIndex: 'menuName',
key: 'menuName',
width: 240,
ellipsis: true,
width: 280,
render: (value: unknown, record) => (
<div className="menu-name-cell">
<span className="menu-name-text">{String(value ?? '-')}</span>
{record.icon ? <Tag className="menu-icon-tag">{String(record.icon)}</Tag> : null}
<span className="menu-icon-chip">{renderIconPreview(String(record.icon ?? ''))}</span>
<div className="menu-name-main">
<div className="menu-name-topline">
<span className="menu-name-text">{String(value ?? '-')}</span>
<Tag color={findDict(menuTypeDict, 'F')?.color}></Tag>
</div>
<div className="menu-meta-line">
<span>{String(record.perms ?? '-')}</span>
{record.icon ? <span>{String(record.icon)}</span> : null}
</div>
</div>
</div>
),
},
{
title: '图标',
dataIndex: 'icon',
key: 'icon',
width: 180,
render: (value: string) => renderIconPreview(value),
},
{
title: '排序',
dataIndex: 'orderNum',
@ -393,66 +552,51 @@ const MenuPage: React.FC = () => {
align: 'center',
width: 80,
},
{
title: '权限标识',
dataIndex: 'perms',
key: 'perms',
ellipsis: true,
render: (value: unknown) => String(value ?? '-') || '-',
},
{
title: '组件路径',
dataIndex: 'component',
key: 'component',
ellipsis: true,
render: (value: unknown) => String(value ?? '-') || '-',
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
align: 'center',
width: 90,
render: (value: string) => {
const dict = findDict(sysNormalDisableDict, value);
return <Tag color={dict?.color}>{dict?.label ?? '-'}</Tag>;
width: 150,
render: (value: string, record) => {
const statusDict = findDict(sysNormalDisableDict, value);
const visibleDict = findDict(sysShowHideDict, String(record.visible ?? '0'));
return (
<Space size={6} wrap>
<Tag color={statusDict?.color}>{statusDict?.label ?? '-'}</Tag>
<Tag variant="filled">{visibleDict?.label ?? '-'}</Tag>
</Space>
);
},
},
{
title: '创建时间',
dataIndex: 'createTime',
key: 'createTime',
align: 'center',
width: 180,
render: (value: string) => parseTime(value) || '-',
},
{
title: '操作',
key: 'operation',
align: 'center',
width: 220,
width: 180,
render: (_value, record) => (
<Space size={4} wrap>
<Button type="link" icon={<EditOutlined />} onClick={() => handleUpdate(record)}>
</Button>
<Button type="link" icon={<PlusOutlined />} onClick={() => handleAdd(record)}>
</Button>
<Popconfirm
title={`确认删除 "${record.menuName}" 吗?`}
okText="确定"
cancelText="取消"
onConfirm={() => void handleDelete(record)}
>
<Button type="link" danger icon={<DeleteOutlined />}>
<Permission permissions="system:menu:edit" fallback={<ReadonlyAction icon={<EditOutlined />}></ReadonlyAction>}>
<Button type="link" icon={<EditOutlined />} onClick={() => handleUpdate(record)}>
</Button>
</Popconfirm>
</Permission>
<Permission permissions="system:menu:remove" fallback={<ReadonlyAction icon={<DeleteOutlined />} danger></ReadonlyAction>}>
<Popconfirm
title={`确认删除 "${record.menuName}" 吗?`}
okText="确定"
cancelText="取消"
onConfirm={() => void handleDelete(record)}
>
<Button type="link" danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
</Permission>
</Space>
),
},
], [menuList]);
], []);
return (
<div className="app-container menu-page-container">
@ -488,52 +632,111 @@ const MenuPage: React.FC = () => {
</Form>
<div className="table-toolbar">
<Space>
<Button type="primary" icon={<PlusOutlined />} onClick={() => void handleAdd()}>
</Button>
<Space align="center" wrap>
<Permission permissions="system:menu:add">
<Button type="primary" icon={<PlusOutlined />} onClick={() => void handleAdd(undefined, { menuType: 'M' }, '新增页面')}>
</Button>
</Permission>
<Permission permissions="system:menu:add">
<Button
icon={<PlusOutlined />}
onClick={() => selectedPage && void handleAdd(selectedPage, { menuType: 'C' }, '新增下级页面')}
disabled={!selectedPage}
>
</Button>
</Permission>
<Button
icon={isExpandAll ? <MinusSquareOutlined /> : <PlusSquareOutlined />}
icon={isAllExpanded ? <MinusSquareOutlined /> : <PlusSquareOutlined />}
onClick={toggleExpandAll}
>
{isExpandAll ? '折叠' : '展开'}
{isAllExpanded ? '折叠' : '展开'}
</Button>
<span className="menu-toolbar-tip"></span>
</Space>
</div>
<div className="menu-table-wrap">
<Table<MenuRecord>
columns={columns}
dataSource={menuList}
rowKey="menuId"
loading={loading}
pagination={false}
size="middle"
expandable={{
expandedRowKeys,
onExpandedRowsChange: (keys) => {
setExpandedRowKeys(keys);
setIsExpandAll(keys.length > 0);
},
expandIcon: ({ expanded, onExpand, record }) => {
const hasChildren = Array.isArray(record.children) && record.children.length > 0;
if (!hasChildren) {
return <span className="menu-expand-spacer" />;
}
return (
<button
type="button"
className="menu-expand-trigger"
onClick={(event) => onExpand(record, event)}
aria-label={expanded ? '收起' : '展开'}
>
{expanded ? <CaretDownFilled /> : <CaretRightFilled />}
</button>
);
},
}}
scroll={{ y: 'calc(100vh - 320px)' }}
/>
<div className="menu-split-layout">
<Card
variant="borderless"
className="menu-tree-card"
title={`页面树 (${pageNodes.length})`}
extra={
selectedPage ? (
<Space size={4} wrap>
<Permission permissions="system:menu:edit" fallback={<ReadonlyAction icon={<EditOutlined />}></ReadonlyAction>}>
<Button type="link" icon={<EditOutlined />} onClick={() => void handleUpdate(selectedPage)}>
</Button>
</Permission>
<Permission permissions="system:menu:remove" fallback={<ReadonlyAction icon={<DeleteOutlined />} danger></ReadonlyAction>}>
<Popconfirm
title={`确认删除 "${selectedPage.menuName}" 吗?`}
okText="确定"
cancelText="取消"
onConfirm={() => void handleDelete(selectedPage)}
>
<Button type="link" danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
</Permission>
</Space>
) : null
}
>
<Tree
blockNode
showLine={{ showLeafIcon: false }}
selectable={false}
expandedKeys={expandedKeys}
onExpand={(keys) => {
setExpandedKeys(keys.map((key) => String(key)));
}}
treeData={pageTreeNodes}
/>
</Card>
<Card
variant="borderless"
className="menu-button-card"
title={selectedPage ? `${selectedPage.menuName} 的按钮` : '页面按钮'}
extra={
<Permission permissions="system:menu:add">
<Button
type="primary"
ghost
icon={<PlusOutlined />}
onClick={() => selectedPage && void handleAdd(selectedPage, { menuType: 'F' }, '新增按钮')}
disabled={!selectedPage}
>
</Button>
</Permission>
}
>
{selectedPage ? (
<>
<div className="menu-button-summary">
<span>{String(selectedPage.path ?? '-')}</span>
<span>{String(selectedPage.component ?? '-')}</span>
<span>{selectedPage.buttonChildren?.length ?? 0}</span>
</div>
<Table<MenuRecord>
columns={buttonColumns}
dataSource={selectedPage.buttonChildren ?? []}
rowKey="menuId"
loading={loading}
pagination={false}
size="middle"
locale={{ emptyText: <Empty description="当前页面没有按钮" image={Empty.PRESENTED_IMAGE_SIMPLE} /> }}
/>
</>
) : (
<Empty description="暂无页面数据" image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
</Card>
</div>
<Modal

View File

@ -1,11 +1,11 @@
import React, { useState, useEffect, useCallback } from 'react';
import {
Table, Form, Input, Select, Button, Modal, message, Space, Tag, DatePicker, Switch, Dropdown, Menu, InputNumber, Tree, Tooltip, Checkbox, Popconfirm, Row, Col
App, Table, Form, Input, Select, Button, Modal, Space, DatePicker, Switch, Dropdown, InputNumber, Tree, Tooltip, Checkbox, Popconfirm, Row, Col
} from 'antd';
import type { TableColumnsType, MenuProps, GetProps } from 'antd';
import type { TableColumnsType } from 'antd';
import {
SearchOutlined, ReloadOutlined, PlusOutlined, EditOutlined, DeleteOutlined, DownloadOutlined,
KeyOutlined, CheckCircleOutlined, DownOutlined, ExclamationCircleOutlined, QuestionCircleOutlined, UserOutlined
CheckCircleOutlined, DownOutlined, ExclamationCircleOutlined, QuestionCircleOutlined, UserOutlined
} from '@ant-design/icons';
import {
listRole, getRole, addRole, updateRole, dataScope, changeRoleStatus, delRole, deptTreeSelect
@ -14,6 +14,8 @@ import { treeselect as menuTreeselect, roleMenuTreeselect } from '../../api/syst
import { saveAs } from 'file-saver';
import dayjs from 'dayjs';
import { parseTime } from '../../utils/ruoyi'; // Custom utility
import Permission from '@/components/Permission';
import ReadonlyAction from '@/components/Permission/ReadonlyAction';
const { RangePicker } = DatePicker;
@ -31,16 +33,36 @@ const dataScopeOptions = [
{ value: '5', label: '仅本人数据权限' },
];
type RoleQueryParams = {
pageNum?: number;
pageSize?: number;
roleName?: string;
roleKey?: string;
status?: string;
beginTime?: string;
endTime?: string;
};
type RoleRecord = {
roleId?: string | number;
roleName?: string;
roleKey?: string;
roleSort?: string | number;
status?: string;
createTime?: string | number | Date;
[key: string]: unknown;
};
const RolePage: React.FC = () => {
const { message, modal } = App.useApp();
const [queryForm] = Form.useForm();
const [roleForm] = Form.useForm();
const [dataScopeForm] = Form.useForm();
const [loading, setLoading] = useState(false);
const [roleList, setRoleList] = useState<any[]>([]);
const [roleList, setRoleList] = useState<RoleRecord[]>([]);
const [total, setTotal] = useState(0);
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
const [selectedRows, setSelectedRows] = useState<any[]>([]); // Added to store selected rows
const [dateRange, setDateRange] = useState<[dayjs.Dayjs, dayjs.Dayjs] | null>(null);
const [dateRange, setDateRange] = useState<[dayjs.Dayjs | null, dayjs.Dayjs | null] | null>(null);
const [addEditModalVisible, setAddEditModalVisible] = useState(false);
const [addEditModalTitle, setAddEditModalTitle] = useState('');
@ -65,9 +87,9 @@ const RolePage: React.FC = () => {
const [menuCheckStrictly, setMenuCheckStrictly] = useState(true); // For menu tree linkage
const [deptCheckStrictly, setDeptCheckStrictly] = useState(true); // For dept tree linkage
const [currentRole, setCurrentRole] = useState<any>({}); // For storing current role in modals
const [currentRole, setCurrentRole] = useState<RoleRecord>({}); // For storing current role in modals
const [queryParams, setQueryParams] = useState({
const [queryParams, setQueryParams] = useState<RoleQueryParams>({
pageNum: 1,
pageSize: 10,
roleName: undefined,
@ -131,17 +153,17 @@ const RolePage: React.FC = () => {
const getList = useCallback(async () => {
setLoading(true);
try {
const formattedQueryParams = { ...queryParams };
if (dateRange && dateRange.length === 2) {
formattedQueryParams['beginTime'] = dateRange[0].format('YYYY-MM-DD');
formattedQueryParams['endTime'] = dateRange[1].format('YYYY-MM-DD');
const formattedQueryParams: RoleQueryParams = { ...queryParams };
if (dateRange && dateRange[0] && dateRange[1]) {
formattedQueryParams.beginTime = dateRange[0].format('YYYY-MM-DD');
formattedQueryParams.endTime = dateRange[1].format('YYYY-MM-DD');
} else {
formattedQueryParams['beginTime'] = undefined;
formattedQueryParams['endTime'] = undefined;
formattedQueryParams.beginTime = undefined;
formattedQueryParams.endTime = undefined;
}
const response = await listRole(formattedQueryParams);
setRoleList(response.rows);
setTotal(response.total);
const response = await listRole(formattedQueryParams) as { rows?: RoleRecord[]; total?: number };
setRoleList(response.rows ?? []);
setTotal(response.total ?? 0);
} catch (error) {
console.error('Failed to fetch role list:', error);
message.error('获取角色列表失败');
@ -164,7 +186,7 @@ const RolePage: React.FC = () => {
const getDeptTreeselect = useCallback(async (roleId?: string | number) => {
try {
const response = await deptTreeSelect(roleId);
const response = await deptTreeSelect(roleId ?? '');
setDeptOptions(extractTreeNodes(response, 'depts'));
return response;
} catch (error) {
@ -179,8 +201,15 @@ const RolePage: React.FC = () => {
}, [getList]);
const handleQuery = () => {
setQueryParams(prev => ({ ...prev, pageNum: 1 }));
const handleQuery = (values?: Partial<RoleQueryParams>) => {
const formValues = values ?? queryForm.getFieldsValue();
setQueryParams((prev) => ({
...prev,
pageNum: 1,
roleName: formValues.roleName || undefined,
roleKey: formValues.roleKey || undefined,
status: formValues.status || undefined,
}));
};
const resetQuery = () => {
@ -195,15 +224,14 @@ const RolePage: React.FC = () => {
});
};
const handleSelectionChange = (selectedKeys: React.Key[], selectedRows: any[]) => {
const handleSelectionChange = (selectedKeys: React.Key[]) => {
setSelectedRowKeys(selectedKeys);
setSelectedRows(selectedRows);
};
const handleStatusChange = async (record: any) => {
const newStatus = record.status === '0' ? '1' : '0';
const text = record.status === '0' ? '停用' : '启用';
Modal.confirm({
modal.confirm({
title: '确认操作',
icon: <ExclamationCircleOutlined />,
content: `确认要"${text}"角色"${record.roleName}"吗?`,
@ -268,13 +296,12 @@ const RolePage: React.FC = () => {
try {
const roleMenuResponse = await getMenuTreeselect(roleId); // Get menu tree with selected keys
const roleResponse = await getRole(roleId);
const checkedKeys = extractCheckedKeys(roleMenuResponse);
const roleData = extractResponseData(roleResponse);
setCurrentRole(roleData);
setMenuCheckStrictly(roleData.menuCheckStrictly === undefined ? true : !!roleData.menuCheckStrictly);
setDeptCheckStrictly(roleData.deptCheckStrictly === undefined ? true : !!roleData.deptCheckStrictly);
setMenuCheckedKeys(checkedKeys);
setMenuCheckedKeys(extractCheckedKeys(roleMenuResponse));
setMenuHalfCheckedKeys([]);
setAddEditModalTitle('修改角色');
setFormType('edit');
@ -296,7 +323,7 @@ const RolePage: React.FC = () => {
message.warning('请选择要删除的角色');
return;
}
Modal.confirm({
modal.confirm({
title: '确认删除',
icon: <ExclamationCircleOutlined />,
content: `是否确认删除角色编号为"${roleIds.join(',')}"的数据项?`,
@ -316,16 +343,16 @@ const RolePage: React.FC = () => {
const handleExport = async () => {
const hide = message.loading('正在导出数据...', 0);
try {
const response = await listRole({ ...queryParams, pageNum: undefined, pageSize: undefined });
const response = await listRole({ ...queryParams, pageNum: undefined, pageSize: undefined }) as { rows?: RoleRecord[] };
const header = ['角色编号', '角色名称', '权限字符', '显示顺序', '状态', '创建时间'];
const data = response.rows.map((role: any) => [
const data = (response.rows ?? []).map((role) => [
role.roleId, role.roleName, role.roleKey, role.roleSort,
sysNormalDisableDict.find((d) => d.value === String(role.status ?? ''))?.label ?? '',
parseTime(role.createTime),
]);
const csvContent = [header, ...data]
.map((row) => row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(','))
.map((row) => row.map((cell: unknown) => `"${String(cell).replace(/"/g, '""')}"`).join(','))
.join('\n');
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
@ -338,7 +365,7 @@ const RolePage: React.FC = () => {
}
};
const handleCommand: MenuProps['onClick'] = ({ key }, record: any) => {
const handleCommand = (key: string, record: any) => {
switch (key) {
case 'handleDataScope':
handleDataScope(record);
@ -479,14 +506,26 @@ const RolePage: React.FC = () => {
{ title: '显示顺序', dataIndex: 'roleSort', align: 'center', width: 80 },
{
title: '状态', dataIndex: 'status', align: 'center', width: 100,
render: (text: string, record: any) => (
<Switch
checked={text === '0'}
checkedChildren="正常"
unCheckedChildren="停用"
onChange={() => handleStatusChange(record)}
disabled={record.roleId === 1} // Disable for admin role
/>
render: (text: string, record: RoleRecord) => (
<Permission
permissions="system:role:edit"
fallback={
<Switch
checked={text === '0'}
checkedChildren="正常"
unCheckedChildren="停用"
disabled
/>
}
>
<Switch
checked={text === '0'}
checkedChildren="正常"
unCheckedChildren="停用"
onChange={() => handleStatusChange(record)}
disabled={record.roleId === 1}
/>
</Permission>
),
},
{ title: '创建时间', dataIndex: 'createTime', align: 'center', width: 180, render: (text: string) => parseTime(text) },
@ -494,42 +533,52 @@ const RolePage: React.FC = () => {
title: '操作', key: 'operation', align: 'center', width: 220,
render: (_, record) => (
<Space size="small">
<Button type="link" icon={<EditOutlined />} onClick={() => handleUpdate(record)} disabled={record.roleId === 1}></Button>
<Popconfirm
title="是否确认删除该角色?"
onConfirm={() => handleDelete(record)}
okText="是"
cancelText="否"
disabled={record.roleId === 1} // Disable delete for admin role
<Permission permissions="system:role:edit" fallback={<ReadonlyAction icon={<EditOutlined />}></ReadonlyAction>}>
<Button type="link" icon={<EditOutlined />} onClick={() => handleUpdate(record)} disabled={record.roleId === 1}></Button>
</Permission>
<Permission permissions="system:role:remove" fallback={<ReadonlyAction icon={<DeleteOutlined />} danger></ReadonlyAction>}>
<Popconfirm
title="是否确认删除该角色?"
onConfirm={() => handleDelete(record)}
okText="是"
cancelText="否"
disabled={record.roleId === 1}
>
<Button type="link" danger icon={<DeleteOutlined />} disabled={record.roleId === 1}></Button>
</Popconfirm>
</Permission>
<Permission
permissions="system:role:edit"
mode="or"
fallback={<ReadonlyAction icon={<DownOutlined />}></ReadonlyAction>}
>
<Button type="link" danger icon={<DeleteOutlined />} disabled={record.roleId === 1}></Button>
</Popconfirm>
<Dropdown
menu={{
items: [
{ key: 'handleDataScope', icon: <CheckCircleOutlined />, label: '数据权限' },
{ key: 'handleAuthUser', icon: <UserOutlined />, label: '分配用户' },
],
onClick: ({ key }) => handleCommand({ key }, record),
}}
trigger={['click']}
disabled={record.roleId === 1} // Disable dropdown for admin role
>
<Button type="link" icon={<DownOutlined />} disabled={record.roleId === 1}></Button>
</Dropdown>
<Dropdown
menu={{
items: [
{ key: 'handleDataScope', icon: <CheckCircleOutlined />, label: '数据权限' },
{ key: 'handleAuthUser', icon: <UserOutlined />, label: '分配用户' },
],
onClick: ({ key }) => handleCommand(String(key), record),
}}
trigger={['click']}
disabled={record.roleId === 1}
>
<Button type="link" icon={<DownOutlined />} disabled={record.roleId === 1}></Button>
</Dropdown>
</Permission>
</Space>
),
},
];
return (
<div className="app-container">
<div className="app-container role-page-container">
<Form form={queryForm} layout="inline" className="search-form" onFinish={handleQuery} initialValues={queryParams}>
<Form.Item label="角色名称" name="roleName">
<Input placeholder="请输入角色名称" allowClear onPressEnter={handleQuery} style={{ width: 240 }} />
<Input placeholder="请输入角色名称" allowClear onPressEnter={() => queryForm.submit()} style={{ width: 240 }} />
</Form.Item>
<Form.Item label="权限字符" name="roleKey">
<Input placeholder="请输入权限字符" allowClear onPressEnter={handleQuery} style={{ width: 240 }} />
<Input placeholder="请输入权限字符" allowClear onPressEnter={() => queryForm.submit()} style={{ width: 240 }} />
</Form.Item>
<Form.Item label="状态" name="status">
<Select placeholder="角色状态" allowClear style={{ width: 240 }}>
@ -553,10 +602,18 @@ const RolePage: React.FC = () => {
</Form>
<Space className="mb8">
<Button type="primary" ghost icon={<PlusOutlined />} onClick={handleAdd}></Button>
<Button type="primary" ghost icon={<EditOutlined />} disabled={selectedRowKeys.length !== 1} onClick={() => handleUpdate()}></Button>
<Button type="danger" ghost icon={<DeleteOutlined />} disabled={selectedRowKeys.length === 0} onClick={() => handleDelete()}></Button>
<Button type="warning" ghost icon={<DownloadOutlined />} onClick={handleExport}></Button>
<Permission permissions="system:role:add">
<Button type="primary" ghost icon={<PlusOutlined />} onClick={handleAdd}></Button>
</Permission>
<Permission permissions="system:role:edit">
<Button type="primary" ghost icon={<EditOutlined />} disabled={selectedRowKeys.length !== 1} onClick={() => handleUpdate()}></Button>
</Permission>
<Permission permissions="system:role:remove">
<Button danger ghost icon={<DeleteOutlined />} disabled={selectedRowKeys.length === 0} onClick={() => handleDelete()}></Button>
</Permission>
<Permission permissions="system:role:export">
<Button ghost icon={<DownloadOutlined />} onClick={handleExport}></Button>
</Permission>
</Space>
<Table
@ -583,6 +640,7 @@ const RolePage: React.FC = () => {
{/* Add/Edit Role Modal */}
<Modal
className="system-admin-modal"
title={addEditModalTitle}
open={addEditModalVisible}
onOk={roleForm.submit}
@ -631,6 +689,7 @@ const RolePage: React.FC = () => {
</Col>
</Row>
<Tree
className="system-tree-panel"
checkable
treeData={menuOptions}
fieldNames={{ title: 'label', key: 'id' }}
@ -653,6 +712,7 @@ const RolePage: React.FC = () => {
{/* Assign Data Scope Modal */}
<Modal
className="system-admin-modal"
title={dataScopeModalTitle}
open={dataScopeModalVisible}
onOk={dataScopeForm.submit}
@ -690,6 +750,7 @@ const RolePage: React.FC = () => {
</Col>
</Row>
<Tree
className="system-tree-panel"
checkable
treeData={deptOptions}
fieldNames={{ title: 'label', key: 'id' }}

View File

@ -32,6 +32,8 @@ import {
changeUserStatus,
} from '../../api/system/user';
import dayjs from 'dayjs';
import Permission from '@/components/Permission';
import ReadonlyAction from '@/components/Permission/ReadonlyAction';
interface UserDept {
deptName?: string;
@ -316,12 +318,24 @@ const UserPage = () => {
dataIndex: 'status',
align: 'center',
render: (_value, record) => (
<Switch
checked={String(record.status ?? '') === '0'}
checkedChildren="正常"
unCheckedChildren="停用"
onChange={() => handleStatusChange(record)}
/>
<Permission
permissions="system:user:edit"
fallback={
<Switch
checked={String(record.status ?? '') === '0'}
checkedChildren="正常"
unCheckedChildren="停用"
disabled
/>
}
>
<Switch
checked={String(record.status ?? '') === '0'}
checkedChildren="正常"
unCheckedChildren="停用"
onChange={() => handleStatusChange(record)}
/>
</Permission>
),
},
{
@ -338,22 +352,28 @@ const UserPage = () => {
width: 280,
render: (_value, record) => (
<Space size="small">
<Button type="link" icon={<EditOutlined />} onClick={() => void handleUpdate(record)}>
</Button>
<Button type="link" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record)}>
</Button>
<Button type="link" icon={<KeyOutlined />} onClick={() => handleResetPwd(record)}>
</Button>
<Permission permissions="system:user:edit" fallback={<ReadonlyAction icon={<EditOutlined />}></ReadonlyAction>}>
<Button type="link" icon={<EditOutlined />} onClick={() => void handleUpdate(record)}>
</Button>
</Permission>
<Permission permissions="system:user:remove" fallback={<ReadonlyAction icon={<DeleteOutlined />} danger></ReadonlyAction>}>
<Button type="link" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record)}>
</Button>
</Permission>
<Permission permissions="system:user:resetPwd" fallback={<ReadonlyAction icon={<KeyOutlined />}></ReadonlyAction>}>
<Button type="link" icon={<KeyOutlined />} onClick={() => handleResetPwd(record)}>
</Button>
</Permission>
</Space>
),
},
];
return (
<div className="app-container">
<div className="app-container user-page-container">
<Form form={queryForm} layout="inline" className="search-form" onFinish={handleQuery} initialValues={queryParams}>
<Form.Item label="用户名称" name="userName">
<Input placeholder="请输入用户名称" allowClear onPressEnter={handleQuery} />
@ -381,15 +401,21 @@ const UserPage = () => {
</Form>
<Space className="mb8">
<Button type="primary" ghost icon={<PlusOutlined />} onClick={handleAdd}>
</Button>
<Button type="primary" ghost icon={<EditOutlined />} disabled={selectedRowKeys.length !== 1} onClick={() => void handleUpdate()}>
</Button>
<Button type="primary" danger ghost icon={<DeleteOutlined />} disabled={selectedRowKeys.length === 0} onClick={() => handleDelete()}>
</Button>
<Permission permissions="system:user:add">
<Button type="primary" ghost icon={<PlusOutlined />} onClick={handleAdd}>
</Button>
</Permission>
<Permission permissions="system:user:edit">
<Button type="primary" ghost icon={<EditOutlined />} disabled={selectedRowKeys.length !== 1} onClick={() => void handleUpdate()}>
</Button>
</Permission>
<Permission permissions="system:user:remove">
<Button type="primary" danger ghost icon={<DeleteOutlined />} disabled={selectedRowKeys.length === 0} onClick={() => handleDelete()}>
</Button>
</Permission>
</Space>
<Table
@ -415,6 +441,7 @@ const UserPage = () => {
/>
<Modal
className="system-admin-modal"
title={modalTitle}
open={modalVisible}
onOk={submitForm}

View File

@ -1,7 +1 @@
.config-page-container .search-form .ant-form-item {
margin-bottom: 0px;
}
.config-page-container .mb8 {
margin-bottom: 8px;
}
@import "./system-admin.css";

View File

@ -1,10 +1,4 @@
.dept-page-container .search-form .ant-form-item {
margin-bottom: 0px;
}
.dept-page-container .mb8 {
margin-bottom: 8px;
}
@import "./system-admin.css";
.dept-page-container .ant-table-row-expand-icon {
margin-right: 8px;

View File

@ -1,14 +1,9 @@
.dict-page-container .search-form .ant-form-item {
margin-bottom: 0px;
}
.dict-page-container .mb8 {
margin-bottom: 8px;
}
@import "./system-admin.css";
.dict-page-container .link-type {
color: #1890ff;
color: #4f46e5;
text-decoration: none;
font-weight: 700;
}
.dict-page-container .link-type:hover {

View File

@ -1,22 +1,23 @@
.menu-page-container {
display: flex;
flex-direction: column;
gap: 12px;
gap: 14px;
padding: 4px 2px 8px;
background:
radial-gradient(circle at top left, rgba(64, 158, 255, 0.06), transparent 22%),
linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
radial-gradient(circle at top left, rgba(21, 128, 61, 0.08), transparent 24%),
radial-gradient(circle at right top, rgba(14, 116, 144, 0.08), transparent 18%),
linear-gradient(180deg, #f7faf8 0%, #f3f6f4 100%);
}
.menu-page-container .search-form {
display: flex;
flex-wrap: wrap;
gap: 8px 0;
padding: 16px 18px 4px;
background: rgba(255, 255, 255, 0.88);
border: 1px solid #e5eaf3;
padding: 14px 16px 2px;
background: rgba(255, 255, 255, 0.82);
border: 1px solid #e4ebe7;
border-radius: 14px;
box-shadow: 0 10px 26px rgba(15, 23, 42, 0.05);
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
}
.menu-page-container .ant-form-item {
@ -32,7 +33,7 @@
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 4px;
padding: 0 4px 2px;
}
.table-toolbar .ant-btn {
@ -40,20 +41,139 @@
box-shadow: none;
}
.menu-toolbar-tip {
color: #64748b;
font-size: 13px;
}
.menu-table-wrap {
background: #fff;
border: 1px solid #e5eaf3;
border: 1px solid #e4ebe7;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 32px rgba(15, 23, 42, 0.06);
box-shadow: 0 14px 32px rgba(15, 23, 42, 0.05);
}
.menu-split-layout {
display: grid;
grid-template-columns: minmax(320px, 1.05fr) minmax(420px, 1.35fr);
gap: 16px;
min-height: calc(100vh - 330px);
}
.menu-tree-card,
.menu-button-card {
border-radius: 16px;
border: 1px solid #e4ebe7;
box-shadow: 0 14px 32px rgba(15, 23, 42, 0.05);
}
.menu-tree-card .ant-card-head,
.menu-button-card .ant-card-head {
min-height: 58px;
border-bottom: 1px solid #edf2ee;
}
.menu-tree-card .ant-card-body,
.menu-button-card .ant-card-body {
padding: 14px 16px 16px;
}
.menu-tree-card .ant-tree {
background: transparent;
}
.menu-tree-card .ant-tree-list-holder-inner {
gap: 4px;
}
.menu-tree-card .ant-tree-treenode {
width: 100%;
padding: 2px 0;
}
.menu-tree-card .ant-tree-switcher {
display: none;
}
.menu-tree-card .ant-tree-node-content-wrapper {
width: 100%;
min-height: 44px;
border-radius: 12px;
transition: background-color 0.2s ease, box-shadow 0.2s ease;
}
.menu-tree-card .ant-tree-node-content-wrapper:hover {
background: #f5faf7;
}
.menu-tree-card .ant-tree-node-selected {
background: #eef8f1 !important;
box-shadow: inset 0 0 0 1px #d7eadc;
}
.menu-tree-node {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
width: 100%;
padding: 8px 10px 8px 2px;
border-radius: 10px;
}
.menu-tree-node--active {
background: #eef8f1;
}
.menu-tree-node-main {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.menu-tree-node-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 14px;
color: #7a8699;
font-size: 12px;
flex: none;
}
.menu-tree-node-title {
min-width: 0;
color: #172033;
font-weight: 600;
}
.menu-tree-node-badge {
color: #0f766e;
background: #eef8f6;
}
.menu-button-summary {
display: flex;
flex-wrap: wrap;
gap: 8px 14px;
padding: 0 0 14px;
color: #64748b;
font-size: 13px;
}
.menu-table-wrap .ant-table {
background: #fff;
}
.menu-table-wrap .ant-table-thead > tr > th {
background: linear-gradient(180deg, #fbfcfe 0%, #f3f6fb 100%);
.menu-button-card .ant-table {
background: #fff;
}
.menu-table-wrap .ant-table-thead > tr > th,
.menu-button-card .ant-table-thead > tr > th {
background: linear-gradient(180deg, #f9fcfa 0%, #f1f6f3 100%);
color: #334155;
font-weight: 700;
border-bottom: 1px solid #ebeef5;
@ -61,21 +181,43 @@
padding-bottom: 14px;
}
.menu-table-wrap .ant-table-tbody > tr > td {
.menu-table-wrap .ant-table-tbody > tr > td,
.menu-button-card .ant-table-tbody > tr > td {
vertical-align: middle;
border-bottom: 1px solid #ebeef5;
transition: background-color 0.2s ease;
}
.menu-table-wrap .ant-table-tbody > tr:hover > td {
background: #f8fbff !important;
.menu-table-wrap .ant-table-tbody > tr:hover > td,
.menu-button-card .ant-table-tbody > tr:hover > td {
background: #f7fbf8 !important;
}
.menu-name-cell {
display: flex;
align-items: flex-start;
gap: 12px;
min-width: 0;
}
.menu-icon-chip {
display: inline-flex;
margin-top: 1px;
}
.menu-name-main {
min-width: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.menu-name-topline {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
flex-wrap: wrap;
}
.menu-name-text {
@ -87,6 +229,14 @@
font-weight: 600;
}
.menu-meta-line {
display: flex;
flex-wrap: wrap;
gap: 6px 12px;
color: #64748b;
font-size: 13px;
}
.menu-icon-tag {
margin-inline-end: 0;
color: #64748b;
@ -108,9 +258,9 @@
width: 30px;
height: 30px;
border-radius: 9px;
background: linear-gradient(180deg, #f8fbff 0%, #edf4ff 100%);
border: 1px solid #dbe7fb;
color: #3b82f6;
background: linear-gradient(180deg, #f9fcfa 0%, #edf7f1 100%);
border: 1px solid #dcebdd;
color: #15803d;
font-size: 14px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9);
}
@ -143,8 +293,8 @@
}
.menu-expand-trigger:hover {
background: #eef5ff;
color: #409eff;
background: #edf7f1;
color: #15803d;
}
.menu-expand-spacer {
@ -213,4 +363,8 @@
.menu-page-container .search-form {
padding: 14px 14px 2px;
}
.menu-split-layout {
grid-template-columns: 1fr;
min-height: auto;
}
}

View File

@ -1,10 +1,4 @@
.role-page-container .search-form .ant-form-item {
margin-bottom: 0px;
}
.role-page-container .mb8 {
margin-bottom: 8px;
}
@import "./system-admin.css";
.role-page-container .ant-tree-checkbox-inner {
margin-right: 8px;
@ -15,9 +9,5 @@
}
.role-page-container .tree-border {
border: 1px solid #d9d9d9;
border-radius: 4px;
padding: 8px;
min-height: 200px;
overflow-y: auto;
min-height: 220px;
}

View File

@ -0,0 +1,132 @@
.user-page-container,
.role-page-container,
.dept-page-container,
.config-page-container,
.dict-page-container {
gap: 18px;
}
.user-page-container .search-form .ant-form-item,
.role-page-container .search-form .ant-form-item,
.dept-page-container .search-form .ant-form-item,
.config-page-container .search-form .ant-form-item,
.dict-page-container .search-form .ant-form-item {
margin-bottom: 14px;
}
.user-page-container .mb8,
.role-page-container .mb8,
.dept-page-container .mb8,
.config-page-container .mb8,
.dict-page-container .mb8 {
margin-bottom: 2px;
}
.user-page-container .ant-card-head,
.role-page-container .ant-card-head,
.dept-page-container .ant-card-head,
.config-page-container .ant-card-head,
.dict-page-container .ant-card-head {
padding: 0 18px;
min-height: 54px;
}
.user-page-container .ant-card-body,
.role-page-container .ant-card-body,
.dept-page-container .ant-card-body,
.config-page-container .ant-card-body,
.dict-page-container .ant-card-body {
padding: 18px;
}
.user-page-container .ant-table-wrapper .ant-btn-link,
.role-page-container .ant-table-wrapper .ant-btn-link,
.dept-page-container .ant-table-wrapper .ant-btn-link,
.config-page-container .ant-table-wrapper .ant-btn-link,
.dict-page-container .ant-table-wrapper .ant-btn-link {
padding-inline: 6px;
font-weight: 700;
}
.user-page-container .ant-select-tree,
.dept-page-container .ant-select-tree {
max-height: 260px;
overflow: auto;
}
.role-page-container .ant-tree-checkbox-inner {
margin-right: 8px;
}
.role-page-container .ant-tree-checkbox + span {
width: calc(100% - 24px);
}
.tree-border,
.system-tree-panel {
min-height: 220px;
padding: 12px;
border: 1px solid rgba(219, 228, 243, 0.84);
border-radius: 18px;
background: rgba(247, 249, 255, 0.82);
overflow-y: auto;
}
.system-admin-modal .ant-modal-content {
border-radius: 24px;
}
.system-admin-modal .ant-modal-header {
padding-bottom: 14px;
}
.system-admin-modal .ant-modal-title {
color: #162033;
font-weight: 800;
}
.system-admin-modal .ant-modal-body {
padding-top: 8px;
}
.system-admin-modal .ant-form-item-label > label {
color: #5f6f92;
font-weight: 700;
}
.system-admin-modal .ant-radio-group {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.system-admin-modal .ant-radio-wrapper {
margin-inline-end: 0;
padding: 8px 12px;
border: 1px solid rgba(219, 228, 243, 0.84);
border-radius: 14px;
background: rgba(247, 249, 255, 0.7);
}
.system-admin-modal .ant-tree,
.system-admin-modal .ant-select-tree {
color: #34425f;
}
.system-admin-modal .ant-tree .ant-tree-node-content-wrapper,
.system-admin-modal .ant-select-tree .ant-select-tree-node-content-wrapper {
min-height: 34px;
border-radius: 10px;
transition: background-color 180ms ease, color 180ms ease;
}
.system-admin-modal .ant-tree .ant-tree-node-content-wrapper:hover,
.system-admin-modal .ant-select-tree .ant-select-tree-node-content-wrapper:hover {
background: rgba(99, 91, 255, 0.08);
}
.system-admin-modal .ant-tree .ant-tree-node-selected,
.system-admin-modal .ant-select-tree .ant-select-tree-node-selected {
background: rgba(99, 91, 255, 0.12) !important;
color: #4f46e5;
}

View File

@ -1,21 +1,14 @@
.user-page-container .search-form .ant-form-item {
margin-bottom: 0px;
}
.user-page-container .mb8 {
margin-bottom: 8px;
}
@import "./system-admin.css";
.user-page-container .ant-card-head {
padding: 0 16px;
min-height: 48px;
padding: 0 18px;
min-height: 54px;
}
.user-page-container .ant-card-body {
padding: 16px;
padding: 18px;
}
/* Adjust Ant Design TreeSelect for deptOptions */
.user-page-container .ant-select-tree {
max-height: 250px;
overflow: auto;

View File

@ -14,6 +14,8 @@ import type { ColumnsType } from 'antd/es/table';
import { ReloadOutlined, SearchOutlined, UserOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { delTaskModule, getTaskModel } from '@/api/appraisal';
import Permission from '@/components/Permission';
import { usePermission } from '@/contexts/PermissionContext';
import { listUser } from '@/api/system/user';
import { parseTime } from '@/utils/ruoyi';
import './appraisal-dashboard.css';
@ -128,6 +130,7 @@ const getUserDisplayName = (user?: UserRow | null) =>
const AppraisalDashboardPage = () => {
const navigate = useNavigate();
const { canAccessPath } = usePermission();
const [queryForm] = Form.useForm<QueryFormValues>();
const [userQueryForm] = Form.useForm<{ userName?: string }>();
@ -151,6 +154,8 @@ const AppraisalDashboardPage = () => {
const [userPageSize, setUserPageSize] = useState(10);
const [userKeyword, setUserKeyword] = useState('');
const [selectedCreator, setSelectedCreator] = useState<UserRow | null>(null);
const canRemoveBoard = canAccessPath('/workAppraisal/taskModule');
const canViewBoardDetail = canAccessPath('/workAppraisal/moduleDetail');
const selectedCreatorRowKeys = useMemo(
() => (selectedCreator ? [selectedCreator._rowKey] : []),
@ -277,6 +282,9 @@ const AppraisalDashboardPage = () => {
};
const handleDeleteBoard = async (row: BoardRow) => {
if (!canRemoveBoard) {
return;
}
const id = row.id;
if (id === undefined || id === null) {
message.warning('看板ID缺失无法删除');
@ -294,6 +302,9 @@ const AppraisalDashboardPage = () => {
};
const handleViewDetail = (row: BoardRow) => {
if (!canViewBoardDetail) {
return;
}
const id = row.id;
if (id === undefined || id === null) {
message.warning('看板ID缺失无法查看详情');
@ -347,19 +358,23 @@ const AppraisalDashboardPage = () => {
align: 'center',
render: (_, record) => (
<Space size={4} className="dashboard-ops">
<Button type="link" onClick={() => handleViewDetail(record)}>
</Button>
<Popconfirm
title="是否确认删除该条看板"
okText="确定"
cancelText="取消"
onConfirm={() => handleDeleteBoard(record)}
>
<Button type="link" danger>
{canViewBoardDetail && (
<Button type="link" onClick={() => handleViewDetail(record)}>
</Button>
</Popconfirm>
)}
<Permission permissions={canRemoveBoard ? undefined : '__forbidden__'}>
<Popconfirm
title="是否确认删除该条看板"
okText="确定"
cancelText="取消"
onConfirm={() => handleDeleteBoard(record)}
>
<Button type="link" danger>
</Button>
</Popconfirm>
</Permission>
</Space>
),
},

View File

@ -15,6 +15,8 @@ import { UserOutlined } from '@ant-design/icons';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { getTaskScoreDetail, saveTaskUserScore } from '@/api/appraisal';
import PageBackButton from '@/components/PageBackButton';
import { usePermission } from '@/contexts/PermissionContext';
import '@/styles/permission-link.css';
import './appraisal-detail.css';
interface DetailItem {
@ -178,6 +180,7 @@ const ScoreBar = ({ value, editable, onChange }: ScoreBarProps) => {
const AppraisalDetailPage = () => {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { canAccessPath } = usePermission();
const isNormal = toBoolean(searchParams.get('isNormal'));
const [isEdit, setIsEdit] = useState(toBoolean(searchParams.get('edit')));
@ -197,6 +200,8 @@ const AppraisalDetailPage = () => {
const [remarkModalOpen, setRemarkModalOpen] = useState(false);
const [editingCell, setEditingCell] = useState<{ groupIndex: number; rowIndex: number } | null>(null);
const [remarkDraft, setRemarkDraft] = useState('');
const canEditScore = canAccessPath('/workAppraisal/detail');
const effectiveIsEdit = isEdit && canEditScore;
const templateType = useMemo(() => String(examineTask.templateType ?? ''), [examineTask.templateType]);
const isTemplateZero = templateType === '0';
@ -447,6 +452,9 @@ const AppraisalDetailPage = () => {
};
const submitScore = async (submitStatus: 0 | 1) => {
if (!effectiveIsEdit) {
return;
}
if (!validateSubmit(submitStatus)) {
return;
}
@ -509,7 +517,7 @@ const AppraisalDetailPage = () => {
render: (_, row, rowIndex) => (
<ScoreBar
value={toNumber(row.score, 0)}
editable={isEdit}
editable={effectiveIsEdit}
onChange={(nextScore) => updateItemScore(groupIndex, rowIndex, nextScore)}
/>
),
@ -522,9 +530,15 @@ const AppraisalDetailPage = () => {
dataIndex: 'remark',
width: 140,
render: (_, row, rowIndex) => (
<Button type="link" onClick={() => openRemarkModal(groupIndex, rowIndex)}>
{String(row.remark ?? '').trim() ? '查看' : '暂未评价'}
</Button>
effectiveIsEdit ? (
<Button type="link" onClick={() => openRemarkModal(groupIndex, rowIndex)}>
{String(row.remark ?? '').trim() ? '查看' : '暂未评价'}
</Button>
) : (
<span className="permission-link-disabled">
{String(row.remark ?? '').trim() ? '查看' : '暂未评价'}
</span>
)
),
});
}
@ -548,7 +562,7 @@ const AppraisalDetailPage = () => {
<span className="block" />
<span>{examineTask.taskName ?? '绩效考核详情'}</span>
</div>
{isEdit && (
{effectiveIsEdit && (
<Space size={20}>
<Button style={{ width: 90 }} onClick={() => submitScore(0)} loading={submitting}>
@ -597,7 +611,7 @@ const AppraisalDetailPage = () => {
showCount
value={group.remarkCate}
onChange={(event) => updateGroupRemark(groupIndex, event.target.value)}
readOnly={!isEdit}
readOnly={!effectiveIsEdit}
placeholder="0/300"
/>
</div>
@ -615,7 +629,7 @@ const AppraisalDetailPage = () => {
showCount
value={judgeContent}
onChange={(event) => setJudgeContent(event.target.value)}
readOnly={!isEdit}
readOnly={!effectiveIsEdit}
placeholder="0/300"
/>
</div>
@ -627,11 +641,11 @@ const AppraisalDetailPage = () => {
<Modal
title="自评总结"
open={remarkModalOpen}
onOk={isEdit ? saveRemarkModal : () => setRemarkModalOpen(false)}
onOk={effectiveIsEdit ? saveRemarkModal : () => setRemarkModalOpen(false)}
onCancel={() => setRemarkModalOpen(false)}
okButtonProps={{ style: { display: isEdit ? 'inline-flex' : 'none' } }}
okButtonProps={{ style: { display: effectiveIsEdit ? 'inline-flex' : 'none' } }}
okText="确定"
cancelText={isEdit ? '取消' : '关闭'}
cancelText={effectiveIsEdit ? '取消' : '关闭'}
>
<Input.TextArea
autoSize={{ minRows: 4 }}
@ -639,7 +653,7 @@ const AppraisalDetailPage = () => {
showCount
value={remarkDraft}
onChange={(event) => setRemarkDraft(event.target.value)}
readOnly={!isEdit}
readOnly={!effectiveIsEdit}
placeholder="0/200"
/>
</Modal>

View File

@ -2,18 +2,22 @@ import React, { useState, useEffect, useCallback } from 'react';
import { Card, Row, Col, message, Tabs, Tag, Empty } from 'antd';
import { getTaskListSelf } from '../../api/appraisal';
import { useNavigate } from 'react-router-dom';
import { usePermission } from '@/contexts/PermissionContext';
import '@/styles/permission-link.css';
import './appraisal.css'; // Assuming a shared CSS for appraisal pages
const { TabPane } = Tabs;
const AppraisalManagerPage: React.FC = () => {
const { canAccessPath } = usePermission();
const [activeTab, setActiveTab] = useState('进行中');
const [taskList, setTaskList] = useState<{ [key: string]: any[] }>({});
const navigate = useNavigate();
const canEditScore = canAccessPath('/workAppraisal/detail');
const getTasks = useCallback(async () => {
try {
const response = await getTaskListSelf();
const response = await getTaskListSelf() as { data?: { [key: string]: any[] } };
setTaskList(response.data || {});
} catch (error) {
console.error('Failed to fetch task list:', error);
@ -26,6 +30,9 @@ const AppraisalManagerPage: React.FC = () => {
}, [getTasks]);
const viewDetails = (task: any, isEdit: number) => {
if (isEdit === 1 && !canEditScore) {
return;
}
if (!task.taskEditFlag) {
message.warning('分数正在计算中,请等待计算完成');
return;
@ -50,10 +57,17 @@ const AppraisalManagerPage: React.FC = () => {
<div className="task-deadline">{task.endTime.split(" ")[0]}</div>
<div className="card-footer">
<div className="people-count">{task.peopleNumber}</div>
<div className={`action-button ${!task.taskEditFlag ? 'disabled' : ''}`} onClick={() => viewDetails(task, isEdit)}>
{isEdit ? '考核评分' : '查看详情'}
<img src="/src/assets/task/right.png" alt=">" style={{ width: 16, height: 16, marginLeft: 8 }} />
</div>
{task.taskEditFlag && (isEdit === 0 || canEditScore) ? (
<div className="action-button" onClick={() => viewDetails(task, isEdit)}>
{isEdit ? '考核评分' : '查看详情'}
<img src="/src/assets/task/right.png" alt=">" style={{ width: 16, height: 16, marginLeft: 8 }} />
</div>
) : (
<div className="action-button disabled permission-link-disabled">
{isEdit ? '考核评分' : '查看详情'}
<img src="/src/assets/task/right.png" alt=">" style={{ width: 16, height: 16, marginLeft: 8 }} />
</div>
)}
</div>
</Card>
</Col>

View File

@ -4,9 +4,13 @@ import type { TableColumnsType } from 'antd';
import { SearchOutlined, ReloadOutlined } from '@ant-design/icons';
import { getTaskUserList } from '../../api/appraisal';
import { useLocation, useNavigate } from 'react-router-dom';
import Permission from '@/components/Permission';
import { usePermission } from '@/contexts/PermissionContext';
import ReadonlyAction from '@/components/Permission/ReadonlyAction';
const AppraisalManagerUserPage: React.FC = () => {
const [queryForm] = Form.useForm();
const { canAccessPath } = usePermission();
const [loading, setLoading] = useState(false);
const [userList, setUserList] = useState<any[]>([]);
const [total, setTotal] = useState(0);
@ -16,6 +20,7 @@ const AppraisalManagerUserPage: React.FC = () => {
const queryParamsFromUrl = new URLSearchParams(location.search);
const taskId = queryParamsFromUrl.get('taskId');
const isEdit = queryParamsFromUrl.get('isEdit');
const canEditScore = canAccessPath('/workAppraisal/detail');
const [queryParams, setQueryParams] = useState({
pageNum: 1,
@ -36,9 +41,9 @@ const AppraisalManagerUserPage: React.FC = () => {
const getList = useCallback(async () => {
setLoading(true);
try {
const response = await getTaskUserList(queryParams);
setUserList(response.rows);
setTotal(response.total);
const response = await getTaskUserList(queryParams) as { rows?: any[]; total?: number };
setUserList(response.rows ?? []);
setTotal(response.total ?? 0);
} catch (error) {
console.error('Failed to fetch task user list:', error);
message.error('获取考核用户列表失败');
@ -68,10 +73,13 @@ const AppraisalManagerUserPage: React.FC = () => {
};
const handleEdit = (row: any, edit: number) => {
if (edit === 1 && !canEditScore) {
return;
}
navigate(`/workAppraisal/detail?examineTaskId=${taskId}&examineId=${row.id}&reviewType=0&edit=${edit}`);
};
const handleTableChange = (pagination: any, filters: any, sorter: any) => {
const handleTableChange = (_pagination: any, _filters: any, sorter: any) => {
if (sorter.field === 'manageScore') {
setQueryParams(prev => ({
...prev,
@ -92,7 +100,12 @@ const AppraisalManagerUserPage: React.FC = () => {
render: (_, record) => (
<Space size="small">
{isEdit === '1' && record.examineStatus === '0' ? (
<Button type="link" onClick={() => handleEdit(record, 1)}></Button>
<Permission
permissions={canEditScore ? 'examine:manager:list' : '__forbidden__'}
fallback={<ReadonlyAction></ReadonlyAction>}
>
<Button type="link" onClick={() => handleEdit(record, 1)}></Button>
</Permission>
) : null}
{record.examineStatus === '1' || isEdit === '0' ? (
<Button type="link" onClick={() => handleEdit(record, 0)}></Button>

View File

@ -279,10 +279,22 @@ const AppraisalModuleDetailPage = () => {
return (
<div className="app-container appraisal-module-detail-page">
<div style={{ marginBottom: 12 }}>
<div className="appraisal-module-back">
<PageBackButton fallbackPath="/workAppraisal/taskModule" />
</div>
<div className="appraisal-module-hero">
<div>
<div className="appraisal-module-kicker">PERFORMANCE MODULE</div>
<div className="appraisal-module-title"></div>
<div className="appraisal-module-subtitle"></div>
</div>
<div className="appraisal-module-summary">
<span></span>
<strong>{TEMPLATE_TYPE_OPTIONS.find((item) => item.value === moduleType)?.label ?? '-'}</strong>
</div>
</div>
<div className="module-detail-header">
<Form layout="inline">
<Form.Item label="看板名称">

View File

@ -4,6 +4,8 @@ import { CarryOutOutlined, RightOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import { useNavigate } from 'react-router-dom';
import { getTaskListSelf } from '@/api/appraisal';
import { usePermission } from '@/contexts/PermissionContext';
import '@/styles/permission-link.css';
import './manager.css';
type TaskStatusKey = '0' | '2';
@ -97,9 +99,11 @@ const isTaskEditable = (value: unknown) => {
const ManagerPage = () => {
const navigate = useNavigate();
const { canAccessPath } = usePermission();
const [activeTab, setActiveTab] = useState<TaskStatusKey>('0');
const [loading, setLoading] = useState(false);
const [taskBuckets, setTaskBuckets] = useState<TaskBuckets>(EMPTY_BUCKETS);
const canEditScore = canAccessPath('/workAppraisal/detail');
const fetchTaskList = useCallback(async () => {
setLoading(true);
@ -128,6 +132,9 @@ const ManagerPage = () => {
);
const handleView = (task: TaskItem, status: TaskStatusKey) => {
if (status === '0' && !canEditScore) {
return;
}
if (!isTaskEditable(task.taskEditFlag)) {
message.warning('分数正在计算中,请等待计算完成');
return;
@ -170,14 +177,21 @@ const ManagerPage = () => {
<div className="appraisal-task-meta">{formatEndDate(task.endTime)}</div>
<div className="appraisal-task-foot">
<span className="appraisal-task-people">{task.peopleNumber ?? 0}</span>
<Button
type="link"
className={`appraisal-task-action ${editable ? '' : 'is-disabled'}`}
onClick={() => handleView(task, status)}
>
{tabMap[status].actionText}
<RightOutlined />
</Button>
{editable && (status === '2' || canEditScore) ? (
<Button
type="link"
className="appraisal-task-action"
onClick={() => handleView(task, status)}
>
{tabMap[status].actionText}
<RightOutlined />
</Button>
) : (
<span className="appraisal-task-action is-disabled permission-link-disabled">
{tabMap[status].actionText}
<RightOutlined />
</span>
)}
</div>
</div>
);

View File

@ -13,6 +13,9 @@ import type { TableColumnsType } from 'antd';
import { ArrowLeftOutlined } from '@ant-design/icons';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { getTaskUserList } from '@/api/appraisal';
import Permission from '@/components/Permission';
import { usePermission } from '@/contexts/PermissionContext';
import ReadonlyAction from '@/components/Permission/ReadonlyAction';
interface UserScoreRow {
_rowKey?: string;
@ -73,6 +76,7 @@ const attachRowKey = (rows: UserScoreRow[], taskId: string, pageNum: number) =>
const ManagerUserPage = () => {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { canAccessPath } = usePermission();
const taskId = searchParams.get('taskId');
const isEdit = searchParams.get('isEdit') === '1';
@ -81,6 +85,7 @@ const ManagerUserPage = () => {
const [total, setTotal] = useState(0);
const [pageNum, setPageNum] = useState(1);
const [pageSize, setPageSize] = useState(10);
const canEditScore = canAccessPath('/workAppraisal/detail');
const loadList = useCallback(async () => {
if (!taskId) {
@ -107,6 +112,9 @@ const ManagerUserPage = () => {
}, [loadList]);
const navigateToDetail = (record: UserScoreRow, edit: 0 | 1) => {
if (edit === 1 && !canEditScore) {
return;
}
if (!taskId) {
message.warning('缺少 taskId 参数,无法打开详情');
return;
@ -154,9 +162,14 @@ const ManagerUserPage = () => {
render: (_, record) => (
<Space size={4}>
{isEdit && String(record.examineStatus ?? '') === '0' && (
<Button type="link" onClick={() => navigateToDetail(record, 1)}>
</Button>
<Permission
permissions={canEditScore ? 'examine:manager:list' : '__forbidden__'}
fallback={<ReadonlyAction></ReadonlyAction>}
>
<Button type="link" onClick={() => navigateToDetail(record, 1)}>
</Button>
</Permission>
)}
{(String(record.examineStatus ?? '') === '1' || !isEdit) && (
<Button type="link" onClick={() => navigateToDetail(record, 0)}>

View File

@ -4,6 +4,8 @@ import { CarryOutOutlined, RightOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import { useNavigate } from 'react-router-dom';
import { getTaskListSelfNormal } from '@/api/appraisal';
import { usePermission } from '@/contexts/PermissionContext';
import '@/styles/permission-link.css';
import './manager.css';
type TaskStatusKey = '0' | '2';
@ -97,9 +99,11 @@ const isSelfSubmitted = (value: unknown) => String(value ?? '') === '1';
const NormalWorkerPage = () => {
const navigate = useNavigate();
const { canAccessPath } = usePermission();
const [activeTab, setActiveTab] = useState<TaskStatusKey>('0');
const [loading, setLoading] = useState(false);
const [taskBuckets, setTaskBuckets] = useState<TaskBuckets>(EMPTY_BUCKETS);
const canEditScore = canAccessPath('/workAppraisal/detail');
const fetchTaskList = useCallback(async () => {
setLoading(true);
@ -128,6 +132,9 @@ const NormalWorkerPage = () => {
);
const goDetail = (task: TaskItem, edit: '0' | '1') => {
if (edit === '1' && !canEditScore) {
return;
}
if (!isTaskEditable(task.taskEditFlag)) {
message.warning('分数正在计算中,请等待计算完成');
return;
@ -151,29 +158,41 @@ const NormalWorkerPage = () => {
const submitted = isSelfSubmitted(task.examineStatusSelf);
const actionText = submitted ? '查看详情' : '考核评分';
const editFlag: '0' | '1' = submitted ? '0' : '1';
const canUseAction = isTaskEditable(task.taskEditFlag) && (submitted || canEditScore);
if (!canUseAction) {
return (
<span className="appraisal-task-action is-disabled permission-link-disabled">
{actionText}
<RightOutlined />
</span>
);
}
return (
<Button
type="link"
className={`appraisal-task-action ${isTaskEditable(task.taskEditFlag) ? '' : 'is-disabled'}`}
onClick={() => goDetail(task, editFlag)}
>
<Button type="link" className="appraisal-task-action" onClick={() => goDetail(task, editFlag)}>
{actionText}
<RightOutlined />
</Button>
);
};
const renderExpiredAction = (task: TaskItem) => (
<Button
type="link"
className={`appraisal-task-action ${isTaskEditable(task.taskEditFlag) ? '' : 'is-disabled'}`}
onClick={() => goDetail(task, '0')}
>
<RightOutlined />
</Button>
);
const renderExpiredAction = (task: TaskItem) =>
isTaskEditable(task.taskEditFlag) ? (
<Button
type="link"
className="appraisal-task-action"
onClick={() => goDetail(task, '0')}
>
<RightOutlined />
</Button>
) : (
<span className="appraisal-task-action is-disabled permission-link-disabled">
<RightOutlined />
</span>
);
const renderTaskList = (status: TaskStatusKey) => {
const list = taskBuckets[status] ?? [];

View File

@ -31,6 +31,9 @@ import {
} from '@ant-design/icons';
import { addTask, delTask, getTaskList, getTaskSet, setTaskSet, updateTask } from '@/api/appraisal';
import { deptTreeSelect, listUser } from '@/api/system/user';
import Permission from '@/components/Permission';
import { usePermission } from '@/contexts/PermissionContext';
import ReadonlyAction from '@/components/Permission/ReadonlyAction';
import { parseTime } from '@/utils/ruoyi';
import './taskSet.css';
@ -345,6 +348,7 @@ const WeightSlider = ({ value, onChange }: WeightSliderProps) => {
};
const TaskSetPage = () => {
const { canAccessPath } = usePermission();
const [queryForm] = Form.useForm<QueryFormValues>();
const [taskForm] = Form.useForm<TaskFormValues>();
const [userQueryForm] = Form.useForm<{ userName?: string }>();
@ -387,6 +391,10 @@ const TaskSetPage = () => {
const [scoreList, setScoreList] = useState<ScoreGroup[]>([]);
const [activeCollapseKey, setActiveCollapseKey] = useState('');
const [selectedSubItemKey, setSelectedSubItemKey] = useState('');
const canAddTask = canAccessPath('/workAppraisal/taskSet');
const canEditTask = canAccessPath('/workAppraisal/taskSet');
const canRemoveTask = canAccessPath('/workAppraisal/taskSet');
const canConfigTask = canAccessPath('/workAppraisal/taskSet');
const disabledPastDate = useCallback((current: Dayjs) => current.endOf('day').isBefore(dayjs().startOf('day')), []);
@ -562,6 +570,9 @@ const TaskSetPage = () => {
};
const handleAddTask = () => {
if (!canAddTask) {
return;
}
setTaskModalTitle('新增考核任务');
setCurrentTask(null);
setSelectedTaskUsers([]);
@ -570,6 +581,9 @@ const TaskSetPage = () => {
};
const handleEditTask = (row: TaskRow) => {
if (!canEditTask) {
return;
}
setTaskModalTitle('编辑考核任务');
setCurrentTask(row);
@ -587,6 +601,9 @@ const TaskSetPage = () => {
};
const handleDeleteTask = async (row: TaskRow) => {
if (!canRemoveTask) {
return;
}
if (row.id === undefined || row.id === null) {
message.warning('任务ID缺失无法删除');
return;
@ -603,6 +620,9 @@ const TaskSetPage = () => {
};
const handleSaveTask = async () => {
if ((currentTask && !canEditTask) || (!currentTask && !canAddTask)) {
return;
}
let values: TaskFormValues;
try {
values = await taskForm.validateFields();
@ -659,6 +679,9 @@ const TaskSetPage = () => {
};
const handleConfigTask = async (row: TaskRow) => {
if (!canConfigTask) {
return;
}
if (row.id === undefined || row.id === null) {
message.warning('任务ID缺失无法配置指标');
return;
@ -737,6 +760,9 @@ const TaskSetPage = () => {
};
const handleSaveConfig = async () => {
if (!canConfigTask) {
return;
}
if (totalWeight !== 100) {
message.warning('累计权重值必须为100%,请调整后再试');
return;
@ -874,25 +900,31 @@ const TaskSetPage = () => {
width: 250,
render: (_, record) => (
<Space size={4} className="task-set-ops">
<Button type="link" icon={<EditOutlined />} onClick={() => handleEditTask(record)}>
</Button>
<Permission permissions={canEditTask ? undefined : '__forbidden__'} fallback={<ReadonlyAction icon={<EditOutlined />}></ReadonlyAction>}>
<Button type="link" icon={<EditOutlined />} onClick={() => handleEditTask(record)}>
</Button>
</Permission>
{String(record.taskStatus ?? '') === '0' && (
<Button type="link" icon={<SettingOutlined />} onClick={() => handleConfigTask(record)}>
</Button>
<Permission permissions={canConfigTask ? undefined : '__forbidden__'} fallback={<ReadonlyAction icon={<SettingOutlined />}></ReadonlyAction>}>
<Button type="link" icon={<SettingOutlined />} onClick={() => handleConfigTask(record)}>
</Button>
</Permission>
)}
<Popconfirm
title="确认删除任务"
description="删除任务及绩效数据,该操作不可逆,请谨慎操作"
okText="确认"
cancelText="取消"
onConfirm={() => handleDeleteTask(record)}
>
<Button type="link" danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
<Permission permissions={canRemoveTask ? undefined : '__forbidden__'} fallback={<ReadonlyAction icon={<DeleteOutlined />} danger></ReadonlyAction>}>
<Popconfirm
title="确认删除任务"
description="删除任务及绩效数据,该操作不可逆,请谨慎操作"
okText="确认"
cancelText="取消"
onConfirm={() => handleDeleteTask(record)}
>
<Button type="link" danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
</Permission>
</Space>
),
},
@ -921,9 +953,11 @@ const TaskSetPage = () => {
</Form>
<div className="task-set-actions">
<Button type="primary" icon={<PlusOutlined />} onClick={handleAddTask}>
</Button>
<Permission permissions={canAddTask ? undefined : '__forbidden__'}>
<Button type="primary" icon={<PlusOutlined />} onClick={handleAddTask}>
</Button>
</Permission>
</div>
<div className="task-set-table">
@ -956,6 +990,7 @@ const TaskSetPage = () => {
confirmLoading={taskSubmitting}
destroyOnHidden
forceRender
okButtonProps={{ disabled: currentTask ? !canEditTask : !canAddTask }}
okText="确定"
cancelText="取消"
width={460}
@ -977,8 +1012,12 @@ const TaskSetPage = () => {
readOnly
placeholder="考核人员"
className="task-set-user-input"
onClick={openUserSelector}
suffix={<UserOutlined onClick={openUserSelector} />}
onClick={currentTask ? (canEditTask ? openUserSelector : undefined) : (canAddTask ? openUserSelector : undefined)}
suffix={
<UserOutlined
onClick={currentTask ? (canEditTask ? openUserSelector : undefined) : (canAddTask ? openUserSelector : undefined)}
/>
}
/>
</Form.Item>
@ -1090,6 +1129,7 @@ const TaskSetPage = () => {
okText="保存"
cancelText="取消"
confirmLoading={configSaving}
okButtonProps={{ disabled: !canConfigTask }}
destroyOnHidden
>
<div className="task-set-config-modal" aria-busy={configLoading}>

View File

@ -1,31 +1,24 @@
.appraisal-dashboard-page {
padding: 20px;
background-color: #fff;
height: calc(100vh - 120px);
box-sizing: border-box;
overflow: hidden;
gap: 18px;
}
.appraisal-dashboard-page .dashboard-card {
height: 100%;
display: flex;
flex-direction: column;
min-height: 0;
gap: 16px;
}
.appraisal-dashboard-page .dashboard-search {
margin-bottom: 18px;
display: flex;
flex-wrap: wrap;
margin-bottom: 0;
}
.appraisal-dashboard-page .dashboard-search .ant-form-item {
margin-bottom: 12px;
margin-bottom: 14px;
}
.appraisal-dashboard-page .dashboard-search .ant-input,
.appraisal-dashboard-page .dashboard-search .ant-select {
width: 300px;
min-width: 220px;
}
.appraisal-dashboard-page .dashboard-search .dashboard-user-picker {
@ -34,16 +27,19 @@
.appraisal-dashboard-page .dashboard-search .ant-input-suffix .anticon {
cursor: pointer;
color: #8c8c8c;
color: #7d8cab;
}
.appraisal-dashboard-page .dashboard-ops {
gap: 4px;
}
.appraisal-dashboard-page .dashboard-ops .ant-btn-link {
padding: 0 6px;
font-weight: 600;
font-weight: 700;
}
.appraisal-dashboard-page .ant-table-wrapper {
flex: 1;
min-height: 0;
}

View File

@ -1,100 +1,112 @@
.appraisal-detail-page .conetentBox {
padding: 40px 30px 30px;
background-color: #fff;
height: calc(100vh - 200px);
min-height: 560px;
height: calc(100vh - 200px);
padding: 28px 24px 24px;
overflow: auto;
border: 1px solid rgba(219, 228, 243, 0.82);
border-radius: 24px;
background:
radial-gradient(circle at top right, rgba(25, 180, 184, 0.08), transparent 24%),
radial-gradient(circle at left top, rgba(99, 91, 255, 0.08), transparent 28%),
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(247, 249, 255, 0.98));
box-shadow: 0 18px 42px rgba(22, 32, 51, 0.06);
}
.appraisal-detail-page .titleBox {
font-size: 20px;
font-weight: 700;
margin-bottom: 28px;
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 22px;
font-size: 22px;
font-weight: 800;
}
.appraisal-detail-page .titleMain {
display: flex;
align-items: center;
gap: 12px;
color: #162033;
}
.appraisal-detail-page .block {
width: 4px;
height: 24px;
background-color: #4096ff;
margin-right: 10px;
width: 6px;
height: 28px;
border-radius: 999px;
background: linear-gradient(180deg, #635bff, #19b4b8);
margin-right: 0;
}
.appraisal-detail-page .headerBox {
padding: 20px;
background-color: #f9f9f9;
border-radius: 2px;
font-size: 16px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 22px;
border: 1px solid rgba(219, 228, 243, 0.76);
border-radius: 20px;
background: rgba(247, 249, 255, 0.82);
}
.appraisal-detail-page .userInfo {
display: flex;
align-items: center;
gap: 8px;
color: #333;
gap: 10px;
color: #34425f;
font-weight: 700;
}
.appraisal-detail-page .totalBox {
width: 180px;
display: flex;
align-items: center;
color: #333;
color: #5f6f92;
}
.appraisal-detail-page .scoreTotal {
font-size: 28px;
font-weight: 700;
color: #ff7d00;
margin-left: 6px;
margin-left: 8px;
color: #4f46e5;
font-size: 30px;
font-weight: 800;
}
.appraisal-detail-page .tableBox {
height: calc(100% - 110px);
height: calc(100% - 104px);
padding: 22px 0 0;
overflow: auto;
padding: 20px 0 0;
background-color: #fff;
}
.appraisal-detail-page .tableRow {
.appraisal-detail-page .tableRow,
.appraisal-detail-page .detail-overall {
margin-bottom: 18px;
padding: 20px;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.06);
margin-bottom: 20px;
border: 1px solid rgba(219, 228, 243, 0.78);
border-radius: 20px;
background: rgba(255, 255, 255, 0.82);
box-shadow: 0 10px 28px rgba(22, 32, 51, 0.05);
}
.appraisal-detail-page .userBox,
.appraisal-detail-page .detail-group-title {
margin: 0 0 16px;
font-size: 16px;
font-weight: 700;
color: #1f2329;
color: #162033;
font-size: 17px;
font-weight: 800;
}
.appraisal-detail-page .detail-subtitle {
margin-bottom: 8px;
color: #1f2329;
font-weight: 500;
color: #34425f;
font-weight: 700;
}
.appraisal-detail-page .detail-group-remark,
.appraisal-detail-page .detail-overall {
margin-top: 12px;
margin-top: 14px;
}
.appraisal-detail-page .detail-score-wrap {
position: relative;
width: 92%;
min-width: 240px;
max-width: 360px;
position: relative;
}
.appraisal-detail-page .detail-score-top {
@ -102,17 +114,17 @@
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
color: #8c8c8c;
font-size: 24px;
color: #8b97ac;
font-size: 22px;
line-height: 1;
}
.appraisal-detail-page .detail-score-track {
position: relative;
height: 14px;
border-radius: 999px;
overflow: visible;
background: #ffd8a7;
border-radius: 999px;
background: #e6e8ff;
}
.appraisal-detail-page .detail-score-track.is-editable {
@ -128,11 +140,11 @@
inset: 0 auto 0 0;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #f3a73f 0%, #f58f14 55%, #f57f00 100%);
background: linear-gradient(90deg, #8a83ff 0%, #635bff 55%, #4f46e5 100%);
}
.appraisal-detail-page .detail-score-track::after {
content: '';
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
@ -150,27 +162,27 @@
position: absolute;
top: -30px;
min-width: 28px;
padding: 0 6px;
height: 24px;
border-radius: 5px;
padding: 0 6px;
transform: translateX(-50%);
background-color: #f57f00;
border-radius: 6px;
background-color: #4f46e5;
color: #fff;
font-size: 20px;
font-size: 18px;
font-weight: 700;
line-height: 24px;
text-align: center;
font-weight: 600;
}
.appraisal-detail-page .detail-score-bubble::after {
content: '';
content: "";
position: absolute;
left: 50%;
bottom: -6px;
margin-left: -5px;
border-width: 6px 5px 0;
border-style: solid;
border-color: #f57f00 transparent transparent;
border-color: #4f46e5 transparent transparent;
}
.appraisal-detail-page .detail-score-bubble.is-start {
@ -182,8 +194,27 @@
}
.appraisal-detail-page .statusText {
color: #ff7d00;
font-size: 14px;
font-weight: 600;
margin-top: 4px;
color: #4f46e5;
font-size: 14px;
font-weight: 700;
}
@media (max-width: 768px) {
.appraisal-detail-page .conetentBox {
height: auto;
padding: 18px 16px 16px;
border-radius: 20px;
}
.appraisal-detail-page .titleBox,
.appraisal-detail-page .headerBox {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.appraisal-detail-page .tableBox {
height: auto;
}
}

View File

@ -1,46 +1,115 @@
.appraisal-module-detail-page {
padding: 24px;
background-color: #fff;
height: calc(100vh - 120px);
box-sizing: border-box;
gap: 18px;
}
.appraisal-module-back {
margin-bottom: -2px;
}
.appraisal-module-hero {
display: flex;
flex-direction: column;
overflow: hidden;
align-items: center;
justify-content: space-between;
gap: 20px;
padding: 24px 26px;
border: 1px solid rgba(219, 228, 243, 0.84);
border-radius: 24px;
background:
radial-gradient(circle at top right, rgba(25, 180, 184, 0.12), transparent 26%),
radial-gradient(circle at left center, rgba(99, 91, 255, 0.12), transparent 34%),
linear-gradient(135deg, rgba(255, 255, 255, 0.98), rgba(246, 249, 255, 0.96));
box-shadow: 0 18px 42px rgba(22, 32, 51, 0.06);
}
.appraisal-module-kicker {
margin-bottom: 10px;
color: #6258f6;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.18em;
text-transform: uppercase;
}
.appraisal-module-title {
color: #162033;
font-size: 28px;
font-weight: 800;
line-height: 1.2;
}
.appraisal-module-subtitle {
margin-top: 8px;
color: #6c7b98;
font-size: 14px;
}
.appraisal-module-summary {
min-width: 160px;
padding: 16px 18px;
border: 1px solid rgba(219, 228, 243, 0.78);
border-radius: 18px;
background: rgba(255, 255, 255, 0.82);
}
.appraisal-module-summary span {
display: block;
margin-bottom: 8px;
color: #7d8cab;
font-size: 12px;
font-weight: 700;
}
.appraisal-module-summary strong {
color: #162033;
font-size: 18px;
font-weight: 800;
}
.appraisal-module-detail-page .module-detail-header {
margin-bottom: 16px;
padding: 18px 20px 4px;
border: 1px solid rgba(219, 228, 243, 0.82);
border-radius: 22px;
background: rgba(255, 255, 255, 0.9);
box-shadow: 0 14px 36px rgba(22, 32, 51, 0.05);
}
.appraisal-module-detail-page .module-detail-layout {
display: flex;
border-top: 1px solid #d9d9d9;
border-bottom: 1px solid #d9d9d9;
height: calc(100vh - 250px);
gap: 18px;
min-height: 460px;
}
.appraisal-module-detail-page .module-detail-left,
.appraisal-module-detail-page .module-detail-right {
border: 1px solid rgba(219, 228, 243, 0.82);
border-radius: 22px;
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 14px 36px rgba(22, 32, 51, 0.05);
}
.appraisal-module-detail-page .module-detail-left {
width: 20%;
border-right: 1px solid #d9d9d9;
width: 22%;
padding: 20px;
overflow: auto;
}
.appraisal-module-detail-page .module-set-text,
.appraisal-module-detail-page .module-set-title,
.appraisal-module-detail-page .module-category-title,
.appraisal-module-detail-page .module-header-item,
.appraisal-module-detail-page .module-row-item.is-name {
color: #162033;
font-weight: 800;
}
.appraisal-module-detail-page .module-set-text {
margin-bottom: 14px;
text-align: right;
font-weight: 600;
color: #1f2329;
}
.appraisal-module-detail-page .module-set-title {
font-weight: 700;
}
.appraisal-module-detail-page .module-status-text {
color: #ff5722;
font-weight: 600;
color: #4f46e5;
font-weight: 800;
}
.appraisal-module-detail-page .module-collapse {
@ -49,47 +118,52 @@
}
.appraisal-module-detail-page .module-collapse .ant-collapse-item {
border-bottom: none;
margin-bottom: 10px;
border: 1px solid rgba(219, 228, 243, 0.76);
border-radius: 16px !important;
overflow: hidden;
background: rgba(247, 249, 255, 0.7);
}
.appraisal-module-detail-page .module-collapse .ant-collapse-header {
padding-left: 0 !important;
padding-left: 12px !important;
}
.appraisal-module-detail-page .module-collapse .ant-collapse-content > .ant-collapse-content-box {
padding: 10px 0;
padding: 10px;
}
.appraisal-module-detail-page .module-content-title {
display: flex;
justify-content: space-between;
align-items: center;
justify-content: space-between;
width: 100%;
padding-right: 12px;
}
.appraisal-module-detail-page .module-left-sub {
width: 100%;
border: none;
background: transparent;
text-align: left;
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px 10px 10px;
margin-bottom: 5px;
width: 100%;
margin-bottom: 6px;
padding: 10px 14px;
border: none;
border-radius: 14px;
background: transparent;
cursor: pointer;
text-align: left;
transition: all 0.2s ease;
border-radius: 4px;
}
.appraisal-module-detail-page .module-left-sub:hover {
background: #f5f8ff;
background: rgba(99, 91, 255, 0.08);
}
.appraisal-module-detail-page .module-left-sub.is-selected {
background-color: #4096ff;
background: linear-gradient(135deg, #635bff, #4f46e5);
color: #fff;
box-shadow: 0 12px 24px rgba(99, 91, 255, 0.2);
}
.appraisal-module-detail-page .module-left-sub.is-selected .module-status-text {
@ -104,44 +178,40 @@
}
.appraisal-module-detail-page .module-total-box {
margin-top: 16px;
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 16px;
padding-right: 12px;
}
.appraisal-module-detail-page .module-detail-right {
width: 80%;
padding: 20px 40px;
width: 78%;
padding: 20px 26px;
overflow: auto;
}
.appraisal-module-detail-page .module-category-box {
margin-bottom: 30px;
margin-bottom: 24px;
border: 1px solid rgba(219, 228, 243, 0.78);
border-radius: 20px;
overflow: hidden;
background: rgba(255, 255, 255, 0.82);
}
.appraisal-module-detail-page .module-category-title {
margin: 10px;
margin: 0;
padding: 18px 18px 10px;
font-size: 18px;
font-weight: 600;
color: #1f2329;
}
.appraisal-module-detail-page .module-set-header {
background-color: #f8f8f9;
padding: 15px;
display: flex;
align-items: center;
}
.appraisal-module-detail-page .module-header-item,
.appraisal-module-detail-page .module-row-item {
color: #1f2329;
}
.appraisal-module-detail-page .module-header-item {
font-weight: 600;
padding: 14px 18px;
background: #f7f9fe;
border-top: 1px solid rgba(219, 228, 243, 0.78);
border-bottom: 1px solid rgba(219, 228, 243, 0.78);
}
.appraisal-module-detail-page .module-header-item.is-name,
@ -160,56 +230,55 @@
}
.appraisal-module-detail-page .module-content-row {
border-bottom: 1px solid #d9d9d9;
padding: 15px;
display: flex;
align-items: center;
min-height: 96px;
padding: 16px 18px;
border-bottom: 1px solid rgba(233, 238, 247, 0.92);
}
.appraisal-module-detail-page .module-row-item.is-name {
font-weight: 600;
.appraisal-module-detail-page .module-content-row:last-child {
border-bottom: none;
}
.appraisal-module-detail-page .module-row-item.is-remark {
color: #4e5969;
line-height: 1.6;
padding-right: 16px;
color: #5f6f92;
line-height: 1.6;
}
.appraisal-module-detail-page .module-score-wrap {
position: relative;
width: 100%;
min-width: 200px;
position: relative;
}
.appraisal-module-detail-page .module-score-scale {
display: flex;
justify-content: space-between;
color: #8c8c8c;
font-weight: 600;
margin-bottom: 10px;
color: #8b97ac;
font-weight: 700;
}
.appraisal-module-detail-page .module-score-track {
position: relative;
height: 14px;
border-radius: 999px;
overflow: hidden;
background-color: #ffd8a7;
border-radius: 999px;
background-color: #e6e8ff;
}
.appraisal-module-detail-page .module-score-fill {
position: absolute;
left: 0;
top: 0;
inset: 0 auto 0 0;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #ffb144 0%, #ff7d00 100%);
background: linear-gradient(90deg, #8a83ff 0%, #635bff 55%, #4f46e5 100%);
}
.appraisal-module-detail-page .module-score-track::after {
content: '';
content: "";
position: absolute;
inset: 0;
pointer-events: none;
@ -226,27 +295,27 @@
position: absolute;
top: -30px;
min-width: 28px;
padding: 0 6px;
height: 24px;
border-radius: 6px;
padding: 0 6px;
transform: translateX(-50%);
background-color: #ff7d00;
border-radius: 6px;
background-color: #4f46e5;
color: #fff;
font-size: 13px;
font-weight: 700;
line-height: 24px;
text-align: center;
font-weight: 600;
}
.appraisal-module-detail-page .module-score-text::after {
content: '';
content: "";
position: absolute;
left: 50%;
bottom: -6px;
margin-left: -5px;
border-width: 6px 5px 0;
border-style: solid;
border-color: #ff7d00 transparent transparent;
border-color: #4f46e5 transparent transparent;
}
.appraisal-module-detail-page .module-score-text.is-start {
@ -256,3 +325,20 @@
.appraisal-module-detail-page .module-score-text.is-end {
transform: translateX(-100%);
}
@media (max-width: 992px) {
.appraisal-module-hero {
flex-direction: column;
align-items: flex-start;
padding: 20px;
}
.appraisal-module-detail-page .module-detail-layout {
flex-direction: column;
}
.appraisal-module-detail-page .module-detail-left,
.appraisal-module-detail-page .module-detail-right {
width: 100%;
}
}

View File

@ -1,54 +1,106 @@
.appraisal-dashboard-page .assessment-container {
max-height: 650px;
overflow-y: auto;
gap: 2%;
padding-top: 20px;
padding-left: 20px;
.appraisal-manager-page {
gap: 18px;
}
.appraisal-dashboard-page .task-card {
border-radius: 8px;
box-shadow: 0 0 8px rgba(0, 0, 0, 0.1);
.appraisal-manager-page .ant-tabs-nav {
margin-bottom: 0;
}
.appraisal-dashboard-page .card-header {
.appraisal-manager-page .ant-tabs-tab {
min-width: 104px;
justify-content: center;
font-weight: 700;
}
.appraisal-dashboard-page .assessment-container,
.appraisal-manager-page .assessment-container {
max-height: none;
overflow: visible;
padding: 18px 0 4px;
}
.appraisal-dashboard-page .task-card,
.appraisal-manager-page .task-card {
overflow: hidden;
border: 1px solid rgba(219, 228, 243, 0.82);
border-radius: 22px;
box-shadow: 0 14px 36px rgba(22, 32, 51, 0.06);
background:
radial-gradient(circle at top right, rgba(25, 180, 184, 0.08), transparent 26%),
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(246, 249, 255, 0.98));
}
.appraisal-dashboard-page .card-header,
.appraisal-manager-page .card-header {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 20px;
gap: 12px;
padding: 24px 22px 18px;
}
.appraisal-dashboard-page .task-name {
font-size: 16px;
font-weight: bold;
.appraisal-dashboard-page .card-header img,
.appraisal-manager-page .card-header img {
width: 40px !important;
height: 42px !important;
padding: 8px;
border-radius: 14px;
background: rgba(99, 91, 255, 0.1);
}
.appraisal-dashboard-page .task-deadline {
font-size: 14px;
color: #999;
.appraisal-dashboard-page .task-name,
.appraisal-manager-page .task-name {
color: #162033;
font-size: 17px;
font-weight: 800;
text-align: center;
margin-bottom: 20px;
}
.appraisal-dashboard-page .card-footer {
.appraisal-dashboard-page .task-deadline,
.appraisal-manager-page .task-deadline {
margin-bottom: 0;
padding: 0 20px 18px;
color: #7a89a8;
text-align: center;
}
.appraisal-dashboard-page .card-footer,
.appraisal-manager-page .card-footer {
display: flex;
justify-content: space-between;
align-items: center;
height: 44px;
background-color: #f7faff;
min-height: 58px;
padding: 0 20px;
border-top: 1px solid rgba(219, 228, 243, 0.82);
background: rgba(247, 249, 255, 0.8);
}
.appraisal-dashboard-page .people-count {
color: #666;
font-size: 14px;
font-weight: bold;
.appraisal-dashboard-page .people-count,
.appraisal-manager-page .people-count {
color: #4d5b78;
font-size: 13px;
font-weight: 700;
}
.appraisal-dashboard-page .action-button {
display: flex;
.appraisal-dashboard-page .action-button,
.appraisal-manager-page .action-button {
display: inline-flex;
align-items: center;
gap: 10px;
color: #4096ff;
font-size: 16px;
font-weight: bold;
gap: 8px;
padding: 10px 0;
color: #4f46e5;
font-size: 14px;
font-weight: 800;
cursor: pointer;
}
.appraisal-dashboard-page .action-button.disabled {
color: #999;
.appraisal-dashboard-page .action-button img,
.appraisal-manager-page .action-button img {
margin-left: 0 !important;
}
.appraisal-dashboard-page .action-button.disabled,
.appraisal-manager-page .action-button.disabled {
color: #9aa6bc;
cursor: not-allowed;
}

View File

@ -1,15 +1,9 @@
.task-set-page {
padding: 20px;
background-color: #fff;
height: calc(100vh - 120px);
box-sizing: border-box;
display: flex;
flex-direction: column;
overflow: hidden;
gap: 18px;
}
.task-set-page .task-set-search {
margin-bottom: 16px;
margin-bottom: 0;
}
.task-set-page .task-set-search .ant-form-item {
@ -19,23 +13,22 @@
.task-set-page .task-set-search .ant-input,
.task-set-page .task-set-search .ant-select,
.task-set-page .task-set-search .ant-picker {
width: 300px;
min-width: 220px;
}
.task-set-page .task-set-actions {
display: flex;
justify-content: flex-end;
margin-bottom: 10px;
margin-bottom: 0;
}
.task-set-page .task-set-table {
flex: 1;
min-height: 0;
}
.task-set-page .task-set-ops .ant-btn-link {
padding: 0 6px;
font-weight: 600;
font-weight: 700;
}
.task-set-page .task-set-user-input {
@ -46,25 +39,27 @@
display: flex;
min-height: 430px;
max-height: 520px;
gap: 16px;
gap: 18px;
}
.task-set-user-tree {
width: 220px;
border-right: 1px solid #f0f0f0;
padding-right: 12px;
width: 240px;
padding: 16px 14px;
border: 1px solid rgba(219, 228, 243, 0.82);
border-radius: 18px;
background: rgba(247, 249, 255, 0.74);
overflow: auto;
}
.task-set-user-tree-title {
margin-bottom: 8px;
font-weight: 600;
color: #1f2329;
margin-bottom: 10px;
color: #162033;
font-weight: 800;
}
.task-set-tree-loading {
margin-bottom: 8px;
color: #999;
color: #7d8cab;
font-size: 12px;
}
@ -74,7 +69,7 @@
}
.task-set-user-query {
margin-bottom: 10px;
margin-bottom: 12px;
}
.task-set-config-modal {
@ -83,37 +78,46 @@
.task-set-config-layout {
display: flex;
border-top: 1px solid #d9d9d9;
border-bottom: 1px solid #d9d9d9;
gap: 0;
border: 1px solid rgba(219, 228, 243, 0.82);
border-radius: 20px;
overflow: hidden;
background: rgba(250, 251, 255, 0.92);
}
.task-set-config-left {
width: 40%;
padding: 20px;
border-right: 1px solid #d9d9d9;
min-height: 450px;
max-height: 450px;
padding: 20px;
border-right: 1px solid rgba(219, 228, 243, 0.82);
overflow: auto;
}
.task-set-weight-title,
.task-set-weight-total,
.task-set-config-head,
.task-set-config-item-name {
color: #162033;
font-weight: 700;
}
.task-set-weight-title {
margin-bottom: 14px;
text-align: right;
font-weight: 600;
color: #1f2329;
}
.task-set-group-head {
display: flex;
justify-content: space-between;
align-items: center;
justify-content: space-between;
width: 100%;
padding-right: 10px;
}
.task-set-weight-value {
color: #ff7d00;
font-weight: 600;
color: #4f46e5;
font-weight: 800;
}
.task-set-config-left .ant-collapse {
@ -122,35 +126,40 @@
}
.task-set-config-left .ant-collapse-item {
border-bottom: none;
margin-bottom: 10px;
border: 1px solid rgba(219, 228, 243, 0.74);
border-radius: 16px !important;
overflow: hidden;
background: rgba(255, 255, 255, 0.72);
}
.task-set-config-left .ant-collapse-header {
padding-left: 0 !important;
padding-left: 12px !important;
}
.task-set-config-left .ant-collapse-content > .ant-collapse-content-box {
padding: 8px 0;
padding: 10px;
}
.task-set-group-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px;
padding: 12px 14px;
margin-bottom: 6px;
border-radius: 4px;
border-radius: 14px;
cursor: pointer;
transition: all 0.2s ease;
}
.task-set-group-item:hover {
background: #f5f8ff;
background: rgba(99, 91, 255, 0.08);
}
.task-set-group-item.is-active {
background: #4096ff;
background: linear-gradient(135deg, #635bff, #4f46e5);
color: #fff;
box-shadow: 0 12px 24px rgba(99, 91, 255, 0.2);
}
.task-set-group-item.is-active .task-set-weight-value {
@ -168,7 +177,6 @@
margin-top: 20px;
display: flex;
justify-content: space-between;
font-weight: 600;
padding-right: 10px;
}
@ -177,18 +185,17 @@
min-height: 450px;
max-height: 450px;
overflow: auto;
padding: 20px 38px 20px 26px;
padding: 22px 32px 20px 24px;
}
.task-set-config-head {
display: flex;
justify-content: space-between;
margin-bottom: 10px;
font-weight: 600;
color: #1f2329;
margin-bottom: 12px;
}
.task-set-config-head span:first-child {
.task-set-config-head span:first-child,
.task-set-config-item-name {
width: 50%;
}
@ -207,13 +214,10 @@
justify-content: space-between;
gap: 10px;
margin-bottom: 14px;
}
.task-set-config-item-name {
width: 50%;
font-weight: 600;
color: #1f2329;
line-height: 1.5;
padding: 12px 14px;
border: 1px solid rgba(219, 228, 243, 0.78);
border-radius: 16px;
background: rgba(255, 255, 255, 0.76);
}
.task-set-config-item-slider {
@ -229,11 +233,11 @@
.task-weight-scale {
display: flex;
justify-content: space-between;
color: #999;
font-size: 14px;
font-weight: 600;
margin-bottom: 2px;
padding: 0 2px;
margin-bottom: 2px;
color: #8b97ac;
font-size: 13px;
font-weight: 700;
}
.task-weight-slider .ant-slider {
@ -247,12 +251,12 @@
}
.task-weight-slider .ant-slider-rail {
background-color: #ffe0ba;
background-color: #e7e8ff;
}
.task-weight-slider .ant-slider-track,
.task-weight-slider .ant-slider:hover .ant-slider-track {
background: linear-gradient(90deg, #ffb144 0%, #ff8f17 55%, #ff7d00 100%);
background: linear-gradient(90deg, #8b83ff 0%, #635bff 55%, #4f46e5 100%);
}
.task-weight-slider .ant-slider-step {
@ -260,62 +264,23 @@
top: 0;
}
.task-weight-slider .ant-slider-dot {
width: 2px;
height: 14px;
margin-left: -1px;
margin-top: 0;
border: none;
border-radius: 0;
background-color: #fff;
top: 0;
}
@media (max-width: 992px) {
.task-set-user-select,
.task-set-config-layout {
flex-direction: column;
}
.task-weight-slider .ant-slider-dot-active {
border: none;
background-color: #fff;
}
.task-set-user-tree,
.task-set-user-table,
.task-set-config-left,
.task-set-config-right {
width: 100%;
max-width: 100%;
}
.task-weight-slider .ant-slider-handle {
width: 0;
height: 0;
border: none;
box-shadow: none;
opacity: 0;
margin-top: 0;
}
.task-weight-slider .ant-slider-handle::after {
display: none;
}
.task-weight-bubble {
position: absolute;
top: 20px;
transform: translateX(-50%);
min-width: 34px;
height: 26px;
line-height: 26px;
text-align: center;
border-radius: 6px;
background: #ff7d00;
color: #fff;
font-size: 12px;
font-weight: 600;
z-index: 2;
}
.task-weight-bubble::after {
content: '';
position: absolute;
left: 50%;
bottom: -6px;
margin-left: -5px;
border-width: 6px 5px 0;
border-style: solid;
border-color: #ff7d00 transparent transparent;
}
.task-weight-bubble.is-end {
transform: translateX(-100%);
.task-set-config-left,
.task-set-config-right {
min-height: auto;
max-height: none;
}
}

View File

@ -1,7 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Button,
Calendar,
DatePicker,
Input,
message,
@ -14,7 +13,7 @@ import {
Table,
Upload,
} from 'antd';
import type { CalendarProps, TableColumnsType, UploadProps } from 'antd';
import type { TableColumnsType, UploadProps } from 'antd';
import {
ArrowLeftOutlined,
ArrowRightOutlined,
@ -43,6 +42,7 @@ import { listProject } from '@/api/project';
import { getUserProfile } from '@/api/user';
import { TokenKey } from '@/utils/auth';
import PageBackButton from '@/components/PageBackButton';
import { usePermission } from '@/contexts/PermissionContext';
import './worklog.css';
const { TextArea } = Input;
@ -65,6 +65,7 @@ interface CalendarLogItem {
interface ProjectRow {
projectId?: string | number;
name?: string;
projectName?: string;
startDate?: string;
endDate?: string;
@ -163,6 +164,38 @@ const normalizeFileList = (value: unknown): FileRecord[] =>
}))
: [];
const normalizeProjectRows = (value: unknown): ProjectRow[] =>
Array.isArray(value)
? value
.filter((item) => isObject(item))
.map((item) => ({
...item,
projectId: item.projectId as string | number | undefined,
projectName: String(item.projectName ?? item.name ?? ''),
name: String(item.name ?? item.projectName ?? ''),
}))
: [];
const toNullableNumber = (value: unknown) => {
if (value === undefined || value === null || value === '') {
return undefined;
}
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : value;
};
const buildWorkLogPayload = (row: WorkLogRow): Record<string, unknown> => ({
loggerId: toNullableNumber(row.loggerId),
loggerDate: row.loggerDate,
projectId: toNullableNumber(row.projectId),
userId: toNullableNumber(row.userId),
workTime: row.workTime === '' ? undefined : row.workTime,
workContent: String(row.workContent ?? ''),
state: row.state,
demandId: toNullableNumber(row.demandId),
fileList: normalizeFileList(row.fileList),
});
const WorkLogPage = () => {
const [searchParams] = useSearchParams();
const queryUserId = searchParams.get('userId') ?? '';
@ -195,6 +228,7 @@ const WorkLogPage = () => {
const previousContentRef = useRef('');
const seedRef = useRef(0);
const { hasPermi } = usePermission();
const token = Cookies.get(TokenKey) ?? '';
@ -212,6 +246,9 @@ const WorkLogPage = () => {
}, [currentUser.userId, queryUserId]);
const disableTable = isReadOnlyByQuery || dayReadonly;
const canAddWorkLog = hasPermi('worklog/worklog:list');
const canEditWorkLog = hasPermi('worklog/worklog:list');
const canDeleteWorkLog = hasPermi('worklog/worklog:list');
const showDetailBack = useMemo(() => Boolean(queryUserId || queryProjectId), [queryProjectId, queryUserId]);
const displayUserName = queryNickName || currentUser.nickName || currentUser.userName || '当日日志';
const detailFallbackPath = useMemo(() => {
@ -327,7 +364,7 @@ const WorkLogPage = () => {
const response = await userProject(viewUserId);
const payload = normalizeResponseData(response);
const rows = isObject(payload) && Array.isArray(payload.rows) ? payload.rows : Array.isArray(payload) ? payload : [];
setProjectList(rows as ProjectRow[]);
setProjectList(normalizeProjectRows(rows));
return;
} catch (error) {
console.warn('Failed to fetch user project list, fallback to full project list:', error);
@ -337,7 +374,7 @@ const WorkLogPage = () => {
const response = await listProject({ pageNum: 1, pageSize: 10000 });
const payload = normalizeResponseData(response);
const rows = isObject(payload) && Array.isArray(payload.rows) ? payload.rows : Array.isArray(payload) ? payload : [];
setProjectList(rows as ProjectRow[]);
setProjectList(normalizeProjectRows(rows));
} catch (error) {
console.error('Failed to fetch all projects:', error);
message.error('获取项目列表失败');
@ -379,7 +416,7 @@ const WorkLogPage = () => {
setLoading(true);
try {
const params: Record<string, unknown> = {
createBy: viewUserId,
userId: viewUserId,
loggerDate: selectedDay,
};
@ -534,8 +571,43 @@ const WorkLogPage = () => {
const hasOtherEditingRows = (rowKey: string) =>
tableData.some((item) => item.edit && item._rowKey !== rowKey);
const canMutateRow = useCallback(
(row: WorkLogRow) => {
if (disableTable) {
return false;
}
return row.loggerId ? canEditWorkLog : canAddWorkLog;
},
[canAddWorkLog, canEditWorkLog, disableTable],
);
const canDeleteRow = useCallback(
(row: WorkLogRow) => {
if (disableTable) {
return false;
}
if (row.loggerId) {
return row.edit ? canEditWorkLog : canDeleteWorkLog;
}
return canAddWorkLog;
},
[canAddWorkLog, canDeleteWorkLog, canEditWorkLog, disableTable],
);
const activeFileDialogRow = useMemo(() => {
if (!fileDialogRowKey) {
return null;
}
return tableData.find((item) => item._rowKey === fileDialogRowKey) ?? null;
}, [fileDialogRowKey, tableData]);
const fileDialogEditable = useMemo(
() => Boolean(activeFileDialogRow && canMutateRow(activeFileDialogRow)),
[activeFileDialogRow, canMutateRow],
);
const handleAdd = async () => {
if (disableTable) {
if (disableTable || !canAddWorkLog) {
return;
}
@ -634,7 +706,7 @@ const WorkLogPage = () => {
};
const handleEdit = async (row: WorkLogRow) => {
if (disableTable) {
if (!canMutateRow(row)) {
return;
}
@ -655,7 +727,7 @@ const WorkLogPage = () => {
return;
}
const payload: Record<string, unknown> = { ...row };
const payload = buildWorkLogPayload(row);
try {
if (row.loggerId !== undefined && row.loggerId !== null && row.loggerId !== '') {
@ -679,7 +751,7 @@ const WorkLogPage = () => {
};
const confirmDeletePersisted = async (row: WorkLogRow) => {
if (row.loggerId === undefined || row.loggerId === null || row.loggerId === '') {
if (!canDeleteWorkLog || row.loggerId === undefined || row.loggerId === null || row.loggerId === '') {
return;
}
@ -695,7 +767,7 @@ const WorkLogPage = () => {
};
const handleDelete = (row: WorkLogRow) => {
if (disableTable) {
if (!canDeleteRow(row)) {
return;
}
@ -785,6 +857,11 @@ const WorkLogPage = () => {
};
const beforeUpload: UploadProps['beforeUpload'] = (file) => {
const row = getRowByKey(fileDialogRowKey);
if (!row || !canMutateRow(row)) {
message.warning('当前无权上传附件');
return Upload.LIST_IGNORE;
}
const limit = 100 * 1024 * 1024;
if (file.size > limit) {
message.warning('单个文件不能大于100M');
@ -823,6 +900,11 @@ const WorkLogPage = () => {
};
const removeFileInDialog = (file: FileRecord) => {
const row = getRowByKey(fileDialogRowKey);
if (!row || !canMutateRow(row)) {
return;
}
if (file.id !== undefined && file.id !== null && file.id !== '') {
setPendingDeletedFileIds((prev) => [...prev, file.id as string | number]);
}
@ -839,6 +921,11 @@ const WorkLogPage = () => {
return;
}
if (!canMutateRow(row)) {
setFileDialogOpen(false);
return;
}
const nextRow: WorkLogRow = {
...row,
fileList: fileDialogList,
@ -849,7 +936,7 @@ const WorkLogPage = () => {
try {
if (nextRow.loggerId !== undefined && nextRow.loggerId !== null && nextRow.loggerId !== '') {
await editLog(nextRow);
await editLog(buildWorkLogPayload(nextRow));
}
if (pendingDeletedFileIds.length > 0) {
@ -865,37 +952,30 @@ const WorkLogPage = () => {
}
};
const calendarFullCellRender: CalendarProps<Dayjs>['fullCellRender'] = (current, info) => {
if (info.type !== 'date') {
return info.originNode;
}
const monthDayList = useMemo(() => {
const start = selectedMonth.startOf('month');
const end = selectedMonth.endOf('month');
const totalDays = end.date();
const dayKey = formatDay(current);
const record = monthCellMap.get(dayKey);
return Array.from({ length: totalDays }, (_item, index) => {
const current = start.add(index, 'day');
const dayKey = formatDay(current);
const record = monthCellMap.get(dayKey);
const isFuture = current.isAfter(dayjs(), 'day');
const hasLog = record !== undefined && toNumber(record.state, -1) !== -1;
const isTimeout = record !== undefined && toNumber(record.state, -1) === -1 && current.isBefore(dayjs(), 'day');
const isSelected = current.format('YYYY-MM-DD') === selectedDayOnly;
const isFuture = current.isAfter(dayjs(), 'day');
const hasLog = record !== undefined && toNumber(record.state, -1) !== -1;
const isTimeout = record !== undefined && toNumber(record.state, -1) === -1 && current.isBefore(dayjs(), 'day');
const isSelected = current.format('YYYY-MM-DD') === selectedDayOnly;
const isOutMonth = !current.isSame(selectedMonth, 'month');
const className = [
'worklog-day-box',
isFuture ? 'is-disabled' : '',
hasLog ? 'is-has-log' : '',
isTimeout ? 'is-timeout' : '',
isSelected ? 'is-selected' : '',
isOutMonth ? 'is-out-month' : '',
]
.filter(Boolean)
.join(' ');
return (
<div className={className} onClick={() => handlePickDay(current)}>
{current.date()}
</div>
);
};
return {
key: dayKey,
current,
isFuture,
hasLog,
isTimeout,
isSelected,
};
});
}, [monthCellMap, selectedDayOnly, selectedMonth]);
const columns: TableColumnsType<WorkLogRow> = [
{
@ -1062,23 +1142,26 @@ const WorkLogPage = () => {
key: 'operation',
width: 220,
align: 'center',
fixed: 'right',
render: (_value, row) => {
const rowCanMutate = canMutateRow(row);
const rowCanDelete = canDeleteRow(row);
const editLabel = row.loggerId && !row.edit ? '编辑' : '确认';
const deleteLabel = row.loggerId && !row.edit ? '删除' : '取消';
return (
<Space size={0}>
<Space size={0} className="worklog-row-actions">
<Button type="link" onClick={() => openFileDialog(row)}>
</Button>
{!disableTable && (
{rowCanMutate && (
<Button type="link" onClick={() => void handleEdit(row)}>
{editLabel}
</Button>
)}
{!disableTable && row.loggerId && !row.edit ? (
{rowCanDelete && row.loggerId && !row.edit ? (
<Popconfirm
title="此操作将永久删除该日志,是否继续?"
okText="确定"
@ -1090,7 +1173,7 @@ const WorkLogPage = () => {
</Button>
</Popconfirm>
) : (
!disableTable && (
rowCanDelete && (
<Button type="link" onClick={() => handleDelete(row)}>
{deleteLabel}
</Button>
@ -1144,15 +1227,33 @@ const WorkLogPage = () => {
</div>
<Spin spinning={monthLoading}>
<Calendar
value={selectedMonth}
fullscreen={false}
fullCellRender={calendarFullCellRender}
onSelect={(value) => handlePickDay(value)}
onPanelChange={(value) => setSelectedMonth(value.startOf('month'))}
headerRender={() => null}
className="worklog-calendar"
/>
<div className="worklog-calendar-strip">
{monthDayList.map((item) => {
const className = [
'worklog-strip-day',
item.isSelected ? 'is-selected' : '',
item.hasLog ? 'is-has-log' : '',
item.isTimeout ? 'is-timeout' : '',
item.isFuture ? 'is-disabled' : '',
]
.filter(Boolean)
.join(' ');
return (
<button
key={item.key}
type="button"
className={className}
onClick={() => handlePickDay(item.current)}
disabled={item.isFuture}
>
<span className="worklog-strip-weekday">{item.current.format('dd')}</span>
<strong className="worklog-strip-date">{item.current.format('DD')}</strong>
<span className="worklog-strip-meta">{item.current.format('MM/DD')}</span>
</button>
);
})}
</div>
</Spin>
</div>
</div>
@ -1199,7 +1300,7 @@ const WorkLogPage = () => {
<div className="worklog-table-toolbar-subtitle"></div>
</div>
<div className="worklog-table-toolbar-actions">
{!disableTable && (
{!disableTable && canAddWorkLog && (
<Button type="primary" icon={<PlusOutlined />} onClick={() => void handleAdd()}>
</Button>
@ -1213,7 +1314,7 @@ const WorkLogPage = () => {
columns={columns}
loading={loading}
pagination={false}
scroll={{ x: 1400 }}
scroll={{ x: 1500 }}
className="worklog-table"
rowClassName={(record) => (record.edit ? 'is-editing' : '')}
/>
@ -1231,9 +1332,10 @@ const WorkLogPage = () => {
}}
width={780}
destroyOnHidden
okButtonProps={{ disabled: !fileDialogEditable }}
>
<Spin spinning={fileDialogLoading}>
{!disableTable && (
{fileDialogEditable && (
<Upload
action={FILE_UPLOAD_URL}
headers={{ Authorization: `Bearer ${token}` }}
@ -1293,7 +1395,7 @@ const WorkLogPage = () => {
width: 90,
align: 'center',
render: (_value, row) =>
!disableTable ? (
fileDialogEditable ? (
<Button type="link" danger onClick={() => removeFileInDialog(row)}>
</Button>

View File

@ -1,69 +1,65 @@
.worklog-layout {
display: flex;
gap: 20px;
flex-direction: column;
gap: 16px;
width: 100%;
min-height: calc(100vh - 190px);
background:
linear-gradient(rgba(52, 86, 142, 0.04) 1px, transparent 1px),
linear-gradient(90deg, rgba(52, 86, 142, 0.04) 1px, transparent 1px),
radial-gradient(circle at top left, rgba(74, 141, 255, 0.08), transparent 24%),
linear-gradient(180deg, #f5f8fd 0%, #edf3fb 100%);
background-size: 24px 24px, 24px 24px, auto, auto;
background: #ffffff;
padding: 6px 0;
}
.worklog-sidebar {
flex: 0 0 336px;
width: 336px;
max-width: 336px;
height: calc(100vh - 190px);
width: 100%;
max-width: 100%;
height: auto;
padding: 0;
box-sizing: border-box;
overflow: auto;
overflow: visible;
}
.worklog-main {
flex: 1;
width: 100%;
min-width: 0;
padding: 0;
box-sizing: border-box;
overflow: auto;
overflow: visible;
}
.worklog-sidebar-panel,
.worklog-table-card {
position: relative;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.96) 0%, rgba(248, 251, 255, 0.98) 100%);
border: 1px solid rgba(205, 219, 240, 0.9);
background: #ffffff;
border: 1px solid rgba(219, 228, 243, 0.9);
border-radius: 22px;
box-shadow:
0 18px 42px rgba(23, 47, 89, 0.06),
inset 0 1px 0 rgba(255, 255, 255, 0.8);
backdrop-filter: blur(10px);
box-shadow: 0 14px 32px rgba(23, 47, 89, 0.05);
overflow: hidden;
}
.worklog-sidebar-panel::before,
.worklog-table-card::before,
.worklog-main-heading::before {
content: '';
position: absolute;
inset: 0 auto auto 0;
width: 100%;
height: 1px;
background: linear-gradient(90deg, rgba(77, 144, 255, 0.55), rgba(77, 144, 255, 0));
display: none;
}
.worklog-sidebar-panel {
padding: 22px 22px 18px;
padding: 12px 16px 10px;
display: grid;
grid-template-columns: 210px minmax(0, 1fr);
grid-template-areas:
"header meta"
"picker meta"
"legend meta";
column-gap: 14px;
align-items: start;
}
.worklog-left-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 16px;
gap: 12px;
margin-bottom: 6px;
gap: 8px;
grid-area: header;
}
.worklog-panel-kicker,
@ -73,38 +69,40 @@
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
color: #7e95b7;
color: #7a88a6;
}
.worklog-panel-title {
margin-top: 8px;
font-size: 20px;
margin-top: 4px;
font-size: 16px;
font-weight: 700;
color: #14213d;
line-height: 1.2;
}
.worklog-panel-subtitle {
margin-top: 6px;
font-size: 13px;
margin-top: 3px;
font-size: 11px;
color: #7c8ba1;
line-height: 1.35;
}
.worklog-month-picker {
margin-bottom: 14px;
margin-bottom: 6px;
grid-area: picker;
}
.worklog-month-picker .ant-picker {
width: 100%;
height: 46px;
border: 1px solid #d7e3f8;
border-radius: 14px;
height: 36px;
border: 1px solid #dbe4f3;
border-radius: 12px;
background: linear-gradient(180deg, #fbfdff 0%, #f5f9ff 100%);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8);
}
.worklog-month-picker .ant-picker-input > input {
font-size: 16px;
font-size: 14px;
color: #223554;
font-weight: 600;
}
@ -112,163 +110,171 @@
.worklog-left-arrows {
display: flex;
align-items: center;
gap: 8px;
gap: 6px;
color: #5c6f8f;
font-size: 15px;
font-size: 14px;
}
.worklog-left-arrows .anticon {
width: 34px;
height: 34px;
width: 30px;
height: 30px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 10px;
background: #f3f7ff;
border: 1px solid #dce7fb;
border-radius: 9px;
background: #f6f8ff;
border: 1px solid #dbe4f3;
cursor: pointer;
transition: all 0.2s ease;
}
.worklog-left-arrows .anticon:hover {
color: #165dff;
background: #e9f1ff;
border-color: #b8d0ff;
color: #4f46e5;
background: rgba(99, 91, 255, 0.08);
border-color: rgba(99, 91, 255, 0.2);
}
.worklog-calendar-legend {
display: flex;
flex-wrap: wrap;
gap: 10px 14px;
margin-bottom: 10px;
padding: 12px 14px;
border: 1px solid #e7eef9;
border-radius: 16px;
gap: 6px 10px;
margin-bottom: 0;
padding: 7px 10px;
border: 1px solid #e9eef7;
border-radius: 14px;
background: rgba(244, 248, 255, 0.9);
grid-area: legend;
}
.worklog-legend-item {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 12px;
gap: 6px;
font-size: 10px;
color: #6b7b92;
}
.worklog-legend-dot {
width: 10px;
height: 10px;
width: 8px;
height: 8px;
border-radius: 999px;
background: #d8e5fb;
}
.worklog-legend-dot.is-selected {
background: #1d63ff;
background: #4f46e5;
}
.worklog-legend-dot.is-filled {
background: #79aefe;
background: #8c84ff;
}
.worklog-legend-dot.is-timeout {
background: #ffb74d;
}
.worklog-calendar .ant-picker-calendar-header {
display: none;
.worklog-calendar-strip {
grid-area: meta;
border: 1px solid #edf2fb;
border-radius: 18px;
padding: 8px;
background: rgba(255, 255, 255, 0.7);
display: flex;
align-items: stretch;
gap: 8px;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: thin;
scrollbar-color: rgba(99, 91, 255, 0.24) transparent;
}
.worklog-calendar {
border-top: 1px solid #edf2fb;
padding-top: 12px;
}
.worklog-calendar .ant-picker-panel {
background: transparent;
}
.worklog-calendar .ant-picker-content th {
font-weight: 600;
font-size: 13px;
color: #8b98ad;
text-align: center;
padding-bottom: 10px;
}
.worklog-calendar .ant-picker-cell {
padding: 7px 0;
}
.worklog-calendar .ant-picker-cell-inner {
margin: 0 auto;
}
.worklog-day-box {
width: 42px;
height: 42px;
line-height: 42px;
margin: 0 auto;
.worklog-strip-day {
flex: 0 0 58px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 3px;
min-height: 62px;
padding: 8px 4px;
text-align: center;
border-radius: 14px;
cursor: pointer;
color: #24344d;
font-weight: 600;
transition: all 0.2s ease;
border: 1px solid transparent;
border: 1px solid #e6edf8;
background: linear-gradient(180deg, #ffffff 0%, #f7faff 100%);
}
.worklog-day-box:hover {
.worklog-strip-day:hover:not(:disabled) {
background: #edf4ff;
border-color: #d6e5ff;
}
.worklog-day-box.is-out-month {
opacity: 0.45;
}
.worklog-day-box.is-selected {
background: linear-gradient(135deg, #2a74ff 0%, #0b56f0 100%);
color: #fff;
box-shadow: 0 10px 20px rgba(42, 116, 255, 0.28);
}
.worklog-day-box.is-has-log {
background: linear-gradient(135deg, #8ebcff 0%, #69a3ff 100%);
color: #fff;
}
.worklog-day-box.is-timeout {
background: linear-gradient(135deg, #ffd36c 0%, #ffb53d 100%);
color: #7a4500;
}
.worklog-day-box.is-disabled {
.worklog-strip-day:disabled {
color: #c0c4cc;
cursor: not-allowed;
background: transparent;
background: #f8fafc;
box-shadow: none;
}
.worklog-strip-day.is-selected {
background: linear-gradient(135deg, #635bff 0%, #4f46e5 100%);
color: #fff;
border-color: #4f46e5;
box-shadow: 0 10px 20px rgba(99, 91, 255, 0.22);
}
.worklog-strip-day.is-has-log:not(.is-selected) {
background: linear-gradient(135deg, rgba(163, 157, 255, 0.22) 0%, rgba(127, 119, 255, 0.14) 100%);
border-color: rgba(127, 119, 255, 0.28);
}
.worklog-strip-day.is-timeout:not(.is-selected) {
background: linear-gradient(135deg, rgba(255, 211, 108, 0.34) 0%, rgba(255, 181, 61, 0.18) 100%);
border-color: rgba(255, 181, 61, 0.35);
color: #8b4f00;
}
.worklog-strip-weekday {
font-size: 11px;
color: #8190a7;
font-weight: 600;
}
.worklog-strip-date {
font-size: 16px;
line-height: 1;
color: inherit;
}
.worklog-strip-meta {
font-size: 10px;
color: #97a3b8;
}
.worklog-strip-day.is-selected .worklog-strip-weekday,
.worklog-strip-day.is-selected .worklog-strip-meta {
color: rgba(255, 255, 255, 0.82);
}
.worklog-main-top {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 16px;
grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
gap: 14px;
align-items: stretch;
margin-bottom: 18px;
margin-bottom: 10px;
}
.worklog-main-heading {
position: relative;
min-width: 0;
padding: 24px 26px;
padding: 18px 22px;
border-radius: 22px;
border: 1px solid rgba(205, 219, 240, 0.92);
background:
linear-gradient(135deg, rgba(43, 118, 255, 0.08) 0%, rgba(43, 118, 255, 0.02) 48%, rgba(255, 255, 255, 0.97) 100%),
rgba(255, 255, 255, 0.96);
box-shadow:
0 18px 42px rgba(23, 47, 89, 0.06),
inset 0 1px 0 rgba(255, 255, 255, 0.85);
border: 1px solid rgba(219, 228, 243, 0.92);
background: #ffffff;
box-shadow: 0 14px 32px rgba(23, 47, 89, 0.05);
}
.worklog-back-wrap {
@ -280,7 +286,7 @@
}
.worklog-main-title {
font-size: 28px;
font-size: 24px;
font-weight: 700;
line-height: 1.2;
color: #18263d;
@ -298,17 +304,17 @@
.worklog-status-chip {
display: inline-flex;
align-items: center;
height: 34px;
height: 30px;
padding: 0 14px;
border-radius: 999px;
font-size: 13px;
font-size: 12px;
font-weight: 600;
}
.worklog-date-chip {
color: #1d4f91;
background: linear-gradient(180deg, #f3f8ff 0%, #ebf3ff 100%);
border: 1px solid #d6e5ff;
color: #4a5680;
background: linear-gradient(180deg, #f7f8ff 0%, #f1f3ff 100%);
border: 1px solid #dfe4ff;
}
.worklog-status-chip.is-editable {
@ -318,9 +324,9 @@
}
.worklog-status-chip.is-readonly {
color: #4d50c8;
background: linear-gradient(180deg, #f4f1ff 0%, #eeebff 100%);
border: 1px solid #e1d7ff;
color: #4f46e5;
background: linear-gradient(180deg, #f5f3ff 0%, #efedff 100%);
border: 1px solid #e4deff;
}
.worklog-status-chip.is-disabled {
@ -330,14 +336,14 @@
}
.worklog-summary-strip {
display: flex;
align-items: stretch;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.worklog-summary-card {
min-width: 132px;
padding: 18px 18px 16px;
min-width: 0;
padding: 14px 14px 12px;
border-radius: 18px;
border: 1px solid rgba(210, 223, 243, 0.92);
background:
@ -349,8 +355,8 @@
.worklog-summary-card.is-primary {
background:
linear-gradient(135deg, #1d67ff 0%, #094cdc 100%);
border-color: #0a54eb;
linear-gradient(135deg, #635bff 0%, #4f46e5 100%);
border-color: #4f46e5;
color: #fff;
}
@ -363,7 +369,7 @@
}
.worklog-summary-card strong {
font-size: 26px;
font-size: 18px;
line-height: 1;
color: #1e2c42;
letter-spacing: -0.03em;
@ -375,7 +381,7 @@
}
.worklog-table-card {
padding: 18px 18px 12px;
padding: 18px 18px 14px;
}
.worklog-table-toolbar {
@ -383,7 +389,7 @@
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 14px;
margin-bottom: 10px;
}
.worklog-table-toolbar-kicker {
@ -406,9 +412,33 @@
height: 40px;
padding: 0 18px;
border-radius: 12px;
background: linear-gradient(135deg, #226cff 0%, #0b56f0 100%);
border-color: #145cf2;
box-shadow: 0 10px 18px rgba(34, 108, 255, 0.18);
background: linear-gradient(135deg, #635bff 0%, #4f46e5 100%);
border-color: #4f46e5;
box-shadow: 0 10px 18px rgba(99, 91, 255, 0.2);
}
.worklog-entry-guide {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 14px;
padding: 12px 14px;
border: 1px solid #e9eef7;
border-radius: 16px;
background: rgba(247, 249, 255, 0.82);
}
.worklog-entry-step {
display: inline-flex;
align-items: center;
min-height: 34px;
padding: 0 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.88);
color: #5f6f92;
font-size: 13px;
font-weight: 700;
border: 1px solid rgba(219, 228, 243, 0.82);
}
.worklog-table .ant-table {
@ -432,7 +462,7 @@
.worklog-table .ant-table-tbody > tr > td {
border-bottom: 1px solid #eef3fb;
vertical-align: top;
vertical-align: middle;
color: #203149;
}
@ -450,12 +480,32 @@
border-radius: 12px !important;
}
.worklog-table .ant-select-selector,
.worklog-table .ant-input-affix-wrapper,
.worklog-table .ant-input,
.worklog-table .ant-input-number,
.worklog-table .ant-input-number-affix-wrapper {
min-height: 40px;
}
.worklog-table .ant-btn-link {
font-weight: 600;
}
.worklog-row-actions {
display: inline-flex;
align-items: center;
justify-content: center;
flex-wrap: nowrap;
white-space: nowrap;
}
.worklog-row-actions .ant-btn {
padding-inline: 8px;
}
.worklog-content-text {
min-height: 38px;
min-height: 44px;
display: flex;
align-items: center;
padding: 8px 12px;
@ -469,8 +519,8 @@
}
.worklog-content-text:hover {
background: #f7faff;
border-color: #d6e5ff;
background: #f7f7ff;
border-color: #ddd8ff;
}
.worklog-content-text.is-empty {
@ -478,21 +528,19 @@
}
.worklog-content-popover {
width: 420px;
width: min(520px, 70vw);
}
.worklog-content-popover .ant-input {
border: 1px solid #dce6f5;
border: 1px solid #dbe4f3;
background: #fbfdff;
}
.worklog-content-actions {
margin-top: 12px;
text-align: right;
}
.worklog-content-actions .ant-btn + .ant-btn {
margin-left: 8px;
display: flex;
justify-content: flex-end;
gap: 8px;
}
.worklog-file-tip {
@ -506,12 +554,6 @@
}
@media (max-width: 1200px) {
.worklog-layout {
flex-direction: column;
min-height: auto;
gap: 16px;
}
.worklog-sidebar {
width: 100%;
max-width: 100%;
@ -519,12 +561,22 @@
flex-basis: auto;
}
.worklog-sidebar-panel {
grid-template-columns: 1fr;
grid-template-areas:
"header"
"picker"
"legend"
"meta";
row-gap: 12px;
}
.worklog-main-top {
grid-template-columns: 1fr;
}
.worklog-summary-strip {
flex-wrap: wrap;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@ -535,6 +587,16 @@
border-radius: 18px;
}
.worklog-calendar-strip {
padding: 8px;
}
.worklog-strip-day {
flex-basis: 56px;
min-height: 58px;
border-radius: 14px;
}
.worklog-main-title {
font-size: 22px;
}
@ -556,4 +618,13 @@
.worklog-table-toolbar-actions {
width: 100%;
}
.worklog-entry-guide {
padding: 10px;
}
.worklog-entry-step {
width: 100%;
justify-content: center;
}
}

View File

@ -44,6 +44,7 @@ export const routeMapper = {
'/projectBank/userProject': UserProjectPage,
'/projectBank/userScore': UserScorePage,
'/projectBank/userScoreDetail': UserScoreDetailPage,
'/user/profile': ProfilePage,
'/profile': ProfilePage,
'/monitor/cache': CacheMonitorPage,
'/monitor/job': JobMonitorPage,
@ -119,6 +120,11 @@ export const routes = [
component: UserScoreDetailPage,
exact: true,
},
{
path: '/user/profile',
component: ProfilePage,
exact: true,
},
{
path: '/profile',
component: ProfilePage,

View File

@ -0,0 +1,15 @@
.permission-link-disabled {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0;
color: #98a2b3;
cursor: not-allowed;
text-decoration: none;
user-select: none;
white-space: normal;
}
.permission-link-disabled.is-danger {
color: #c0a2a8;
}

View File

@ -0,0 +1,15 @@
import * as echarts from 'echarts/core';
import { PieChart, GaugeChart } from 'echarts/charts';
import { TooltipComponent, LegendComponent, TitleComponent } from 'echarts/components';
import { CanvasRenderer } from 'echarts/renderers';
echarts.use([
PieChart,
GaugeChart,
TooltipComponent,
LegendComponent,
TitleComponent,
CanvasRenderer,
]);
export { echarts };

View File

@ -0,0 +1,43 @@
import type { MessageInstance } from 'antd/es/message/interface';
let messageApi: MessageInstance | null = null;
export const bindMessageApi = (api: MessageInstance) => {
messageApi = api;
};
const logFallback = (level: 'error' | 'warn' | 'info' | 'log', content: string) => {
const logger = console[level] ?? console.log;
logger(content);
};
export const notify = {
success(content: string) {
if (messageApi) {
messageApi.success(content);
return;
}
logFallback('log', content);
},
error(content: string) {
if (messageApi) {
messageApi.error(content);
return;
}
logFallback('error', content);
},
warning(content: string) {
if (messageApi) {
messageApi.warning(content);
return;
}
logFallback('warn', content);
},
info(content: string) {
if (messageApi) {
messageApi.info(content);
return;
}
logFallback('info', content);
},
};

View File

@ -1,8 +1,8 @@
import axios, { AxiosHeaders, type AxiosError, type AxiosRequestConfig } from 'axios';
import Cookies from 'js-cookie';
import { message } from 'antd';
import { TokenKey } from './auth';
import type { ApiEnvelope } from '@/types/api';
import { notify } from './notify';
type RequestConfig<D = unknown> = AxiosRequestConfig<D> & {
headers?: Record<string, unknown>;
@ -51,7 +51,7 @@ service.interceptors.response.use(
} else if (errorMessage.includes('Request failed with status code')) {
errorMessage = `系统接口${errorMessage.slice(-3)}异常`;
}
message.error(errorMessage);
notify.error(errorMessage);
return Promise.reject(error);
},
);
@ -61,7 +61,7 @@ const unwrapResponse = <T>(raw: unknown): T => {
const res = raw as ApiEnvelope<T>;
if (res.code !== 200) {
const errorMessage = res.msg ?? 'Error';
message.error(errorMessage);
notify.error(errorMessage);
if (res.code === 401 || res.code === 50012 || res.code === 50014) {
Cookies.remove(TokenKey);
@ -69,6 +69,18 @@ const unwrapResponse = <T>(raw: unknown): T => {
}
throw new Error(errorMessage);
} else {
if (
res.data !== undefined &&
typeof res.data === 'object' &&
res.data !== null &&
('roleGroup' in (res as Record<string, unknown>) || 'postGroup' in (res as Record<string, unknown>))
) {
return {
user: res.data,
roleGroup: (res as Record<string, unknown>).roleGroup,
postGroup: (res as Record<string, unknown>).postGroup,
} as T;
}
// Some APIs return business fields on the root object (e.g. captcha img/uuid),
// so when `data` is absent we must return the envelope itself.
return (res.data !== undefined ? res.data : res) as T;

View File

@ -2,6 +2,43 @@ import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path' // Import path module
const inferChunkName = (moduleIds: string[]) => {
const joined = moduleIds.join('\n')
if (joined.includes('/node_modules/antd/es/table') || joined.includes('/node_modules/rc-table/')) {
return 'antd-table'
}
if (joined.includes('/node_modules/antd/es/input') || joined.includes('/node_modules/rc-input/')) {
return 'antd-input'
}
if (joined.includes('/node_modules/antd/es/button') || joined.includes('/node_modules/rc-button/')) {
return 'antd-button'
}
if (joined.includes('/node_modules/antd/es/collapse') || joined.includes('/node_modules/rc-collapse/')) {
return 'antd-collapse'
}
if (joined.includes('/node_modules/antd/es/skeleton')) {
return 'antd-skeleton'
}
if (joined.includes('/node_modules/antd/es/grid') || joined.includes('/node_modules/antd/es/row')) {
return 'antd-grid'
}
if (joined.includes('/node_modules/antd/es/locale') || joined.includes('/node_modules/antd/locale/')) {
return 'antd-locale'
}
if (joined.includes('/node_modules/@ant-design/icons/')) {
return 'antd-icons'
}
if (joined.includes('/src/components/')) {
return 'app-components'
}
if (joined.includes('/src/utils/')) {
return 'app-utils'
}
return 'shared'
}
// https://vitejs.dev/config/
export default defineConfig({
// base: '/pms-react-new/', // Set base URL if deploying to a sub-path
@ -11,6 +48,35 @@ export default defineConfig({
'@': path.resolve(__dirname, './src'), // Alias @ to ./src
},
},
build: {
rollupOptions: {
output: {
chunkFileNames(chunkInfo) {
const moduleIds = chunkInfo.moduleIds ?? []
const chunkName = chunkInfo.name === 'index' ? inferChunkName(moduleIds) : chunkInfo.name
return `assets/${chunkName}-[hash].js`
},
manualChunks(id) {
if (!id.includes('node_modules')) {
return undefined;
}
if (/node_modules\/(react|react-dom|scheduler)\//.test(id)) {
return 'react-vendor';
}
if (/node_modules\/(echarts|echarts-for-react)\//.test(id)) {
return 'echarts-vendor';
}
if (/node_modules\/(react-router|react-router-dom)\//.test(id)) {
return 'router-vendor';
}
if (/node_modules\/dayjs\//.test(id)) {
return 'dayjs-vendor';
}
return undefined;
},
},
},
},
server: {
proxy: {
'/api': {