| | |
| | | > **用法**:新对话开始后,先读本文件 + 项目根目录 `AGENTS.md`,即可无缝接力。 |
| | | > **每天结束时**:把当天进展、踩过的坑、新需求、未完成事项更新到本文件,然后可以放心开新对话。 |
| | | |
| | | ## 今日(2026-08-28) |
| | | |
| | | ### 1. 登录验证码:测试阶段可跳过(已完成并验证) |
| | | - 后端新增 `app.captcha.required` 开关(缺省 `true`=启用);本机 application.yml 与 example 现为 `false`(测试阶段跳过,**正式发布前改回 `true`**)。 |
| | | - LoginController:`captchaRequired=false` 时登录不校验验证码;新增公开接口 `GET /api/auth/captcha-config` 返回 `{required}`。 |
| | | - Login.vue:启动时拉取该配置;`required=false` 时隐藏验证码区域并显示「测试模式:行为验证码已跳过」提示,登录跳过验证;配置请求失败时默认按启用处理(安全兜底)。 |
| | | - 已实测:captcha-config 返回 required=false;admin/admin123 不带验证码直接登录 200;后端 mvn compile ✅、前端 npm run build ✅。 |
| | | - ⚠️ 需重新打包才在测试环境生效;正式发布时把 `app.captcha.required` 改回 `true`。 |
| | | |
| | | ### 2. 数据导入浏览器崩溃排查(进行中,用户线索更新) |
| | | - 用户补充:共 3 台机器出现(对方开发机 + 昨天打包测试 1 台 + 今天 1 台);对方记录的「迅雷/驱动」是其主观判断;现象时好时坏、每台约 1/3 用户崩。 |
| | | - 已排除:/import/batches(limit≤200、81 条约 17KB);进入页面无轮询/定时器。 |
| | | - 用户怀疑「目录导入(浏览/扫描目录)」→ 已加固(见下)。注意:该功能需用户主动打开目录浏览/点目录导入才触发,与「点数据导入菜单就崩」现象不完全吻合,**待用户确认崩溃时点的具体按钮**。 |
| | | - 加固(已完成,编译/构建通过):后端 `listDirs` 子目录/文件列表各限 300(新增 `dirsTruncated`/`filesTruncated` 标记,`excelCount` 仍为真实总数);前端「从目录批量导入」每批限 60 个文件(超限提示分批);目录浏览弹窗显示截断提示。 |
| | | - 待办:让崩溃机器用户在 Chrome 开 `chrome://crashes` 记下原因码(OOM / GPU / STATUS_ACCESS_VIOLATION 等),并确认崩溃时点的是侧边栏「数据导入」菜单还是页面上某个按钮;据此决定下一步。 |
| | | |
| | | ### 3. 待办不变 |
| | | - `docs/打包部署说明.docx` 第六节「外置 application.yml 需包含完整配置」改为「属性级合并」后重新导出 docx(未做)。 |
| | | - 崩溃根因确认后:修正 commit `cf57c2b` 中「指向客户端环境」的旧结论。 |
| | | |
| | | |
| | | ## 〇、关机交接(2026-08-27 深夜,用户关机前) |
| | | - **当前运行状态**:前后端应用服务全部已停止(开发服务、打包版均未运行);仅数据库在跑 |
| | | (MySQL80 服务=3305,本项目用;MySQL57=3306 勿动);8080 上 IIS 站点仍在但本项目已不再依赖 IIS。 |
| | |
| | | result.put("parent", cur.getParent() == null ? "" : cur.getParent()); |
| | | List<Map<String, Object>> dirs = new ArrayList<>(); |
| | | File[] subs = cur.listFiles(File::isDirectory); |
| | | int dirTotal = 0; |
| | | if (subs != null) { |
| | | Arrays.sort(subs, Comparator.comparing(File::getName)); |
| | | for (File d : subs) { |
| | | if (d.isHidden()) continue; |
| | | dirTotal++; |
| | | if (dirs.size() >= 300) continue; |
| | | Map<String, Object> m = new LinkedHashMap<>(); |
| | | m.put("name", d.getName()); |
| | | m.put("path", d.getAbsolutePath()); |
| | |
| | | } |
| | | } |
| | | result.put("dirs", dirs); |
| | | result.put("dirsTruncated", dirTotal > dirs.size()); |
| | | List<String> excelFiles = new ArrayList<>(); |
| | | File[] files = cur.listFiles((d, n) -> { |
| | | String lower = n.toLowerCase(); |
| | | return lower.endsWith(".xlsx") || lower.endsWith(".xls"); |
| | | }); |
| | | int excelTotal = files == null ? 0 : files.length; |
| | | if (files != null) { |
| | | Arrays.sort(files, Comparator.comparing(File::getName)); |
| | | for (File f : files) excelFiles.add(f.getName()); |
| | | int take = Math.min(files.length, 300); |
| | | for (int i = 0; i < take; i++) excelFiles.add(files[i].getName()); |
| | | } |
| | | result.put("excelCount", excelFiles.size()); |
| | | result.put("excelCount", excelTotal); |
| | | result.put("files", excelFiles); |
| | | result.put("filesTruncated", excelTotal > excelFiles.size()); |
| | | return result; |
| | | } |
| | | |
| | |
| | | import org.springframework.security.crypto.password.PasswordEncoder; |
| | | import org.springframework.web.bind.annotation.*; |
| | | |
| | | import org.springframework.beans.factory.annotation.Value; |
| | | import javax.annotation.Resource; |
| | | import java.util.Collections; |
| | | import java.util.HashMap; |
| | |
| | | @Resource |
| | | private PasswordEncoder passwordEncoder; |
| | | |
| | | @Value("${app.captcha.required:true}") |
| | | private boolean captchaRequired; |
| | | |
| | | @PostMapping("/login") |
| | | public Result<Map<String, Object>> login(@RequestBody Map<String, Object> params) { |
| | | String username = (String) params.get("username"); |
| | |
| | | String captchaId = (String) params.get("captchaId"); |
| | | List<String> clickIds = strList(params.get("captchaClickIds")); |
| | | |
| | | CaptchaService.ConsumeResult cr = captchaService.consume(captchaId, clickIds); |
| | | if (cr == CaptchaService.ConsumeResult.NOT_FOUND) { |
| | | return Result.error(4002, "验证码不存在或已过期,请刷新重试"); |
| | | } |
| | | if (cr == CaptchaService.ConsumeResult.NOT_VERIFIED) { |
| | | return Result.error(4003, "请先完成行为验证"); |
| | | } |
| | | if (cr == CaptchaService.ConsumeResult.WRONG) { |
| | | return Result.error(4001, "行为验证未通过,请重新验证"); |
| | | if (captchaRequired) { |
| | | CaptchaService.ConsumeResult cr = captchaService.consume(captchaId, clickIds); |
| | | if (cr == CaptchaService.ConsumeResult.NOT_FOUND) { |
| | | return Result.error(4002, "验证码不存在或已过期,请刷新重试"); |
| | | } |
| | | if (cr == CaptchaService.ConsumeResult.NOT_VERIFIED) { |
| | | return Result.error(4003, "请先完成行为验证"); |
| | | } |
| | | if (cr == CaptchaService.ConsumeResult.WRONG) { |
| | | return Result.error(4001, "行为验证未通过,请重新验证"); |
| | | } |
| | | } |
| | | |
| | | if (username == null || username.trim().isEmpty() || password == null || password.isEmpty()) { |
| | |
| | | return Result.ok(data); |
| | | } |
| | | |
| | | @GetMapping("/captcha-config") |
| | | public Result<Map<String, Object>> captchaConfig() { |
| | | Map<String, Object> data = new HashMap<>(); |
| | | data.put("required", captchaRequired); |
| | | return Result.ok(data); |
| | | } |
| | | |
| | | @SuppressWarnings("unchecked") |
| | | private List<String> strList(Object o) { |
| | | if (o instanceof List) { |
| | |
| | | api-key: ${DEEPSEEK_API_KEY:your-deepseek-api-key} |
| | | api-url: https://api.deepseek.com/v1/chat/completions |
| | | model: deepseek-chat |
| | | |
| | | # Behavior captcha on login. Set to false to skip verification during test phase; enable before production release. |
| | | app: |
| | | captcha: |
| | | required: false |
| | |
| | | <div class="tip">单击文件夹进入子文件夹;<b>双击目标文件夹立即导入</b>其中全部 Excel;也可进入后点下方按钮。导入时会按文件名自动识别月份。</div> |
| | | <div v-if="dirExcelCount === 0" class="tip">当前文件夹内没有 Excel 文件(.xlsx/.xls)</div> |
| | | <div v-else class="tip">当前文件夹内 Excel:{{ dirExcelCount }} 个 —— {{ dirFiles.slice(0, 6).join('、') }}{{ dirFiles.length > 6 ? ' 等' : '' }}(仅导入直接位于该文件夹的文件)</div> |
| | | <div v-if="dirDirsTruncated" class="tip">子文件夹过多,仅显示前 300 个</div> |
| | | <div v-if="dirFilesTruncated" class="tip">Excel 文件过多(共 {{ dirExcelCount }} 个),仅显示前 300 个</div> |
| | | </div> |
| | | <span slot="footer"> |
| | | <el-button size="small" @click="dirDialog = false">取消</el-button> |
| | |
| | | dirCurrent: '', |
| | | dirParent: '', |
| | | dirDirs: [], |
| | | dirDirsTruncated: false, |
| | | dirExcelCount: 0, |
| | | dirFilesTruncated: false, |
| | | dirFiles: [], |
| | | dirLoading: false, |
| | | dirImporting: false, |
| | |
| | | this.dirCurrent = d.current || '' |
| | | this.dirParent = d.parent || '' |
| | | this.dirDirs = d.dirs || [] |
| | | this.dirDirsTruncated = !!d.dirsTruncated |
| | | this.dirExcelCount = d.excelCount || 0 |
| | | this.dirFiles = d.files || [] |
| | | this.dirFilesTruncated = !!d.filesTruncated |
| | | }) |
| | | .catch(e => { this.$message.error((e.response && e.response.data && e.response.data.message) || '目录加载失败') }) |
| | | .finally(() => { this.dirLoading = false }) |
| | |
| | | const d = (res && res.data) || {} |
| | | const files = (d.files || []).filter(n => /\.(xlsx|xls)$/i.test(n)) |
| | | if (!files.length) { this.$message.warning('该目录没有 Excel 文件(.xlsx/.xls):' + dir); this.dirImporting = false; return } |
| | | if (files.length > 60) { |
| | | this.dirImporting = false |
| | | this.$message.warning('目录内 Excel 文件过多(' + files.length + ' 个),为避免浏览器卡顿请分批导入(每批不超过 60 个)') |
| | | return |
| | | } |
| | | localStorage.setItem('dirLast_' + type, dir) |
| | | this.dirPath = dir |
| | | this.dirImportFiles = files.map(n => ({ name: n, status: 'pending', period: '', msg: '' })) |
| | |
| | | <el-form-item prop="password"> |
| | | <el-input v-model="form.password" type="password" placeholder="密码" @keyup.enter.native="login"></el-input> |
| | | </el-form-item> |
| | | <el-form-item> |
| | | <el-form-item v-if="captchaRequired"> |
| | | <captcha3-d ref="captcha" @verified="onCaptchaVerified" @reset="onCaptchaReset"></captcha3-d> |
| | | </el-form-item> |
| | | <el-form-item v-else> |
| | | <div class="captcha-skip-tip">测试模式:行为验证码已跳过,可直接点击登录(正式发布后将启用)</div> |
| | | </el-form-item> |
| | | <el-form-item> |
| | | <el-button type="primary" :loading="loggingIn" @click="login" style="width:100%">登 录</el-button> |
| | |
| | | captchaVerified: false, |
| | | captchaId: '', |
| | | clickIds: [], |
| | | loggingIn: false |
| | | loggingIn: false, |
| | | captchaRequired: true |
| | | } |
| | | }, |
| | | created() { |
| | | axios.get('/api/auth/captcha-config') |
| | | .then(res => { |
| | | const d = res.data |
| | | if (d && d.code === 200) this.captchaRequired = d.data.required !== false |
| | | }) |
| | | .catch(() => {}) |
| | | }, |
| | | methods: { |
| | | onCaptchaVerified(payload) { |
| | |
| | | login() { |
| | | this.$refs.form.validate(valid => { |
| | | if (!valid) return |
| | | if (!this.captchaVerified) { |
| | | if (this.captchaRequired && !this.captchaVerified) { |
| | | this.$message.warning('请先完成行为验证') |
| | | return |
| | | } |
| | |
| | | this.$router.push('/dashboard') |
| | | } else if (d.code === 401) { |
| | | this.$message.error(d.message || '用户名或密码错误') |
| | | this.$refs.captcha.load() |
| | | if (this.$refs.captcha) this.$refs.captcha.load() |
| | | } else if (d.code === 4001 || d.code === 4002 || d.code === 4003) { |
| | | this.$message.error(d.message || '验证未通过,请重新验证') |
| | | this.$refs.captcha.load() |
| | | if (this.$refs.captcha) this.$refs.captcha.load() |
| | | } else { |
| | | this.$message.error(d.message || '登录失败,请重试') |
| | | this.$refs.captcha.load() |
| | | if (this.$refs.captcha) this.$refs.captcha.load() |
| | | } |
| | | }).catch(() => { |
| | | this.loggingIn = false |
| | |
| | | </script> |
| | | <style scoped> |
| | | .login-container { display: flex; justify-content: center; align-items: center; min-height: 100vh; padding: 20px 0; box-sizing: border-box; background: #f0f2f5; } |
| | | .captcha-skip-tip { font-size: 12px; color: #909399; line-height: 1.6; padding: 8px 10px; background: #F5F7FA; border-radius: 4px; } |
| | | .login-card { width: 400px; } |
| | | .login-card h2 { text-align: center; margin-bottom: 18px; } |
| | | .login-card .el-form-item { margin-bottom: 16px; } |