diff --git a/system-admin/CLAUDE.md b/system-admin/CLAUDE.md new file mode 100644 index 0000000..133800b --- /dev/null +++ b/system-admin/CLAUDE.md @@ -0,0 +1,144 @@ +# CLAUDE.md — system-admin + +Backend module: Spring Boot 3.5 admin application (port 8080, context `/system-admin`). + +--- + +## Build & Run + +```bash +mvn clean install -DskipTests # full build (tests skipped by default) +mvn clean install -DskipTests=false # build with tests +mvn -pl system-admin -DskipTests=false -Dtest=YourTestClass test # single test +``` + +Tests use **JUnit 4** (`@RunWith(SpringRunner.class)`), not JUnit 5. Only 2 test files exist (Redis, dynamic datasource). No Maven wrapper (`mvnw`). Maven remote repository: Aliyun mirror. + +Launch from IntelliJ: +- `AdminApplication` (`system-admin/`) → port 8080, context `/system-admin` +- `GeneratorApplication` (`renren-generator/`) — commented out of build + +## Service layer pattern + +Two base classes in `system-common`: + +| Base | Purpose | +|---|---| +| `CrudService` | Generic CRUD: `page()`, `get()`, `save()`, `update()`, `delete()` | +| `BaseService` | Lighter base without DTO generic | + +Module convention: +``` +modules// +├── controller/ → @RestController, returns Result +├── dao/ → extends BaseMapper (MyBatis-Plus) +├── dto/ → request/query DTOs (often extends BaseEntity) +├── entity/ → @TableName JPA entity +├── service/ → interface extends CrudService/BaseService +│ └── impl/ → @Service implementation +├── excel/ → EasyExcel VO classes (optional) +└── vo/ → response VO classes (optional) +``` + +Mapper XMLs: `src/main/resources/mapper//**/*.xml` + +### Conventions + +- Lombok used throughout: `@Data`, `@AllArgsConstructor`, `@Slf4j` are standard on entity/service classes. +- DTO/Entity/VO separation per module — request DTOs often extend `BaseEntity`. + +## PK & Auth + +- PK: `ASSIGN_ID` (Snowflake) via `IdUtil.getSnowflakeNextId()`. All entities extend `BaseEntity`. Exception: `WeatherStationEntity` uses `AUTO_INCREMENT`. +- Auth: Apache Shiro 1.12 (**Jakarta classifier**) + OAuth2 token. Login → `token` header. +- **Do not introduce Spring Security** — the project uses Shiro exclusively. + +## Key cross-cutting mechanisms + +| Mechanism | How | +|---|---| +| **Data permissions** | `@DataFilter` on controller → `DataFilterAspect` → MyBatis interceptor injects dept-based SQL | +| **Auto-fill** | `FieldMetaObjectHandler` fills creator/date via MyBatis-Plus. **Only works with `insert()`/`updateById()`** — batch inserts (e.g. `insertBatchMultiRow`) bypass auto-fill; fields must be set manually. | +| **Scheduled jobs** | Quartz. `schedule_job` table, implements `ITask`, `@Component("beanName")`. Jobs auto-register at startup via `JobCommandLineRunner`. Seed data: `testTask` (paused, every 30 min), `fileScanTask` (paused, every 5 min), `weatherSummarizeCacheTask` (**active**, daily 1 AM). | +| **File scanning** | `WatchService` (primary, background thread) + Quartz fallback (`FileScanTask`) + startup runner (`FileScanStartupRunner`). Files identified by MD5 hash. | +| **Excel import** | EasyExcel + async dual-pass via `WeatherDataImportManager`. Progress tracked in-memory (`ConcurrentHashMap`), lost on restart. | +| **API responses** | Always wrapped in `Result`. Frontend expects `code === 0` for success. | +| **Validation** | Hibernate Validator. XSS filter via `XssFilter`. i18n messages in `system-common/src/main/resources/i18n/validation.properties`. | + +## Exception handling + +Single `@RestControllerAdvice` handler in the admin module: + +| Handler | Catches | Persists errors? | +|---|---|---| +| `CustomExceptionHandler` | `CommonException`, `DuplicateKeyException`, `UnauthorizedException`, generic `Exception` | **Yes** — saves to `SysLogErrorService` (IP, user-agent, URI, params, stack trace) | + +Both return a generic error for caught `Exception` (not the exception message). `CommonException` uses i18n message lookup via `MessageUtils.getMessage(code)`. Error codes follow `int` scheme: 5 digits, first 2 = module, last 3 = business (e.g. `10001`-`10029`). + +## Logging + +- Logback-spring config in `system-admin/src/main/resources/logback-spring.xml`. Logger names use `io.renren` (fork legacy), **not** `com.weather`. +- Admin dev profile enables MyBatis SQL stdout logging (`StdOutImpl`). +- When adding `@Slf4j` to `com.weather.*` classes, add a `com.weather` level override or change the existing `io.renren` logger scope. + +## Redis & Docs + +- Redis: `project-options.redis.open: true` in dev YAML controls whether `RedisAspect` intercepts Redis calls (default `false` — Redis operations silently skipped when disabled). +- Knife4j: disabled by default (`knife4j.enable: false`). Docs at `/doc.html` when enabled. +- `RedisAspect` wraps `@RedisCache` annotations with channel publish for cache invalidation. + +## MyBatis-Plus gotchas + +- **Batch inserts bypass auto-fill** — `FieldMetaObjectHandler` only fires on `insert()`/`updateById()`. Custom batch methods must manually set `creator`, `createDate`, `updater`, `updateDate`, `deptId`. +- Column names with special characters (e.g. `rain_20_20`) require explicit `@TableField` annotations — MyBatis-Plus cannot auto-map them from camelCase. +- `typeAliasesPackage: com.weather.modules.*.entity` — all entity classes must reside under a `modules` sub-package. + +## Weather domain + +Three sub-modules under `system-admin/.../modules/weather/`: + +| Module | Purpose | +|---|---| +| `dailydata/` | Daily observations, Excel batch import (async dual-pass), EasyExcel listener, summary export | +| `station/` | Weather station CRUD, linked to dept via `dept_id` | +| `filescan/` | File monitoring + serving. Format: `<地区>地区-<指标>.png` / `<地区>地区631信息.txt` | + +### Weather data import flow + +1. **First pass**: `AnalysisEventListener` counts total rows. +2. **Second pass**: `WeatherDataListener` processes with batch insert (2000 records/batch). +3. Progress tracked in-memory via `ConcurrentHashMap` (`volatile` fields + `AtomicInteger`). +4. Runs on `CompletableFuture` with manual `UserContextHolder` propagation for security context. (Note: `UserContextHolder` is in `system-admin/.../security/user/`, not the empty `system-common/.../holder/` package.) +5. On completion, clears Redis summary cache (`weather:summarize:*`). + +### Weather summarize cache + +`WeatherSummarizeCacheTask` (Quartz job) pre-computes daily historical summaries into Redis. Uses **MySQL-specific** SQL functions (`MONTH()`, `DAY()` on `observe_date`). Cache key: `weather:summarize:{month}:{day}`, non-expiring. Service checks cache first for queries spanning same month/day across years. + +### Station priority ordering + +`WeatherDailyDataServiceImpl.page()` uses custom `CASE WHEN` SQL to order stations belonging to the user's department + sub-departments first. + +## System alert module + +CRUD + polling notification system under `modules/sys/`: + +| Layer | File | +|---|---| +| Entity | `entity/SysAlertEntity.java` — `sys_alert` table, Snowflake PK | +| DAO | `dao/SysAlertDao.java` + `mapper/sys/SysAlertDao.xml` — `selectActiveAlerts`, `selectActiveAlertsSince`, `countBySource` | +| DTO | `dto/SysAlertDTO.java` — `publishTime`/`createDate`/`updateDate` read-only | +| VO | `vo/SysAlertVO.java` — `id` as String (mapped from Long) | +| Service | `service/SysAlertService.java` + `impl/` — `publishIfNotExists` (dedup by source), `withdraw` (soft), CRUD | +| Controller | `controller/SysAlertController.java` — polling: `GET active`, `GET active/since` (no auth); CRUD: `page`/`get`/`save`/`update`/`delete`/`withdraw` (Shiro permissions) | +| Enums | `enums/AlertLevelEnum.java` (info/warning/danger), `enums/AlertSourceTypeEnum.java` (manual/import/schedule/datasource) | + +### Data source extension + +| File | Role | +|---|---| +| `alert/AlertSource.java` | SPI interface: `getName()`, `check()` returning `AlertMessage` records | +| `alert/AlertSourceCollector.java` | `@Autowired(required=false) List` auto-discovery, calls `publishIfNotExists` for each | +| `alert/task/AlertSourcePollingTask.java` | Quartz job (`alertSourcePollingTask`), default paused in `schedule_job` | + +To add a new alert source: implement `AlertSource`, register as `@Component`, enable the polling task. diff --git a/system-admin/src/main/java/com/weather/modules/sys/alert/AlertCreatedEvent.java b/system-admin/src/main/java/com/weather/modules/sys/alert/AlertCreatedEvent.java new file mode 100644 index 0000000..a9ee5e2 --- /dev/null +++ b/system-admin/src/main/java/com/weather/modules/sys/alert/AlertCreatedEvent.java @@ -0,0 +1,22 @@ +package com.weather.modules.sys.alert; + +import com.weather.modules.sys.vo.SysAlertVO; +import org.springframework.context.ApplicationEvent; + +/** + * 通知创建事件。由 {@link com.weather.modules.sys.service.impl.SysAlertServiceImpl} + * 在手动发布或数据源推送成功后发布,SSE 服务监听此事件并实时推送给前端。 + */ +public class AlertCreatedEvent extends ApplicationEvent { + + private final SysAlertVO alert; + + public AlertCreatedEvent(SysAlertVO alert) { + super(alert); + this.alert = alert; + } + + public SysAlertVO getAlert() { + return alert; + } +} diff --git a/system-admin/src/main/java/com/weather/modules/sys/alert/AlertDeletedEvent.java b/system-admin/src/main/java/com/weather/modules/sys/alert/AlertDeletedEvent.java new file mode 100644 index 0000000..244a760 --- /dev/null +++ b/system-admin/src/main/java/com/weather/modules/sys/alert/AlertDeletedEvent.java @@ -0,0 +1,21 @@ +package com.weather.modules.sys.alert; + +import org.springframework.context.ApplicationEvent; + +/** + * 通知删除事件。由 {@link com.weather.modules.sys.service.impl.SysAlertServiceImpl#delete(Long[])} + * 发布,SSE 服务监听此事件并通知前端批量移除通知。 + */ +public class AlertDeletedEvent extends ApplicationEvent { + + private final Long[] alertIds; + + public AlertDeletedEvent(Long[] alertIds) { + super(alertIds); + this.alertIds = alertIds; + } + + public Long[] getAlertIds() { + return alertIds; + } +} diff --git a/system-admin/src/main/java/com/weather/modules/sys/alert/AlertSource.java b/system-admin/src/main/java/com/weather/modules/sys/alert/AlertSource.java new file mode 100644 index 0000000..3314897 --- /dev/null +++ b/system-admin/src/main/java/com/weather/modules/sys/alert/AlertSource.java @@ -0,0 +1,46 @@ +package com.weather.modules.sys.alert; + +import java.util.List; + +/** + * 通知数据源接口。 + * 任何需要自动产生通知的模块,实现此接口并注册为 Spring Bean, + * AlertSourceCollector 会定时轮询所有实现并收集通知。 + * + *

示例: + *

{@code
+ * @Component
+ * public class RainWarningSource implements AlertSource {
+ *     public String getName() { return "rain-warning"; }
+ *     public List check() {
+ *         // 查询数据,发现异常则返回 AlertMessage 列表
+ *     }
+ * }
+ * }
+ */ +public interface AlertSource { + + /** + * 数据源唯一标识,用于日志和 source_id 去重 + */ + String getName(); + + /** + * 检查该数据源,返回本次发现的通知。 + * 返回空列表或 null 表示无新通知。 + * 返回的通知会通过 (sourceType=datasource, sourceId=getName():message.sourceId) 去重。 + */ + List check(); + + /** + * 数据源产生的单条通知 + */ + record AlertMessage(String level, String title, String content, String sourceId) { + public AlertMessage { + if (level == null) level = "info"; + if (title == null) title = ""; + if (content == null) content = ""; + if (sourceId == null) sourceId = ""; + } + } +} diff --git a/system-admin/src/main/java/com/weather/modules/sys/alert/AlertSourceCollector.java b/system-admin/src/main/java/com/weather/modules/sys/alert/AlertSourceCollector.java new file mode 100644 index 0000000..5fad722 --- /dev/null +++ b/system-admin/src/main/java/com/weather/modules/sys/alert/AlertSourceCollector.java @@ -0,0 +1,56 @@ +package com.weather.modules.sys.alert; + +import com.weather.modules.sys.service.SysAlertService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.util.Collections; +import java.util.List; + +/** + * 通知数据源收集器。 + * 自动发现所有 AlertSource 实现,轮询并发布通知。 + */ +@Slf4j +@Component +public class AlertSourceCollector { + + @Autowired(required = false) + private List sources = Collections.emptyList(); + + /** + * 轮询所有数据源,通过 SysAlertService 发布去重后的通知。 + * + * @param alertService 通知服务 + * @return 本次新发布的通知总数 + */ + public int collectAll(SysAlertService alertService) { + if (sources.isEmpty()) { + return 0; + } + int total = 0; + for (AlertSource source : sources) { + try { + List alerts = source.check(); + if (alerts == null || alerts.isEmpty()) { + continue; + } + for (AlertSource.AlertMessage a : alerts) { + boolean isNew = alertService.publishIfNotExists( + a.level(), a.title(), a.content(), + "datasource", + source.getName() + ":" + a.sourceId() + ); + if (isNew) total++; + } + } catch (Exception e) { + log.error("数据源 [{}] 检查失败", source.getName(), e); + } + } + if (total > 0) { + log.info("从 {} 个数据源收集到 {} 条新通知", sources.size(), total); + } + return total; + } +} diff --git a/system-admin/src/main/java/com/weather/modules/sys/alert/AlertWithdrawnEvent.java b/system-admin/src/main/java/com/weather/modules/sys/alert/AlertWithdrawnEvent.java new file mode 100644 index 0000000..62e4c82 --- /dev/null +++ b/system-admin/src/main/java/com/weather/modules/sys/alert/AlertWithdrawnEvent.java @@ -0,0 +1,21 @@ +package com.weather.modules.sys.alert; + +import org.springframework.context.ApplicationEvent; + +/** + * 通知撤回事件。由 {@link com.weather.modules.sys.service.impl.SysAlertServiceImpl#withdraw(Long)} + * 发布,SSE 服务监听此事件并通知前端移除该通知。 + */ +public class AlertWithdrawnEvent extends ApplicationEvent { + + private final Long alertId; + + public AlertWithdrawnEvent(Long alertId) { + super(alertId); + this.alertId = alertId; + } + + public Long getAlertId() { + return alertId; + } +} diff --git a/system-admin/src/main/java/com/weather/modules/sys/alert/SseAlertService.java b/system-admin/src/main/java/com/weather/modules/sys/alert/SseAlertService.java new file mode 100644 index 0000000..4f51dbe --- /dev/null +++ b/system-admin/src/main/java/com/weather/modules/sys/alert/SseAlertService.java @@ -0,0 +1,122 @@ +package com.weather.modules.sys.alert; + +import com.weather.modules.sys.vo.SysAlertVO; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Service; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.io.IOException; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArraySet; +import java.util.stream.Collectors; + +/** + * SSE 连接管理服务。 + * 管理所有活跃的 SSE 连接,监听 {@link AlertCreatedEvent} 并实时推送给前端。 + */ +@Slf4j +@Service +public class SseAlertService { + + private final Set emitters = new CopyOnWriteArraySet<>(); + + /** + * 创建并注册一个新的 SSE 连接。 + * 绑定清理回调:连接完成/超时/异常时自动移除。 + */ + public SseEmitter createEmitter() { + SseEmitter emitter = new SseEmitter(0L); // 无超时 + emitters.add(emitter); + log.info("SSE 连接已建立,当前连接数: {}", emitters.size()); + + emitter.onCompletion(() -> { + emitters.remove(emitter); + log.info("SSE 连接已关闭(completion),当前连接数: {}", emitters.size()); + }); + emitter.onTimeout(() -> { + emitters.remove(emitter); + log.info("SSE 连接已关闭(timeout),当前连接数: {}", emitters.size()); + }); + emitter.onError(e -> { + emitters.remove(emitter); + log.info("SSE 连接已关闭(error),当前连接数: {}", emitters.size()); + }); + + // 初始连接确认事件 + try { + emitter.send(SseEmitter.event().name("connected").data("ok")); + } catch (IOException e) { + emitters.remove(emitter); + } + + return emitter; + } + + /** + * 监听通知创建事件,广播到所有活跃 SSE 连接。 + */ + @EventListener + public void onAlertCreated(AlertCreatedEvent event) { + SysAlertVO alert = event.getAlert(); + if (emitters.isEmpty()) { + return; + } + log.info("广播新通知 [{}] 到 {} 个 SSE 连接", alert.getTitle(), emitters.size()); + for (SseEmitter emitter : emitters) { + try { + emitter.send(SseEmitter.event().name("alert").data(alert)); + } catch (IOException e) { + emitters.remove(emitter); + log.debug("SSE 发送失败,移除连接"); + } + } + } + + /** + * 监听通知撤回事件,广播到所有活跃 SSE 连接。 + */ + @EventListener + public void onAlertWithdrawn(AlertWithdrawnEvent event) { + if (emitters.isEmpty()) return; + Map data = Map.of("id", String.valueOf(event.getAlertId())); + log.info("广播撤回通知 [{}] 到 {} 个 SSE 连接", event.getAlertId(), emitters.size()); + for (SseEmitter emitter : emitters) { + try { + emitter.send(SseEmitter.event().name("alert-withdrawn").data(data)); + } catch (IOException e) { + emitters.remove(emitter); + } + } + } + + /** + * 监听通知删除事件,广播到所有活跃 SSE 连接。 + */ + @EventListener + public void onAlertDeleted(AlertDeletedEvent event) { + if (emitters.isEmpty()) return; + List ids = Arrays.stream(event.getAlertIds()) + .map(String::valueOf) + .collect(Collectors.toList()); + Map> data = Map.of("ids", ids); + log.info("广播删除通知 {} 条到 {} 个 SSE 连接", ids.size(), emitters.size()); + for (SseEmitter emitter : emitters) { + try { + emitter.send(SseEmitter.event().name("alert-deleted").data(data)); + } catch (IOException e) { + emitters.remove(emitter); + } + } + } + + /** + * 当前活跃连接数。 + */ + public int getActiveCount() { + return emitters.size(); + } +} diff --git a/system-admin/src/main/java/com/weather/modules/sys/alert/task/AlertSourcePollingTask.java b/system-admin/src/main/java/com/weather/modules/sys/alert/task/AlertSourcePollingTask.java new file mode 100644 index 0000000..5fd749e --- /dev/null +++ b/system-admin/src/main/java/com/weather/modules/sys/alert/task/AlertSourcePollingTask.java @@ -0,0 +1,31 @@ +package com.weather.modules.sys.alert.task; + +import com.weather.modules.job.task.ITask; +import com.weather.modules.sys.alert.AlertSourceCollector; +import com.weather.modules.sys.service.SysAlertService; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +/** + * 通知数据源定时轮询任务。 + * 通过 Quartz schedule_job 表注册: + * beanName = alertSourcePollingTask,建议 cron = 每 10 分钟。 + * + *

默认暂停(status=0),待数据源实现就绪后启用。 + */ +@Slf4j +@Component("alertSourcePollingTask") +@AllArgsConstructor +public class AlertSourcePollingTask implements ITask { + + private final AlertSourceCollector collector; + private final SysAlertService sysAlertService; + + @Override + public void run(String params) { + log.info("开始轮询通知数据源"); + int count = collector.collectAll(sysAlertService); + log.info("通知数据源轮询完成,新增 {} 条", count); + } +} diff --git a/system-admin/src/main/java/com/weather/modules/sys/controller/SysAlertController.java b/system-admin/src/main/java/com/weather/modules/sys/controller/SysAlertController.java new file mode 100644 index 0000000..75107a3 --- /dev/null +++ b/system-admin/src/main/java/com/weather/modules/sys/controller/SysAlertController.java @@ -0,0 +1,132 @@ +package com.weather.modules.sys.controller; + +import com.weather.common.annotation.LogOperation; +import com.weather.common.constant.Constant; +import com.weather.common.page.PageData; +import com.weather.common.utils.Result; +import com.weather.common.validator.AssertUtils; +import com.weather.common.validator.ValidatorUtils; +import com.weather.common.validator.group.AddGroup; +import com.weather.common.validator.group.DefaultGroup; +import com.weather.common.validator.group.UpdateGroup; +import com.weather.modules.sys.alert.SseAlertService; +import com.weather.modules.sys.dto.SysAlertDTO; +import com.weather.modules.sys.service.SysAlertService; +import com.weather.modules.sys.vo.SysAlertVO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.Parameters; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.AllArgsConstructor; +import org.apache.shiro.authz.annotation.RequiresPermissions; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.util.List; +import java.util.Map; + +/** + * 系统通知 + */ +@RestController +@RequestMapping("sys/alert") +@Tag(name = "系统通知") +@AllArgsConstructor +public class SysAlertController { + private final SysAlertService sysAlertService; + private final SseAlertService sseAlertService; + + // ===== 前端轮询接口(无需权限,登录即可访问) ===== + + @GetMapping("active") + @Operation(summary = "获取所有有效通知") + public Result> active() { + List list = sysAlertService.getActiveAlerts(); + return new Result>().ok(list); + } + + @GetMapping("active/since") + @Operation(summary = "增量获取通知") + @Parameter(name = "since", description = "起始ID(返回ID大于此值的通知)", in = ParameterIn.QUERY) + public Result> activeSince(@RequestParam(required = false) Long since) { + if (since == null) { + List list = sysAlertService.getActiveAlerts(); + return new Result>().ok(list); + } + List list = sysAlertService.getActiveAlertsSince(since); + return new Result>().ok(list); + } + + @GetMapping("stream") + @Operation(summary = "通知SSE实时推送") + public SseEmitter stream() { + return sseAlertService.createEmitter(); + } + + // ===== 管理后台接口 ===== + + @GetMapping("page") + @Operation(summary = "分页") + @Parameters({ + @Parameter(name = Constant.PAGE, description = "当前页码,从1开始", in = ParameterIn.QUERY, required = true, ref = "int"), + @Parameter(name = Constant.LIMIT, description = "每页显示记录数", in = ParameterIn.QUERY, required = true, ref = "int"), + @Parameter(name = Constant.ORDER_FIELD, description = "排序字段", in = ParameterIn.QUERY, ref = "String"), + @Parameter(name = Constant.ORDER, description = "排序方式,可选值(asc、desc)", in = ParameterIn.QUERY, ref = "String"), + @Parameter(name = "level", description = "级别", in = ParameterIn.QUERY, ref = "String"), + @Parameter(name = "title", description = "标题", in = ParameterIn.QUERY, ref = "String"), + @Parameter(name = "sourceType", description = "来源类型", in = ParameterIn.QUERY, ref = "String") + }) + @RequiresPermissions("sys:alert:page") + public Result> page(@Parameter(hidden = true) @RequestParam Map params) { + PageData page = sysAlertService.pageAlerts(params); + return new Result>().ok(page); + } + + @GetMapping("{id}") + @Operation(summary = "信息") + @RequiresPermissions("sys:alert:info") + public Result get(@PathVariable("id") Long id) { + SysAlertVO data = sysAlertService.get(id); + return new Result().ok(data); + } + + @PostMapping + @Operation(summary = "保存") + @LogOperation("发布通知") + @RequiresPermissions("sys:alert:save") + public Result save(@RequestBody SysAlertDTO dto) { + ValidatorUtils.validateEntity(dto, AddGroup.class, DefaultGroup.class); + sysAlertService.save(dto); + return new Result(); + } + + @PutMapping + @Operation(summary = "修改") + @LogOperation("修改通知") + @RequiresPermissions("sys:alert:update") + public Result update(@RequestBody SysAlertDTO dto) { + ValidatorUtils.validateEntity(dto, UpdateGroup.class, DefaultGroup.class); + sysAlertService.update(dto); + return new Result(); + } + + @DeleteMapping + @Operation(summary = "删除") + @LogOperation("删除通知") + @RequiresPermissions("sys:alert:delete") + public Result delete(@RequestBody Long[] ids) { + AssertUtils.isArrayEmpty(ids, "id"); + sysAlertService.delete(ids); + return new Result(); + } + + @PutMapping("{id}/withdraw") + @Operation(summary = "撤回通知") + @LogOperation("撤回通知") + @RequiresPermissions("sys:alert:retract") + public Result withdraw(@PathVariable Long id) { + sysAlertService.withdraw(id); + return new Result(); + } +} diff --git a/system-admin/src/main/java/com/weather/modules/sys/dao/SysAlertDao.java b/system-admin/src/main/java/com/weather/modules/sys/dao/SysAlertDao.java new file mode 100644 index 0000000..4deb22b --- /dev/null +++ b/system-admin/src/main/java/com/weather/modules/sys/dao/SysAlertDao.java @@ -0,0 +1,30 @@ +package com.weather.modules.sys.dao; + +import com.weather.common.dao.BaseDao; +import com.weather.modules.sys.entity.SysAlertEntity; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** + * 系统通知 + */ +@Mapper +public interface SysAlertDao extends BaseDao { + + /** + * 查询有效且未过期的通知 + */ + List selectActiveAlerts(); + + /** + * 增量查询:ID 大于 sinceId 的有效通知 + */ + List selectActiveAlertsSince(@Param("sinceId") Long sinceId); + + /** + * 检查 source_type + source_id 是否已存在 + */ + int countBySource(@Param("sourceType") String sourceType, @Param("sourceId") String sourceId); +} diff --git a/system-admin/src/main/java/com/weather/modules/sys/dto/SysAlertDTO.java b/system-admin/src/main/java/com/weather/modules/sys/dto/SysAlertDTO.java new file mode 100644 index 0000000..f384f96 --- /dev/null +++ b/system-admin/src/main/java/com/weather/modules/sys/dto/SysAlertDTO.java @@ -0,0 +1,61 @@ +package com.weather.modules.sys.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.weather.common.validator.group.AddGroup; +import com.weather.common.validator.group.DefaultGroup; +import com.weather.common.validator.group.UpdateGroup; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Null; +import lombok.Data; + +import java.io.Serializable; +import java.util.Date; + +/** + * 系统通知 + */ +@Data +@Schema(title = "系统通知") +public class SysAlertDTO implements Serializable { + private static final long serialVersionUID = 1L; + + @Schema(title = "id") + @Null(message = "{id.null}", groups = AddGroup.class) + @NotNull(message = "{id.require}", groups = UpdateGroup.class) + private Long id; + + @Schema(title = "级别: info|warning|danger") + @NotBlank(message = "通知级别不能为空", groups = DefaultGroup.class) + private String level; + + @Schema(title = "通知标题") + @NotBlank(message = "通知标题不能为空", groups = DefaultGroup.class) + private String title; + + @Schema(title = "通知正文") + @NotBlank(message = "通知正文不能为空", groups = DefaultGroup.class) + private String content; + + @Schema(title = "来源类型") + private String sourceType; + + @Schema(title = "业务关联ID") + private String sourceId; + + @Schema(title = "过期时间") + private Date expireTime; + + @Schema(title = "发布时间") + @JsonProperty(access = JsonProperty.Access.READ_ONLY) + private Date publishTime; + + @Schema(title = "创建时间") + @JsonProperty(access = JsonProperty.Access.READ_ONLY) + private Date createDate; + + @Schema(title = "更新时间") + @JsonProperty(access = JsonProperty.Access.READ_ONLY) + private Date updateDate; +} diff --git a/system-admin/src/main/java/com/weather/modules/sys/entity/SysAlertEntity.java b/system-admin/src/main/java/com/weather/modules/sys/entity/SysAlertEntity.java new file mode 100644 index 0000000..6e55b95 --- /dev/null +++ b/system-admin/src/main/java/com/weather/modules/sys/entity/SysAlertEntity.java @@ -0,0 +1,71 @@ +package com.weather.modules.sys.entity; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableName; +import com.weather.common.entity.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.util.Date; + +/** + * 系统通知 + */ +@Data +@EqualsAndHashCode(callSuper = false) +@TableName("sys_alert") +public class SysAlertEntity extends BaseEntity { + private static final long serialVersionUID = 1L; + + /** + * 级别: info | warning | danger + */ + private String level; + /** + * 通知标题 + */ + private String title; + /** + * 通知正文 + */ + private String content; + /** + * 来源: manual | import | schedule | datasource + */ + private String sourceType; + /** + * 业务关联ID + */ + private String sourceId; + /** + * 1=有效 0=已撤回 + */ + private Integer isActive; + /** + * 发布时间 + */ + private Date publishTime; + /** + * 过期时间(null=永不过期) + */ + private Date expireTime; + /** + * 更新者 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private Long updater; + /** + * 更新时间 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private Date updateDate; + + public boolean isActive() { + return isActive != null && isActive == 1; + } + + public boolean isExpired() { + return expireTime != null && expireTime.before(new Date()); + } +} diff --git a/system-admin/src/main/java/com/weather/modules/sys/enums/AlertLevelEnum.java b/system-admin/src/main/java/com/weather/modules/sys/enums/AlertLevelEnum.java new file mode 100644 index 0000000..703f014 --- /dev/null +++ b/system-admin/src/main/java/com/weather/modules/sys/enums/AlertLevelEnum.java @@ -0,0 +1,33 @@ +package com.weather.modules.sys.enums; + +/** + * 通知级别 + */ +public enum AlertLevelEnum { + INFO("info", "提示"), + WARNING("warning", "警告"), + DANGER("danger", "紧急"); + + private final String value; + private final String label; + + AlertLevelEnum(String value, String label) { + this.value = value; + this.label = label; + } + + public String getValue() { + return value; + } + + public String getLabel() { + return label; + } + + public static boolean isValid(String value) { + for (AlertLevelEnum e : values()) { + if (e.value.equals(value)) return true; + } + return false; + } +} diff --git a/system-admin/src/main/java/com/weather/modules/sys/enums/AlertSourceTypeEnum.java b/system-admin/src/main/java/com/weather/modules/sys/enums/AlertSourceTypeEnum.java new file mode 100644 index 0000000..1630480 --- /dev/null +++ b/system-admin/src/main/java/com/weather/modules/sys/enums/AlertSourceTypeEnum.java @@ -0,0 +1,27 @@ +package com.weather.modules.sys.enums; + +/** + * 通知来源类型 + */ +public enum AlertSourceTypeEnum { + MANUAL("manual", "手动发布"), + IMPORT("import", "导入任务"), + SCHEDULE("schedule", "定时任务"), + DATASOURCE("datasource", "数据源监测"); + + private final String value; + private final String label; + + AlertSourceTypeEnum(String value, String label) { + this.value = value; + this.label = label; + } + + public String getValue() { + return value; + } + + public String getLabel() { + return label; + } +} diff --git a/system-admin/src/main/java/com/weather/modules/sys/service/SysAlertService.java b/system-admin/src/main/java/com/weather/modules/sys/service/SysAlertService.java new file mode 100644 index 0000000..eeac30e --- /dev/null +++ b/system-admin/src/main/java/com/weather/modules/sys/service/SysAlertService.java @@ -0,0 +1,55 @@ +package com.weather.modules.sys.service; + +import com.weather.common.page.PageData; +import com.weather.common.service.BaseService; +import com.weather.modules.sys.dto.SysAlertDTO; +import com.weather.modules.sys.entity.SysAlertEntity; +import com.weather.modules.sys.vo.SysAlertVO; + +import java.util.List; +import java.util.Map; + +/** + * 系统通知 + */ +public interface SysAlertService extends BaseService { + + // ===== 前端轮询 ===== + + /** + * 获取所有有效且未过期的通知 + */ + List getActiveAlerts(); + + /** + * 增量获取:只返回 sinceId 之后的通知 + */ + List getActiveAlertsSince(Long sinceId); + + // ===== 发布与撤回 ===== + + /** + * 发布通知(去重:同 sourceType + sourceId 不重复发) + * + * @return true=新发布 false=已存在(跳过) + */ + boolean publishIfNotExists(String level, String title, String content, + String sourceType, String sourceId); + + /** + * 撤回通知(软删除) + */ + void withdraw(Long id); + + // ===== 管理后台 ===== + + PageData pageAlerts(Map params); + + SysAlertVO get(Long id); + + void save(SysAlertDTO dto); + + void update(SysAlertDTO dto); + + void delete(Long[] ids); +} diff --git a/system-admin/src/main/java/com/weather/modules/sys/service/impl/SysAlertServiceImpl.java b/system-admin/src/main/java/com/weather/modules/sys/service/impl/SysAlertServiceImpl.java new file mode 100644 index 0000000..0eb503f --- /dev/null +++ b/system-admin/src/main/java/com/weather/modules/sys/service/impl/SysAlertServiceImpl.java @@ -0,0 +1,172 @@ +package com.weather.modules.sys.service.impl; + +import cn.hutool.core.date.DateUtil; +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.weather.common.constant.Constant; +import com.weather.common.page.PageData; +import com.weather.common.service.impl.BaseServiceImpl; +import com.weather.common.utils.ConvertUtils; +import cn.hutool.core.util.IdUtil; +import com.weather.modules.sys.alert.AlertCreatedEvent; +import com.weather.modules.sys.alert.AlertDeletedEvent; +import com.weather.modules.sys.alert.AlertWithdrawnEvent; +import com.weather.modules.sys.dao.SysAlertDao; +import com.weather.modules.sys.dto.SysAlertDTO; +import com.weather.modules.sys.entity.SysAlertEntity; +import com.weather.modules.sys.enums.AlertLevelEnum; +import com.weather.modules.sys.service.SysAlertService; +import com.weather.modules.sys.vo.SysAlertVO; +import lombok.AllArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Arrays; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * 系统通知 + */ +@Service +@AllArgsConstructor +public class SysAlertServiceImpl extends BaseServiceImpl implements SysAlertService { + + private final ApplicationEventPublisher eventPublisher; + + @Override + public List getActiveAlerts() { + List entities = baseDao.selectActiveAlerts(); + return toVOList(entities); + } + + @Override + public List getActiveAlertsSince(Long sinceId) { + List entities = baseDao.selectActiveAlertsSince(sinceId); + return toVOList(entities); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public boolean publishIfNotExists(String level, String title, String content, + String sourceType, String sourceId) { + if (!AlertLevelEnum.isValid(level)) { + level = AlertLevelEnum.INFO.getValue(); + } + if (StrUtil.isNotBlank(sourceId)) { + int count = baseDao.countBySource(sourceType, sourceId); + if (count > 0) { + return false; + } + } + SysAlertEntity entity = new SysAlertEntity(); + entity.setId(IdUtil.getSnowflakeNextId()); + entity.setLevel(level); + entity.setTitle(title); + entity.setContent(content); + entity.setSourceType(sourceType); + entity.setSourceId(sourceId); + entity.setIsActive(1); + entity.setPublishTime(new Date()); + entity.setCreateDate(new Date()); + insert(entity); + eventPublisher.publishEvent(new AlertCreatedEvent(toVO(entity))); + return true; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void withdraw(Long id) { + SysAlertEntity entity = new SysAlertEntity(); + entity.setId(id); + entity.setIsActive(0); + entity.setUpdateDate(new Date()); + updateById(entity); + eventPublisher.publishEvent(new AlertWithdrawnEvent(id)); + } + + @Override + public PageData pageAlerts(Map params) { + IPage page = baseDao.selectPage( + getPage(params, "publish_time", false), + getWrapper(params) + ); + List voList = toVOList(page.getRecords()); + PageData pageData = new PageData<>(voList, page.getTotal()); + return pageData; + } + + private QueryWrapper getWrapper(Map params) { + String level = (String) params.get("level"); + String title = (String) params.get("title"); + String sourceType = (String) params.get("sourceType"); + + QueryWrapper wrapper = new QueryWrapper<>(); + wrapper.eq("is_active", 1); + wrapper.eq(StrUtil.isNotBlank(level), "level", level); + wrapper.like(StrUtil.isNotBlank(title), "title", title); + wrapper.eq(StrUtil.isNotBlank(sourceType), "source_type", sourceType); + return wrapper; + } + + @Override + public SysAlertVO get(Long id) { + SysAlertEntity entity = baseDao.selectById(id); + return toVO(entity); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void save(SysAlertDTO dto) { + SysAlertEntity entity = ConvertUtils.sourceToTarget(dto, SysAlertEntity.class); + entity.setId(IdUtil.getSnowflakeNextId()); + entity.setIsActive(1); + entity.setSourceType(StrUtil.blankToDefault(dto.getSourceType(), "manual")); + entity.setPublishTime(new Date()); + entity.setCreateDate(new Date()); + insert(entity); + eventPublisher.publishEvent(new AlertCreatedEvent(toVO(entity))); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void update(SysAlertDTO dto) { + SysAlertEntity entity = ConvertUtils.sourceToTarget(dto, SysAlertEntity.class); + entity.setUpdateDate(new Date()); + updateById(entity); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void delete(Long[] ids) { + deleteBatchIds(Arrays.asList(ids)); + eventPublisher.publishEvent(new AlertDeletedEvent(ids)); + } + + // ===== 工具方法 ===== + + private SysAlertVO toVO(SysAlertEntity entity) { + if (entity == null) return null; + SysAlertVO vo = new SysAlertVO(); + vo.setId(String.valueOf(entity.getId())); + vo.setLevel(entity.getLevel()); + vo.setTitle(entity.getTitle()); + vo.setContent(entity.getContent()); + vo.setSourceType(entity.getSourceType()); + vo.setPublishTime(entity.getPublishTime() != null + ? DateUtil.formatDateTime(entity.getPublishTime()) + : null); + vo.setExpireTime(entity.getExpireTime() != null + ? DateUtil.formatDateTime(entity.getExpireTime()) + : null); + return vo; + } + + private List toVOList(List entities) { + return entities.stream().map(this::toVO).collect(Collectors.toList()); + } +} diff --git a/system-admin/src/main/java/com/weather/modules/sys/vo/SysAlertVO.java b/system-admin/src/main/java/com/weather/modules/sys/vo/SysAlertVO.java new file mode 100644 index 0000000..f03eb88 --- /dev/null +++ b/system-admin/src/main/java/com/weather/modules/sys/vo/SysAlertVO.java @@ -0,0 +1,43 @@ +package com.weather.modules.sys.vo; + +import lombok.Data; + +import java.io.Serializable; +import java.util.Date; + +/** + * 系统通知 - 前端响应对象 + */ +@Data +public class SysAlertVO implements Serializable { + private static final long serialVersionUID = 1L; + + /** + * 通知ID(对应前端 AlertMessage.id) + */ + private String id; + /** + * 级别: info | warning | danger(对应前端 AlertMessage.level) + */ + private String level; + /** + * 标题 + */ + private String title; + /** + * 正文(对应前端 AlertMessage.content) + */ + private String content; + /** + * 来源类型 + */ + private String sourceType; + /** + * 发布时间(对应前端 AlertMessage.publishTime) + */ + private String publishTime; + /** + * 过期时间 + */ + private String expireTime; +} diff --git a/system-admin/src/main/resources/mapper/sys/SysAlertDao.xml b/system-admin/src/main/resources/mapper/sys/SysAlertDao.xml new file mode 100644 index 0000000..4012fc0 --- /dev/null +++ b/system-admin/src/main/resources/mapper/sys/SysAlertDao.xml @@ -0,0 +1,30 @@ + + + + + + is_active = 1 + AND (expire_time IS NULL OR expire_time > NOW()) + + + + + + + + + diff --git a/system-common/CLAUDE.md b/system-common/CLAUDE.md new file mode 100644 index 0000000..1bf56b7 --- /dev/null +++ b/system-common/CLAUDE.md @@ -0,0 +1,20 @@ +# CLAUDE.md — system-common + +Shared Java library used by all backend modules. + +--- + +## Service layer pattern + +Two base classes defined here: + +| Base | Purpose | +|---|---| +| `CrudService` | Generic CRUD: `page()`, `get()`, `save()`, `update()`, `delete()` | +| `BaseService` | Lighter base without DTO generic | + +These are used by all service implementations in `system-admin` and other modules. See `system-admin/CLAUDE.md` for the full module convention. + +## i18n validation messages + +Located at `src/main/resources/i18n/validation.properties`. Hibernate Validator messages used by `@RestControllerAdvice` exception handler in `system-admin`. Error codes follow `int` scheme: 5 digits, first 2 = module, last 3 = business (e.g. `10001`-`10029`). diff --git a/weather-data-ui/CLAUDE.md b/weather-data-ui/CLAUDE.md index 4095e6d..17d5df4 100644 --- a/weather-data-ui/CLAUDE.md +++ b/weather-data-ui/CLAUDE.md @@ -4,6 +4,14 @@ Frontend module: Vue 3 / Vite 5 / TypeScript SPA. --- +## Code Style + +- **No emoji in UI strings.** Plain Chinese text for labels, buttons, status. +- **No emoji in code comments or docstrings.** Plain text only. +- **Keep CLAUDE.md current** — whenever code is modified, added, deleted, or any file change affects the module structure, build, conventions, or component patterns, update this file (and the root `CLAUDE.md` if cross-cutting) in the same commit to reflect the new state. Stale documentation is a bug. + +--- + ## Commands ```bash @@ -55,7 +63,9 @@ Pre-commit: `lint-staged` runs `eslint --fix` on `*.ts`/`*.vue` via `yorkie` git - `src/store/importTasks.ts`: separate store for import task tracking (computed getters: `activeTasks`, `hasActiveTasks`, `recentTasks`). - `src/utils/router.ts`: converts backend menu records → Vue router records, supports iframe/external links with `openStyle` flags. -Layout is event-driven: `src/layout/` shell + `mitt` event bus (`src/utils/emits.ts`). The `EMitt` enum defines 13 events for sidebar, theme, tabs, layout changes. **Trace both the Pinia store and mitt events** when changing navigation/sidebar/tabs/theme. +Layout is event-driven: `src/layout/` shell + `mitt` event bus (`src/utils/emits.ts`). The `EMitt` enum defines events for sidebar, theme, tabs, layout changes. **Trace both the Pinia store and mitt events** when changing navigation/sidebar/tabs/theme. + +Header right side: notification bell (combined badge) → `expand` (user menu). The old `import-task-indicator.vue` has been removed in favor of the notification center drawer. ## Common page pattern: `useView` hook @@ -104,8 +114,8 @@ All helper functions must accept `number | null` and return `"—"` or `""` for ### 2. Heavy libraries must use dynamic imports `html2canvas`, `jspdf`, and `echarts` are NOT imported at module level. Load them via `await import()` only when triggered by user action. This saves ~600KB from the initial bundle. -### 3. Google Fonts go in index.html, not scoped styles -Never use `@import url("https://fonts.googleapis.com/...")` inside Vue scoped styles. Use `` + `` in `index.html`. +### 3. Fonts are self-hosted — no external network dependency +Fonts (Noto Sans SC, JetBrains Mono) are bundled via `@fontsource/*` packages, imported in `src/main.ts`. Do NOT add Google Fonts `` tags or `@import` back — the system runs on intranet where external network may be unavailable. To add a new font weight, import the corresponding fontsource CSS file in `main.ts`. ### 4. Export must show user feedback Always show `ElLoading.service` fullscreen and `ElMessage` success/failure. Disable the export button during rendering. @@ -113,9 +123,62 @@ Always show `ElLoading.service` fullscreen and `ElMessage` success/failure. Disa ### 5. Deep watchers on filter objects are banned Never use `watch(filters, callback, { deep: true })`. Derive a precise computed trigger key (e.g. `dataHash`, `filteredHash`, `extremesVersion`) and watch that instead. -## Import progress polling pattern +## Notification center + +The global notification system (`src/components/alert-marquee/index.vue`) aggregates two feed types: + +| Feed | Source | Clickable? | +|---|---|---| +| System alerts | `useAlertMarquee.ts` composable (polls `/sys/alert/active/since` every 30s) | Yes — opens detail dialog | +| Import tasks | `useImportTaskStore` Pinia store | No — shows real-time progress inline | + +### Components & composables + +| File | Role | +|---|---| +| `src/composables/useAlertMarquee.ts` | Module-level singleton: message queue, drawer toggle, detail dialog state, scrollbar position. Real-time delivery: SSE (`/sys/alert/stream`) primary + 10s polling fallback. Exports danger-specific computed: `dangerMessages`, `latestDangerMessage`, `dangerCount`, `hasDangerMessages` | +| `src/composables/useFloatingDrag.ts` | Pointer Events drag logic: `setPointerCapture`, viewport clamping, deferred `isDragging` (activates only on >3px move), `dragMoved` flag to distinguish drag vs click | +| `src/components/alert-marquee/index.vue` | Floating scrollbar (640px wide, centered top, draggable) + `el-drawer` notification center + `el-dialog` detail popup | +| `src/layout/header/base-header.vue` | Bell button with combined badge (alerts + active import tasks), toggles drawer | +| `src/store/importTasks.ts` | Import task CRUD: `addTask`, `updateTask`, `removeTask`, `clearCompleted`; getters: `activeTasks`, `recentTasks` | + +### Level-based routing + +- **danger (紧急)**: triggers the floating scrollbar + appears in notification center drawer + bell badge +- **warning / info (警告 / 提示)**: notification center drawer + bell badge only (no scrollbar) +- The bell badge in base-header always shows total count (all levels + active import tasks) + +### Floating scrollbar behavior + +- Visible only when logged in (`appStore.state.appIsLogin`) and has danger-level alerts (`hasDangerMessages`) +- Positioned centered at top (`y: 56` below header), draggable to reposition, re-centers on window resize +- Shows latest alert headline + count badge +- **Close button** hides the bar; **auto-reappears** when new danger alerts arrive (watch on `dangerCount`) +- **查看详情** button opens `el-dialog` with full alert text +- The old `import-task-indicator.vue` (bell icon with popover in header) has been **removed** + +### Import progress integration 1. Upload via `baseService.upload()` (FormData, no explicit Content-Type). -2. On success, poll `GET .../import/progress/{backendTaskId}` every 3 seconds. -3. Update Pinia store (`useImportTaskStore`) with percentage/state. -4. Emit `refreshDataList` on completion. Header indicator (`import-task-indicator.vue`) shows active tasks with spinning badge + popover. +2. On upload start, toast: `"导入已开始,可在通知中心查看进度"`. +3. Poll `GET .../import/progress/{backendTaskId}` every 3 seconds. +4. Update Pinia store (`useImportTaskStore`) — progress bar + status tag render reactively in the drawer. +5. On completion: green checkmark; on failure: red cross + error message. Completed/failed tasks show a dismiss button. + +### Alert management page + +Manual alert CRUD at route `sys/system-alert`: + +| File | Role | +|---|---| +| `src/views/sys/system-alert.vue` | List page: `useView`-based, search by level/title/sourceType, batch delete, per-row withdraw | +| `src/views/sys/system-alert-add-or-update.vue` | Add/edit dialog: level (info/warning/danger), title, content (textarea), sourceType, expireTime (datetime picker) | + +Backend endpoints under `/sys/alert`: +- `GET /page` — paginated list (`sys:alert:page`) +- `GET /{id}` — detail (`sys:alert:info`) +- `POST /` — create (`sys:alert:save`) +- `PUT /` — update (`sys:alert:update`) +- `DELETE /` — batch delete (`sys:alert:delete`) +- `PUT /{id}/withdraw` — soft-withdraw (`sys:alert:update`) +- `GET /active`, `GET /active/since` — frontend polling (no permission required) diff --git a/weather-data-ui/index.html b/weather-data-ui/index.html index a600b25..9d67175 100644 --- a/weather-data-ui/index.html +++ b/weather-data-ui/index.html @@ -5,13 +5,6 @@ 气象数据 - 管理系统 - - - - diff --git a/weather-data-ui/src/views/sys/system-alert.vue b/weather-data-ui/src/views/sys/system-alert.vue new file mode 100644 index 0000000..e5e8523 --- /dev/null +++ b/weather-data-ui/src/views/sys/system-alert.vue @@ -0,0 +1,128 @@ + + +