feat(juhe): 增加Redis缓存与每日调用限流

重构聚合数据客户端为服务层,引入Redis缓存及每日调用上限50次,超限后降级使用缓存;调整默认激活prod并移除MySQL profile;处理静态资源404异常。

添加用户友好界面,增加京备案号
This commit is contained in:
promise_cyf
2026-10-10 14:24:49 +08:00
parent 9c04f81f2d
commit 7dcb094f4e
18 changed files with 1273 additions and 61 deletions
+9
View File
@@ -0,0 +1,9 @@
target/
.git/
.gitignore
.idea/
*.iml
.codebuddy/
README.md
Dockerfile
docker-compose.yml
+9
View File
@@ -0,0 +1,9 @@
# 运行阶段:直接使用你本地已打好的 jar(放在本目录,与 Dockerfile / docker-compose.yml 同级)
# 用法:先本地执行 mvn package(JDK 21),把 target/devToolBox-1.0-SNAPSHOT.jar 复制到本目录
FROM eclipse-temurin:21-jre
WORKDIR /app
ENV TZ=Asia/Shanghai
# 本目录下放且仅放一个 jar(.dockerignore 已排除 target/,避免误打包)
COPY *.jar app.jar
EXPOSE 8077
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
+37
View File
@@ -0,0 +1,37 @@
# 只部署应用本身;Redis 已在宿主机 Docker 中运行,MySQL 暂不需要。
services:
app:
build:
context: .
dockerfile: Dockerfile
image: dev-toolbox:latest
container_name: dev-toolbox
restart: unless-stopped
ports:
- "8077:8077"
environment:
# 激活生产 profile(info 日志、关闭 H2 控制台)
SPRING_PROFILES_ACTIVE: prod
# Redis:容器内通过 host.docker.internal 访问宿主机上已发布的 6379
# 前提:宿主机运行的 Redis 容器已把端口映射出来(docker run 时带 -p 6379:6379)
REDIS_HOST: host.docker.internal
REDIS_PORT: "6379"
REDIS_PASSWORD: ""
REDIS_DATABASE: "0"
# 项目暂未使用数据库,用 H2 内存库避免依赖 MySQL;将来接入 MySQL 时删掉这三行,
# 改用 MYSQL_HOST/MYSQL_PORT/MYSQL_DATABASE/MYSQL_USERNAME/MYSQL_PASSWORD 即可
SPRING_DATASOURCE_DRIVER_CLASS_NAME: org.h2.Driver
SPRING_DATASOURCE_URL: "jdbc:h2:mem:dev_toolbox;MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE"
SPRING_DATASOURCE_USERNAME: sa
SPRING_DATASOURCE_PASSWORD: ""
# 聚合数据 key:如需覆盖 yml 里的默认值,取消注释并填写(或写在同目录 .env 文件里)
# JUHE_WEATHER_KEY: ""
# JUHE_SOUP_KEY: ""
TZ: Asia/Shanghai
# 让容器能通过 host.docker.internal 访问宿主机(Linux 上必需)
extra_hosts:
- "host.docker.internal:host-gateway"
+12
View File
@@ -30,6 +30,18 @@
<artifactId>spring-boot-starter-web</artifactId> <artifactId>spring-boot-starter-web</artifactId>
</dependency> </dependency>
<!-- Redis(缓存 + 每日调用限流) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- Lettuce 连接池实现,使 spring.data.redis.lettuce.pool 配置生效 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
<!-- MyBatis-Plus --> <!-- MyBatis-Plus -->
<dependency> <dependency>
<groupId>com.baomidou</groupId> <groupId>com.baomidou</groupId>
@@ -4,6 +4,7 @@ import com.cyf.devToolBox.juhe.exception.JuheApiException;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.resource.NoResourceFoundException;
/** /**
* 全局异常处理,保证异常时也返回统一的 {@link Result} 结构。 * 全局异常处理,保证异常时也返回统一的 {@link Result} 结构。
@@ -35,6 +36,13 @@ public class GlobalExceptionHandler {
return Result.error(500, e.getMessage()); return Result.error(500, e.getMessage());
} }
/** 静态资源不存在(如浏览器默认请求的 favicon.ico),按 404 处理,不记为系统异常 */
@ExceptionHandler(NoResourceFoundException.class)
public Result<Void> handleNoResourceFoundException(NoResourceFoundException e) {
log.warn("静态资源不存在:{}", e.getResourcePath());
return Result.error(404, "资源不存在:" + e.getResourcePath());
}
/** 兜底 */ /** 兜底 */
@ExceptionHandler(Exception.class) @ExceptionHandler(Exception.class)
public Result<Void> handleException(Exception e) { public Result<Void> handleException(Exception e) {
@@ -1,8 +1,8 @@
package com.cyf.devToolBox.controller; package com.cyf.devToolBox.controller;
import com.cyf.devToolBox.juhe.soup.JuheSoupClient; import com.cyf.devToolBox.juhe.soup.JuheSoupService;
import com.cyf.devToolBox.juhe.soup.model.JuheSoupResponse; import com.cyf.devToolBox.juhe.soup.model.JuheSoupResponse;
import com.cyf.devToolBox.juhe.weather.JuheWeatherClient; import com.cyf.devToolBox.juhe.weather.JuheWeatherService;
import com.cyf.devToolBox.juhe.weather.model.JuheWeatherResponse; import com.cyf.devToolBox.juhe.weather.model.JuheWeatherResponse;
import com.cyf.devToolBox.common.Result; import com.cyf.devToolBox.common.Result;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
@@ -16,23 +16,23 @@ import org.springframework.web.bind.annotation.RestController;
* <pre> * <pre>
* GET /api/juhe/weather?city=丰台 查询实时天气(未来天数取配置默认值) * GET /api/juhe/weather?city=丰台 查询实时天气(未来天数取配置默认值)
* GET /api/juhe/weather?city=北京&futureNum=3 指定未来天气天数 * GET /api/juhe/weather?city=北京&futureNum=3 指定未来天气天数
* GET /api/juhe/weather/raw?city=北京 返回原始 JSON,便于排查
* GET /api/juhe/soup 查询每日一句 * GET /api/juhe/soup 查询每日一句
* GET /api/juhe/soup/raw 返回原始 JSON,便于排查
* </pre> * </pre>
* *
* <p>天气与每日一句均带 Redis 缓存与每日调用限流,详见对应 Service。</p>
*
* @author cyf * @author cyf
*/ */
@RestController @RestController
@RequestMapping("/api/juhe") @RequestMapping("/api/juhe")
public class JuheController { public class JuheController {
private final JuheWeatherClient juheWeatherClient; private final JuheWeatherService juheWeatherService;
private final JuheSoupClient juheSoupClient; private final JuheSoupService juheSoupService;
public JuheController(JuheWeatherClient juheWeatherClient, JuheSoupClient juheSoupClient) { public JuheController(JuheWeatherService juheWeatherService, JuheSoupService juheSoupService) {
this.juheWeatherClient = juheWeatherClient; this.juheWeatherService = juheWeatherService;
this.juheSoupClient = juheSoupClient; this.juheSoupService = juheSoupService;
} }
/** /**
@@ -45,19 +45,7 @@ public class JuheController {
@GetMapping("/weather") @GetMapping("/weather")
public Result<JuheWeatherResponse.Result> weather(@RequestParam String city, public Result<JuheWeatherResponse.Result> weather(@RequestParam String city,
@RequestParam(required = false) Integer futureNum) { @RequestParam(required = false) Integer futureNum) {
JuheWeatherResponse.Result data = futureNum == null return Result.success(juheWeatherService.query(city, futureNum));
? juheWeatherClient.query(city)
: juheWeatherClient.query(city, futureNum);
return Result.success(data);
}
/**
* 天气接口原始 JSON,便于排查问题。
*/
@GetMapping("/weather/raw")
public Result<String> weatherRaw(@RequestParam String city,
@RequestParam(required = false) Integer futureNum) {
return Result.success(juheWeatherClient.queryRaw(city, futureNum));
} }
/** /**
@@ -67,14 +55,6 @@ public class JuheController {
*/ */
@GetMapping("/soup") @GetMapping("/soup")
public Result<JuheSoupResponse.Result> soup() { public Result<JuheSoupResponse.Result> soup() {
return Result.success(juheSoupClient.query()); return Result.success(juheSoupService.query());
}
/**
* 每日一句接口原始 JSON,便于排查问题。
*/
@GetMapping("/soup/raw")
public Result<String> soupRaw() {
return Result.success(juheSoupClient.queryRaw());
} }
} }
@@ -35,6 +35,9 @@ public class JuheProperties {
/** 读取超时时间 */ /** 读取超时时间 */
private Duration readTimeout = Duration.ofSeconds(5); private Duration readTimeout = Duration.ofSeconds(5);
/** 每个接口每天允许真实调用上游的最大次数,超过后走 Redis 缓存降级 */
private int dailyLimit = 50;
/** 天气查询配置 */ /** 天气查询配置 */
private Weather weather = new Weather(); private Weather weather = new Weather();
@@ -55,6 +58,9 @@ public class JuheProperties {
/** 默认查询的未来天气天数(1~7),为空表示只查询实时天气 */ /** 默认查询的未来天气天数(1~7),为空表示只查询实时天气 */
private Integer futureNum; private Integer futureNum;
/** 天气缓存有效期(动态数据,默认 4 小时) */
private Duration cacheTtl = Duration.ofHours(4);
} }
/** /**
@@ -11,7 +11,8 @@ public class JuheApiException extends RuntimeException {
private final int errorCode; private final int errorCode;
public JuheApiException(int errorCode, String reason) { public JuheApiException(int errorCode, String reason) {
super("聚合数据接口调用失败 [error_code=" + errorCode + "]: " + reason); // super("聚合数据接口调用失败 [error_code=" + errorCode + "]: " + reason);
super("数据接口调用失败 [error_code=" + errorCode + "]: " + reason);
this.errorCode = errorCode; this.errorCode = errorCode;
} }
@@ -0,0 +1,28 @@
package com.cyf.devToolBox.juhe.redis;
import java.time.Duration;
import java.time.LocalDate;
import java.time.ZonedDateTime;
/**
* 「按自然日」维度的辅助工具:日期 key 片段与距 24 点的剩余时长。
*
* @author cyf
*/
public final class JuheDailySupport {
private JuheDailySupport() {
}
/** 当前日期字符串(yyyy-MM-dd),作为当天维度的 key 片段。 */
public static String today() {
return LocalDate.now().toString();
}
/** 距当天 24 点的剩余时长,用作计数 key 的过期时间。 */
public static Duration tillMidnight() {
ZonedDateTime now = ZonedDateTime.now();
ZonedDateTime nextMidnight = now.toLocalDate().plusDays(1).atStartOfDay(now.getZone());
return Duration.between(now, nextMidnight);
}
}
@@ -0,0 +1,111 @@
package com.cyf.devToolBox.juhe.redis;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.util.Optional;
/**
* Redis 操作的容错封装。
*
* <p>所有方法内部捕获异常并降级,保证 Redis 不可用时不会影响主流程:</p>
* <ul>
* <li>读取失败:视为缓存未命中</li>
* <li>写入失败:跳过缓存</li>
* <li>计数失败:{@link #increment} 返回负数,调用方据此视为「不受限」照常调用上游</li>
* </ul>
* 使用 {@link StringRedisTemplate} 存字符串,对象与 JSON 的转换由 Jackson 完成。
*
* @author cyf
*/
@Slf4j
@Component
public class JuheRedisClient {
/** Redis 不可用(计数失败)时的哨兵值 */
public static final long UNAVAILABLE = -1L;
private final StringRedisTemplate redis;
private final ObjectMapper objectMapper;
public JuheRedisClient(StringRedisTemplate redis, ObjectMapper objectMapper) {
this.redis = redis;
this.objectMapper = objectMapper;
}
/**
* 读取并反序列化缓存,未命中或 Redis 异常时返回 {@link Optional#empty()}。
*/
public <T> Optional<T> get(String key, Class<T> type) {
try {
String json = redis.opsForValue().get(key);
if (json == null) {
return Optional.empty();
}
return Optional.of(objectMapper.readValue(json, type));
} catch (Exception e) {
log.warn("读取 Redis 失败,降级为缓存未命中。key={}, 原因={}", key, e.getMessage());
return Optional.empty();
}
}
/**
* 写入缓存并设置过期时间。
*/
public void set(String key, Object value, Duration ttl) {
try {
redis.opsForValue().set(key, objectMapper.writeValueAsString(value), ttl);
} catch (Exception e) {
log.warn("写入 Redis 失败,跳过缓存。key={}, 原因={}", key, e.getMessage());
}
}
/**
* 对当天计数进行自增,首次自增时设置过期时间。
*
* @param key 计数 key
* @param ttl 过期时间(通常为距当天 24 点的剩余时长)
* @return 自增后的计数;Redis 不可用时返回 {@link #UNAVAILABLE}(负数)
*/
public long increment(String key, Duration ttl) {
try {
Long count = redis.opsForValue().increment(key);
if (count == null) {
return UNAVAILABLE;
}
if (count == 1L) {
redis.expire(key, ttl);
}
return count;
} catch (Exception e) {
log.warn("Redis 计数失败,降级为不受限。key={}, 原因={}", key, e.getMessage());
return UNAVAILABLE;
}
}
/**
* 向集合中添加元素(自动去重),永久存储。
*/
public void addToSet(String key, String value) {
try {
redis.opsForSet().add(key, value);
} catch (Exception e) {
log.warn("写入 Redis 集合失败。key={}, 原因={}", key, e.getMessage());
}
}
/**
* 从集合中随机取一个元素,集合不存在或 Redis 异常时返回 {@link Optional#empty()}。
*/
public Optional<String> randomFromSet(String key) {
try {
return Optional.ofNullable(redis.opsForSet().randomMember(key));
} catch (Exception e) {
log.warn("读取 Redis 集合失败。key={}, 原因={}", key, e.getMessage());
return Optional.empty();
}
}
}
@@ -0,0 +1,41 @@
package com.cyf.devToolBox.juhe.redis;
/**
* 聚合数据相关的 Redis key 统一定义,避免散落在各处。
*
* <pre>
* juhe:weather:{日期}:{城市}:{未来天数} 天气缓存(带 TTL)
* juhe:weather:call:{日期} 天气当天调用计数
* juhe:soup:pool 每日一句缓存池(Set,永久)
* juhe:soup:call:{日期} 每日一句当天调用计数
* </pre>
*
* @author cyf
*/
public final class JuheRedisKeys {
private static final String PREFIX = "juhe:";
private JuheRedisKeys() {
}
/** 天气缓存 key,按「日期 + 城市 + 未来天数」维度缓存。 */
public static String weather(String date, String city, Integer futureNum) {
return PREFIX + "weather:" + date + ":" + city + ":" + futureNum;
}
/** 天气当天调用计数 key。 */
public static String weatherCallCount(String date) {
return PREFIX + "weather:call:" + date;
}
/** 每日一句缓存池 key(Set,永久存储,用于去重与随机返回)。 */
public static String soupPool() {
return PREFIX + "soup:pool";
}
/** 每日一句当天调用计数 key。 */
public static String soupCallCount(String date) {
return PREFIX + "soup:call:" + date;
}
}
@@ -0,0 +1,71 @@
package com.cyf.devToolBox.juhe.soup;
import com.cyf.devToolBox.juhe.JuheProperties;
import com.cyf.devToolBox.juhe.exception.JuheApiException;
import com.cyf.devToolBox.juhe.redis.JuheDailySupport;
import com.cyf.devToolBox.juhe.redis.JuheRedisClient;
import com.cyf.devToolBox.juhe.redis.JuheRedisKeys;
import com.cyf.devToolBox.juhe.soup.model.JuheSoupResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
/**
* 每日一句业务编排:调用结果永久缓存 + 每日调用限流。
*
* <p>流程:</p>
* <ol>
* <li>每天前 {@code juhe.daily-limit} 次真实调用上游,结果写入去重集合(永久)</li>
* <li>超过上限后不再调用上游,从缓存池随机返回一条</li>
* </ol>
* Redis 不可用时降级为每次直接调用上游。
*
* @author cyf
*/
@Slf4j
@Service
public class JuheSoupService {
/** 超过每日调用上限时返回的业务码 */
private static final int CODE_OVER_LIMIT = 429;
private final JuheSoupClient juheSoupClient;
private final JuheProperties properties;
private final JuheRedisClient redis;
public JuheSoupService(JuheSoupClient juheSoupClient,
JuheProperties properties,
JuheRedisClient redis) {
this.juheSoupClient = juheSoupClient;
this.properties = properties;
this.redis = redis;
}
/**
* 查询每日一句(超限则从缓存池随机返回)。
*/
public JuheSoupResponse.Result query() {
String today = JuheDailySupport.today();
long count = redis.increment(JuheRedisKeys.soupCallCount(today), JuheDailySupport.tillMidnight());
// 超过每日上限:不再调用上游,从缓存池随机返回
if (count >= 0 && count > properties.getDailyLimit()) {
return randomFromPool();
}
JuheSoupResponse.Result data = juheSoupClient.query();
if (data != null && StringUtils.hasText(data.getText())) {
redis.addToSet(JuheRedisKeys.soupPool(), data.getText());
}
return data;
}
private JuheSoupResponse.Result randomFromPool() {
String text = redis.randomFromSet(JuheRedisKeys.soupPool())
.orElseThrow(() -> new JuheApiException(CODE_OVER_LIMIT, "今日每日一句额度已用完且暂无缓存,请明天再试"));
log.info("每日一句今日调用已达上限,从缓存池随机返回");
JuheSoupResponse.Result result = new JuheSoupResponse.Result();
result.setText(text);
return result;
}
}
@@ -0,0 +1,82 @@
package com.cyf.devToolBox.juhe.weather;
import com.cyf.devToolBox.juhe.JuheProperties;
import com.cyf.devToolBox.juhe.exception.JuheApiException;
import com.cyf.devToolBox.juhe.redis.JuheDailySupport;
import com.cyf.devToolBox.juhe.redis.JuheRedisClient;
import com.cyf.devToolBox.juhe.redis.JuheRedisKeys;
import com.cyf.devToolBox.juhe.weather.model.JuheWeatherResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.Optional;
/**
* 天气查询业务编排:缓存优先 + 每日调用限流。
*
* <p>流程:</p>
* <ol>
* <li>命中「城市 + 未来天数」缓存(TTL 4 小时)直接返回,不消耗当天额度</li>
* <li>未命中则判断当天真实调用上游次数,超过 {@code juhe.daily-limit} 返回 429</li>
* <li>正常调用上游后写入缓存</li>
* </ol>
* Redis 不可用时降级为每次直接调用上游。
*
* @author cyf
*/
@Slf4j
@Service
public class JuheWeatherService {
/** 超过每日调用上限时返回的业务码 */
private static final int CODE_OVER_LIMIT = 429;
private final JuheWeatherClient juheWeatherClient;
private final JuheProperties properties;
private final JuheRedisClient redis;
public JuheWeatherService(JuheWeatherClient juheWeatherClient,
JuheProperties properties,
JuheRedisClient redis) {
this.juheWeatherClient = juheWeatherClient;
this.properties = properties;
this.redis = redis;
}
/**
* 查询指定城市的天气(缓存优先)。
*
* @param city 城市名称
* @param futureNum 未来天气天数,为 null 时使用配置默认值
*/
public JuheWeatherResponse.Result query(String city, Integer futureNum) {
if (!StringUtils.hasText(city)) {
throw new IllegalArgumentException("城市名称不能为空");
}
Integer effectiveFutureNum = futureNum == null ? properties.getWeather().getFutureNum() : futureNum;
String today = JuheDailySupport.today();
// 1. 命中缓存直接返回,不消耗当天额度
String cacheKey = JuheRedisKeys.weather(today, city, effectiveFutureNum);
Optional<JuheWeatherResponse.Result> cached = redis.get(cacheKey, JuheWeatherResponse.Result.class);
if (cached.isPresent()) {
log.debug("天气缓存命中:{}", cacheKey);
return cached.get();
}
// 2. 未命中:校验当天调用额度(计数失败=Redis 不可用,视为不受限)
long count = redis.increment(JuheRedisKeys.weatherCallCount(today), JuheDailySupport.tillMidnight());
if (count >= 0 && count > properties.getDailyLimit()) {
log.warn("天气接口今日调用已达上限 {},city={}", properties.getDailyLimit(), city);
throw new JuheApiException(CODE_OVER_LIMIT, "今日天气查询额度已用完,请明天再试");
}
// 3. 调用上游并写入缓存
JuheWeatherResponse.Result data = effectiveFutureNum == null
? juheWeatherClient.query(city)
: juheWeatherClient.query(city, effectiveFutureNum);
redis.set(cacheKey, data, properties.getWeather().getCacheTtl());
return data;
}
}
+31
View File
@@ -0,0 +1,31 @@
# 开发环境:H2 内存库 + 本地 Redis,无需任何外部依赖即可启动
spring:
# H2 内存库(MODE=MySQL 兼容 MySQL 语法)
datasource:
driver-class-name: org.h2.Driver
url: jdbc:h2:mem:dev_toolbox;MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE
username: sa
password: ""
h2:
console:
enabled: true
path: /h2-console
# Redis:天气缓存、每日一句缓存池与每日调用限流
data:
redis:
host: localhost
port: 6379
database: 0
timeout: 3s
# password: 本地无密码时留空
lettuce:
pool:
max-active: 8
max-idle: 8
min-idle: 0
logging:
level:
com.cyf.devToolBox: debug
+29
View File
@@ -0,0 +1,29 @@
# 生产环境:MySQL + 独立 Redis,连接参数建议全部通过环境变量注入
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://${MYSQL_HOST:localhost}:${MYSQL_PORT:3306}/${MYSQL_DATABASE:dev_toolbox}?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true
username: ${MYSQL_USERNAME:root}
password: ${MYSQL_PASSWORD:root}
# 生产关闭 H2 控制台
h2:
console:
enabled: false
data:
redis:
host: ${REDIS_HOST:127.0.0.1}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:}
database: ${REDIS_DATABASE:0}
timeout: 3s
lettuce:
pool:
max-active: 16
max-idle: 16
min-idle: 0
logging:
level:
com.cyf.devToolBox: info
+9 -29
View File
@@ -5,17 +5,9 @@ spring:
application: application:
name: dev-toolbox name: dev-toolbox
# 默认使用 H2 内存库,本地无需任何外部依赖即可启动 # 默认激活 dev;生产可用环境变量 SPRING_PROFILES_ACTIVE=prod(或 --spring.profiles.active=prod)覆盖
datasource: profiles:
driver-class-name: org.h2.Driver active: ${SPRING_PROFILES_ACTIVE:prod}
url: jdbc:h2:mem:dev_toolbox;MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE
username: sa
password: ""
h2:
console:
enabled: true
path: /h2-console
jackson: jackson:
date-format: yyyy-MM-dd HH:mm:ss date-format: yyyy-MM-dd HH:mm:ss
@@ -30,37 +22,25 @@ mybatis-plus:
db-config: db-config:
id-type: auto id-type: auto
# 聚合数据(每个接口使用各自的 key) # 聚合数据(各环境共用,不随 dev/prod 变化;key 用环境变量覆盖)
# 天气文档:https://www.juhe.cn/docs/api/id/73 # 天气文档:https://www.juhe.cn/docs/api/id/73
# 每日一句文档:https://www.juhe.cn/docs/api/id/86 # 每日一句文档:https://www.juhe.cn/docs/api/id/86
juhe: juhe:
connect-timeout: 3s connect-timeout: 3s
read-timeout: 5s read-timeout: 5s
# 每个接口每天真实调用上游的最大次数,超过后走 Redis 缓存降级
daily-limit: 50
# 天气查询 # 天气查询
weather: weather:
base-url: https://apis.juhe.cn/simpleWeather/query base-url: https://apis.juhe.cn/simpleWeather/query
# 建议改用环境变量 JUHE_WEATHER_KEY 覆盖,避免明文提交到仓库 # 建议改用环境变量 JUHE_WEATHER_KEY 覆盖,避免明文提交到仓库
key: ${JUHE_WEATHER_KEY:ca38eda68ea7242a3601e4e98afa8499} key: ${JUHE_WEATHER_KEY:ca38eda68ea7242a3601e4e98afa8499}
# 查询未来天气天数(1~7),不填写则只返回实时天气 # 查询未来天气天数(1~7),不填写则只返回实时天气
future-num: 5 future-num: 7
# 天气缓存有效期(动态数据,默认 4 小时)
cache-ttl: 4h
# 每日一句 # 每日一句
soup: soup:
base-url: https://apis.juhe.cn/fapig/soup/query base-url: https://apis.juhe.cn/fapig/soup/query
# 每日一句接口的 key,请填入并建议用环境变量 JUHE_SOUP_KEY 覆盖 # 每日一句接口的 key,请填入并建议用环境变量 JUHE_SOUP_KEY 覆盖
key: ${JUHE_SOUP_KEY:cf8695f97027c22b346a468697ba11db} key: ${JUHE_SOUP_KEY:cf8695f97027c22b346a468697ba11db}
---
# 切换为 MySQL 时使用该 profile:mvn spring-boot:run -Dspring-boot.run.profiles=mysql
spring:
config:
activate:
on-profile: mysql
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/dev_toolbox?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true
username: root
password: root
logging:
level:
com.cyf.devToolBox: debug
+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#5b8def"/>
<stop offset="1" stop-color="#8e6dfb"/>
</linearGradient>
</defs>
<rect width="64" height="64" rx="14" fill="url(#g)"/>
<text x="32" y="44" font-family="Helvetica, Arial, sans-serif" font-size="34" font-weight="700"
fill="#ffffff" text-anchor="middle">YF</text>
</svg>

After

Width:  |  Height:  |  Size: 488 B

+766
View File
@@ -0,0 +1,766 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>工具箱 · 聚合数据</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<style>
/* ==================== 设计令牌 ==================== */
:root {
/* 浅色模式(iOS 26 Light Glass) */
--bg-1: #5b8def;
--bg-2: #8e6dfb;
--bg-3: #ec5fb7;
--bg-4: #ff8a5b;
--bg-base: linear-gradient(135deg, #f5f7ff 0%, #ecebff 100%);
--glass-bg: rgba(255, 255, 255, 0.22);
--glass-bg-strong: rgba(255, 255, 255, 0.32);
--glass-border: rgba(255, 255, 255, 0.45);
--glass-shadow: 0 12px 40px rgba(31, 38, 135, 0.18);
--glass-inner: inset 0 1px 0 rgba(255, 255, 255, 0.5);
--text: #1d1d1f;
--text-2: rgba(60, 60, 67, 0.72);
--text-3: rgba(60, 60, 67, 0.45);
--on-glass: #fff;
--header-text: #fff;
/* 骨架屏 */
--skeleton-base: rgba(60, 60, 67, 0.08);
--skeleton-shine: rgba(60, 60, 67, 0.18);
}
/* 深色模式(iOS 26 Dark Glass):通过 [data-theme="dark"] 切换 */
[data-theme="dark"] {
--bg-1: #1d4ed8;
--bg-2: #6d28d9;
--bg-3: #be185d;
--bg-4: #c2410c;
--bg-base: linear-gradient(135deg, #050514 0%, #0a0a1f 100%);
--glass-bg: rgba(255, 255, 255, 0.06);
--glass-bg-strong: rgba(255, 255, 255, 0.1);
--glass-border: rgba(255, 255, 255, 0.12);
--glass-shadow: 0 12px 40px rgba(0, 0, 0, 0.5);
--glass-inner: inset 0 1px 0 rgba(255, 255, 255, 0.1);
--text: #f5f5f7;
--text-2: rgba(235, 235, 245, 0.6);
--text-3: rgba(235, 235, 245, 0.3);
--header-text: #fff;
--skeleton-base: rgba(255, 255, 255, 0.06);
--skeleton-shine: rgba(255, 255, 255, 0.16);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { height: 100%; }
body {
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display",
"PingFang SC", "Helvetica Neue", "Microsoft YaHei", sans-serif;
color: var(--text);
min-height: 100vh;
overflow-x: hidden;
/* iOS 26 风的多色渐变背景 */
background:
radial-gradient(at 20% 20%, var(--bg-1) 0%, transparent 50%),
radial-gradient(at 80% 10%, var(--bg-2) 0%, transparent 50%),
radial-gradient(at 70% 80%, var(--bg-3) 0%, transparent 55%),
radial-gradient(at 10% 90%, var(--bg-4) 0%, transparent 50%),
var(--bg-base);
background-attachment: fixed;
transition: background 0.4s;
padding: 48px 20px 80px;
-webkit-font-smoothing: antialiased;
}
/* 背景里两个大光斑,制造 Liquid Glass 的折射感 */
body::before, body::after {
content: '';
position: fixed;
border-radius: 50%;
filter: blur(90px);
z-index: 0;
pointer-events: none;
}
body::before {
width: 460px; height: 460px;
background: radial-gradient(circle, #ff7eb6 0%, transparent 70%);
top: -120px; right: -120px;
opacity: 0.55;
}
body::after {
width: 560px; height: 560px;
background: radial-gradient(circle, #7eb3ff 0%, transparent 70%);
bottom: -240px; left: -160px;
opacity: 0.5;
}
.container {
max-width: 1080px;
margin: 0 auto;
position: relative;
z-index: 1;
}
/* ==================== 顶部 ==================== */
.header {
text-align: center;
color: var(--header-text);
margin-bottom: 40px;
text-shadow: 0 2px 24px rgba(0, 0, 0, 0.18);
position: relative;
}
.header h1 {
font-size: 44px;
font-weight: 700;
letter-spacing: -0.5px;
}
.header p {
margin-top: 8px;
font-size: 15px;
opacity: 0.92;
}
/* ==================== 卡片栅格 ==================== */
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
}
@media (max-width: 768px) {
.grid { grid-template-columns: 1fr; }
.header h1 { font-size: 32px; }
}
.card {
position: relative;
background: var(--glass-bg);
backdrop-filter: blur(30px) saturate(180%);
-webkit-backdrop-filter: blur(30px) saturate(180%);
border: 1px solid var(--glass-border);
border-radius: 28px;
padding: 28px;
box-shadow: var(--glass-shadow), var(--glass-inner);
transition: transform 0.35s cubic-bezier(.2,.7,.2,1), box-shadow 0.35s;
}
.card:hover {
transform: translateY(-3px);
box-shadow: 0 18px 50px rgba(31, 38, 135, 0.25), var(--glass-inner);
}
.card-title {
display: flex;
align-items: center;
gap: 12px;
font-size: 22px;
font-weight: 600;
margin-bottom: 6px;
}
.card-title .icon {
width: 40px; height: 40px;
border-radius: 12px;
display: flex; align-items: center; justify-content: center;
font-size: 22px;
background: var(--glass-bg-strong);
box-shadow: inset 0 1px 0 rgba(255,255,255,0.6);
}
.card-subtitle {
color: var(--text-2);
font-size: 13px;
margin-bottom: 18px;
}
/* ==================== 表单 ==================== */
.form-row {
display: flex;
gap: 10px;
margin-bottom: 14px;
flex-wrap: wrap;
}
.input {
flex: 1;
min-width: 0;
padding: 12px 16px;
background: var(--glass-bg-strong);
border: 1px solid var(--glass-border);
border-radius: 14px;
font-size: 15px;
color: var(--text);
outline: none;
transition: all 0.2s;
backdrop-filter: blur(10px);
}
.input:focus {
background: rgba(255, 255, 255, 0.55);
border-color: rgba(255, 255, 255, 0.85);
}
.input::placeholder { color: var(--text-3); }
.input-num { flex: 0 0 90px; }
.btn {
padding: 12px 22px;
background: var(--glass-bg-strong);
border: 1px solid var(--glass-border);
border-radius: 14px;
font-size: 15px;
font-weight: 500;
color: var(--text);
cursor: pointer;
transition: all 0.2s;
backdrop-filter: blur(10px);
display: inline-flex;
align-items: center;
gap: 6px;
}
.btn:hover {
background: rgba(255, 255, 255, 0.5);
transform: translateY(-1px);
}
.btn:active { transform: translateY(0); }
.btn-primary {
background: linear-gradient(135deg, #0a84ff 0%, #5e5ce6 100%);
color: #fff;
border-color: rgba(255, 255, 255, 0.4);
box-shadow: 0 6px 18px rgba(10, 132, 255, 0.35);
}
.btn-primary:hover {
background: linear-gradient(135deg, #0a84ff 0%, #7d7aff 100%);
}
.quick-cities {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 14px;
}
.chip {
padding: 5px 12px;
background: var(--glass-bg-strong);
border: 1px solid var(--glass-border);
border-radius: 999px;
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
backdrop-filter: blur(10px);
}
.chip:hover {
background: rgba(255, 255, 255, 0.55);
transform: scale(1.05);
}
/* ==================== 结果区 ==================== */
.result {
margin-top: 18px;
padding: 18px;
background: rgba(255, 255, 255, 0.28);
border: 1px solid rgba(255, 255, 255, 0.35);
border-radius: 18px;
min-height: 90px;
font-size: 14px;
line-height: 1.65;
}
.result.loading,
.result.empty {
display: flex;
align-items: center;
justify-content: center;
color: var(--text-3);
font-size: 13px;
}
.result.error {
background: rgba(255, 59, 48, 0.12);
border-color: rgba(255, 59, 48, 0.3);
color: #b91c1c;
}
.result.success { color: var(--text); }
/* 天气当前 */
.weather-now {
display: flex;
align-items: center;
gap: 18px;
margin-bottom: 16px;
}
.weather-temp {
font-size: 56px;
font-weight: 200;
line-height: 1;
letter-spacing: -1px;
}
.weather-temp::after {
content: '°';
font-weight: 300;
}
.weather-meta .city {
font-size: 18px;
font-weight: 600;
}
.weather-meta .desc {
color: var(--text-2);
font-size: 13px;
margin-top: 2px;
}
.weather-meta .extra {
color: var(--text-2);
font-size: 12px;
margin-top: 4px;
}
/* 未来天气 */
.future-list {
display: flex;
gap: 8px;
overflow-x: auto;
padding: 4px 2px 6px;
scrollbar-width: thin;
}
.future-list::-webkit-scrollbar { height: 4px; }
.future-list::-webkit-scrollbar-thumb {
background: rgba(255,255,255,0.4);
border-radius: 4px;
}
.future-item {
flex: 0 0 auto;
min-width: 90px;
padding: 10px;
background: rgba(255, 255, 255, 0.4);
border: 1px solid rgba(255, 255, 255, 0.4);
border-radius: 12px;
text-align: center;
font-size: 12px;
}
.future-item .date { font-weight: 600; margin-bottom: 4px; }
.future-item .temp { font-size: 13px; margin: 2px 0; font-weight: 500; }
.future-item .weather { color: var(--text-2); }
/* 每日一句 */
.quote {
font-size: 17px;
line-height: 1.85;
font-weight: 500;
color: var(--text);
text-align: center;
padding: 14px 8px;
position: relative;
}
.quote::before {
content: '"';
position: absolute;
top: -10px;
left: 12px;
font-size: 60px;
color: var(--bg-2);
opacity: 0.18;
font-family: Georgia, serif;
}
.quote::after {
content: '"';
font-size: 60px;
color: var(--bg-2);
opacity: 0.18;
font-family: Georgia, serif;
line-height: 0;
}
/* ==================== Toast ==================== */
.toast {
position: fixed;
bottom: 32px;
left: 50%;
transform: translateX(-50%) translateY(20px);
padding: 12px 24px;
background: rgba(20, 20, 25, 0.78);
color: #fff;
border-radius: 100px;
font-size: 14px;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.12);
opacity: 0;
transition: all 0.3s;
z-index: 1000;
pointer-events: none;
}
.toast.show {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
/* ==================== 旋转 ==================== */
@keyframes spin { to { transform: rotate(360deg); } }
.spinner {
display: inline-block;
width: 14px; height: 14px;
border: 2px solid rgba(60,60,67,0.2);
border-top-color: rgba(60,60,67,0.6);
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-right: 8px;
vertical-align: -2px;
}
/* ==================== 骨架屏 ==================== */
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.skeleton {
display: block;
background: linear-gradient(
90deg,
var(--skeleton-base) 0%,
var(--skeleton-shine) 50%,
var(--skeleton-base) 100%
);
background-size: 200% 100%;
animation: shimmer 1.4s ease-in-out infinite;
border-radius: 8px;
}
.skeleton-row {
display: flex;
align-items: center;
gap: 18px;
margin-bottom: 18px;
}
.skeleton-temp {
width: 90px; height: 56px;
border-radius: 12px;
}
.skeleton-lines { flex: 1; }
.skeleton-line {
height: 14px;
margin-bottom: 8px;
border-radius: 6px;
}
.skeleton-line.short { width: 40%; }
.skeleton-line.mid { width: 70%; }
.skeleton-line.full { width: 90%; }
.skeleton-future {
display: flex; gap: 8px; overflow-x: auto;
}
.skeleton-future-item {
flex: 0 0 90px;
height: 70px;
border-radius: 12px;
}
.skeleton-quote {
height: 18px;
margin: 12px 0;
}
/* ==================== 主题切换 ==================== */
.theme-toggle {
position: absolute;
top: 0;
right: 0;
width: 44px; height: 44px;
border-radius: 50%;
background: var(--glass-bg);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border: 1px solid var(--glass-border);
box-shadow: var(--glass-shadow);
display: flex; align-items: center; justify-content: center;
cursor: pointer;
font-size: 18px;
color: var(--text);
transition: all 0.3s cubic-bezier(.2,.7,.2,1);
}
.theme-toggle:hover {
transform: scale(1.08) rotate(15deg);
background: var(--glass-bg-strong);
}
.theme-toggle:active { transform: scale(0.95); }
@media (max-width: 768px) {
.theme-toggle { top: -8px; right: 4px; }
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<button class="theme-toggle" id="themeToggle" onclick="toggleTheme()" title="切换深色 / 浅色">
<span id="themeIcon">🌙</span>
</button>
<h1>🧰 工具箱</h1>
<p>聚合数据 API · 实时调用演示</p>
</div>
<div class="grid">
<!-- ============ 天气 ============ -->
<div class="card">
<div class="card-title">
<div class="icon">🌤</div>
天气查询
</div>
<div class="card-subtitle">输入城市名查询实时天气,可选 1~7 天预报</div>
<div class="quick-cities">
<span class="chip" onclick="fillCity('北京')">北京</span>
<span class="chip" onclick="fillCity('上海')">上海</span>
<span class="chip" onclick="fillCity('广州')">广州</span>
<span class="chip" onclick="fillCity('深圳')">深圳</span>
<span class="chip" onclick="fillCity('杭州')">杭州</span>
<span class="chip" onclick="fillCity('丰台')">丰台</span>
</div>
<div class="form-row">
<input id="city" class="input" placeholder="城市名,如:丰台">
<input id="futureNum" class="input input-num" placeholder="天数" type="number" min="1" max="7">
<button class="btn btn-primary" onclick="queryWeather()">查询</button>
</div>
<div id="weatherResult" class="result empty">
点击「查询」或选择城市
</div>
</div>
<!-- ============ 每日一句 ============ -->
<div class="card">
<div class="card-title">
<div class="icon">✨</div>
每日一句
</div>
<div class="card-subtitle">一句温暖的话,伴你度过今天</div>
<div class="form-row">
<button class="btn btn-primary" onclick="querySoup()">换一句</button>
<button class="btn" onclick="copyQuote()">📋 复制</button>
</div>
<div id="soupResult" class="result empty">
点击「换一句」开始
</div>
</div>
</div>
</div>
<div id="toast" class="toast"></div>
<footer style="text-align:center;padding:1rem 0;font-size:14px;color:#666;margin-top:40px;">
<p>
<a href="https://beian.miit.gov.cn/" target="_blank" style="color:#666;text-decoration:none;">京ICP备2026042952号</a>
</p>
</footer>
<script>
const $ = (id) => document.getElementById(id);
let lastQuote = '';
function showToast(msg) {
const t = $('toast');
t.textContent = msg;
t.classList.add('show');
clearTimeout(showToast._t);
showToast._t = setTimeout(() => t.classList.remove('show'), 1800);
}
function fillCity(name) {
$('city').value = name;
queryWeather();
}
function setResult(el, type, html) {
el.className = 'result ' + type;
el.innerHTML = html;
}
async function callApi(path) {
const res = await fetch(path, { headers: { 'Accept': 'application/json' } });
const text = await res.text();
try { return JSON.parse(text); }
catch (e) { throw new Error('接口返回非 JSON:' + text.slice(0, 80)); }
}
// 城市名编码(保险起见,前后端都做编码)
function escCity(city) { return encodeURIComponent(city.trim()); }
/* ==================== 天气 ==================== */
async function queryWeather() {
const city = $('city').value.trim();
if (!city) { showToast('请输入城市名'); return; }
const result = $('weatherResult');
setResult(result, 'loading', renderWeatherSkeleton());
const futureNum = $('futureNum').value;
let url = `/api/juhe/weather?city=${escCity(city)}`;
if (futureNum) url += `&futureNum=${encodeURIComponent(futureNum)}`;
try {
const json = await callApi(url);
if (!json || typeof json.code === 'undefined') {
setResult(result, 'error', '返回格式异常:' + JSON.stringify(json).slice(0, 100));
return;
}
if (json.code !== 0) {
setResult(result, 'error',
`❌ ${escapeHtml(json.message || '查询失败')}(code: ${json.code})`);
return;
}
renderWeather(json.data);
} catch (e) {
setResult(result, 'error', '🚨 请求失败:' + escapeHtml(e.message));
}
}
function renderWeather(d) {
const result = $('weatherResult');
if (!d) { setResult(result, 'empty', '暂无数据'); return; }
const rt = d.realtime || {};
const future = d.future || [];
let html = `
<div class="weather-now">
<div class="weather-temp">${escapeHtml(rt.temperature || '--')}</div>
<div class="weather-meta">
<div class="city">${escapeHtml(d.city || '')}</div>
<div class="desc">${escapeHtml(rt.info || '')} · ${escapeHtml(rt.direct || '')} ${escapeHtml(rt.power || '')}</div>
<div class="extra">湿度 ${escapeHtml(rt.humidity || '--')}% · AQI ${escapeHtml(rt.aqi || '--')}</div>
</div>
</div>
`;
if (future.length > 0) {
html += '<div class="future-list">';
future.forEach(f => {
html += `<div class="future-item">
<div class="date">${escapeHtml(f.date || '')}</div>
<div class="temp">${escapeHtml(f.temperature || '')}</div>
<div class="weather">${escapeHtml(f.weather || '')}</div>
</div>`;
});
html += '</div>';
}
setResult(result, 'success', html);
}
/* ==================== 每日一句 ==================== */
async function querySoup() {
const result = $('soupResult');
setResult(result, 'loading', renderSoupSkeleton());
try {
const json = await callApi('/api/juhe/soup');
if (!json || typeof json.code === 'undefined') {
setResult(result, 'error', '返回格式异常:' + JSON.stringify(json).slice(0, 100));
return;
}
if (json.code !== 0) {
setResult(result, 'error',
`❌ ${escapeHtml(json.message || '查询失败')}(code: ${json.code})`);
return;
}
const text = (json.data && json.data.text) || '';
lastQuote = text;
setResult(result, 'success', `<div class="quote">${escapeHtml(text)}</div>`);
} catch (e) {
setResult(result, 'error', '🚨 请求失败:' + escapeHtml(e.message));
}
}
async function copyQuote() {
if (!lastQuote) { showToast('还没有内容哦'); return; }
try {
await navigator.clipboard.writeText(lastQuote);
showToast('已复制到剪贴板');
} catch (e) {
// 后备方案
const ta = document.createElement('textarea');
ta.value = lastQuote;
document.body.appendChild(ta);
ta.select();
try { document.execCommand('copy'); showToast('已复制'); }
catch { showToast('复制失败'); }
document.body.removeChild(ta);
}
}
/* ==================== 工具 ==================== */
function escapeHtml(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/* ==================== 骨架屏 ==================== */
function renderWeatherSkeleton() {
return `
<div class="skeleton-row">
<span class="skeleton skeleton-temp"></span>
<div class="skeleton-lines">
<span class="skeleton skeleton-line short"></span>
<span class="skeleton skeleton-line mid"></span>
<span class="skeleton skeleton-line short"></span>
</div>
</div>
<div class="skeleton-future">
<span class="skeleton skeleton-future-item"></span>
<span class="skeleton skeleton-future-item"></span>
<span class="skeleton skeleton-future-item"></span>
<span class="skeleton skeleton-future-item"></span>
<span class="skeleton skeleton-future-item"></span>
</div>
`;
}
function renderSoupSkeleton() {
return `
<span class="skeleton skeleton-quote full"></span>
<span class="skeleton skeleton-quote mid"></span>
<span class="skeleton skeleton-quote short"></span>
`;
}
/* ==================== 主题切换 ==================== */
const THEME_KEY = 'toolbox-theme';
function applyTheme(theme) {
const root = document.documentElement;
const icon = $('themeIcon');
if (theme === 'dark') {
root.setAttribute('data-theme', 'dark');
if (icon) icon.textContent = '☀️';
} else {
root.removeAttribute('data-theme');
if (icon) icon.textContent = '🌙';
}
}
function toggleTheme() {
const current = document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
const next = current === 'dark' ? 'light' : 'dark';
applyTheme(next);
try { localStorage.setItem(THEME_KEY, next); } catch {}
showToast(next === 'dark' ? '已切换到深色模式' : '已切换到浅色模式');
}
// 初始化:本地存储 > 系统偏好 > 浅色
(function initTheme() {
let saved = null;
try { saved = localStorage.getItem(THEME_KEY); } catch {}
if (saved === 'dark' || saved === 'light') {
applyTheme(saved);
} else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
applyTheme('dark');
} else {
applyTheme('light');
}
})();
// 回车提交
$('city').addEventListener('keydown', e => { if (e.key === 'Enter') queryWeather(); });
$('futureNum').addEventListener('keydown', e => { if (e.key === 'Enter') queryWeather(); });
</script>
</body>
</html>