diff --git a/README.md b/README.md
index ead55cf..4c57519 100644
--- a/README.md
+++ b/README.md
@@ -93,6 +93,22 @@ SHA-256,下次启动时切换。新资源若未能成功启动,原生更新
更新版本元数据和 ZIP 保存在数据库中,部署新服务端版本前必须先执行
`--migrate-only`。
+### Android 开屏、快捷入口与桌面组件
+
+Android App 在系统静态启动页之后显示智能问候:优先使用当前登录姓名和春节、端午、
+中秋、国庆等节日文案,其次按早上、中午、下午和晚上展示问候;轻触可立即跳过,并
+遵守系统“减少动画”设置。
+
+长按 App 图标提供“我的课表、考试安排、课堂签到、消息中心”四个快捷入口。“课堂
+签到”会按当前角色将学生带到扫码/定位签到,将教师带到发起签到。桌面组件提供“今日
+课表”和“近期考试”,展示 App 最近一次成功加载并安全写入 Android 本地缓存的数据;
+退出账号时会清空组件,跨日且尚未打开 App 刷新时不会继续展示过期的今日课表。
+
+原生 Java、清单和组件资源模板保存在 `web/native/android`。每次运行
+`npm run cap:sync` 后,`configure-capacitor.mjs` 会把模板同步到被 Git 忽略的
+`web/android` 生成目录。上述能力涉及 Android 原生代码,首次加入或以后修改时必须
+重新构建 App,不能通过前端 OTA 单独下发。
+
## MySQL 8.4 生产部署
非 Development 环境只允许使用 MySQL。构建发布包与数据库配置相互独立:
diff --git a/web/native/android/app/src/main/AndroidManifest.xml b/web/native/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..5156edb
--- /dev/null
+++ b/web/native/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,82 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/native/android/app/src/main/java/edu/mingxu/jiaowu/ExamWidgetProvider.java b/web/native/android/app/src/main/java/edu/mingxu/jiaowu/ExamWidgetProvider.java
new file mode 100644
index 0000000..6a0a843
--- /dev/null
+++ b/web/native/android/app/src/main/java/edu/mingxu/jiaowu/ExamWidgetProvider.java
@@ -0,0 +1,16 @@
+package edu.mingxu.jiaowu;
+
+import android.appwidget.AppWidgetManager;
+import android.appwidget.AppWidgetProvider;
+import android.content.Context;
+
+public class ExamWidgetProvider extends AppWidgetProvider {
+ @Override
+ public void onUpdate(
+ Context context,
+ AppWidgetManager appWidgetManager,
+ int[] appWidgetIds
+ ) {
+ WidgetRenderer.updateExams(context, appWidgetManager, appWidgetIds);
+ }
+}
diff --git a/web/native/android/app/src/main/java/edu/mingxu/jiaowu/MainActivity.java b/web/native/android/app/src/main/java/edu/mingxu/jiaowu/MainActivity.java
new file mode 100644
index 0000000..170a721
--- /dev/null
+++ b/web/native/android/app/src/main/java/edu/mingxu/jiaowu/MainActivity.java
@@ -0,0 +1,55 @@
+package edu.mingxu.jiaowu;
+
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Bundle;
+
+import com.getcapacitor.BridgeActivity;
+import com.getcapacitor.JSObject;
+
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Set;
+
+public class MainActivity extends BridgeActivity {
+ private static final Set SHORTCUT_ROUTES =
+ new HashSet<>(Arrays.asList(
+ "timetable",
+ "exams",
+ "attendance",
+ "notifications"));
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ registerPlugin(NativeHomePlugin.class);
+ super.onCreate(savedInstanceState);
+ }
+
+ @Override
+ protected void onNewIntent(Intent intent) {
+ super.onNewIntent(intent);
+ setIntent(intent);
+ dispatchShortcut(intent);
+ }
+
+ private void dispatchShortcut(Intent intent) {
+ Uri data = intent == null ? null : intent.getData();
+ if (data == null ||
+ !"mingxu".equals(data.getScheme()) ||
+ !"open".equals(data.getHost())) {
+ return;
+ }
+
+ String route = data.getLastPathSegment();
+ if (route == null || !SHORTCUT_ROUTES.contains(route)) {
+ return;
+ }
+
+ NativeHomePlugin.savePendingRoute(this, route);
+ if (bridge != null) {
+ JSObject detail = new JSObject();
+ detail.put("route", route);
+ bridge.triggerWindowJSEvent("mingxuShortcut", detail.toString());
+ }
+ }
+}
diff --git a/web/native/android/app/src/main/java/edu/mingxu/jiaowu/NativeHomePlugin.java b/web/native/android/app/src/main/java/edu/mingxu/jiaowu/NativeHomePlugin.java
new file mode 100644
index 0000000..fa46b01
--- /dev/null
+++ b/web/native/android/app/src/main/java/edu/mingxu/jiaowu/NativeHomePlugin.java
@@ -0,0 +1,61 @@
+package edu.mingxu.jiaowu;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+
+import com.getcapacitor.JSObject;
+import com.getcapacitor.Plugin;
+import com.getcapacitor.PluginCall;
+import com.getcapacitor.PluginMethod;
+import com.getcapacitor.annotation.CapacitorPlugin;
+
+@CapacitorPlugin(name = "NativeHome")
+public class NativeHomePlugin extends Plugin {
+ static final String PREFERENCES = "mingxu_native_home";
+ static final String WIDGET_PAYLOAD = "widget_payload";
+ private static final String PENDING_ROUTE = "pending_route";
+
+ @PluginMethod
+ public void updateWidgets(PluginCall call) {
+ JSObject payload = call.getObject("payload");
+ if (payload == null) {
+ call.reject("缺少桌面组件数据。");
+ return;
+ }
+
+ preferences(getContext()).edit()
+ .putString(WIDGET_PAYLOAD, payload.toString())
+ .apply();
+ WidgetRenderer.updateAll(getContext());
+ call.resolve();
+ }
+
+ @PluginMethod
+ public void clearWidgets(PluginCall call) {
+ preferences(getContext()).edit()
+ .remove(WIDGET_PAYLOAD)
+ .apply();
+ WidgetRenderer.updateAll(getContext());
+ call.resolve();
+ }
+
+ @PluginMethod
+ public void getLaunchRoute(PluginCall call) {
+ SharedPreferences preferences = preferences(getContext());
+ String route = preferences.getString(PENDING_ROUTE, null);
+ preferences.edit().remove(PENDING_ROUTE).apply();
+ JSObject result = new JSObject();
+ if (route != null) {
+ result.put("route", route);
+ }
+ call.resolve(result);
+ }
+
+ static void savePendingRoute(Context context, String route) {
+ preferences(context).edit().putString(PENDING_ROUTE, route).apply();
+ }
+
+ static SharedPreferences preferences(Context context) {
+ return context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE);
+ }
+}
diff --git a/web/native/android/app/src/main/java/edu/mingxu/jiaowu/ScheduleWidgetProvider.java b/web/native/android/app/src/main/java/edu/mingxu/jiaowu/ScheduleWidgetProvider.java
new file mode 100644
index 0000000..40361f2
--- /dev/null
+++ b/web/native/android/app/src/main/java/edu/mingxu/jiaowu/ScheduleWidgetProvider.java
@@ -0,0 +1,16 @@
+package edu.mingxu.jiaowu;
+
+import android.appwidget.AppWidgetManager;
+import android.appwidget.AppWidgetProvider;
+import android.content.Context;
+
+public class ScheduleWidgetProvider extends AppWidgetProvider {
+ @Override
+ public void onUpdate(
+ Context context,
+ AppWidgetManager appWidgetManager,
+ int[] appWidgetIds
+ ) {
+ WidgetRenderer.updateSchedule(context, appWidgetManager, appWidgetIds);
+ }
+}
diff --git a/web/native/android/app/src/main/java/edu/mingxu/jiaowu/WidgetRenderer.java b/web/native/android/app/src/main/java/edu/mingxu/jiaowu/WidgetRenderer.java
new file mode 100644
index 0000000..12391d9
--- /dev/null
+++ b/web/native/android/app/src/main/java/edu/mingxu/jiaowu/WidgetRenderer.java
@@ -0,0 +1,200 @@
+package edu.mingxu.jiaowu;
+
+import android.app.PendingIntent;
+import android.appwidget.AppWidgetManager;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import android.view.View;
+import android.widget.RemoteViews;
+
+import org.json.JSONArray;
+import org.json.JSONObject;
+
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+
+final class WidgetRenderer {
+ private static final int[] ROWS = {
+ R.id.widget_row_one,
+ R.id.widget_row_two,
+ R.id.widget_row_three
+ };
+ private static final int[] TIMES = {
+ R.id.widget_time_one,
+ R.id.widget_time_two,
+ R.id.widget_time_three
+ };
+ private static final int[] TITLES = {
+ R.id.widget_item_title_one,
+ R.id.widget_item_title_two,
+ R.id.widget_item_title_three
+ };
+ private static final int[] SUBTITLES = {
+ R.id.widget_item_subtitle_one,
+ R.id.widget_item_subtitle_two,
+ R.id.widget_item_subtitle_three
+ };
+
+ private WidgetRenderer() {}
+
+ static void updateAll(Context context) {
+ AppWidgetManager manager = AppWidgetManager.getInstance(context);
+ int[] scheduleIds = manager.getAppWidgetIds(
+ new ComponentName(context, ScheduleWidgetProvider.class));
+ int[] examIds = manager.getAppWidgetIds(
+ new ComponentName(context, ExamWidgetProvider.class));
+ updateSchedule(context, manager, scheduleIds);
+ updateExams(context, manager, examIds);
+ }
+
+ static void updateSchedule(
+ Context context,
+ AppWidgetManager manager,
+ int[] appWidgetIds
+ ) {
+ JSONObject payload = payload(context);
+ JSONArray items = payload.optJSONArray("schedule");
+ boolean current = LocalDate.now().toString()
+ .equals(payload.optString("scheduleDate"));
+ render(
+ context,
+ manager,
+ appWidgetIds,
+ payload,
+ current ? items : new JSONArray(),
+ "今日课表",
+ current ? "今天没有课程" : "打开 App 刷新今日课表",
+ "timetable",
+ 1101);
+ }
+
+ static void updateExams(
+ Context context,
+ AppWidgetManager manager,
+ int[] appWidgetIds
+ ) {
+ JSONObject payload = payload(context);
+ render(
+ context,
+ manager,
+ appWidgetIds,
+ payload,
+ payload.optJSONArray("exams"),
+ "近期考试",
+ "近期没有已发布考试",
+ "exams",
+ 1201);
+ }
+
+ private static void render(
+ Context context,
+ AppWidgetManager manager,
+ int[] appWidgetIds,
+ JSONObject payload,
+ JSONArray items,
+ String title,
+ String emptyText,
+ String route,
+ int requestCode
+ ) {
+ if (appWidgetIds == null || appWidgetIds.length == 0) {
+ return;
+ }
+
+ for (int appWidgetId : appWidgetIds) {
+ RemoteViews views = new RemoteViews(
+ context.getPackageName(),
+ R.layout.widget_academic);
+ String displayName = payload.optString("displayName", "");
+ views.setTextViewText(
+ R.id.widget_title,
+ displayName.trim().isEmpty() ? title : displayName + " · " + title);
+ views.setTextViewText(
+ R.id.widget_updated,
+ updatedLabel(payload.optString("updatedAt")));
+
+ int count = items == null ? 0 : Math.min(items.length(), ROWS.length);
+ views.setViewVisibility(
+ R.id.widget_empty,
+ count == 0 ? View.VISIBLE : View.GONE);
+ views.setTextViewText(
+ R.id.widget_empty,
+ payload.length() == 0 ? "登录 App 后同步教务数据" : emptyText);
+
+ for (int index = 0; index < ROWS.length; index++) {
+ if (index < count) {
+ JSONObject item = items.optJSONObject(index);
+ views.setViewVisibility(ROWS[index], View.VISIBLE);
+ views.setTextViewText(
+ TIMES[index],
+ item == null ? "" : item.optString("time"));
+ views.setTextViewText(
+ TITLES[index],
+ item == null ? "" : item.optString("title"));
+ views.setTextViewText(
+ SUBTITLES[index],
+ item == null ? "" : item.optString("subtitle"));
+ } else {
+ views.setViewVisibility(ROWS[index], View.GONE);
+ }
+ }
+
+ views.setOnClickPendingIntent(
+ R.id.widget_root,
+ openIntent(context, route, requestCode + appWidgetId));
+ manager.updateAppWidget(appWidgetId, views);
+ }
+ }
+
+ private static PendingIntent openIntent(
+ Context context,
+ String route,
+ int requestCode
+ ) {
+ Intent intent = new Intent(
+ Intent.ACTION_VIEW,
+ Uri.parse("mingxu://open/" + route),
+ context,
+ MainActivity.class);
+ intent.addFlags(
+ Intent.FLAG_ACTIVITY_NEW_TASK |
+ Intent.FLAG_ACTIVITY_CLEAR_TOP |
+ Intent.FLAG_ACTIVITY_SINGLE_TOP);
+ return PendingIntent.getActivity(
+ context,
+ requestCode,
+ intent,
+ PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
+ }
+
+ private static JSONObject payload(Context context) {
+ String value = NativeHomePlugin.preferences(context)
+ .getString(NativeHomePlugin.WIDGET_PAYLOAD, null);
+ if (value == null || value.trim().isEmpty()) {
+ return new JSONObject();
+ }
+ try {
+ return new JSONObject(value);
+ } catch (Exception ignored) {
+ return new JSONObject();
+ }
+ }
+
+ private static String updatedLabel(String value) {
+ if (value == null || value.trim().isEmpty()) {
+ return "等待同步";
+ }
+ try {
+ Instant instant = Instant.parse(value);
+ return "更新于 " + DateTimeFormatter.ofPattern("HH:mm")
+ .withZone(ZoneId.systemDefault())
+ .format(instant);
+ } catch (Exception ignored) {
+ return "最近已同步";
+ }
+ }
+}
diff --git a/web/native/android/app/src/main/res/drawable/ic_shortcut_attendance.xml b/web/native/android/app/src/main/res/drawable/ic_shortcut_attendance.xml
new file mode 100644
index 0000000..6f51d1b
--- /dev/null
+++ b/web/native/android/app/src/main/res/drawable/ic_shortcut_attendance.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/web/native/android/app/src/main/res/drawable/ic_shortcut_exam.xml b/web/native/android/app/src/main/res/drawable/ic_shortcut_exam.xml
new file mode 100644
index 0000000..ebe6663
--- /dev/null
+++ b/web/native/android/app/src/main/res/drawable/ic_shortcut_exam.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/web/native/android/app/src/main/res/drawable/ic_shortcut_notifications.xml b/web/native/android/app/src/main/res/drawable/ic_shortcut_notifications.xml
new file mode 100644
index 0000000..759d8a1
--- /dev/null
+++ b/web/native/android/app/src/main/res/drawable/ic_shortcut_notifications.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/web/native/android/app/src/main/res/drawable/ic_shortcut_timetable.xml b/web/native/android/app/src/main/res/drawable/ic_shortcut_timetable.xml
new file mode 100644
index 0000000..b653718
--- /dev/null
+++ b/web/native/android/app/src/main/res/drawable/ic_shortcut_timetable.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/web/native/android/app/src/main/res/drawable/widget_accent.xml b/web/native/android/app/src/main/res/drawable/widget_accent.xml
new file mode 100644
index 0000000..4b7594b
--- /dev/null
+++ b/web/native/android/app/src/main/res/drawable/widget_accent.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
diff --git a/web/native/android/app/src/main/res/drawable/widget_background.xml b/web/native/android/app/src/main/res/drawable/widget_background.xml
new file mode 100644
index 0000000..a92adb4
--- /dev/null
+++ b/web/native/android/app/src/main/res/drawable/widget_background.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
diff --git a/web/native/android/app/src/main/res/layout/widget_academic.xml b/web/native/android/app/src/main/res/layout/widget_academic.xml
new file mode 100644
index 0000000..915f3d4
--- /dev/null
+++ b/web/native/android/app/src/main/res/layout/widget_academic.xml
@@ -0,0 +1,102 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/native/android/app/src/main/res/values/strings.xml b/web/native/android/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..4d3c5d9
--- /dev/null
+++ b/web/native/android/app/src/main/res/values/strings.xml
@@ -0,0 +1,17 @@
+
+
+ 明序教务
+ 明序教务
+ edu.mingxu.jiaowu
+ edu.mingxu.jiaowu
+ 我的课表
+ 查看我的今日课表
+ 考试安排
+ 查看近期考试安排
+ 课堂签到
+ 扫码、定位或发起签到
+ 消息中心
+ 查看教务通知和待办
+ 明序今日课表
+ 明序近期考试
+
diff --git a/web/native/android/app/src/main/res/values/styles.xml b/web/native/android/app/src/main/res/values/styles.xml
new file mode 100644
index 0000000..8a307e7
--- /dev/null
+++ b/web/native/android/app/src/main/res/values/styles.xml
@@ -0,0 +1,67 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/native/android/app/src/main/res/xml/exam_widget_info.xml b/web/native/android/app/src/main/res/xml/exam_widget_info.xml
new file mode 100644
index 0000000..cefc9a0
--- /dev/null
+++ b/web/native/android/app/src/main/res/xml/exam_widget_info.xml
@@ -0,0 +1,10 @@
+
+
diff --git a/web/native/android/app/src/main/res/xml/schedule_widget_info.xml b/web/native/android/app/src/main/res/xml/schedule_widget_info.xml
new file mode 100644
index 0000000..bed285e
--- /dev/null
+++ b/web/native/android/app/src/main/res/xml/schedule_widget_info.xml
@@ -0,0 +1,10 @@
+
+
diff --git a/web/native/android/app/src/main/res/xml/shortcuts.xml b/web/native/android/app/src/main/res/xml/shortcuts.xml
new file mode 100644
index 0000000..a30a7e5
--- /dev/null
+++ b/web/native/android/app/src/main/res/xml/shortcuts.xml
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/scripts/configure-capacitor.mjs b/web/scripts/configure-capacitor.mjs
index d8105d2..fc56673 100644
--- a/web/scripts/configure-capacitor.mjs
+++ b/web/scripts/configure-capacitor.mjs
@@ -1,4 +1,11 @@
-import { existsSync, readFileSync, writeFileSync } from 'node:fs'
+import {
+ copyFileSync,
+ existsSync,
+ mkdirSync,
+ readFileSync,
+ readdirSync,
+ writeFileSync,
+} from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
@@ -12,17 +19,36 @@ function updateFile(path, update) {
return true
}
+function copyTemplateTree(source, destination) {
+ if (!existsSync(source)) {
+ throw new Error(`缺少原生模板目录:${source}`)
+ }
+ mkdirSync(destination, { recursive: true })
+ for (const entry of readdirSync(source, { withFileTypes: true })) {
+ const sourcePath = resolve(source, entry.name)
+ const destinationPath = resolve(destination, entry.name)
+ if (entry.isDirectory()) copyTemplateTree(sourcePath, destinationPath)
+ else copyFileSync(sourcePath, destinationPath)
+ }
+}
+
function configureAndroid() {
+ const androidRoot = resolve(webRoot, 'android')
const variablesPath = resolve(webRoot, 'android', 'variables.gradle')
+ const templateRoot = resolve(webRoot, 'native', 'android')
const manifestPath = resolve(
- webRoot,
- 'android',
+ androidRoot,
'app',
'src',
'main',
'AndroidManifest.xml',
)
- if (!existsSync(variablesPath) || !existsSync(manifestPath)) return false
+ if (!existsSync(variablesPath)) return false
+
+ copyTemplateTree(templateRoot, androidRoot)
+ if (!existsSync(manifestPath)) {
+ throw new Error('Android 原生模板未生成 AndroidManifest.xml。')
+ }
updateFile(variablesPath, content => {
if (!/minSdkVersion\s*=\s*\d+/.test(content)) {
@@ -78,4 +104,4 @@ if (!configuredPlatforms.length) {
throw new Error('尚未生成 Capacitor 原生工程,请先运行 npx cap add android 或 ios。')
}
-console.log(`已配置 ${configuredPlatforms.join('、')} 的扫码和定位权限。`)
+console.log(`已配置 ${configuredPlatforms.join('、')} 的扫码、定位和原生桌面体验。`)
diff --git a/web/src/App.vue b/web/src/App.vue
index 7c2aa3f..9b555aa 100644
--- a/web/src/App.vue
+++ b/web/src/App.vue
@@ -1,3 +1,8 @@
+
+
+
diff --git a/web/src/components.d.ts b/web/src/components.d.ts
index db6b364..23cc1a9 100644
--- a/web/src/components.d.ts
+++ b/web/src/components.d.ts
@@ -55,6 +55,7 @@ declare module 'vue' {
RichMessageContent: typeof import('./components/RichMessageContent.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
+ SmartLaunchScreen: typeof import('./components/SmartLaunchScreen.vue')['default']
}
export interface GlobalDirectives {
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
diff --git a/web/src/components/SmartLaunchScreen.vue b/web/src/components/SmartLaunchScreen.vue
new file mode 100644
index 0000000..0043349
--- /dev/null
+++ b/web/src/components/SmartLaunchScreen.vue
@@ -0,0 +1,193 @@
+
+
+
+
+
+
+
+
+
+
+
+
MINGXU ACADEMIC
+
{{ greeting.label }}
+
{{ greeting.title }}
+
{{ greeting.subtitle }}
+
+ 轻触跳过
+
+
+
+
+
diff --git a/web/src/main.ts b/web/src/main.ts
index aca2a6b..cb72a5e 100644
--- a/web/src/main.ts
+++ b/web/src/main.ts
@@ -4,6 +4,7 @@ import './style.css'
import App from './App.vue'
import router from './router'
import { initializeAppUpdates } from './services/appUpdates'
+import { initializeNativeHome } from './services/nativeHome'
import { setRouter } from './utils/navigate'
const app = createApp(App)
@@ -12,3 +13,4 @@ app.use(router)
setRouter(router)
app.mount('#app')
void initializeAppUpdates()
+initializeNativeHome(router)
diff --git a/web/src/services/nativeHome.ts b/web/src/services/nativeHome.ts
new file mode 100644
index 0000000..0c6ceed
--- /dev/null
+++ b/web/src/services/nativeHome.ts
@@ -0,0 +1,239 @@
+import { Capacitor, registerPlugin } from '@capacitor/core'
+import type { Router } from 'vue-router'
+import http from '../api/http'
+
+interface WidgetItem {
+ title: string
+ subtitle: string
+ time: string
+}
+
+interface WidgetPayload {
+ displayName: string
+ scheduleDate: string
+ schedule: WidgetItem[]
+ exams: WidgetItem[]
+ updatedAt: string
+}
+
+interface NativeHomePlugin {
+ updateWidgets(options: { payload: WidgetPayload }): Promise
+ clearWidgets(): Promise
+ getLaunchRoute(): Promise<{ route?: string }>
+}
+
+interface StoredUser {
+ displayName?: string
+ roles?: string[]
+}
+
+const NativeHome = registerPlugin('NativeHome')
+const native = Capacitor.isNativePlatform()
+const syncInterval = 10 * 60 * 1000
+let lastSyncAt = 0
+let syncPromise: Promise | null = null
+
+function storedUser(): StoredUser | null {
+ try {
+ const value = localStorage.getItem('jiaowu_user')
+ return value ? JSON.parse(value) as StoredUser : null
+ } catch {
+ return null
+ }
+}
+
+function dateKey(date: Date) {
+ const year = date.getFullYear()
+ const month = String(date.getMonth() + 1).padStart(2, '0')
+ const day = String(date.getDate()).padStart(2, '0')
+ return `${year}-${month}-${day}`
+}
+
+function parseDateOnly(value?: string) {
+ if (!value) return null
+ const [year, month, day] = value.slice(0, 10).split('-').map(Number)
+ return year && month && day ? new Date(year, month - 1, day) : null
+}
+
+function slotTime(slots: any[], period: number, field: 'startsAt' | 'endsAt') {
+ return slots.find((slot) => slot.periodNumber === period)?.[field]?.slice(0, 5) ?? ''
+}
+
+function activeWeek(timetable: any, now: Date) {
+ const start = parseDateOnly(timetable?.term?.startDate)
+ const end = parseDateOnly(timetable?.term?.endDate)
+ if (!start || !end || now < start || now > new Date(end.getTime() + 86400000)) return null
+ const mondayOffset = start.getDay() === 0 ? 6 : start.getDay() - 1
+ const firstMonday = new Date(start)
+ firstMonday.setDate(firstMonday.getDate() - mondayOffset)
+ return Math.floor((now.getTime() - firstMonday.getTime()) / 604800000) + 1
+}
+
+function scheduleItems(timetable: any, now: Date): WidgetItem[] {
+ const week = activeWeek(timetable, now)
+ if (!week) return []
+ const weekday = now.getDay() || 7
+ return (timetable?.entries ?? [])
+ .filter((entry: any) =>
+ entry.dayOfWeek === weekday &&
+ week >= entry.startWeek &&
+ week <= entry.endWeek &&
+ (entry.weekPattern === 'All' ||
+ (entry.weekPattern === 'Odd' && week % 2 === 1) ||
+ (entry.weekPattern === 'Even' && week % 2 === 0)))
+ .sort((left: any, right: any) => left.startPeriod - right.startPeriod)
+ .slice(0, 3)
+ .map((entry: any) => {
+ const endPeriod = entry.startPeriod + entry.periodCount - 1
+ const startsAt = slotTime(timetable.slots ?? [], entry.startPeriod, 'startsAt')
+ const endsAt = slotTime(timetable.slots ?? [], endPeriod, 'endsAt')
+ return {
+ title: String(entry.courseName ?? '未命名课程'),
+ subtitle: [entry.buildingName, entry.classroomName].filter(Boolean).join(' · ') || '地点待定',
+ time: startsAt && endsAt ? `${startsAt}—${endsAt}` : `第 ${entry.startPeriod}—${endPeriod} 节`,
+ }
+ })
+}
+
+function examItems(exams: any[], now: Date): WidgetItem[] {
+ return exams
+ .map((exam) => {
+ const startsAt = new Date(exam.startsAt)
+ const date = Number.isNaN(startsAt.getTime())
+ ? parseDateOnly(exam.examDate)
+ : startsAt
+ return { exam, date }
+ })
+ .filter(({ date }) => date && date.getTime() >= new Date(
+ now.getFullYear(), now.getMonth(), now.getDate(),
+ ).getTime())
+ .sort((left, right) => left.date!.getTime() - right.date!.getTime())
+ .slice(0, 3)
+ .map(({ exam, date }) => ({
+ title: String(exam.courseName ?? '未命名考试'),
+ subtitle: [
+ exam.buildingName,
+ exam.classroomName,
+ exam.seatNumber ? `${exam.seatNumber} 号座` : '',
+ ].filter(Boolean).join(' · ') || '考场待定',
+ time: `${date!.getMonth() + 1}/${date!.getDate()} ${
+ Number.isNaN(new Date(exam.startsAt).getTime())
+ ? '时间待定'
+ : new Intl.DateTimeFormat('zh-CN', {
+ hour: '2-digit',
+ minute: '2-digit',
+ hour12: false,
+ }).format(new Date(exam.startsAt))
+ }`,
+ }))
+}
+
+export async function clearNativeWidgets() {
+ if (!native) return
+ try {
+ await NativeHome.clearWidgets()
+ } catch (error) {
+ console.warn('清理 Android 桌面组件失败', error)
+ }
+}
+
+export async function syncNativeWidgets(force = false) {
+ if (!native) return
+ if (!localStorage.getItem('jiaowu_token')) {
+ await clearNativeWidgets()
+ return
+ }
+ if (!force && Date.now() - lastSyncAt < syncInterval) return
+ if (syncPromise) return syncPromise
+
+ syncPromise = (async () => {
+ const user = storedUser()
+ const roles = user?.roles ?? []
+ if (!roles.some((role) => role === 'Student' || role === 'Teacher')) {
+ await clearNativeWidgets()
+ return
+ }
+
+ const [timetableResult, examResult] = await Promise.allSettled([
+ http.get('/timetables/mine'),
+ http.get('/exams/my-schedule'),
+ ])
+ const now = new Date()
+ const payload: WidgetPayload = {
+ displayName: user?.displayName?.trim() || '同学',
+ scheduleDate: dateKey(now),
+ schedule: timetableResult.status === 'fulfilled'
+ ? scheduleItems(timetableResult.value.data, now)
+ : [],
+ exams: examResult.status === 'fulfilled'
+ ? examItems(examResult.value.data, now)
+ : [],
+ updatedAt: now.toISOString(),
+ }
+ await NativeHome.updateWidgets({ payload })
+ lastSyncAt = Date.now()
+ })().catch((error) => {
+ console.warn('同步 Android 桌面组件失败', error)
+ }).finally(() => {
+ syncPromise = null
+ })
+
+ return syncPromise
+}
+
+function routeForShortcut(route: string) {
+ const roles = storedUser()?.roles ?? []
+ if (route === 'timetable') {
+ return roles.some((role) => role === 'Student' || role === 'Teacher')
+ ? '/my-timetable'
+ : '/class-timetable'
+ }
+ if (route === 'exams') return '/exams'
+ if (route === 'attendance') {
+ return roles.includes('Student') ? '/my-attendance' : '/teacher-attendance'
+ }
+ if (route === 'notifications') return '/notifications'
+ return '/dashboard'
+}
+
+export function initializeNativeHome(router: Router) {
+ if (!native) return
+
+ const openShortcut = (route?: string) => {
+ if (!route) return
+ if (!localStorage.getItem('jiaowu_token')) {
+ sessionStorage.setItem('mingxu_pending_shortcut', route)
+ void router.push('/login')
+ return
+ }
+ void router.push(routeForShortcut(route))
+ }
+ const shortcutListener = (event: Event) => {
+ const value = event as CustomEvent<{ route?: string }> & { route?: string }
+ const eventRoute = value.detail?.route ?? value.route
+ void NativeHome.getLaunchRoute()
+ .then(({ route }) => openShortcut(route ?? eventRoute))
+ .catch(() => openShortcut(eventRoute))
+ }
+ window.addEventListener('mingxuShortcut', shortcutListener)
+ document.addEventListener('visibilitychange', () => {
+ if (!document.hidden) void syncNativeWidgets()
+ })
+ window.addEventListener('mingxu-auth-changed', () => {
+ lastSyncAt = 0
+ const pendingShortcut = sessionStorage.getItem('mingxu_pending_shortcut')
+ if (localStorage.getItem('jiaowu_token') && pendingShortcut) {
+ sessionStorage.removeItem('mingxu_pending_shortcut')
+ openShortcut(pendingShortcut)
+ }
+ void syncNativeWidgets(true)
+ })
+ router.afterEach(() => {
+ void syncNativeWidgets()
+ })
+
+ void NativeHome.getLaunchRoute()
+ .then(({ route }) => openShortcut(route))
+ .catch((error) => console.warn('读取 Android 快捷入口失败', error))
+ void syncNativeWidgets()
+}
diff --git a/web/src/stores/auth.ts b/web/src/stores/auth.ts
index 662f608..4e8a904 100644
--- a/web/src/stores/auth.ts
+++ b/web/src/stores/auth.ts
@@ -24,6 +24,7 @@ export const useAuthStore = defineStore('auth', () => {
user.value = data.user
localStorage.setItem('jiaowu_token', data.token)
localStorage.setItem('jiaowu_user', JSON.stringify(data.user))
+ window.dispatchEvent(new Event('mingxu-auth-changed'))
}
async function refresh() {
@@ -38,6 +39,7 @@ export const useAuthStore = defineStore('auth', () => {
user.value = null
localStorage.removeItem('jiaowu_token')
localStorage.removeItem('jiaowu_user')
+ window.dispatchEvent(new Event('mingxu-auth-changed'))
}
return { token, user, isLoggedIn, isSuperAdmin, login, refresh, logout }
diff --git a/web/src/utils/smartGreeting.ts b/web/src/utils/smartGreeting.ts
new file mode 100644
index 0000000..434aabe
--- /dev/null
+++ b/web/src/utils/smartGreeting.ts
@@ -0,0 +1,74 @@
+export interface SmartGreeting {
+ title: string
+ subtitle: string
+ label: string
+}
+
+const dailySubtitles = [
+ '愿今天的课程与计划都清晰顺利。',
+ '新的一天,从有序安排开始。',
+ '把每一次学习,都变成看得见的进步。',
+]
+
+function lunarFestival(date: Date) {
+ try {
+ const parts = new Intl.DateTimeFormat('zh-CN-u-ca-chinese', {
+ month: 'long',
+ day: 'numeric',
+ }).formatToParts(date)
+ const month = parts.find((part) => part.type === 'month')?.value ?? ''
+ const day = parts.find((part) => part.type === 'day')?.value ?? ''
+
+ if (month === '正月' && ['1', '2', '3'].includes(day)) return '春节快乐'
+ if (month === '正月' && day === '15') return '元宵节快乐'
+ if (month === '五月' && day === '5') return '端午安康'
+ if (month === '八月' && day === '15') return '中秋快乐'
+ } catch {
+ // 少数精简 WebView 不支持中国农历日历,继续使用公历与时段问候。
+ }
+ return ''
+}
+
+function festivalGreeting(date: Date) {
+ const month = date.getMonth() + 1
+ const day = date.getDate()
+ const lunar = lunarFestival(date)
+ if (lunar) return lunar
+ if (month === 1 && day === 1) return '新年快乐'
+ if (month === 5 && day >= 1 && day <= 5) return '劳动节愉快'
+ if (month === 9 && day === 10) return '教师节快乐'
+ if (month === 10 && day >= 1 && day <= 7) return '国庆节快乐'
+ return ''
+}
+
+function timeGreeting(date: Date) {
+ const hour = date.getHours()
+ if (hour < 5) return '夜深了'
+ if (hour < 11) return '早上好'
+ if (hour < 14) return '中午好'
+ if (hour < 18) return '下午好'
+ return '晚上好'
+}
+
+export function getSmartGreeting(
+ date = new Date(),
+ displayName?: string | null,
+): SmartGreeting {
+ const greeting = festivalGreeting(date) || timeGreeting(date)
+ const name = displayName?.trim()
+ const weekday = new Intl.DateTimeFormat('zh-CN', { weekday: 'long' }).format(date)
+ const label = new Intl.DateTimeFormat('zh-CN', {
+ month: 'long',
+ day: 'numeric',
+ }).format(date)
+ const index = Math.abs(date.getFullYear() * 372 + date.getMonth() * 31 + date.getDate())
+ % dailySubtitles.length
+
+ return {
+ title: name ? `${name},${greeting}` : `${greeting},欢迎使用明序教务`,
+ subtitle: date.getDay() === 0 || date.getDay() === 6
+ ? '周末也要记得放松一下,查看安排后从容出发。'
+ : dailySubtitles[index],
+ label: `${label} · ${weekday}`,
+ }
+}