fix: 菜单高亮间歇性失效根因(el-menu 同路由导航回滚 activeIndex)——default-active 绑定路由+select 后宏任务纠正;feat: 修改密码(用户自改)+ admin 重置他人密码为 123456
| | |
| | | sys_user 已加 modules 列;admin 密码已重置为 BCrypt(admin/admin123)。 |
| | | - 无已知阻塞性问题。 |
| | | |
| | | ### 6. 08-31:菜单高亮间歇性根因修复(实锤)+ 修改密码功能(已完成并验证) |
| | | - **菜单高亮根因实锤**:不是 el-tabs 遗留问题,而是 Element UI `el-menu` 的 `handleItemClick` 在**同路由导航**(vue-router NavigationDuplicated)时,`routeToItem` 的 onError 回调把 `activeIndex` 恢复为点击前旧值(其只排除了 push 抛异常路径,未排除同路由回调路径)。触发场景:上一次菜单导航未完成时快速点另一个菜单(如「数据导入」未切完就点「审核结果」)→ 页面已是目标页但高亮停留在旧菜单,**间歇性、时好时坏**(复现:puppeteer 快速连点,puppeteer 用 node24:`C:\Users\jcxiong\.cache\codex-runtimes\codex-primary-runtime\dependencies\node\bin\node.exe`)。 |
| | | - **修复**:8 个页面 el-menu 统一改为 `ref="sideMenu"` + `:default-active="$route.path"` + `@select="onMenuSelect"`;`onMenuSelect` 加在 main.js 全局 mixin(setTimeout 宏任务强制纠正 activeIndex,宏任务晚于 push 的微任务 onError 回调,保证最终高亮正确)。验证:6 轮×3 场景(顺序点/快速连点/同路由往返)全部高亮正确。 |
| | | - **修改密码**:后端 `POST /api/auth/change-password`(用户改自己密码,需旧密码校验、新密码≥6位、新旧不同)+ `POST /api/user/reset-password`(仅 admin,重置他人为 123456,admin 自身禁止重置);JwtUtils 新增 `resolveUsername(Authorization头)`。前端 AppHeader 公共顶栏加「修改密码」弹窗(旧/新/确认),UserManage 操作列加「重置密码」按钮(`isAdmin` 控制显示,非 admin 隐藏+后端 403 双保险)。API 与 UI(admin/非admin 双视角)均已验证,**测试后数据库密码已恢复原状**(admin/admin123 有效,chenzj 等原 hash 未变)。 |
| | | - 测试脚本:`D:\Codex\_tools\pptr-core\menu_audit_rounds.js`(高亮回归)、`pwd_ui_test.js`(改密 UI)。 |
| | | |
| | | ## 二、快速启动 |
| | | 统一用一键脚本(必须沙箱外/提权运行): |
| | | ``` |
| | |
| | | return Result.ok(data); |
| | | } |
| | | |
| | | /** 用户修改自己的密码:需提供原密码,新密码至少 6 位 */ |
| | | @PostMapping("/change-password") |
| | | public Result<Void> changePassword(@RequestHeader(value = "Authorization", required = false) String auth, |
| | | @RequestBody Map<String, Object> params) { |
| | | String username = jwtUtils.resolveUsername(auth); |
| | | if (username == null) { |
| | | return Result.error(401, "未登录或登录已过期"); |
| | | } |
| | | String oldPassword = (String) params.get("oldPassword"); |
| | | String newPassword = (String) params.get("newPassword"); |
| | | if (oldPassword == null || oldPassword.isEmpty()) { |
| | | return Result.error(400, "请输入原密码"); |
| | | } |
| | | if (newPassword == null || newPassword.length() < 6) { |
| | | return Result.error(400, "新密码长度不能少于 6 位"); |
| | | } |
| | | if (newPassword.equals(oldPassword)) { |
| | | return Result.error(400, "新密码不能与原密码相同"); |
| | | } |
| | | User user = userMapper.selectOne(new LambdaQueryWrapper<User>().eq(User::getUsername, username)); |
| | | if (user == null) { |
| | | return Result.error(404, "用户不存在"); |
| | | } |
| | | String encoded = user.getPassword(); |
| | | boolean ok; |
| | | try { |
| | | ok = encoded != null && passwordEncoder.matches(oldPassword, encoded); |
| | | } catch (IllegalArgumentException e) { |
| | | // 库中密码不是合法 BCrypt 时回退为明文对比(与登录一致) |
| | | ok = encoded != null && encoded.equals(oldPassword); |
| | | } |
| | | if (!ok) { |
| | | return Result.error(400, "原密码错误"); |
| | | } |
| | | user.setPassword(passwordEncoder.encode(newPassword)); |
| | | userMapper.updateById(user); |
| | | return Result.ok(null); |
| | | } |
| | | |
| | | @GetMapping("/captcha-config") |
| | | public Result<Map<String, Object>> captchaConfig() { |
| | | Map<String, Object> data = new HashMap<>(); |
| | |
| | | return false; |
| | | } |
| | | } |
| | | |
| | | /** 从 Authorization: Bearer xxx 请求头解析用户名;无效返回 null */ |
| | | public String resolveUsername(String authorizationHeader) { |
| | | if (authorizationHeader == null || !authorizationHeader.startsWith("Bearer ")) { |
| | | return null; |
| | | } |
| | | try { |
| | | return getUsernameFromToken(authorizationHeader.substring(7)); |
| | | } catch (Exception e) { |
| | | return null; |
| | | } |
| | | } |
| | | } |
| | |
| | | |
| | | import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; |
| | | import com.trafficaudit.common.Result; |
| | | import com.trafficaudit.security.utils.JwtUtils; |
| | | import com.trafficaudit.system.entity.User; |
| | | import com.trafficaudit.system.mapper.UserMapper; |
| | | import org.springframework.security.crypto.password.PasswordEncoder; |
| | |
| | | private UserMapper userMapper; |
| | | @Resource |
| | | private PasswordEncoder passwordEncoder; |
| | | @Resource |
| | | private JwtUtils jwtUtils; |
| | | |
| | | @GetMapping("/list") |
| | | public Result<List<Map<String, Object>>> list(@RequestParam(value = "keyword", required = false) String keyword) { |
| | |
| | | return Result.ok(null); |
| | | } |
| | | |
| | | /** admin 重置他人密码为 123456 */ |
| | | @PostMapping("/reset-password") |
| | | public Result<Void> resetPassword(@RequestHeader(value = "Authorization", required = false) String auth, |
| | | @RequestBody Map<String, Object> params) { |
| | | String operator = jwtUtils.resolveUsername(auth); |
| | | if (operator == null) { |
| | | return Result.error(401, "未登录或登录已过期"); |
| | | } |
| | | if (!"admin".equals(operator)) { |
| | | return Result.error(403, "仅 admin 可以重置其他用户密码"); |
| | | } |
| | | Object idObj = params.get("id"); |
| | | if (idObj == null) { |
| | | return Result.error(400, "缺少用户 id"); |
| | | } |
| | | Long id = Long.valueOf(idObj.toString()); |
| | | User u = userMapper.selectById(id); |
| | | if (u == null) { |
| | | return Result.error(404, "用户不存在"); |
| | | } |
| | | if ("admin".equals(u.getUsername())) { |
| | | return Result.error(400, "admin 密码不允许重置"); |
| | | } |
| | | u.setPassword(passwordEncoder.encode("123456")); |
| | | userMapper.updateById(u); |
| | | return Result.ok(null); |
| | | } |
| | | |
| | | @PostMapping("/delete") |
| | | public Result<Void> delete(@RequestParam("id") Long id) { |
| | | User u = userMapper.selectById(id); |
| | |
| | | <span class="header-user"> |
| | | <i class="el-icon-user"></i> {{ username }} |
| | | </span> |
| | | <el-button type="text" class="header-link" @click="openChangePwd"> |
| | | <i class="el-icon-key"></i> 修改密码 |
| | | </el-button> |
| | | <el-button type="text" class="header-logout" @click="logout"> |
| | | <i class="el-icon-switch-button"></i> 退出登录 |
| | | </el-button> |
| | | </div> |
| | | |
| | | <el-dialog title="修改密码" :visible.sync="pwdDialogVisible" width="420px" @closed="resetPwdForm"> |
| | | <el-form ref="pwdForm" :model="pwdForm" :rules="pwdRules" label-width="90px"> |
| | | <el-form-item label="原密码" prop="oldPassword"> |
| | | <el-input v-model="pwdForm.oldPassword" type="password" show-password placeholder="请输入原密码"></el-input> |
| | | </el-form-item> |
| | | <el-form-item label="新密码" prop="newPassword"> |
| | | <el-input v-model="pwdForm.newPassword" type="password" show-password placeholder="至少 6 位"></el-input> |
| | | </el-form-item> |
| | | <el-form-item label="确认新密码" prop="confirmPassword"> |
| | | <el-input v-model="pwdForm.confirmPassword" type="password" show-password placeholder="再次输入新密码"></el-input> |
| | | </el-form-item> |
| | | </el-form> |
| | | <div slot="footer"> |
| | | <el-button @click="pwdDialogVisible = false">取消</el-button> |
| | | <el-button type="primary" :loading="pwdSaving" @click="submitPwd">保存</el-button> |
| | | </div> |
| | | </el-dialog> |
| | | </el-header> |
| | | </template> |
| | | |
| | | <script> |
| | | import api from '@/api' |
| | | |
| | | export default { |
| | | name: 'AppHeader', |
| | | props: { |
| | | title: { type: String, required: true } |
| | | }, |
| | | data() { |
| | | return { |
| | | pwdDialogVisible: false, |
| | | pwdSaving: false, |
| | | pwdForm: { oldPassword: '', newPassword: '', confirmPassword: '' }, |
| | | pwdRules: { |
| | | oldPassword: [{ required: true, message: '请输入原密码', trigger: 'blur' }], |
| | | newPassword: [ |
| | | { required: true, message: '请输入新密码', trigger: 'blur' }, |
| | | { min: 6, message: '新密码长度不能少于 6 位', trigger: 'blur' } |
| | | ], |
| | | confirmPassword: [ |
| | | { required: true, message: '请再次输入新密码', trigger: 'blur' }, |
| | | { |
| | | validator: (rule, value, cb) => (value === this.pwdForm.newPassword ? cb() : cb(new Error('两次输入的密码不一致'))), |
| | | trigger: 'blur' |
| | | } |
| | | ] |
| | | } |
| | | } |
| | | }, |
| | | computed: { |
| | | username() { |
| | |
| | | } |
| | | }, |
| | | methods: { |
| | | openChangePwd() { |
| | | this.pwdForm = { oldPassword: '', newPassword: '', confirmPassword: '' } |
| | | this.pwdDialogVisible = true |
| | | this.$nextTick(() => this.$refs.pwdForm && this.$refs.pwdForm.clearValidate()) |
| | | }, |
| | | resetPwdForm() { |
| | | this.$refs.pwdForm && this.$refs.pwdForm.clearValidate() |
| | | }, |
| | | submitPwd() { |
| | | this.$refs.pwdForm.validate(valid => { |
| | | if (!valid) return |
| | | this.pwdSaving = true |
| | | api.post('/auth/change-password', { |
| | | oldPassword: this.pwdForm.oldPassword, |
| | | newPassword: this.pwdForm.newPassword |
| | | }) |
| | | .then(() => { |
| | | this.$message.success('密码修改成功') |
| | | this.pwdDialogVisible = false |
| | | }) |
| | | .catch(err => { |
| | | if (err && err._toast) return |
| | | const d = err && err.response && err.response.data |
| | | this.$message.error((d && d.message) || '密码修改失败') |
| | | }) |
| | | .finally(() => { this.pwdSaving = false }) |
| | | }) |
| | | }, |
| | | logout() { |
| | | this.$confirm('确定退出登录吗?', '提示', { |
| | | confirmButtonText: '退出', |
| | |
| | | line-height: normal; |
| | | } |
| | | .header-user { opacity: 0.92; } |
| | | .header-link { color: #fff; font-size: 14px; padding: 0; } |
| | | .header-link:hover { color: #ffd04b; } |
| | | .header-logout { color: #fff; font-size: 14px; padding: 0; } |
| | | .header-logout:hover { color: #ffd04b; } |
| | | </style> |
| | | </style> |
| | |
| | | // 全局登出:清除登录态并回登录页(各页面侧边栏「退出登录」菜单共用) |
| | | Vue.mixin({ |
| | | methods: { |
| | | // el-menu 同路由导航(NavigationDuplicated)时 Element UI 会把高亮回滚为点击前旧值; |
| | | // 点击后延迟一个宏任务强制纠正(宏任务晚于 push 的微任务回调,保证最终高亮正确)。 |
| | | onMenuSelect(index) { |
| | | setTimeout(() => { |
| | | const menu = this.$refs.sideMenu |
| | | if (menu && menu.activeIndex !== index) menu.activeIndex = index |
| | | }, 0) |
| | | }, |
| | | logout() { |
| | | sessionStorage.removeItem('token') |
| | | sessionStorage.removeItem('username') |
| | |
| | | <app-header title="交通统计报表审核系统 - 审核结果"></app-header> |
| | | <el-container> |
| | | <el-aside width="200px"> |
| | | <el-menu router default-active="/audit"> |
| | | <el-menu ref="sideMenu" router :default-active="$route.path" @select="onMenuSelect"> |
| | | <el-menu-item index="/dashboard">工作台</el-menu-item> |
| | | <el-menu-item index="/import">数据导入</el-menu-item> |
| | | <el-menu-item index="/data">数据查看</el-menu-item> |
| | |
| | | <app-header title="交通统计报表审核系统 - 工作台"></app-header> |
| | | <el-container> |
| | | <el-aside width="200px"> |
| | | <el-menu router default-active="/dashboard"> |
| | | <el-menu ref="sideMenu" router :default-active="$route.path" @select="onMenuSelect"> |
| | | <el-menu-item index="/dashboard">工作台</el-menu-item> |
| | | <el-menu-item index="/import">数据导入</el-menu-item> |
| | | <el-menu-item index="/data">数据查看</el-menu-item> |
| | |
| | | <app-header title="交通统计报表审核系统 - 数据导入"></app-header> |
| | | <el-container> |
| | | <el-aside width="200px"> |
| | | <el-menu router default-active="/import"> |
| | | <el-menu ref="sideMenu" router :default-active="$route.path" @select="onMenuSelect"> |
| | | <el-menu-item index="/dashboard">工作台</el-menu-item> |
| | | <el-menu-item index="/import">数据导入</el-menu-item> |
| | | <el-menu-item index="/data">数据查看</el-menu-item> |
| | |
| | | <app-header title="交通统计报表审核系统 - 数据查看"></app-header> |
| | | <el-container> |
| | | <el-aside width="200px"> |
| | | <el-menu router default-active="/data"> |
| | | <el-menu ref="sideMenu" router :default-active="$route.path" @select="onMenuSelect"> |
| | | <el-menu-item index="/dashboard">工作台</el-menu-item> |
| | | <el-menu-item index="/import">数据导入</el-menu-item> |
| | | <el-menu-item index="/data">数据查看</el-menu-item> |
| | |
| | | <app-header title="交通统计报表审核系统 - 企业解释"></app-header> |
| | | <el-container> |
| | | <el-aside width="200px"> |
| | | <el-menu router default-active="/explain"> |
| | | <el-menu ref="sideMenu" router :default-active="$route.path" @select="onMenuSelect"> |
| | | <el-menu-item index="/dashboard">工作台</el-menu-item> |
| | | <el-menu-item index="/import">数据导入</el-menu-item> |
| | | <el-menu-item index="/data">数据查看</el-menu-item> |
| | |
| | | <app-header title="交通统计报表审核系统 - 报表生成"></app-header> |
| | | <el-container> |
| | | <el-aside width="200px"> |
| | | <el-menu router :default-active="activeMenu"> |
| | | <el-menu ref="sideMenu" router :default-active="$route.path" @select="onMenuSelect"> |
| | | <el-menu-item index="/dashboard">工作台</el-menu-item> |
| | | <el-menu-item index="/import">数据导入</el-menu-item> |
| | | <el-menu-item index="/data">数据查看</el-menu-item> |
| | |
| | | data() { |
| | | return { |
| | | loading: '', |
| | | activeMenu: '/report', |
| | | activeKey: '', |
| | | updatedAt: '', |
| | | // 查询条件:cycle 周期(month 月报 / quarter 季报 / year 年报),period 报表期(月份 yyyy-MM 或年份 yyyy) |
| | |
| | | <app-header title="交通统计报表审核系统 - 规则管理"></app-header> |
| | | <el-container> |
| | | <el-aside width="200px"> |
| | | <el-menu router default-active="/rules"> |
| | | <el-menu ref="sideMenu" router :default-active="$route.path" @select="onMenuSelect"> |
| | | <el-menu-item index="/dashboard">工作台</el-menu-item> |
| | | <el-menu-item index="/import">数据导入</el-menu-item> |
| | | <el-menu-item index="/data">数据查看</el-menu-item> |
| | |
| | | <app-header title="交通统计报表审核系统 - 用户管理"></app-header> |
| | | <el-container> |
| | | <el-aside width="200px"> |
| | | <el-menu router :default-active="activeMenu"> |
| | | <el-menu ref="sideMenu" router :default-active="$route.path" @select="onMenuSelect"> |
| | | <el-menu-item index="/dashboard">工作台</el-menu-item> |
| | | <el-menu-item index="/import">数据导入</el-menu-item> |
| | | <el-menu-item index="/data">数据查看</el-menu-item> |
| | |
| | | <el-table-column label="操作" width="140" align="center"> |
| | | <template slot-scope="{ row }"> |
| | | <el-button type="text" size="mini" @click="openEdit(row)">编辑</el-button> |
| | | <el-button v-if="isAdmin" type="text" size="mini" @click="resetPwd(row)">重置密码</el-button> |
| | | <el-button type="text" size="mini" style="color:#f56c6c" @click="removeUser(row)">删除</el-button> |
| | | </template> |
| | | </el-table-column> |
| | |
| | | name: 'UserManage', |
| | | data() { |
| | | return { |
| | | activeMenu: '/users', |
| | | keyword: '', |
| | | list: [], |
| | | loading: false, |
| | |
| | | username: [{ required: true, message: '请输入用户名', trigger: 'blur' }], |
| | | password: [{ required: true, message: '请输入初始密码', trigger: 'blur' }] |
| | | } |
| | | } |
| | | }, |
| | | computed: { |
| | | isAdmin() { |
| | | return sessionStorage.getItem('username') === 'admin' |
| | | } |
| | | }, |
| | | mounted() { this.loadList() }, |
| | |
| | | .finally(() => { this.saving = false }) |
| | | }) |
| | | }, |
| | | resetPwd(row) { |
| | | this.$confirm('确定将用户「' + (row.realName || row.username) + '」的密码重置为 123456 吗?', '重置密码', { type: 'warning' }) |
| | | .then(() => api.post('/user/reset-password', { id: row.id })) |
| | | .then(() => { this.$message.success('已重置为 123456'); this.loadList() }) |
| | | .catch(() => {}) |
| | | }, |
| | | removeUser(row) { |
| | | this.$confirm('确定删除用户「' + (row.realName || row.username) + '」吗?', '删除确认', { type: 'warning' }) |
| | | .then(() => api.post('/user/delete', null, { params: { id: row.id } })) |