- A1 业务层统一 async:gencode 表内省改 run_sync 异步(不再阻塞事件循环)、 check_db 改 async_engine、dict_util 启动预热异步载入;psycopg 降为 APScheduler SQLAlchemyJobStore 专用同步孤岛(注释标注) - A2 测试真 PG 化:conftest 弃 SQLite + mock Redis,改连真实 PG16(dpb_test)+ Redis(number_gen advisory lock 路径真被测);新增 docker/test-compose.yaml 测试依赖栈(postgres:16:5433 + redis:7:6380);run_ci 前置探测 + 容器兜底, pytest -m pg 10 套正式 tc 全过(真实 PG16 + Redis) - B1 Redis 启动不强依赖:redis_connect 失败降级启动,缓存类回源 DB、存储类 (会话/AI 配置/调度)经 require_redis 守卫返回 503;真机 3 阶段降级测试 PASS=11 FAIL=0(独立 Redis 6390 + 后端 8091,不动共享 dev 服务) - C 扶正 DPB:后端横幅/日志(dpb.log)/README/pyproject/ai_factory agent 名、 前端 package.json/署名注释/链接文案/deploy.sh/docker 注释全部去 fastapiadmin; grep backend/app + frontend/web/src 零残留 - 验证:全量回归 pass=72 fail=0
307 lines
9.0 KiB
TypeScript
307 lines
9.0 KiB
TypeScript
/**
|
||
* useCeremony - 节日庆祝管理
|
||
*
|
||
* 提供节日烟花效果和祝福文本展示功能,为系统增添节日氛围。
|
||
* 自动检测当前日期是否为节日,并在首次进入时播放烟花动画和显示祝福语。
|
||
*
|
||
* ## 主要功能
|
||
*
|
||
* 1. 节日检测 - 「内置 + 手动」节日优先;其次周六/日周末提示;最后 `isResidentBanner` 常驻。同级节日区间更短优先,同长度手动优先于内置
|
||
* 2. 烟花动画 - 播放节日烟花特效,支持自定义图片和触发次数
|
||
* 3. 祝福文本 - 烟花结束后显示节日祝福文本
|
||
* 4. 状态管理 - 记录烟花播放状态,避免重复播放
|
||
* 5. 清理机制 - 提供清理方法,支持手动停止和重置
|
||
*
|
||
* ## 手动礼花快捷键
|
||
*
|
||
* 全局 **Ctrl+Shift+P** / **⌘+Shift+P** 由 `art-fireworks-effect` 监听;礼花图与当天节日配置一致,
|
||
* 详见 `src/config/modules/festival.ts` 文件头说明。
|
||
*
|
||
* ## 使用示例
|
||
*
|
||
* ```typescript
|
||
* // 在配置文件中定义节日
|
||
* // 单日节日
|
||
* {
|
||
* date: '2024-12-25',
|
||
* name: '圣诞节',
|
||
* image: christmasImage,
|
||
* count: 3 // 可选,不设置则使用默认值 3 次
|
||
* scrollText: 'Merry Christmas!',
|
||
* }
|
||
*
|
||
* // 跨日期节日
|
||
* {
|
||
* date: '2025-11-07',
|
||
* endDate: '2025-11-10',
|
||
* name: 'v3.0 测试阶段',
|
||
* image: '',
|
||
* count: 5 // 自定义烟花播放次数
|
||
* scrollText: '系统 v3.0 测试阶段正式开启!',
|
||
* }
|
||
* ```
|
||
*
|
||
* @module useCeremony
|
||
* @author DPB 团队
|
||
*/
|
||
|
||
import { useTimeoutFn, useIntervalFn, useDateFormat } from "@vueuse/core";
|
||
import { storeToRefs } from "pinia";
|
||
import { computed } from "vue";
|
||
import { useSettingsStore } from "@stores";
|
||
import { mittBus, formatToDate } from "@utils";
|
||
import { festivalConfigList } from "@/config/modules/festival";
|
||
import { buildBuiltinSolarFestivals } from "@/config/modules/festival.builtin";
|
||
import type { FestivalConfig } from "@/types/config";
|
||
/** 手动配置 + 内置公历节日合并项(内部排序用) */
|
||
type TaggedFestival = FestivalConfig & { _origin: "manual" | "builtin" };
|
||
|
||
/**
|
||
* 节日庆祝配置常量
|
||
*/
|
||
const FESTIVAL_CONFIG = {
|
||
/** 初始延迟(毫秒) */
|
||
INITIAL_DELAY: 300,
|
||
/** 烟花播放间隔(毫秒) */
|
||
FIREWORK_INTERVAL: 1000,
|
||
/** 文本显示延迟(毫秒) */
|
||
TEXT_DELAY: 2000,
|
||
/** 默认烟花播放次数 */
|
||
DEFAULT_FIREWORKS_COUNT: 3,
|
||
} as const;
|
||
|
||
/**
|
||
* 节日庆祝功能
|
||
* 提供节日烟花效果和祝福文本展示
|
||
*/
|
||
export function useCeremony() {
|
||
const settingStore = useSettingsStore();
|
||
const { holidayFireworksLoaded, isShowFireworks } = storeToRefs(settingStore);
|
||
|
||
let fireworksInterval: { pause: () => void } | null = null;
|
||
|
||
/**
|
||
* 检查日期是否在节日范围内
|
||
* @param currentDate 当前日期
|
||
* @param festivalDate 节日开始日期
|
||
* @param festivalEndDate 节日结束日期(可选)
|
||
*/
|
||
const isDateInRange = (
|
||
currentDate: string,
|
||
festivalDate: string,
|
||
festivalEndDate?: string
|
||
): boolean => {
|
||
if (!festivalEndDate) {
|
||
// 单日节日
|
||
return currentDate === festivalDate;
|
||
}
|
||
|
||
// 跨日期节日
|
||
const current = new Date(currentDate);
|
||
const start = new Date(festivalDate);
|
||
const end = new Date(festivalEndDate);
|
||
|
||
return current >= start && current <= end;
|
||
};
|
||
|
||
/** 区间越短越「具体」,重叠时优先展示(如单日元旦优先于全年公告) */
|
||
const festivalRangeSpanDays = (item: FestivalConfig): number => {
|
||
if (!item.endDate) return 1;
|
||
const s = new Date(item.date).getTime();
|
||
const e = new Date(item.endDate).getTime();
|
||
return Math.max(1, Math.round((e - s) / 86400000) + 1);
|
||
};
|
||
|
||
/** 是否为周六、周日(本地时区) */
|
||
const isWeekendDate = (dateStr: string): boolean => {
|
||
const w = new Date(dateStr).getDay();
|
||
return w === 0 || w === 6;
|
||
};
|
||
|
||
/** 无其它节日命中时的周末顶栏(仅文案,与常驻同为 skipFireworks) */
|
||
const buildWeekendPrompt = (dateStr: string): FestivalConfig => {
|
||
const w = new Date(dateStr).getDay();
|
||
const isSat = w === 6;
|
||
return {
|
||
name: isSat ? "周六愉快" : "周日愉快",
|
||
date: dateStr,
|
||
image: "",
|
||
scrollText: isSat
|
||
? "🌤️ 周末到啦,适当放松,记得分支与构建状态也要照顾好~"
|
||
: "🌅 周日休整好,新一周迭代加油。",
|
||
skipFireworks: true,
|
||
count: 3,
|
||
};
|
||
};
|
||
|
||
const pickFestivalWinner = (candidates: TaggedFestival[]): TaggedFestival | undefined => {
|
||
if (!candidates.length) return undefined;
|
||
const sorted = [...candidates];
|
||
sorted.sort((a, b) => {
|
||
const spanDiff = festivalRangeSpanDays(a) - festivalRangeSpanDays(b);
|
||
if (spanDiff !== 0) return spanDiff;
|
||
if (a._origin !== b._origin) return a._origin === "manual" ? -1 : 1;
|
||
return 0;
|
||
});
|
||
return sorted[0];
|
||
};
|
||
|
||
/**
|
||
* 内置公历 + 手动配置(当年)
|
||
*/
|
||
const mergedFestivalEntries = computed<TaggedFestival[]>(() => {
|
||
const year = new Date().getFullYear();
|
||
const builtin = buildBuiltinSolarFestivals(year).map((f) => ({
|
||
...f,
|
||
_origin: "builtin" as const,
|
||
}));
|
||
const manual = festivalConfigList.map((f) => ({
|
||
...f,
|
||
_origin: "manual" as const,
|
||
}));
|
||
return [...builtin, ...manual];
|
||
});
|
||
|
||
/**
|
||
* 当前展示配置:节日(非常驻)> 周末提示 > 常驻 `isResidentBanner`
|
||
*/
|
||
const currentFestivalData = computed((): FestivalConfig | undefined => {
|
||
const currentDate = useDateFormat(new Date(), "YYYY-MM-DD").value;
|
||
const matches = mergedFestivalEntries.value.filter((item) =>
|
||
isDateInRange(currentDate, item.date, item.endDate)
|
||
);
|
||
|
||
const nonResident = matches.filter((m) => !m.isResidentBanner);
|
||
const winnerTagged = pickFestivalWinner(nonResident);
|
||
if (winnerTagged) {
|
||
const row = { ...winnerTagged };
|
||
delete (row as Partial<TaggedFestival>)._origin;
|
||
return row as FestivalConfig;
|
||
}
|
||
|
||
if (isWeekendDate(currentDate)) {
|
||
return buildWeekendPrompt(currentDate);
|
||
}
|
||
|
||
const residents = matches.filter((m) => m.isResidentBanner);
|
||
const residentWinner = pickFestivalWinner(residents);
|
||
if (residentWinner) {
|
||
const row = { ...residentWinner };
|
||
delete (row as Partial<TaggedFestival>)._origin;
|
||
return row as FestivalConfig;
|
||
}
|
||
|
||
return undefined;
|
||
});
|
||
|
||
/**
|
||
* 标记「今日已完成烟花流程」,用于 store 按自然日防抖(与节日配置的 date 字段无关)
|
||
*/
|
||
const updateFestivalDate = () => {
|
||
settingStore.setFestivalDate(formatToDate(new Date()));
|
||
};
|
||
|
||
/**
|
||
* 触发烟花效果
|
||
*/
|
||
const triggerFirework = () => {
|
||
mittBus.emit("triggerFireworks", currentFestivalData.value?.image);
|
||
};
|
||
|
||
/**
|
||
* 完成烟花效果后显示文本
|
||
*/
|
||
const showFestivalText = () => {
|
||
settingStore.setholidayFireworksLoaded(true);
|
||
|
||
useTimeoutFn(() => {
|
||
settingStore.setShowFestivalText(true);
|
||
updateFestivalDate();
|
||
}, FESTIVAL_CONFIG.TEXT_DELAY);
|
||
};
|
||
|
||
/**
|
||
* 启动烟花循环
|
||
*/
|
||
const startFireworksLoop = () => {
|
||
const cur = currentFestivalData.value;
|
||
let playedCount = 0;
|
||
const count = cur?.count ?? FESTIVAL_CONFIG.DEFAULT_FIREWORKS_COUNT;
|
||
const intervalMs = cur?.fireworkInterval ?? FESTIVAL_CONFIG.FIREWORK_INTERVAL;
|
||
|
||
const { pause } = useIntervalFn(() => {
|
||
triggerFirework();
|
||
playedCount++;
|
||
|
||
if (playedCount >= count) {
|
||
pause();
|
||
showFestivalText();
|
||
}
|
||
}, intervalMs);
|
||
|
||
fireworksInterval = { pause };
|
||
};
|
||
|
||
/**
|
||
* 顶栏纯文案关闭(仅 skipFireworks)持久化 key
|
||
*/
|
||
const festivalScrollDismissKey = (cur: FestivalConfig): string =>
|
||
`festival_scroll_dismissed_${cur.date}_${cur.endDate ?? ""}`;
|
||
|
||
/**
|
||
* 开启节日庆祝:skipFireworks 时直接显示滚动条;否则走烟花再出字
|
||
*/
|
||
const openFestival = () => {
|
||
const cur = currentFestivalData.value;
|
||
if (!cur) return;
|
||
|
||
if (cur.skipFireworks) {
|
||
if (
|
||
typeof localStorage !== "undefined" &&
|
||
localStorage.getItem(festivalScrollDismissKey(cur)) === "1"
|
||
) {
|
||
return;
|
||
}
|
||
settingStore.setShowFestivalText(true);
|
||
return;
|
||
}
|
||
|
||
if (!isShowFireworks.value) {
|
||
return;
|
||
}
|
||
|
||
const { start } = useTimeoutFn(startFireworksLoop, FESTIVAL_CONFIG.INITIAL_DELAY);
|
||
start();
|
||
};
|
||
|
||
/**
|
||
* 清理烟花效果
|
||
*/
|
||
const cleanup = () => {
|
||
if (fireworksInterval) {
|
||
fireworksInterval.pause();
|
||
fireworksInterval = null;
|
||
}
|
||
settingStore.setShowFestivalText(false);
|
||
};
|
||
|
||
/** 关闭顶栏滚动:skipFireworks 时写入 localStorage,下次不再自动展开 */
|
||
const closeFestivalScroll = () => {
|
||
const cur = currentFestivalData.value;
|
||
settingStore.setShowFestivalText(false);
|
||
if (cur?.skipFireworks && typeof localStorage !== "undefined") {
|
||
localStorage.setItem(festivalScrollDismissKey(cur), "1");
|
||
}
|
||
};
|
||
|
||
return {
|
||
openFestival,
|
||
cleanup,
|
||
holidayFireworksLoaded,
|
||
currentFestivalData,
|
||
isShowFireworks,
|
||
festivalScrollDismissKey,
|
||
closeFestivalScroll,
|
||
};
|
||
}
|