fix: 菜单高亮间歇性失效根因(el-menu 同路由导航回滚 activeIndex)——default-active 绑定路由+select 后宏任务纠正;feat: 修改密码(用户自改)+ admin 重置他人密码为 123456
14个文件已修改
202 ■■■■■ 已修改文件
HANDOFF.md 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/security/controller/LoginController.java 39 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/security/utils/JwtUtils.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-server/src/main/java/com/trafficaudit/system/controller/UserController.java 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/components/AppHeader.vue 76 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/main.js 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/views/AuditResult.vue 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/views/Dashboard.vue 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/views/DataImport.vue 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/views/DataView.vue 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/views/ExplainReview.vue 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/views/ReportExport.vue 3 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/views/RuleManage.vue 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
traffic-audit-web/src/views/UserManage.vue 15 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
HANDOFF.md
@@ -92,6 +92,12 @@
  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)。
## 二、快速启动
统一用一键脚本(必须沙箱外/提权运行):
```
traffic-audit-server/src/main/java/com/trafficaudit/security/controller/LoginController.java
@@ -83,6 +83,45 @@
        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<>();
traffic-audit-server/src/main/java/com/trafficaudit/security/utils/JwtUtils.java
@@ -45,4 +45,16 @@
            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;
        }
    }
}
traffic-audit-server/src/main/java/com/trafficaudit/system/controller/UserController.java
@@ -2,6 +2,7 @@
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;
@@ -22,6 +23,8 @@
    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) {
@@ -92,6 +95,34 @@
        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);
traffic-audit-web/src/components/AppHeader.vue
@@ -5,18 +5,62 @@
      <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() {
@@ -24,6 +68,34 @@
    }
  },
  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: '退出',
@@ -62,6 +134,8 @@
  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>
traffic-audit-web/src/main.js
@@ -10,6 +10,14 @@
// 全局登出:清除登录态并回登录页(各页面侧边栏「退出登录」菜单共用)
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')
traffic-audit-web/src/views/AuditResult.vue
@@ -3,7 +3,7 @@
    <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>
traffic-audit-web/src/views/Dashboard.vue
@@ -3,7 +3,7 @@
    <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>
traffic-audit-web/src/views/DataImport.vue
@@ -3,7 +3,7 @@
    <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>
traffic-audit-web/src/views/DataView.vue
@@ -3,7 +3,7 @@
    <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>
traffic-audit-web/src/views/ExplainReview.vue
@@ -3,7 +3,7 @@
    <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>
traffic-audit-web/src/views/ReportExport.vue
@@ -3,7 +3,7 @@
    <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>
@@ -154,7 +154,6 @@
  data() {
    return {
      loading: '',
      activeMenu: '/report',
      activeKey: '',
      updatedAt: '',
      // 查询条件:cycle 周期(month 月报 / quarter 季报 / year 年报),period 报表期(月份 yyyy-MM 或年份 yyyy)
traffic-audit-web/src/views/RuleManage.vue
@@ -3,7 +3,7 @@
    <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>
traffic-audit-web/src/views/UserManage.vue
@@ -3,7 +3,7 @@
    <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>
@@ -43,6 +43,7 @@
              <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>
@@ -101,7 +102,6 @@
  name: 'UserManage',
  data() {
    return {
      activeMenu: '/users',
      keyword: '',
      list: [],
      loading: false,
@@ -113,6 +113,11 @@
        username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
        password: [{ required: true, message: '请输入初始密码', trigger: 'blur' }]
      }
    }
  },
  computed: {
    isAdmin() {
      return sessionStorage.getItem('username') === 'admin'
    }
  },
  mounted() { this.loadList() },
@@ -168,6 +173,12 @@
          .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 } }))