diff --git a/admin-frontend-vue/src/pages/records/RecordsHistoryPage.vue b/admin-frontend-vue/src/pages/records/RecordsHistoryPage.vue index d90c0ecc..37b642f0 100644 --- a/admin-frontend-vue/src/pages/records/RecordsHistoryPage.vue +++ b/admin-frontend-vue/src/pages/records/RecordsHistoryPage.vue @@ -2,11 +2,12 @@ /** 历史生成记录页(module 13 task 259 对齐 admin panel-history):指定用户下拉 + 起止时间筛选、类型中文、≤3 缩略图、大图预览、空态。 */ import { formatDateTime } from '@/utils/datetime' import { computed, onMounted, reactive, ref } from 'vue' -import { fetchHistoryList, fetchHistoryUserOptions } from './history-api.ts' +import { fetchHistoryList, fetchHistoryUserOptions, clearHistory } from './history-api.ts' import type { HistoryUserOption } from './history-user-option.ts' import type { HistoryRecordItem } from './history-dto.ts' import { historyEmptyText, historyErrorText } from './history-feedback.ts' import { historyPanelTypeLabel } from './history-type.ts' +import { ElMessage, ElMessageBox } from 'element-plus' const loading = ref(false) const userLoading = ref(false) @@ -98,6 +99,32 @@ function openPreview(item: HistoryRecordItem) { previewVisible.value = true } +const clearing = ref(false) + +/** 清空全部生成记录:二次确认后调后端 DELETE /api/admin/history。 */ +async function clearAll() { + try { + await ElMessageBox.confirm('确定清空全部生成记录吗?该操作会删除所有历史数据,无法恢复。', '清理生成记录', { + confirmButtonText: '确认清空', + cancelButtonText: '取消', + type: 'warning', + }) + } catch { + return + } + clearing.value = true + try { + const removed = await clearHistory() + ElMessage.success(`已清空 ${removed} 条生成记录`) + page.value = 1 + await load() + } catch (error) { + ElMessage.error(error instanceof Error ? error.message : '清理失败') + } finally { + clearing.value = false + } +} + onMounted(() => { load() loadUserOptions() @@ -111,6 +138,9 @@ onMounted(() => {

生成记录

查看各用户在后台生成的历史记录与结果图。

+
+ 清理数据 +
diff --git a/admin-frontend-vue/src/pages/records/history-api.ts b/admin-frontend-vue/src/pages/records/history-api.ts index e28f29a7..ce6a99d6 100644 --- a/admin-frontend-vue/src/pages/records/history-api.ts +++ b/admin-frontend-vue/src/pages/records/history-api.ts @@ -17,3 +17,11 @@ export async function fetchHistoryUserOptions(): Promise { const { data } = await http.get('/api/admin/users', { params: { page: 1, page_size: 999 } }) return parseHistoryUserOptions(data) } + +/** 清空全部生成记录(验收反馈:生成记录数据要可清理)。返回清理条数。 */ +export async function clearHistory(): Promise { + const { data } = await http.delete(HISTORY_ENDPOINT) + const record = data && typeof data === 'object' ? (data as Record) : null + const removed = record?.data + return typeof removed === 'number' ? removed : 0 +} diff --git a/admin-frontend-vue/tests/align-history-clear.test.ts b/admin-frontend-vue/tests/align-history-clear.test.ts new file mode 100644 index 00000000..f2086f2f --- /dev/null +++ b/admin-frontend-vue/tests/align-history-clear.test.ts @@ -0,0 +1,34 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readSource } from './helpers.ts' + +// 验收反馈:生成记录的数据要可清理。 +// 契约:页面有「清理数据」入口(二次确认)、前端 DELETE /api/admin/history、后端清空端点与日志。 + +test('align_history_clear_normal_primary_path', () => { + const page = readSource('src/pages/records/RecordsHistoryPage.vue') + assert.match(page, /清理数据/, '页头有清理数据按钮') + assert.match(page, /确定清空全部生成记录吗/, '清理前需二次确认') + assert.match(page, /clearHistory\(\)/, '页面调用清理 API') +}) + +test('align_history_clear_normal_variant_input', () => { + const api = readSource('src/pages/records/history-api.ts') + assert.match(api, /http\.delete\(HISTORY_ENDPOINT\)/, '清理走 DELETE /api/admin/history') + assert.match(api, /clearHistory/, '暴露 clearHistory 适配函数') +}) + +test('align_history_clear_boundary_empty_input', () => { + const page = readSource('src/pages/records/RecordsHistoryPage.vue') + assert.match(page, /已清空 \$\{removed\} 条生成记录/, '成功提示含清理条数') +}) + +test('align_history_clear_dependency_failure_returns_actionable_message', () => { + // 后端契约:DELETE /history 端点 + service 清空 + 中文排查日志。 + const ctrl = readSource('../backend-java/src/main/java/com/nanri/aiimage/modules/imagehistory/controller/ImageHistoryController.java') + assert.match(ctrl, /@DeleteMapping\("\/history"\)/, '后端有 DELETE /api/admin/history 端点') + assert.match(ctrl, /admin_history/, '清理端点沿用生成记录模块权限') + const service = readSource('../backend-java/src/main/java/com/nanri/aiimage/modules/imagehistory/service/ImageHistoryService.java') + assert.match(service, /clearAllHistory/, 'service 有清空方法') + assert.match(service, /清空生成记录,共清理/, '清空操作留中文排查日志') +}) diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/imagehistory/controller/ImageHistoryController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/imagehistory/controller/ImageHistoryController.java index 84eb878a..c99360f6 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/imagehistory/controller/ImageHistoryController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/imagehistory/controller/ImageHistoryController.java @@ -11,6 +11,7 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; @@ -43,4 +44,15 @@ public class ImageHistoryController { ImageHistoryListVo vo = imageHistoryService.listHistory(page, pageSize, userId, timeStart, timeEnd); return ApiResponse.success(vo); } + + @DeleteMapping("/history") + @Operation(summary = "清空全部生成记录") + public ApiResponse clearHistory(HttpServletRequest request) { + AdminUserEntity operator = adminAuthSupport.requireAdminOrInternal(request); + if (!permissionMenuService.hasAnyAdminMenu(operator, List.of("admin_history"))) { + throw new BusinessException(403, "无权访问生成记录模块"); + } + long removed = imageHistoryService.clearAllHistory(operator.getUsername()); + return ApiResponse.success(removed); + } } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/imagehistory/service/ImageHistoryService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/imagehistory/service/ImageHistoryService.java index f2a34ca5..36c7e644 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/imagehistory/service/ImageHistoryService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/imagehistory/service/ImageHistoryService.java @@ -49,24 +49,21 @@ public class ImageHistoryService { if (safeSize < 10) safeSize = 10; if (safeSize > 50) safeSize = 50; - LambdaQueryWrapper query = new LambdaQueryWrapper<>(); - if (userId != null && userId > 0) { - query.eq(ImageHistoryEntity::getUserId, userId); - } LocalDateTime startDt = parseDateTime(timeStart, false); - if (startDt != null) { - query.ge(ImageHistoryEntity::getCreatedAt, startDt); - } LocalDateTime endDt = parseDateTime(timeEnd, true); - if (endDt != null) { - query.le(ImageHistoryEntity::getCreatedAt, endDt); - } - Long total = imageHistoryMapper.selectCount(query); - query.orderByDesc(ImageHistoryEntity::getCreatedAt); + Long total = imageHistoryMapper.selectCount(filterOf(userId, startDt, endDt)); + + // 先只取本页 id(仅排 id/时间小列,避免对大文本 filesort 撑爆 sort buffer),再按 id 取行。 + LambdaQueryWrapper idPage = filterOf(userId, startDt, endDt) + .select(ImageHistoryEntity::getId) + .orderByDesc(ImageHistoryEntity::getCreatedAt); int offset = (safePage - 1) * safeSize; - query.last("LIMIT " + safeSize + " OFFSET " + offset); - List rows = imageHistoryMapper.selectList(query); + idPage.last("LIMIT " + safeSize + " OFFSET " + offset); + List pageIds = imageHistoryMapper.selectList(idPage).stream() + .map(ImageHistoryEntity::getId) + .toList(); + List rows = rowsByIds(pageIds); Map usernameMap = loadUsernameMap(rows); @@ -83,6 +80,50 @@ public class ImageHistoryService { return vo; } + /** 清空全部生成记录(验收反馈:生成记录数据要可清理)。返回清理条数。 */ + public long clearAllHistory(String operatorName) { + Long count = imageHistoryMapper.selectCount(new LambdaQueryWrapper<>()); + long removed = count == null ? 0L : count; + imageHistoryMapper.delete(new LambdaQueryWrapper<>()); + log.info("[image-history] 管理员 {} 清空生成记录,共清理 {} 条", operatorName == null ? "unknown" : operatorName, removed); + return removed; + } + + /** 按用户/时间范围构造筛选条件(不排序、不分页)。 */ + private LambdaQueryWrapper filterOf(Long userId, LocalDateTime startDt, LocalDateTime endDt) { + LambdaQueryWrapper filter = new LambdaQueryWrapper<>(); + if (userId != null && userId > 0) { + filter.eq(ImageHistoryEntity::getUserId, userId); + } + if (startDt != null) { + filter.ge(ImageHistoryEntity::getCreatedAt, startDt); + } + if (endDt != null) { + filter.le(ImageHistoryEntity::getCreatedAt, endDt); + } + return filter; + } + + /** 按本页 id 顺序回表取完整行(id 集合小,避免对全量大文本字段排序)。 */ + private List rowsByIds(List ids) { + if (ids == null || ids.isEmpty()) { + return Collections.emptyList(); + } + List fetched = imageHistoryMapper.selectBatchIds(ids); + Map byId = new HashMap<>(fetched.size() * 2); + for (ImageHistoryEntity row : fetched) { + byId.put(row.getId(), row); + } + List ordered = new ArrayList<>(ids.size()); + for (Long id : ids) { + ImageHistoryEntity row = byId.get(id); + if (row != null) { + ordered.add(row); + } + } + return ordered; + } + private Map loadUsernameMap(List rows) { Set userIds = new LinkedHashSet<>(); for (ImageHistoryEntity r : rows) {