From 66622ca2073e09988e18b09b03ea54c1249a33fe Mon Sep 17 00:00:00 2001 From: biss Date: Wed, 29 Jul 2026 11:11:37 +0800 Subject: [PATCH] =?UTF-8?q?=E6=99=BA=E8=83=BD=E5=BC=80=E5=B1=8F=EF=BC=9A?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E2=80=9C=E5=A7=93=E5=90=8D=EF=BC=8B=E6=97=A9?= =?UTF-8?q?=E4=B8=8A/=E4=B8=AD=E5=8D=88/=E4=B8=8B=E5=8D=88/=E6=99=9A?= =?UTF-8?q?=E4=B8=8A=E9=97=AE=E5=80=99=E2=80=9D=EF=BC=8C=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E6=98=A5=E8=8A=82=E3=80=81=E7=AB=AF=E5=8D=88=E3=80=81=E4=B8=AD?= =?UTF-8?q?=E7=A7=8B=E3=80=81=E5=9B=BD=E5=BA=86=E7=AD=89=E8=8A=82=E6=97=A5?= =?UTF-8?q?=E6=96=87=E6=A1=88=EF=BC=8C=E5=8F=AF=E7=82=B9=E5=87=BB=E8=B7=B3?= =?UTF-8?q?=E8=BF=87=EF=BC=9A[SmartLaunchScreen.vue](E:/jiaowu/web/src/com?= =?UTF-8?q?ponents/SmartLaunchScreen.vue)=20=E9=95=BF=E6=8C=89=20App=20?= =?UTF-8?q?=E5=9B=BE=E6=A0=87=EF=BC=9A=E6=8F=90=E4=BE=9B=E2=80=9C=E6=88=91?= =?UTF-8?q?=E7=9A=84=E8=AF=BE=E8=A1=A8=E3=80=81=E8=80=83=E8=AF=95=E5=AE=89?= =?UTF-8?q?=E6=8E=92=E3=80=81=E8=AF=BE=E5=A0=82=E7=AD=BE=E5=88=B0=E3=80=81?= =?UTF-8?q?=E6=B6=88=E6=81=AF=E4=B8=AD=E5=BF=83=E2=80=9D=E5=9B=9B=E4=B8=AA?= =?UTF-8?q?=E5=85=A5=E5=8F=A3=E3=80=82=E7=AD=BE=E5=88=B0=E4=BC=9A=E6=8C=89?= =?UTF-8?q?=E5=AD=A6=E7=94=9F/=E6=95=99=E5=B8=88=E8=A7=92=E8=89=B2?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E5=88=86=E6=B5=81=EF=BC=9A[shortcuts.xml](E:?= =?UTF-8?q?/jiaowu/web/native/android/app/src/main/res/xml/shortcuts.xml)?= =?UTF-8?q?=20=E6=A1=8C=E9=9D=A2=E5=B0=8F=E7=BB=84=E4=BB=B6=EF=BC=9A?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E2=80=9C=E4=BB=8A=E6=97=A5=E8=AF=BE=E8=A1=A8?= =?UTF-8?q?=E2=80=9D=E5=92=8C=E2=80=9C=E8=BF=91=E6=9C=9F=E8=80=83=E8=AF=95?= =?UTF-8?q?=E2=80=9D=EF=BC=8C=E5=B1=95=E7=A4=BA=E6=9C=80=E8=BF=91=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E7=9A=84=E6=95=B0=E6=8D=AE=EF=BC=8C=E7=82=B9=E5=87=BB?= =?UTF-8?q?=E5=8F=AF=E8=BF=9B=E5=85=A5=E5=AF=B9=E5=BA=94=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=EF=BC=9A[WidgetRenderer.java](E:/jiaowu/web/native/android/app?= =?UTF-8?q?/src/main/java/edu/mingxu/jiaowu/WidgetRenderer.java)=20?= =?UTF-8?q?=E5=AE=89=E5=85=A8=E5=A4=84=E7=90=86=EF=BC=9A=E7=BB=84=E4=BB=B6?= =?UTF-8?q?=E5=8F=AA=E7=BC=93=E5=AD=98=E8=AF=BE=E7=A8=8B=E5=92=8C=E8=80=83?= =?UTF-8?q?=E8=AF=95=E6=91=98=E8=A6=81=EF=BC=8C=E4=B8=8D=E4=BF=9D=E5=AD=98?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E4=BB=A4=E7=89=8C=EF=BC=9B=E9=80=80=E5=87=BA?= =?UTF-8?q?=E8=B4=A6=E5=8F=B7=E4=BC=9A=E6=B8=85=E7=A9=BA=E7=BB=84=E4=BB=B6?= =?UTF-8?q?=EF=BC=8C=E9=9A=94=E5=A4=A9=E6=9C=AA=E5=88=B7=E6=96=B0=E6=97=B6?= =?UTF-8?q?=E4=B8=8D=E4=BC=9A=E7=BB=A7=E7=BB=AD=E5=B1=95=E7=A4=BA=E6=97=A7?= =?UTF-8?q?=E7=9A=84=E2=80=9C=E4=BB=8A=E6=97=A5=E8=AF=BE=E8=A1=A8=E2=80=9D?= =?UTF-8?q?=E3=80=82=20=E5=8E=9F=E7=94=9F=E6=A8=A1=E6=9D=BF=E5=B7=B2?= =?UTF-8?q?=E7=BA=B3=E5=85=A5=E7=89=88=E6=9C=AC=E7=AE=A1=E7=90=86=EF=BC=8C?= =?UTF-8?q?=E6=AF=8F=E6=AC=A1=20npm=20run=20cap:sync=20=E4=BC=9A=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E6=81=A2=E5=A4=8D=E5=88=B0=E8=A2=AB=E5=BF=BD=E7=95=A5?= =?UTF-8?q?=E7=9A=84=20Android=20=E5=B7=A5=E7=A8=8B=EF=BC=9A[configure-cap?= =?UTF-8?q?acitor.mjs](E:/jiaowu/web/scripts/configure-capacitor.mjs)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 16 ++ .../android/app/src/main/AndroidManifest.xml | 82 ++++++ .../edu/mingxu/jiaowu/ExamWidgetProvider.java | 16 ++ .../java/edu/mingxu/jiaowu/MainActivity.java | 55 ++++ .../edu/mingxu/jiaowu/NativeHomePlugin.java | 61 +++++ .../mingxu/jiaowu/ScheduleWidgetProvider.java | 16 ++ .../edu/mingxu/jiaowu/WidgetRenderer.java | 200 +++++++++++++++ .../res/drawable/ic_shortcut_attendance.xml | 9 + .../main/res/drawable/ic_shortcut_exam.xml | 9 + .../drawable/ic_shortcut_notifications.xml | 9 + .../res/drawable/ic_shortcut_timetable.xml | 9 + .../src/main/res/drawable/widget_accent.xml | 6 + .../main/res/drawable/widget_background.xml | 12 + .../src/main/res/layout/widget_academic.xml | 102 ++++++++ .../app/src/main/res/values/strings.xml | 17 ++ .../app/src/main/res/values/styles.xml | 67 +++++ .../app/src/main/res/xml/exam_widget_info.xml | 10 + .../src/main/res/xml/schedule_widget_info.xml | 10 + .../app/src/main/res/xml/shortcuts.xml | 51 ++++ web/scripts/configure-capacitor.mjs | 36 ++- web/src/App.vue | 5 + web/src/components.d.ts | 1 + web/src/components/SmartLaunchScreen.vue | 193 ++++++++++++++ web/src/main.ts | 2 + web/src/services/nativeHome.ts | 239 ++++++++++++++++++ web/src/stores/auth.ts | 2 + web/src/utils/smartGreeting.ts | 74 ++++++ 27 files changed, 1304 insertions(+), 5 deletions(-) create mode 100644 web/native/android/app/src/main/AndroidManifest.xml create mode 100644 web/native/android/app/src/main/java/edu/mingxu/jiaowu/ExamWidgetProvider.java create mode 100644 web/native/android/app/src/main/java/edu/mingxu/jiaowu/MainActivity.java create mode 100644 web/native/android/app/src/main/java/edu/mingxu/jiaowu/NativeHomePlugin.java create mode 100644 web/native/android/app/src/main/java/edu/mingxu/jiaowu/ScheduleWidgetProvider.java create mode 100644 web/native/android/app/src/main/java/edu/mingxu/jiaowu/WidgetRenderer.java create mode 100644 web/native/android/app/src/main/res/drawable/ic_shortcut_attendance.xml create mode 100644 web/native/android/app/src/main/res/drawable/ic_shortcut_exam.xml create mode 100644 web/native/android/app/src/main/res/drawable/ic_shortcut_notifications.xml create mode 100644 web/native/android/app/src/main/res/drawable/ic_shortcut_timetable.xml create mode 100644 web/native/android/app/src/main/res/drawable/widget_accent.xml create mode 100644 web/native/android/app/src/main/res/drawable/widget_background.xml create mode 100644 web/native/android/app/src/main/res/layout/widget_academic.xml create mode 100644 web/native/android/app/src/main/res/values/strings.xml create mode 100644 web/native/android/app/src/main/res/values/styles.xml create mode 100644 web/native/android/app/src/main/res/xml/exam_widget_info.xml create mode 100644 web/native/android/app/src/main/res/xml/schedule_widget_info.xml create mode 100644 web/native/android/app/src/main/res/xml/shortcuts.xml create mode 100644 web/src/components/SmartLaunchScreen.vue create mode 100644 web/src/services/nativeHome.ts create mode 100644 web/src/utils/smartGreeting.ts 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 @@ + + + + + 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}`, + } +}