feat(juhe): 增加Redis缓存与每日调用限流
重构聚合数据客户端为服务层,引入Redis缓存及每日调用上限50次,超限后降级使用缓存;调整默认激活prod并移除MySQL profile;处理静态资源404异常。 添加用户友好界面,增加京备案号
This commit is contained in:
@@ -0,0 +1,9 @@
|
|||||||
|
target/
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
|
.codebuddy/
|
||||||
|
README.md
|
||||||
|
Dockerfile
|
||||||
|
docker-compose.yml
|
||||||
@@ -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"]
|
||||||
@@ -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"
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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 |
@@ -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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 骨架屏 ==================== */
|
||||||
|
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>
|
||||||
Reference in New Issue
Block a user