diff --git a/.gitignore b/.gitignore index fe2a649..d5eac6c 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,9 @@ # dependencies node_modules/ +# 项目使用 bun 作为包管理器;package-lock.json 由 npm 生成,不入库 +package-lock.json + # Expo .expo/ dist/ @@ -43,5 +46,8 @@ app-example /android **/android/build/ +# Gradle 本地构建缓存 +.gradle/ + # OTA !assets/certificate.pem diff --git a/app/(tabs)/function.tsx b/app/(tabs)/function.tsx index c0f45b6..4f84831 100644 --- a/app/(tabs)/function.tsx +++ b/app/(tabs)/function.tsx @@ -1,6 +1,6 @@ import { Ionicons } from "@expo/vector-icons"; import { type Href, router } from "expo-router"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { KeyboardAvoidingView, Modal, @@ -20,103 +20,19 @@ import { IS_DEV } from "@/constants/is-dev"; import { useColorScheme } from "@/hooks/use-color-scheme"; import { useHaptics } from "@/hooks/use-haptics"; import { useMarkRouteInteractive } from "@/hooks/use-mark-route-interactive"; -import { type TKey, useT } from "@/lib/i18n"; +import { getResolvedLang, useT } from "@/lib/i18n"; +import type { + FunctionWebApp, + LocalizedString, +} from "@/services/function-apps"; +import { useFunctionAppsStore } from "@/store/function-apps"; import { useScheduleStore } from "@/store/schedule"; -type WebApp = { - icon: React.ComponentProps["name"]; - labelKey: TKey; - color: string; - // 应用内页面:走 expo-router 类型安全路由(即时跳转,也是 iwut:// 深链接的目标) - route?: Href; - // 外部网页:在内置浏览器打开 - uri?: string; - lan?: boolean; -}; - -type Section = { - titleKey: TKey; - items: WebApp[]; -}; - -const SECTIONS: Section[] = [ - { - titleKey: "fn.section.study", - items: [ - { - icon: "bar-chart-outline", - labelKey: "fn.app.grade", - color: "#8b5cf6", - route: "/grade", - }, - { - icon: "calendar-clear-outline", - labelKey: "fn.app.exam", - color: "#3b82f6", - route: "/exam", - }, - { - icon: "book-outline", - labelKey: "fn.app.classroom", - color: "#0d9488", - uri: "https://classroom-iwut.tokenteam.net", - lan: false, - }, - { - icon: "easel-outline", - labelKey: "fn.app.icSpace", - color: "#06b6d4", - uri: "https://zhlgd.whut.edu.cn/tpass/login?service=https%3A%2F%2Fzw.whut.edu.cn%2Frem%2Fstatic%2Fsso%2FwebOAuthRed", - lan: true, - }, - { - icon: "library-outline", - labelKey: "fn.app.library", - color: "#3b82f6", - uri: "https://library-info-iwut.tokenteam.net", - lan: true, - }, - ], - }, - { - titleKey: "fn.section.life", - items: [ - { - icon: "card-outline", - labelKey: "fn.app.card", - color: "#10b981", - uri: "https://cardcare-iwut.tokenteam.net", - lan: false, - }, - { - icon: "flash-outline", - labelKey: "fn.app.elec", - color: "#eab308", - uri: "https://zhlgd.whut.edu.cn/tpass/login?service=http://nyyzf.whut.edu.cn/MobileWebOnlineHall/#/", - lan: false, - }, - { - icon: "wifi-outline", - labelKey: "fn.app.netPay", - color: "#2563eb", - uri: "https://zhlgd.whut.edu.cn/tpass/login?service=http://cwsf.whut.edu.cn/netdetails515N023", - lan: false, - }, - ], - }, - { - titleKey: "fn.section.info", - items: [ - { - icon: "newspaper-outline", - labelKey: "fn.app.campusNews", - color: "#f97316", - uri: "http://i.whut.edu.cn", - lan: true, - }, - ], - }, -]; +function resolveLocalizedString(value: LocalizedString): string { + if (typeof value === "string") return value; + const lang = getResolvedLang(); + return value[lang] ?? value.zh ?? ""; +} function openWeb(uri: string) { router.push({ pathname: "/browser", params: { uri } }); @@ -133,12 +49,22 @@ export default function FunctionScreen() { const { height } = useWindowDimensions(); const [showBrowser, setShowBrowser] = useState(false); const [uri, setUri] = useState(""); - const [pendingLanApp, setPendingLanApp] = useState(null); + const [pendingLanApp, setPendingLanApp] = useState( + null, + ); - const handleOpenApp = (app: WebApp) => { + const remoteSections = useFunctionAppsStore((s) => s.sections); + const fetchFunctionApps = useFunctionAppsStore((s) => s.fetch); + const sections = remoteSections; + + useEffect(() => { + fetchFunctionApps(); + }, [fetchFunctionApps]); + + const handleOpenApp = (app: FunctionWebApp) => { haptic(); if (app.route) { - router.push(app.route); + router.push(app.route as Href); return; } if (!app.uri) return; @@ -211,17 +137,17 @@ export default function FunctionScreen() { }`} /> - {SECTIONS.map((section) => ( - + {sections.map((section) => ( + - {t(section.titleKey)} + {resolveLocalizedString(section.title)} {section.items.map((app) => ( handleOpenApp(app)} @@ -299,7 +225,9 @@ export default function FunctionScreen() { onClose={() => setPendingLanApp(null)} title={t("fn.lanTitle")} description={t("fn.lanDesc", { - app: pendingLanApp ? t(pendingLanApp.labelKey) : "", + app: pendingLanApp + ? resolveLocalizedString(pendingLanApp.label) + : "", })} confirmText={t("fn.lanConfirm")} onConfirm={handleConfirmLan} @@ -316,7 +244,7 @@ function AppItem({ hasBg, onPress, }: { - app: WebApp; + app: FunctionWebApp; label: string; isDark: boolean; hasBg: boolean; @@ -359,7 +287,11 @@ function AppItem({ backgroundColor: isDark ? `${app.color}18` : `${app.color}12`, }} /> - + ["name"]} + size={20} + color={app.color} + /> { useUpdateStore.getState().check(); useAnnouncementStore.getState().fetch(); + useFunctionAppsStore.getState().fetch(); }, []); useEffect(() => { diff --git a/lib/i18n/locales/en.json b/lib/i18n/locales/en.json index 87f1c7f..c85721f 100644 --- a/lib/i18n/locales/en.json +++ b/lib/i18n/locales/en.json @@ -225,22 +225,6 @@ "fn": { "title": "Apps", "subtitle": "Campus services at your fingertips", - "section": { - "study": "Study", - "life": "Life", - "info": "Campus Info" - }, - "app": { - "exam": "Exams", - "grade": "Grades", - "classroom": "Study Rooms", - "icSpace": "IC Space Booking", - "library": "Library", - "card": "Campus Card", - "elec": "Electricity", - "netPay": "Network Payment", - "campusNews": "Campus News" - }, "lanTitle": "Confirm campus network", "lanDesc": "{app} is only available on the campus network. Please confirm you are connected before continuing.", "lanConfirm": "Connected, open" diff --git a/lib/i18n/locales/zh.json b/lib/i18n/locales/zh.json index 00af6a9..509f201 100644 --- a/lib/i18n/locales/zh.json +++ b/lib/i18n/locales/zh.json @@ -225,22 +225,6 @@ "fn": { "title": "功能", "subtitle": "校园服务,触手可及", - "section": { - "study": "学习工具", - "life": "生活服务", - "info": "校园资讯" - }, - "app": { - "exam": "考试安排", - "grade": "成绩查询", - "classroom": "自习室查询", - "icSpace": "IC 空间预约", - "library": "图书馆管家", - "card": "智寻卡片", - "elec": "电费查询", - "netPay": "网络缴费", - "campusNews": "校园公告" - }, "lanTitle": "请确认校园网环境", "lanDesc": "「{app}」需要在校园网环境下访问。请确认当前已连接校园网后继续打开。", "lanConfirm": "已连接,继续打开" diff --git a/schemas/function-apps.schema.json b/schemas/function-apps.schema.json new file mode 100644 index 0000000..18a3823 --- /dev/null +++ b/schemas/function-apps.schema.json @@ -0,0 +1,132 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://raw.githubusercontent.com/TokenTeam/iwut/main/schemas/function-apps.schema.json", + "title": "iwut Function Apps", + "description": "iwut 功能页(学习工具/生活服务/校园资讯)的远程配置结构。内容仓库见 https://cnb.cool/TokenTeam/iwut-config", + "type": "object", + "additionalProperties": false, + "required": ["version", "sections"], + "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 缓存。客户端不依赖此字段做业务判断" + }, + "sections": { + "type": "array", + "description": "功能分区列表,客户端按顺序渲染。至少一个有效分区", + "minItems": 1, + "items": { "$ref": "#/definitions/functionSection" } + } + }, + "definitions": { + "localizedString": { + "description": "本地化字符串。可以是单一字符串(所有语言相同),或按语言代码(zh / en)分别提供。客户端按当前 i18n 语言选择,缺失时回退到 zh", + "oneOf": [ + { "type": "string", "minLength": 1 }, + { + "type": "object", + "required": ["zh"], + "properties": { + "zh": { "type": "string", "minLength": 1 }, + "en": { "type": "string", "minLength": 1 } + }, + "additionalProperties": { "type": "string", "minLength": 1 } + } + ] + }, + "functionSection": { + "type": "object", + "additionalProperties": false, + "required": ["id", "title", "items"], + "properties": { + "id": { + "type": "string", + "description": "稳定且唯一的分区 ID(kebab-case)。请勿随意修改,避免与客户端持久化状态冲突", + "pattern": "^[a-z0-9][a-z0-9-]*$", + "minLength": 1, + "maxLength": 64 + }, + "title": { "$ref": "#/definitions/localizedString" }, + "enabled": { + "type": "boolean", + "description": "是否展示该分区;false 时客户端跳过整个分区(含其下所有入口)。默认 true", + "default": true + }, + "items": { + "type": "array", + "description": "分区下的功能入口列表,至少一个有效入口", + "minItems": 1, + "items": { "$ref": "#/definitions/functionApp" } + } + } + }, + "functionApp": { + "type": "object", + "additionalProperties": false, + "required": ["id", "label", "icon", "color"], + "properties": { + "id": { + "type": "string", + "description": "稳定且唯一的功能 ID(kebab-case)。发布后请勿修改,避免与客户端持久化状态冲突", + "pattern": "^[a-z0-9][a-z0-9-]*$", + "minLength": 1, + "maxLength": 64 + }, + "label": { "$ref": "#/definitions/localizedString" }, + "icon": { + "type": "string", + "description": "Ionicons 图标名(如 \"bar-chart-outline\"),与客户端 Ionicons 字体可用的 name 一一对应", + "minLength": 1 + }, + "color": { + "type": "string", + "description": "图标强调色,十六进制 6 位色值", + "pattern": "^#[0-9a-fA-F]{6}$" + }, + "route": { + "type": "string", + "description": "应用内路由,客户端直接 router.push 跳转。当前仅允许白名单内的路由", + "enum": ["/grade", "/exam"] + }, + "uri": { + "type": "string", + "description": "外部链接,客户端用内置浏览器(webview)打开。必须是 http:// 或 https://", + "format": "uri", + "pattern": "^https?://" + }, + "lan": { + "type": "boolean", + "description": "是否仅限校园网访问;true 时客户端打开前先弹「校园网确认」提示。默认 false", + "default": false + }, + "enabled": { + "type": "boolean", + "description": "是否展示该入口;false 时客户端跳过此入口。默认 true", + "default": true + } + }, + "oneOf": [ + { + "description": "应用内路由与外部链接二选一:填 route 则不能填 uri", + "required": ["route"], + "not": { "required": ["uri"] } + }, + { + "description": "应用内路由与外部链接二选一:填 uri 则不能填 route", + "required": ["uri"], + "not": { "required": ["route"] } + } + ] + } + } +} diff --git a/services/function-apps.ts b/services/function-apps.ts new file mode 100644 index 0000000..7d85fb1 --- /dev/null +++ b/services/function-apps.ts @@ -0,0 +1,188 @@ +import { CONFIG_REPO_CDN } from "@/constants/api"; + +export type LocalizedString = string | Partial>; + +export interface FunctionWebApp { + id: string; + label: LocalizedString; + icon: string; + color: string; + uri?: string; + route?: string; + lan?: boolean; +} + +export interface FunctionSection { + id: string; + title: LocalizedString; + items: FunctionWebApp[]; +} + +export interface FunctionAppsManifest { + version: 1; + updatedAt: string | null; + sections: FunctionSection[]; +} + +const SUPPORTED_VERSION = 1; +const ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/; +const COLOR_PATTERN = /^#[0-9a-fA-F]{6}$/; +const ROUTE_ALLOWLIST = new Set(["/grade", "/exam"]); + +function isObject(v: unknown): v is Record { + return typeof v === "object" && v !== null && !Array.isArray(v); +} + +function parseLocalizedString(raw: unknown): LocalizedString | null { + if (typeof raw === "string") { + return raw.length > 0 ? raw : null; + } + + if (!isObject(raw)) return null; + + const out: Partial> = {}; + + if (typeof raw.zh === "string" && raw.zh.length > 0) { + out.zh = raw.zh; + } + + if (typeof raw.en === "string" && raw.en.length > 0) { + out.en = raw.en; + } + + return out.zh ? out : null; +} + +function parseWebApp(raw: unknown): FunctionWebApp | null { + if (!isObject(raw)) return null; + if (raw.enabled === false) return null; + + const id = raw.id; + if (typeof id !== "string" || !ID_PATTERN.test(id)) return null; + + const label = parseLocalizedString(raw.label); + if (!label) return null; + + const icon = raw.icon; + if (typeof icon !== "string" || icon.length === 0) return null; + + const color = raw.color; + if (typeof color !== "string" || !COLOR_PATTERN.test(color)) return null; + + const route = raw.route; + const uri = raw.uri; + const validRoute = + typeof route === "string" && ROUTE_ALLOWLIST.has(route) ? route : null; + const validUri = + typeof uri === "string" && /^https?:\/\//.test(uri) ? uri : null; + + // route 与 uri 必须二选一:两个都缺、或两个都填,都判非法 + if ((validRoute === null) === (validUri === null)) return null; + + return { + id, + label, + icon, + color, + route: validRoute ?? undefined, + uri: validUri ?? undefined, + lan: raw.lan === true, + }; +} + +function parseSection(raw: unknown): FunctionSection | null { + if (!isObject(raw)) return null; + if (raw.enabled === false) return null; + + const id = raw.id; + if (typeof id !== "string" || !ID_PATTERN.test(id)) return null; + + const title = parseLocalizedString(raw.title); + if (!title) return null; + + if (!Array.isArray(raw.items)) return null; + + const items: FunctionWebApp[] = []; + const seenIds = new Set(); + + for (const item of raw.items) { + const app = parseWebApp(item); + if (!app || seenIds.has(app.id)) continue; + + seenIds.add(app.id); + items.push(app); + } + + if (items.length === 0) return null; + + return { + id, + title, + items, + }; +} + +function parseManifest(raw: unknown): FunctionAppsManifest | null { + if (!isObject(raw)) return null; + if (raw.version !== SUPPORTED_VERSION) return null; + if (!Array.isArray(raw.sections)) return null; + + const sections: FunctionSection[] = []; + const seenIds = new Set(); + + for (const item of raw.sections) { + const section = parseSection(item); + if (!section || seenIds.has(section.id)) continue; + + seenIds.add(section.id); + sections.push(section); + } + + if (sections.length === 0) return null; + + return { + version: 1, + updatedAt: typeof raw.updatedAt === "string" ? raw.updatedAt : null, + sections, + }; +} + +export async function fetchFunctionAppsManifest(): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 5000); + + try { + const res = await fetch(`${CONFIG_REPO_CDN}/function-apps.json`, { + signal: controller.signal, + }); + + if (!res.ok) return null; + + let data: unknown; + try { + data = await res.json(); + } catch { + return null; + } + + return parseManifest(data); + } finally { + clearTimeout(timer); + } +} + +export function isNetworkError(err: unknown): boolean { + if (typeof DOMException !== "undefined" && err instanceof DOMException) { + return err.name === "AbortError"; + } + + if ( + err && + typeof err === "object" && + (err as { name?: string }).name === "AbortError" + ) { + return true; + } + + return err instanceof TypeError; +} \ No newline at end of file diff --git a/store/function-apps.ts b/store/function-apps.ts new file mode 100644 index 0000000..7d5a266 --- /dev/null +++ b/store/function-apps.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 { + fetchFunctionAppsManifest, + isNetworkError, + type FunctionSection, +} from "@/services/function-apps"; + +const MIN_FETCH_INTERVAL_MS = 5 * 60 * 1000; + +interface FunctionAppsStore { + sections: FunctionSection[]; + fetching: boolean; + fetchedAt: number | null; + fetch: (options?: { force?: boolean }) => Promise; +} + +export const useFunctionAppsStore = create()( + persist( + (set, get) => ({ + sections: [], + 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 manifest = await fetchFunctionAppsManifest(); + + if (!manifest) { + set({ fetchedAt: Date.now() }); + return; + } + + set({ + sections: manifest.sections, + fetchedAt: Date.now(), + }); + } catch (e) { + if (!isNetworkError(e)) { + reportError(e, { module: "function-apps" }); + } + } finally { + set({ fetching: false }); + } + }, + }), + { + name: "function-apps", + storage: zustandStorage, + partialize: (s) => ({ + sections: s.sections, + fetchedAt: s.fetchedAt, + }), + }, + ), +); \ No newline at end of file