diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx
index d723fed..8c2469c 100644
--- a/app/(tabs)/index.tsx
+++ b/app/(tabs)/index.tsx
@@ -19,6 +19,7 @@ import Animated, {
import { SafeAreaView } from "react-native-safe-area-context";
import { DAY_LABELS } from "@/components/layout/schedule";
+import { AnnouncementBanner } from "@/components/ui/announcement-banner";
import { useColorScheme } from "@/hooks/use-color-scheme";
import { useHaptics } from "@/hooks/use-haptics";
import {
@@ -28,10 +29,12 @@ import {
getTomorrowWeek,
isVacation,
} from "@/lib/date";
+import { filterActiveAnnouncements } from "@/services/announcements";
import {
formatCourseSectionTimeRange,
SECTION_TIMES,
} from "@/services/course-time";
+import { useAnnouncementStore } from "@/store/announcements";
import type { Course } from "@/store/course";
import { useCourseStore } from "@/store/course";
import { useScheduleStore } from "@/store/schedule";
@@ -104,6 +107,13 @@ export default function HomeScreen() {
const hasUpdate = useUpdateStore((s) => s.hasUpdate);
const colorPalette = useScheduleStore((s) => s.colorPalette);
const courseColorOverrides = useScheduleStore((s) => s.courseColorOverrides);
+ const announcements = useAnnouncementStore((s) => s.announcements);
+ const dismissedIds = useAnnouncementStore((s) => s.dismissedIds);
+
+ const activeAnnouncements = useMemo(
+ () => filterActiveAnnouncements(announcements, dismissedIds),
+ [announcements, dismissedIds],
+ );
const greeting = getGreeting();
const vacation = isVacation(termStart);
@@ -312,6 +322,11 @@ export default function HomeScreen() {
+
+
{vacation ? (
diff --git a/app/_layout.tsx b/app/_layout.tsx
index bb05e58..3cb7fbb 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -47,6 +47,7 @@ import {
showUpcomingLiveActivity,
} from "@/services/course-notification";
import { syncWidgetData } from "@/services/widget-sync";
+import { useAnnouncementStore } from "@/store/announcements";
import { useCourseStore } from "@/store/course";
import { useSettingsStore } from "@/store/settings";
import { useThemeStore } from "@/store/theme";
@@ -85,6 +86,7 @@ function RootLayout() {
useEffect(() => {
useUpdateStore.getState().check();
+ useAnnouncementStore.getState().fetch();
}, []);
useEffect(() => {
@@ -95,11 +97,12 @@ function RootLayout() {
}, []);
useEffect(() => {
- if (Platform.OS !== "ios") return;
const sub = AppState.addEventListener("change", (state) => {
- if (state === "active") {
+ if (state !== "active") return;
+ if (Platform.OS === "ios") {
showUpcomingLiveActivity().catch(() => {});
}
+ useAnnouncementStore.getState().fetch();
});
return () => sub.remove();
}, []);
diff --git a/components/ui/announcement-banner.tsx b/components/ui/announcement-banner.tsx
new file mode 100644
index 0000000..3841ec0
--- /dev/null
+++ b/components/ui/announcement-banner.tsx
@@ -0,0 +1,365 @@
+import { Ionicons } from "@expo/vector-icons";
+import { useRouter } from "expo-router";
+import { useCallback, useEffect, useRef, useState } from "react";
+import {
+ Linking,
+ NativeScrollEvent,
+ NativeSyntheticEvent,
+ Pressable,
+ ScrollView,
+ Text,
+ View,
+} from "react-native";
+import Animated, { LinearTransition } from "react-native-reanimated";
+
+import { useHaptics } from "@/hooks/use-haptics";
+import type { Announcement, AnnouncementType } from "@/services/announcements";
+import { useAnnouncementStore } from "@/store/announcements";
+
+const TYPE_STYLES: Record<
+ AnnouncementType,
+ {
+ icon: React.ComponentProps["name"];
+ color: string;
+ }
+> = {
+ info: { icon: "information-circle-outline", color: "#3b82f6" },
+ warning: { icon: "warning-outline", color: "#ca8a04" },
+ event: { icon: "megaphone-outline", color: "#10b981" },
+ maintenance: { icon: "construct-outline", color: "#737373" },
+};
+
+const AUTO_INTERVAL_MS = 5000;
+const RESUME_DELAY_MS = 10000;
+
+export function AnnouncementBanner({
+ announcements,
+ isDark,
+}: {
+ announcements: Announcement[];
+ isDark: boolean;
+}) {
+ const [width, setWidth] = useState(0);
+ const [activeIdx, setActiveIdx] = useState(0);
+ const scrollRef = useRef(null);
+ const pausedUntilRef = useRef(0);
+ const activeIdxRef = useRef(0);
+ const expandedIdsRef = useRef>(new Set());
+
+ useEffect(() => {
+ activeIdxRef.current = activeIdx;
+ }, [activeIdx]);
+
+ const idsKey = announcements.map((a) => a.id).join("|");
+ useEffect(() => {
+ setActiveIdx(0);
+ activeIdxRef.current = 0;
+ expandedIdsRef.current = new Set();
+ if (width > 0) {
+ scrollRef.current?.scrollTo({ x: 0, animated: false });
+ }
+ }, [idsKey, width]);
+
+ useEffect(() => {
+ if (announcements.length <= 1 || width === 0) return;
+ const id = setInterval(() => {
+ if (Date.now() < pausedUntilRef.current) return;
+ if (expandedIdsRef.current.size > 0) return;
+ const next = (activeIdxRef.current + 1) % announcements.length;
+ setActiveIdx(next);
+ scrollRef.current?.scrollTo({ x: next * width, animated: true });
+ }, AUTO_INTERVAL_MS);
+ return () => clearInterval(id);
+ }, [announcements.length, width]);
+
+ const handleExpandedChange = useCallback((id: string, expanded: boolean) => {
+ if (expanded) {
+ expandedIdsRef.current.add(id);
+ } else {
+ expandedIdsRef.current.delete(id);
+ }
+ }, []);
+
+ if (announcements.length === 0) return null;
+
+ const handleMomentumEnd = (e: NativeSyntheticEvent) => {
+ if (width === 0) return;
+ const idx = Math.round(e.nativeEvent.contentOffset.x / width);
+ setActiveIdx(idx);
+ pausedUntilRef.current = Date.now() + RESUME_DELAY_MS;
+ };
+
+ return (
+ setWidth(e.nativeEvent.layout.width)}
+ style={{ marginHorizontal: 24, marginTop: 14 }}
+ layout={LinearTransition.duration(180)}
+ >
+ {width > 0 && (
+ <>
+ 1}
+ >
+ {announcements.map((a) => (
+
+ ))}
+
+ {announcements.length > 1 && (
+
+ )}
+ >
+ )}
+
+ );
+}
+
+function AnnouncementCard({
+ announcement,
+ width,
+ isDark,
+ onExpandedChange,
+}: {
+ announcement: Announcement;
+ width: number;
+ isDark: boolean;
+ onExpandedChange: (id: string, expanded: boolean) => void;
+}) {
+ const router = useRouter();
+ const haptic = useHaptics();
+ const dismiss = useAnnouncementStore((s) => s.dismiss);
+ const [expanded, setExpanded] = useState(false);
+
+ const typeStyle = TYPE_STYLES[announcement.type];
+ const hasBody = !!announcement.body;
+ const hasLink = announcement.link !== null;
+
+ useEffect(() => {
+ onExpandedChange(announcement.id, expanded);
+ return () => onExpandedChange(announcement.id, false);
+ }, [announcement.id, expanded, onExpandedChange]);
+
+ const triggerLink = () => {
+ const link = announcement.link;
+ if (!link) return;
+ haptic();
+ if (link.kind === "internal") {
+ router.push(link.url as never);
+ } else {
+ Linking.openURL(link.url).catch(() => {});
+ }
+ };
+
+ const handleCardPress = () => {
+ if (hasBody) {
+ haptic();
+ setExpanded((v) => !v);
+ } else if (hasLink) {
+ triggerLink();
+ }
+ };
+
+ const handleDismiss = () => {
+ haptic();
+ dismiss(announcement.id);
+ };
+
+ const trailingIcon: React.ComponentProps["name"] | null =
+ hasBody
+ ? expanded
+ ? "chevron-up"
+ : "chevron-down"
+ : hasLink
+ ? "chevron-forward"
+ : null;
+
+ const cardInner = (
+
+
+
+
+
+
+ {announcement.title}
+
+ {announcement.body ? (
+
+ {announcement.body}
+
+ ) : null}
+ {expanded && hasLink ? (
+ ({
+ alignSelf: "flex-start",
+ marginTop: 8,
+ opacity: pressed ? 0.5 : 1,
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 2,
+ })}
+ >
+
+ 查看详情
+
+
+
+ ) : null}
+
+ {trailingIcon ? (
+
+ ) : null}
+
+
+ );
+
+ return (
+
+
+ ({
+ opacity: pressed && (hasBody || hasLink) ? 0.7 : 1,
+ })}
+ >
+ {cardInner}
+
+ {announcement.dismissible && (
+ ({
+ position: "absolute",
+ top: 0,
+ right: 0,
+ paddingVertical: 10,
+ paddingHorizontal: 10,
+ opacity: pressed ? 0.5 : 1,
+ })}
+ >
+
+
+ )}
+
+
+ );
+}
+
+function DotIndicator({
+ count,
+ activeIdx,
+ isDark,
+}: {
+ count: number;
+ activeIdx: number;
+ isDark: boolean;
+}) {
+ return (
+
+ {Array.from({ length: count }).map((_, i) => {
+ const active = i === activeIdx;
+ return (
+
+ );
+ })}
+
+ );
+}
diff --git a/constants/api.ts b/constants/api.ts
index 52e416a..edcaae1 100644
--- a/constants/api.ts
+++ b/constants/api.ts
@@ -1,3 +1,7 @@
-export const CONFIG_BASE = "https://conf-hub.iwut-api.heavensdoor.cn/";
+export const CONFIG_REPO_CDN =
+ "https://cdn.jsdmirror.com/cnb/TokenTeam/iwut-config@main";
export const SENTRY_DSN =
"https://7f4eaabc8a05416aa70e91f96f210b3f@glitchtip.tokenteam.net/1";
+
+// 准备废弃
+export const CONFIG_BASE = "https://conf-hub.iwut-api.heavensdoor.cn/";
diff --git a/schemas/announcements.schema.json b/schemas/announcements.schema.json
new file mode 100644
index 0000000..fc8c9b0
--- /dev/null
+++ b/schemas/announcements.schema.json
@@ -0,0 +1,138 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "$id": "https://raw.githubusercontent.com/TokenTeam/iwut/main/schemas/announcements.schema.json",
+ "title": "iwut Announcements",
+ "description": "iwut 主页公告位的远程配置文件结构。内容仓库见 https://github.com/TokenTeam/iwut-config",
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["version", "announcements"],
+ "properties": {
+ "$schema": {
+ "type": "string",
+ "format": "uri"
+ },
+ "version": {
+ "type": "integer",
+ "description": "协议版本号;字段语义发生不兼容变化时 +1。老客户端遇到不认识的 version 应放弃解析",
+ "const": 1
+ },
+ "updatedAt": {
+ "type": "string",
+ "format": "date-time",
+ "description": "文件最后更新时间(ISO 8601 / UTC),便于排查 CDN 缓存。客户端不依赖此字段做业务判断"
+ },
+ "announcements": {
+ "type": "array",
+ "description": "公告列表,允许空数组(表示当前无公告)",
+ "items": { "$ref": "#/definitions/announcement" }
+ }
+ },
+ "definitions": {
+ "announcement": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["id", "type", "title"],
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "稳定且唯一的 ID(kebab-case),用作客户端「已关闭公告」持久化 key。一旦发布请勿修改",
+ "pattern": "^[a-z0-9][a-z0-9-]*$",
+ "minLength": 1,
+ "maxLength": 64
+ },
+ "type": {
+ "type": "string",
+ "description": "公告类型,驱动客户端图标与强调色",
+ "enum": ["info", "warning", "event", "maintenance"]
+ },
+ "title": {
+ "type": "string",
+ "description": "主标题,建议 ≤ 20 个汉字以避免在窄屏被截断",
+ "minLength": 1,
+ "maxLength": 40
+ },
+ "body": {
+ "type": ["string", "null"],
+ "description": "正文 / 详细描述,可省略。客户端默认折叠为单行,点击卡片展开查看全文",
+ "maxLength": 200
+ },
+ "link": {
+ "description": "点击公告时的跳转目标;省略或为 null 表示不可点击",
+ "oneOf": [{ "type": "null" }, { "$ref": "#/definitions/link" }]
+ },
+ "startAt": {
+ "type": ["string", "null"],
+ "format": "date-time",
+ "description": "生效起始时间(ISO 8601);null 或省略表示不限起始"
+ },
+ "endAt": {
+ "type": ["string", "null"],
+ "format": "date-time",
+ "description": "生效截止时间(ISO 8601);null 或省略表示不限截止"
+ },
+ "priority": {
+ "type": "integer",
+ "description": "优先级,越大越靠前;默认 0。多条同时生效时客户端按此降序排序",
+ "minimum": 0,
+ "maximum": 100,
+ "default": 0
+ },
+ "dismissible": {
+ "type": "boolean",
+ "description": "用户能否手动关闭;默认 true。重大维护通告建议设为 false",
+ "default": true
+ },
+ "minVersion": {
+ "type": ["string", "null"],
+ "description": "最低 app 版本(含),如 \"1.2.0\";null 或省略表示不限",
+ "pattern": "^\\d+(\\.\\d+){0,2}$"
+ },
+ "maxVersion": {
+ "type": ["string", "null"],
+ "description": "最高 app 版本(含),如 \"2.0.0\";null 或省略表示不限",
+ "pattern": "^\\d+(\\.\\d+){0,2}$"
+ },
+ "platforms": {
+ "type": "array",
+ "description": "限定生效平台;省略或为空表示全平台",
+ "uniqueItems": true,
+ "items": {
+ "type": "string",
+ "enum": ["ios", "android", "web"]
+ }
+ }
+ }
+ },
+ "link": {
+ "type": "object",
+ "description": "跳转目标。internal = 应用内路由(router.push),external = 外部链接(Linking.openURL)",
+ "oneOf": [
+ {
+ "additionalProperties": false,
+ "required": ["kind", "url"],
+ "properties": {
+ "kind": { "const": "internal" },
+ "url": {
+ "type": "string",
+ "description": "应用内路由路径,必须以 / 开头,如 \"/about\" 或 \"/wlan\"",
+ "pattern": "^/"
+ }
+ }
+ },
+ {
+ "additionalProperties": false,
+ "required": ["kind", "url"],
+ "properties": {
+ "kind": { "const": "external" },
+ "url": {
+ "type": "string",
+ "description": "外部 URL,必须为 http:// 或 https://",
+ "format": "uri",
+ "pattern": "^https?://"
+ }
+ }
+ }
+ ]
+ }
+ }
+}
diff --git a/services/announcements.ts b/services/announcements.ts
new file mode 100644
index 0000000..602c14b
--- /dev/null
+++ b/services/announcements.ts
@@ -0,0 +1,207 @@
+import Constants from "expo-constants";
+import { Platform } from "react-native";
+
+import { CONFIG_REPO_CDN } from "@/constants/api";
+
+export type AnnouncementType = "info" | "warning" | "event" | "maintenance";
+export type AnnouncementPlatform = "ios" | "android" | "web";
+
+export interface AnnouncementLink {
+ kind: "internal" | "external";
+ url: string;
+}
+
+export interface Announcement {
+ id: string;
+ type: AnnouncementType;
+ title: string;
+ body: string | null;
+ link: AnnouncementLink | null;
+ startAt: string | null;
+ endAt: string | null;
+ priority: number;
+ dismissible: boolean;
+ minVersion: string | null;
+ maxVersion: string | null;
+ platforms: AnnouncementPlatform[] | null;
+}
+
+const SUPPORTED_VERSION = 1;
+const VALID_TYPES: ReadonlySet = new Set([
+ "info",
+ "warning",
+ "event",
+ "maintenance",
+]);
+const VALID_PLATFORMS: ReadonlySet = new Set([
+ "ios",
+ "android",
+ "web",
+]);
+const ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
+const VERSION_PATTERN = /^\d+(\.\d+){0,2}$/;
+const MAX_RENDERED = 5;
+
+function compareVersions(a: string, b: string): number {
+ const pa = a.split(".").map(Number);
+ const pb = b.split(".").map(Number);
+ const len = Math.max(pa.length, pb.length);
+ for (let i = 0; i < len; i++) {
+ const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
+ if (diff !== 0) return diff;
+ }
+ return 0;
+}
+
+function isObject(v: unknown): v is Record {
+ return typeof v === "object" && v !== null && !Array.isArray(v);
+}
+
+function parseLink(raw: unknown): AnnouncementLink | null {
+ if (raw === null || raw === undefined) return null;
+ if (!isObject(raw)) return null;
+ const kind = raw.kind;
+ const url = raw.url;
+ if (typeof url !== "string") return null;
+ if (kind === "internal" && url.startsWith("/")) {
+ return { kind: "internal", url };
+ }
+ if (kind === "external" && /^https?:\/\//.test(url)) {
+ return { kind: "external", url };
+ }
+ return null;
+}
+
+function parseAnnouncement(raw: unknown): Announcement | null {
+ if (!isObject(raw)) return null;
+
+ const id = raw.id;
+ if (typeof id !== "string" || !ID_PATTERN.test(id) || id.length > 64) {
+ return null;
+ }
+
+ const type = raw.type;
+ if (typeof type !== "string" || !VALID_TYPES.has(type as AnnouncementType)) {
+ return null;
+ }
+
+ const title = raw.title;
+ if (typeof title !== "string" || title.length === 0 || title.length > 40) {
+ return null;
+ }
+
+ const body =
+ typeof raw.body === "string" && raw.body.length <= 200 ? raw.body : null;
+
+ const link = parseLink(raw.link);
+
+ const startAt = typeof raw.startAt === "string" ? raw.startAt : null;
+ const endAt = typeof raw.endAt === "string" ? raw.endAt : null;
+
+ let priority = 0;
+ if (typeof raw.priority === "number" && Number.isFinite(raw.priority)) {
+ priority = Math.max(0, Math.min(100, Math.floor(raw.priority)));
+ }
+
+ const dismissible = raw.dismissible === false ? false : true;
+
+ const minVersion =
+ typeof raw.minVersion === "string" && VERSION_PATTERN.test(raw.minVersion)
+ ? raw.minVersion
+ : null;
+ const maxVersion =
+ typeof raw.maxVersion === "string" && VERSION_PATTERN.test(raw.maxVersion)
+ ? raw.maxVersion
+ : null;
+
+ let platforms: AnnouncementPlatform[] | null = null;
+ if (Array.isArray(raw.platforms)) {
+ const filtered = raw.platforms.filter(
+ (p): p is AnnouncementPlatform =>
+ typeof p === "string" && VALID_PLATFORMS.has(p as AnnouncementPlatform),
+ );
+ platforms = filtered.length > 0 ? Array.from(new Set(filtered)) : null;
+ }
+
+ return {
+ id,
+ type: type as AnnouncementType,
+ title,
+ body,
+ link,
+ startAt,
+ endAt,
+ priority,
+ dismissible,
+ minVersion,
+ maxVersion,
+ platforms,
+ };
+}
+
+export async function fetchAnnouncements(): Promise {
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), 5000);
+
+ try {
+ const data = await fetch(`${CONFIG_REPO_CDN}/announcements.json`, {
+ signal: controller.signal,
+ }).then((res) => res.json());
+
+ if (!isObject(data) || data.version !== SUPPORTED_VERSION) return [];
+ if (!Array.isArray(data.announcements)) return [];
+
+ const parsed: Announcement[] = [];
+ const seenIds = new Set();
+ for (const item of data.announcements) {
+ const a = parseAnnouncement(item);
+ if (!a || seenIds.has(a.id)) continue;
+ seenIds.add(a.id);
+ parsed.push(a);
+ }
+ return parsed;
+ } finally {
+ clearTimeout(timer);
+ }
+}
+
+export function isNetworkError(err: unknown): boolean {
+ return (
+ (err instanceof DOMException && err.name === "AbortError") ||
+ err instanceof TypeError
+ );
+}
+
+export function filterActiveAnnouncements(
+ announcements: Announcement[],
+ dismissedIds: readonly string[],
+ now: Date = new Date(),
+): Announcement[] {
+ const appVersion = Constants.expoConfig?.version ?? "0.0.0";
+ const platform = Platform.OS as AnnouncementPlatform;
+ const dismissed = new Set(dismissedIds);
+ const nowMs = now.getTime();
+
+ return announcements
+ .filter((a) => {
+ if (dismissed.has(a.id)) return false;
+ if (a.startAt) {
+ const t = Date.parse(a.startAt);
+ if (Number.isFinite(t) && nowMs < t) return false;
+ }
+ if (a.endAt) {
+ const t = Date.parse(a.endAt);
+ if (Number.isFinite(t) && nowMs > t) return false;
+ }
+ if (a.minVersion && compareVersions(appVersion, a.minVersion) < 0) {
+ return false;
+ }
+ if (a.maxVersion && compareVersions(appVersion, a.maxVersion) > 0) {
+ return false;
+ }
+ if (a.platforms && !a.platforms.includes(platform)) return false;
+ return true;
+ })
+ .sort((a, b) => b.priority - a.priority)
+ .slice(0, MAX_RENDERED);
+}
diff --git a/store/announcements.ts b/store/announcements.ts
new file mode 100644
index 0000000..c533956
--- /dev/null
+++ b/store/announcements.ts
@@ -0,0 +1,72 @@
+import { create } from "zustand";
+import { persist } from "zustand/middleware";
+
+import { reportError } from "@/lib/report";
+import { zustandStorage } from "@/lib/storage";
+import {
+ fetchAnnouncements,
+ isNetworkError,
+ type Announcement,
+} from "@/services/announcements";
+
+const MIN_FETCH_INTERVAL_MS = 5 * 60 * 1000;
+
+interface AnnouncementStore {
+ announcements: Announcement[];
+ dismissedIds: string[];
+ fetching: boolean;
+ fetchedAt: number | null;
+ fetch: (options?: { force?: boolean }) => Promise;
+ dismiss: (id: string) => void;
+}
+
+export const useAnnouncementStore = create()(
+ persist(
+ (set, get) => ({
+ announcements: [],
+ dismissedIds: [],
+ fetching: false,
+ fetchedAt: null,
+ fetch: async (options) => {
+ if (get().fetching) return;
+ const fetchedAt = get().fetchedAt;
+ if (
+ !options?.force &&
+ fetchedAt !== null &&
+ Date.now() - fetchedAt < MIN_FETCH_INTERVAL_MS
+ ) {
+ return;
+ }
+ set({ fetching: true });
+ try {
+ const list = await fetchAnnouncements();
+ const validIds = new Set(list.map((a) => a.id));
+ const dismissedIds = get().dismissedIds.filter((id) =>
+ validIds.has(id),
+ );
+ set({ announcements: list, dismissedIds, fetchedAt: Date.now() });
+ } catch (e) {
+ if (!isNetworkError(e)) {
+ reportError(e, { module: "announcements" });
+ }
+ } finally {
+ set({ fetching: false });
+ }
+ },
+ dismiss: (id: string) => {
+ const cur = get().dismissedIds;
+ if (cur.includes(id)) return;
+ set({ dismissedIds: [...cur, id] });
+ },
+ }),
+ {
+ name: "announcements",
+ storage: zustandStorage,
+ partialize: (s) => ({
+ announcements: s.announcements,
+ dismissedIds: s.dismissedIds,
+ fetchedAt: s.fetchedAt,
+ }),
+ },
+ ),
+);