From d46368e14ee32b5619163a8a4924dcd0becead74 Mon Sep 17 00:00:00 2001 From: zhxycn Date: Fri, 15 May 2026 11:41:14 +0800 Subject: [PATCH 1/2] =?UTF-8?q?:pencil:=20feat:=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E5=85=AC=E5=91=8A=20Schema?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- constants/api.ts | 6 +- schemas/announcements.schema.json | 138 ++++++++++++++++++++++++++++++ 2 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 schemas/announcements.schema.json 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?://" + } + } + } + ] + } + } +} From 871f9c0df3a828c71098858be36e207428fa95fc Mon Sep 17 00:00:00 2001 From: zhxycn Date: Fri, 15 May 2026 11:42:05 +0800 Subject: [PATCH 2/2] =?UTF-8?q?:sparkles:=20feat:=20=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E9=A6=96=E9=A1=B5=E5=85=AC=E5=91=8A=E6=9D=BF=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(tabs)/index.tsx | 15 ++ app/_layout.tsx | 7 +- components/ui/announcement-banner.tsx | 365 ++++++++++++++++++++++++++ services/announcements.ts | 207 +++++++++++++++ store/announcements.ts | 72 +++++ 5 files changed, 664 insertions(+), 2 deletions(-) create mode 100644 components/ui/announcement-banner.tsx create mode 100644 services/announcements.ts create mode 100644 store/announcements.ts 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/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, + }), + }, + ), +);