Compare commits
5 Commits
cb2087f779
...
b50bd9abb3
| Author | SHA1 | Date | |
|---|---|---|---|
| b50bd9abb3 | |||
| 582589d252 | |||
| eb24a3bbdf | |||
|
|
592167e667 | ||
|
|
e9d42fdb01 |
8
.idea/.gitignore
generated
vendored
Normal file
8
.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# 默认忽略的文件
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# 基于编辑器的 HTTP 客户端请求
|
||||||
|
/httpRequests/
|
||||||
|
# Datasource local storage ignored files
|
||||||
|
/dataSources/
|
||||||
|
/dataSources.local.xml
|
||||||
9
.idea/frontend.iml
generated
Normal file
9
.idea/frontend.iml
generated
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="JAVA_MODULE" version="4">
|
||||||
|
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
||||||
|
<exclude-output />
|
||||||
|
<content url="file://$MODULE_DIR$" />
|
||||||
|
<orderEntry type="inheritedJdk" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
448
.idea/gradle.xml
generated
Normal file
448
.idea/gradle.xml
generated
Normal file
@@ -0,0 +1,448 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="GradleSettings">
|
||||||
|
<option name="linkedExternalProjectsSettings">
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/@expo/dom-webview/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/@expo/dom-webview/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/@expo/log-box/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/@expo/log-box/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/@react-native-async-storage/async-storage/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/@react-native-async-storage/async-storage/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/@react-native/gradle-plugin" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/@react-native/gradle-plugin" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-application/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/expo-application/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-background-fetch/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/expo-background-fetch/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-constants/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/expo-constants/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-constants/scripts" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/expo-constants/scripts" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-dev-client/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/expo-dev-client/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-dev-launcher/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/expo-dev-launcher/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-dev-launcher/expo-dev-launcher-gradle-plugin" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/expo-dev-launcher/expo-dev-launcher-gradle-plugin" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-dev-menu-interface/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/expo-dev-menu-interface/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-dev-menu/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/expo-dev-menu/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-eas-client/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/expo-eas-client/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-file-system/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/expo-file-system/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-font/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/expo-font/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-haptics/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/expo-haptics/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-image-loader/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/expo-image-loader/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-image-picker/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/expo-image-picker/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-image/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/expo-image/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-json-utils/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/expo-json-utils/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-linear-gradient/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/expo-linear-gradient/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-linking/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/expo-linking/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-manifests/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/expo-manifests/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-media-library/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/expo-media-library/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-modules-autolinking/android/expo-gradle-plugin" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/expo-modules-autolinking/android/expo-gradle-plugin" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-modules-core/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/expo-modules-core/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-modules-core/expo-module-gradle-plugin" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/expo-modules-core/expo-module-gradle-plugin" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-notifications/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/expo-notifications/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-router/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/expo-router/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-sqlite/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/expo-sqlite/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-structured-headers/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/expo-structured-headers/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-system-ui/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/expo-system-ui/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-task-manager/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/expo-task-manager/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-updates-interface/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/expo-updates-interface/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-updates/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/expo-updates/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-updates/expo-updates-gradle-plugin" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/expo-updates/expo-updates-gradle-plugin" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-video/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/expo-video/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/expo/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo/node_modules/expo-asset/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/expo/node_modules/expo-asset/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo/node_modules/expo-keep-awake/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/expo/node_modules/expo-keep-awake/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/react-native" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/react-native" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/react-native-gesture-handler/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/react-native-gesture-handler/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/react-native-pager-view/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/react-native-pager-view/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/react-native-reanimated/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/react-native-reanimated/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/react-native-safe-area-context/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/react-native-safe-area-context/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/react-native-screens/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/react-native-screens/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/react-native-worklets/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/react-native-worklets/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/unimodules-app-loader/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/unimodules-app-loader/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
7
.idea/misc.xml
generated
Normal file
7
.idea/misc.xml
generated
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||||
|
<component name="ProjectRootManager">
|
||||||
|
<output url="file://$PROJECT_DIR$/out" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
8
.idea/modules.xml
generated
Normal file
8
.idea/modules.xml
generated
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/frontend.iml" filepath="$PROJECT_DIR$/.idea/frontend.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -20,6 +20,7 @@ import Avatar from '../common/Avatar';
|
|||||||
import { ImageGrid, ImageGridItem, SmartImage } from '../common';
|
import { ImageGrid, ImageGridItem, SmartImage } from '../common';
|
||||||
import VotePreview from './VotePreview';
|
import VotePreview from './VotePreview';
|
||||||
import { useResponsive, useResponsiveValue } from '../../hooks/useResponsive';
|
import { useResponsive, useResponsiveValue } from '../../hooks/useResponsive';
|
||||||
|
import { getPreviewImageUrl, ImageDisplayMode } from '../../utils/imageHelper';
|
||||||
|
|
||||||
interface PostCardProps {
|
interface PostCardProps {
|
||||||
post: Post;
|
post: Post;
|
||||||
@@ -206,6 +207,8 @@ const PostCard: React.FC<PostCardProps> = ({
|
|||||||
id: img.id,
|
id: img.id,
|
||||||
url: img.url,
|
url: img.url,
|
||||||
thumbnail_url: img.thumbnail_url,
|
thumbnail_url: img.thumbnail_url,
|
||||||
|
preview_url: img.preview_url,
|
||||||
|
preview_url_large: img.preview_url_large,
|
||||||
width: img.width,
|
width: img.width,
|
||||||
height: img.height,
|
height: img.height,
|
||||||
}));
|
}));
|
||||||
@@ -224,6 +227,8 @@ const PostCard: React.FC<PostCardProps> = ({
|
|||||||
borderRadius={imageBorderRadius}
|
borderRadius={imageBorderRadius}
|
||||||
showMoreOverlay={true}
|
showMoreOverlay={true}
|
||||||
onImagePress={onImagePress}
|
onImagePress={onImagePress}
|
||||||
|
displayMode={variant === 'grid' ? 'grid' : 'list'}
|
||||||
|
usePreview={true}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -335,6 +340,11 @@ const PostCard: React.FC<PostCardProps> = ({
|
|||||||
const aspectRatios = [0.7, 0.75, 0.8, 0.85, 0.9, 1, 1.1, 1.2];
|
const aspectRatios = [0.7, 0.75, 0.8, 0.85, 0.9, 1, 1.1, 1.2];
|
||||||
const aspectRatio = aspectRatios[hash % aspectRatios.length];
|
const aspectRatio = aspectRatios[hash % aspectRatios.length];
|
||||||
|
|
||||||
|
// 获取封面图预览 URL
|
||||||
|
const coverUrl = hasImage
|
||||||
|
? getPreviewImageUrl(coverImage, 'grid')
|
||||||
|
: '';
|
||||||
|
|
||||||
// 获取正文预览(无图时显示)
|
// 获取正文预览(无图时显示)
|
||||||
const getContentPreview = (content: string | undefined | null): string => {
|
const getContentPreview = (content: string | undefined | null): string => {
|
||||||
if (!content) return '';
|
if (!content) return '';
|
||||||
@@ -369,9 +379,10 @@ const PostCard: React.FC<PostCardProps> = ({
|
|||||||
onPress={() => onImagePress?.(post.images || [], 0)}
|
onPress={() => onImagePress?.(post.images || [], 0)}
|
||||||
>
|
>
|
||||||
<SmartImage
|
<SmartImage
|
||||||
source={{ uri: coverImage.thumbnail_url || coverImage.url || '' }}
|
source={{ uri: coverUrl }}
|
||||||
style={[styles.gridCoverImage, { aspectRatio }]}
|
style={[styles.gridCoverImage, { aspectRatio }]}
|
||||||
resizeMode="cover"
|
resizeMode="cover"
|
||||||
|
usePreview={true}
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import { colors, spacing, borderRadius } from '../../theme';
|
|||||||
import { SystemMessageResponse, SystemMessageType } from '../../types/dto';
|
import { SystemMessageResponse, SystemMessageType } from '../../types/dto';
|
||||||
import Text from '../common/Text';
|
import Text from '../common/Text';
|
||||||
import Avatar from '../common/Avatar';
|
import Avatar from '../common/Avatar';
|
||||||
import { useResponsive, useResponsiveValue } from '../../hooks/useResponsive';
|
|
||||||
|
|
||||||
interface SystemMessageItemProps {
|
interface SystemMessageItemProps {
|
||||||
message: SystemMessageResponse;
|
message: SystemMessageResponse;
|
||||||
@@ -165,14 +164,6 @@ const SystemMessageItem: React.FC<SystemMessageItemProps> = ({
|
|||||||
onRequestAction,
|
onRequestAction,
|
||||||
requestActionLoading = false,
|
requestActionLoading = false,
|
||||||
}) => {
|
}) => {
|
||||||
// 响应式适配
|
|
||||||
const { isWideScreen, isDesktop } = useResponsive();
|
|
||||||
const containerPadding = useResponsiveValue({ xs: spacing.md, lg: spacing.lg, xl: spacing.xl });
|
|
||||||
const avatarSize = useResponsiveValue({ xs: 40, lg: 48, xl: 52 });
|
|
||||||
const iconSize = useResponsiveValue({ xs: 20, lg: 22, xl: 24 });
|
|
||||||
const contentFontSize = useResponsiveValue({ xs: 14, lg: 15, xl: 16 });
|
|
||||||
const titleFontSize = useResponsiveValue({ xs: 14, lg: 15, xl: 16 });
|
|
||||||
const timeFontSize = useResponsiveValue({ xs: 12, lg: 13, xl: 14 });
|
|
||||||
// 格式化时间
|
// 格式化时间
|
||||||
const formatTime = (dateString: string): string => {
|
const formatTime = (dateString: string): string => {
|
||||||
try {
|
try {
|
||||||
@@ -198,7 +189,15 @@ const SystemMessageItem: React.FC<SystemMessageItemProps> = ({
|
|||||||
requestStatus === 'pending' &&
|
requestStatus === 'pending' &&
|
||||||
!!onRequestAction;
|
!!onRequestAction;
|
||||||
|
|
||||||
const styles = React.useMemo(() => StyleSheet.create({
|
// 使用固定的响应式值,兼容移动端和Web端
|
||||||
|
const containerPadding = spacing.md;
|
||||||
|
const avatarSize = 44;
|
||||||
|
const iconSize = 22;
|
||||||
|
const contentFontSize = 14;
|
||||||
|
const titleFontSize = 14;
|
||||||
|
const timeFontSize = 12;
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'flex-start',
|
alignItems: 'flex-start',
|
||||||
@@ -208,7 +207,7 @@ const SystemMessageItem: React.FC<SystemMessageItemProps> = ({
|
|||||||
borderBottomColor: colors.divider,
|
borderBottomColor: colors.divider,
|
||||||
},
|
},
|
||||||
iconContainer: {
|
iconContainer: {
|
||||||
marginRight: isWideScreen ? spacing.lg : spacing.md,
|
marginRight: spacing.md,
|
||||||
},
|
},
|
||||||
iconWrapper: {
|
iconWrapper: {
|
||||||
width: avatarSize,
|
width: avatarSize,
|
||||||
@@ -220,6 +219,7 @@ const SystemMessageItem: React.FC<SystemMessageItemProps> = ({
|
|||||||
content: {
|
content: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
|
minWidth: 0,
|
||||||
},
|
},
|
||||||
titleRow: {
|
titleRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -237,7 +237,7 @@ const SystemMessageItem: React.FC<SystemMessageItemProps> = ({
|
|||||||
fontSize: timeFontSize,
|
fontSize: timeFontSize,
|
||||||
},
|
},
|
||||||
messageContent: {
|
messageContent: {
|
||||||
lineHeight: isWideScreen ? 22 : 18,
|
lineHeight: 20,
|
||||||
fontSize: contentFontSize,
|
fontSize: contentFontSize,
|
||||||
},
|
},
|
||||||
extraInfo: {
|
extraInfo: {
|
||||||
@@ -245,7 +245,7 @@ const SystemMessageItem: React.FC<SystemMessageItemProps> = ({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
marginTop: spacing.xs,
|
marginTop: spacing.xs,
|
||||||
backgroundColor: colors.background.default,
|
backgroundColor: colors.background.default,
|
||||||
paddingHorizontal: isWideScreen ? spacing.md : spacing.sm,
|
paddingHorizontal: spacing.sm,
|
||||||
paddingVertical: spacing.xs,
|
paddingVertical: spacing.xs,
|
||||||
borderRadius: borderRadius.sm,
|
borderRadius: borderRadius.sm,
|
||||||
alignSelf: 'flex-start',
|
alignSelf: 'flex-start',
|
||||||
@@ -259,8 +259,8 @@ const SystemMessageItem: React.FC<SystemMessageItemProps> = ({
|
|||||||
marginTop: spacing.sm,
|
marginTop: spacing.sm,
|
||||||
},
|
},
|
||||||
actionBtn: {
|
actionBtn: {
|
||||||
paddingVertical: isWideScreen ? spacing.sm : spacing.xs,
|
paddingVertical: spacing.xs,
|
||||||
paddingHorizontal: isWideScreen ? spacing.lg : spacing.md,
|
paddingHorizontal: spacing.md,
|
||||||
borderRadius: borderRadius.sm,
|
borderRadius: borderRadius.sm,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
marginRight: spacing.sm,
|
marginRight: spacing.sm,
|
||||||
@@ -278,8 +278,9 @@ const SystemMessageItem: React.FC<SystemMessageItemProps> = ({
|
|||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
height: 20,
|
height: 20,
|
||||||
|
width: 24,
|
||||||
},
|
},
|
||||||
}), [containerPadding, isWideScreen, avatarSize, titleFontSize, timeFontSize, contentFontSize]);
|
});
|
||||||
|
|
||||||
// 判断是否显示操作者头像
|
// 判断是否显示操作者头像
|
||||||
const showOperatorAvatar = ['follow', 'like_post', 'like_comment', 'like_reply', 'favorite_post', 'comment', 'reply', 'mention', 'group_join_apply'].includes(
|
const showOperatorAvatar = ['follow', 'like_post', 'like_comment', 'like_reply', 'favorite_post', 'comment', 'reply', 'mention', 'group_join_apply'].includes(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
* ImageGrid 图片网格组件
|
* ImageGrid 图片网格组件
|
||||||
* 支持 1-9 张图片的智能布局
|
* 支持 1-9 张图片的智能布局
|
||||||
* 根据图片数量自动选择最佳展示方式
|
* 根据图片数量自动选择最佳展示方式
|
||||||
|
* 支持预览图优化
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useMemo, useCallback, useState, useEffect } from 'react';
|
import React, { useMemo, useCallback, useState, useEffect } from 'react';
|
||||||
@@ -16,6 +17,7 @@ import {
|
|||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { SmartImage, ImageSource } from './SmartImage';
|
import { SmartImage, ImageSource } from './SmartImage';
|
||||||
import { colors, spacing, borderRadius } from '../../theme';
|
import { colors, spacing, borderRadius } from '../../theme';
|
||||||
|
import { getPreviewImageUrl, ImageDisplayMode } from '../../utils/imageHelper';
|
||||||
|
|
||||||
const { width: SCREEN_WIDTH } = Dimensions.get('window');
|
const { width: SCREEN_WIDTH } = Dimensions.get('window');
|
||||||
|
|
||||||
@@ -69,6 +71,10 @@ export interface ImageGridProps {
|
|||||||
onImagePress?: (images: ImageGridItem[], index: number) => void;
|
onImagePress?: (images: ImageGridItem[], index: number) => void;
|
||||||
/** 测试ID */
|
/** 测试ID */
|
||||||
testID?: string;
|
testID?: string;
|
||||||
|
/** 显示模式 */
|
||||||
|
displayMode?: ImageDisplayMode;
|
||||||
|
/** 是否使用预览图 */
|
||||||
|
usePreview?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -201,6 +207,7 @@ const SingleImageItem: React.FC<SingleImageItemProps> = ({
|
|||||||
/**
|
/**
|
||||||
* 图片网格组件
|
* 图片网格组件
|
||||||
* 智能布局:根据图片数量自动选择最佳展示方式
|
* 智能布局:根据图片数量自动选择最佳展示方式
|
||||||
|
* 支持预览图优化
|
||||||
*/
|
*/
|
||||||
export const ImageGrid: React.FC<ImageGridProps> = ({
|
export const ImageGrid: React.FC<ImageGridProps> = ({
|
||||||
images,
|
images,
|
||||||
@@ -214,6 +221,8 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
|
|||||||
gridColumns = 3,
|
gridColumns = 3,
|
||||||
onImagePress,
|
onImagePress,
|
||||||
testID,
|
testID,
|
||||||
|
displayMode = 'list',
|
||||||
|
usePreview = true,
|
||||||
}) => {
|
}) => {
|
||||||
// 通过 onLayout 拿到容器实际宽度
|
// 通过 onLayout 拿到容器实际宽度
|
||||||
const [containerWidth, setContainerWidth] = useState(0);
|
const [containerWidth, setContainerWidth] = useState(0);
|
||||||
@@ -256,6 +265,12 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
|
|||||||
const renderSingleImage = () => {
|
const renderSingleImage = () => {
|
||||||
const image = displayImages[0];
|
const image = displayImages[0];
|
||||||
if (!image) return null;
|
if (!image) return null;
|
||||||
|
|
||||||
|
// 获取预览图 URL
|
||||||
|
const previewUrl = usePreview && displayMode
|
||||||
|
? getPreviewImageUrl(image as any, displayMode)
|
||||||
|
: (image.uri || image.url || '');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SingleImageItem
|
<SingleImageItem
|
||||||
image={image}
|
image={image}
|
||||||
@@ -271,7 +286,12 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
|
|||||||
const renderHorizontal = () => {
|
const renderHorizontal = () => {
|
||||||
return (
|
return (
|
||||||
<View style={[styles.horizontalContainer, { gap }]}>
|
<View style={[styles.horizontalContainer, { gap }]}>
|
||||||
{displayImages.map((image, index) => (
|
{displayImages.map((image, index) => {
|
||||||
|
const previewUrl = usePreview && displayMode
|
||||||
|
? getPreviewImageUrl(image as any, displayMode)
|
||||||
|
: (image.uri || image.url || '');
|
||||||
|
|
||||||
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
key={image.id || index}
|
key={image.id || index}
|
||||||
onPress={() => handleImagePress(index)}
|
onPress={() => handleImagePress(index)}
|
||||||
@@ -285,13 +305,14 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
|
|||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<SmartImage
|
<SmartImage
|
||||||
source={{ uri: image.uri || image.url, width: image.width, height: image.height }}
|
source={{ uri: previewUrl }}
|
||||||
style={styles.fullSize}
|
style={styles.fullSize}
|
||||||
resizeMode="cover"
|
resizeMode="cover"
|
||||||
borderRadius={borderRadiusValue}
|
borderRadius={borderRadiusValue}
|
||||||
/>
|
/>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -304,6 +325,10 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
|
|||||||
const isLastVisible = index === displayImages.length - 1;
|
const isLastVisible = index === displayImages.length - 1;
|
||||||
const showOverlay = isLastVisible && remainingCount > 0 && showMoreOverlay;
|
const showOverlay = isLastVisible && remainingCount > 0 && showMoreOverlay;
|
||||||
|
|
||||||
|
const previewUrl = usePreview && displayMode
|
||||||
|
? getPreviewImageUrl(image as any, displayMode)
|
||||||
|
: (image.uri || image.url || '');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
key={image.id || index}
|
key={image.id || index}
|
||||||
@@ -317,7 +342,7 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
|
|||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<SmartImage
|
<SmartImage
|
||||||
source={{ uri: image.uri || image.url, width: image.width, height: image.height }}
|
source={{ uri: previewUrl }}
|
||||||
style={styles.fullSize}
|
style={styles.fullSize}
|
||||||
resizeMode="cover"
|
resizeMode="cover"
|
||||||
borderRadius={borderRadiusValue}
|
borderRadius={borderRadiusValue}
|
||||||
@@ -362,6 +387,10 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
|
|||||||
: 1;
|
: 1;
|
||||||
const height = itemWidth / aspectRatio;
|
const height = itemWidth / aspectRatio;
|
||||||
|
|
||||||
|
const previewUrl = usePreview && displayMode
|
||||||
|
? getPreviewImageUrl(image as any, displayMode)
|
||||||
|
: (image.uri || image.url || '');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
key={image.id || actualIndex}
|
key={image.id || actualIndex}
|
||||||
@@ -376,7 +405,7 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
|
|||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<SmartImage
|
<SmartImage
|
||||||
source={{ uri: image.uri || image.url, width: image.width, height: image.height }}
|
source={{ uri: previewUrl }}
|
||||||
style={styles.fullSize}
|
style={styles.fullSize}
|
||||||
resizeMode="cover"
|
resizeMode="cover"
|
||||||
borderRadius={borderRadiusValue}
|
borderRadius={borderRadiusValue}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* 基于 expo-image 封装,原生支持 GIF/WebP 动图
|
* 基于 expo-image 封装,原生支持 GIF/WebP 动图
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useCallback, useRef } from 'react';
|
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
@@ -55,6 +55,14 @@ export interface SmartImageProps {
|
|||||||
onError?: (error: any) => void;
|
onError?: (error: any) => void;
|
||||||
/** 测试ID */
|
/** 测试ID */
|
||||||
testID?: string;
|
testID?: string;
|
||||||
|
/** 预览图 URL */
|
||||||
|
previewUrl?: string;
|
||||||
|
/** 是否使用预览图(优先加载预览图) */
|
||||||
|
usePreview?: boolean;
|
||||||
|
/** 是否懒加载 */
|
||||||
|
lazyLoad?: boolean;
|
||||||
|
/** 缓存策略 */
|
||||||
|
cachePolicy?: 'memory' | 'disk' | 'memory-disk' | 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -74,14 +82,33 @@ export const SmartImage: React.FC<SmartImageProps> = ({
|
|||||||
onLoad,
|
onLoad,
|
||||||
onError,
|
onError,
|
||||||
testID,
|
testID,
|
||||||
|
previewUrl,
|
||||||
|
usePreview = false,
|
||||||
|
lazyLoad = false,
|
||||||
|
cachePolicy = 'memory-disk',
|
||||||
}) => {
|
}) => {
|
||||||
const [loadState, setLoadState] = useState<ImageLoadState>('loading');
|
const [loadState, setLoadState] = useState<ImageLoadState>('loading');
|
||||||
|
const [isVisible, setIsVisible] = useState(!lazyLoad);
|
||||||
|
const [shouldLoadOriginal, setShouldLoadOriginal] = useState(false);
|
||||||
|
|
||||||
// 解析图片源 - 支持 uri 或 url 字段
|
// 解析图片源 - 支持 uri 或 url 字段
|
||||||
const imageUri = typeof source === 'string'
|
const imageUri = typeof source === 'string'
|
||||||
? source
|
? source
|
||||||
: (source.uri || source.url || '');
|
: (source.uri || source.url || '');
|
||||||
|
|
||||||
|
// 解析预览图 URL
|
||||||
|
const previewUri = typeof previewUrl === 'string' ? previewUrl : '';
|
||||||
|
|
||||||
|
// 智能选择图片 URL
|
||||||
|
const getImageUrl = useCallback((): string => {
|
||||||
|
// 如果应该加载原图(预览图已加载完成或没有预览图)
|
||||||
|
if (shouldLoadOriginal || !usePreview || !previewUri) {
|
||||||
|
return imageUri;
|
||||||
|
}
|
||||||
|
// 否则使用预览图
|
||||||
|
return previewUri;
|
||||||
|
}, [imageUri, previewUri, usePreview, shouldLoadOriginal]);
|
||||||
|
|
||||||
// 处理加载开始
|
// 处理加载开始
|
||||||
const handleLoadStart = useCallback(() => {
|
const handleLoadStart = useCallback(() => {
|
||||||
setLoadState('loading');
|
setLoadState('loading');
|
||||||
@@ -90,16 +117,25 @@ export const SmartImage: React.FC<SmartImageProps> = ({
|
|||||||
// 处理加载完成
|
// 处理加载完成
|
||||||
const handleLoad = useCallback(() => {
|
const handleLoad = useCallback(() => {
|
||||||
setLoadState('success');
|
setLoadState('success');
|
||||||
|
// 如果正在加载预览图,切换到原图
|
||||||
|
if (usePreview && !shouldLoadOriginal && previewUri && getImageUrl() === previewUri) {
|
||||||
|
setShouldLoadOriginal(true);
|
||||||
|
}
|
||||||
onLoad?.();
|
onLoad?.();
|
||||||
}, [onLoad]);
|
}, [onLoad, usePreview, shouldLoadOriginal, previewUri, getImageUrl]);
|
||||||
|
|
||||||
// 处理加载错误
|
// 处理加载错误
|
||||||
const handleError = useCallback(
|
const handleError = useCallback(
|
||||||
(error: any) => {
|
(error: any) => {
|
||||||
|
// 如果预览图加载失败,尝试加载原图
|
||||||
|
if (usePreview && !shouldLoadOriginal && getImageUrl() === previewUri && imageUri) {
|
||||||
|
setShouldLoadOriginal(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setLoadState('error');
|
setLoadState('error');
|
||||||
onError?.(error);
|
onError?.(error);
|
||||||
},
|
},
|
||||||
[onError]
|
[onError, usePreview, shouldLoadOriginal, getImageUrl, previewUri, imageUri]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 重试加载
|
// 重试加载
|
||||||
@@ -144,7 +180,7 @@ export const SmartImage: React.FC<SmartImageProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 图片源配置
|
// 图片源配置
|
||||||
const imageSource = imageUri && imageUri.trim() !== '' ? { uri: imageUri } : undefined;
|
const imageSource = imageUri && imageUri.trim() !== '' ? { uri: getImageUrl() } : undefined;
|
||||||
|
|
||||||
// 如果没有有效的图片源,显示错误占位
|
// 如果没有有效的图片源,显示错误占位
|
||||||
if (!imageSource) {
|
if (!imageSource) {
|
||||||
@@ -164,6 +200,16 @@ export const SmartImage: React.FC<SmartImageProps> = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 如果是懒加载且不可见,显示占位
|
||||||
|
if (lazyLoad && !isVisible) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[containerStyle, style as ViewStyle, { backgroundColor: '#F5F5F5' }]}
|
||||||
|
testID={testID}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
style={[containerStyle, style as ViewStyle]}
|
style={[containerStyle, style as ViewStyle]}
|
||||||
@@ -181,7 +227,7 @@ export const SmartImage: React.FC<SmartImageProps> = ({
|
|||||||
resizeMode === 'center' ? 'scale-down' :
|
resizeMode === 'center' ? 'scale-down' :
|
||||||
resizeMode
|
resizeMode
|
||||||
}
|
}
|
||||||
cachePolicy="memory-disk"
|
cachePolicy={cachePolicy}
|
||||||
onLoadStart={handleLoadStart}
|
onLoadStart={handleLoadStart}
|
||||||
onLoad={handleLoad}
|
onLoad={handleLoad}
|
||||||
onError={handleError}
|
onError={handleError}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
RefreshControl,
|
RefreshControl,
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
|
Dimensions,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { useIsFocused } from '@react-navigation/native';
|
import { useIsFocused } from '@react-navigation/native';
|
||||||
@@ -25,7 +26,6 @@ import { messageService } from '../../services/messageService';
|
|||||||
import { commentService } from '../../services/commentService';
|
import { commentService } from '../../services/commentService';
|
||||||
import { SystemMessageItem } from '../../components/business';
|
import { SystemMessageItem } from '../../components/business';
|
||||||
import { Text, EmptyState, ResponsiveContainer } from '../../components/common';
|
import { Text, EmptyState, ResponsiveContainer } from '../../components/common';
|
||||||
import { useResponsive, useBreakpointGTE } from '../../hooks/useResponsive';
|
|
||||||
import { RootStackParamList } from '../../navigation/types';
|
import { RootStackParamList } from '../../navigation/types';
|
||||||
import { useMessageManagerSystemUnreadCount, useUserStore } from '../../stores';
|
import { useMessageManagerSystemUnreadCount, useUserStore } from '../../stores';
|
||||||
|
|
||||||
@@ -48,9 +48,27 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
|
|||||||
const { setSystemUnreadCount, decrementSystemUnreadCount } = useMessageManagerSystemUnreadCount();
|
const { setSystemUnreadCount, decrementSystemUnreadCount } = useMessageManagerSystemUnreadCount();
|
||||||
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||||
|
|
||||||
// 响应式布局
|
// 修复竞态条件:使用本地状态管理窗口尺寸,避免 hydration 问题
|
||||||
const { isDesktop, isTablet } = useResponsive();
|
const [windowSize, setWindowSize] = useState({ width: 0, height: 0 });
|
||||||
const isWideScreen = useBreakpointGTE('lg');
|
const [isHydrated, setIsHydrated] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 延迟设置窗口尺寸,确保在客户端完全加载后再获取
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
const { width, height } = Dimensions.get('window');
|
||||||
|
setWindowSize({ width, height });
|
||||||
|
setIsHydrated(true);
|
||||||
|
}, 100);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 使用本地尺寸状态计算断点 (lg = 768)
|
||||||
|
const isWideScreen = windowSize.width >= 768;
|
||||||
|
const isDesktop = windowSize.width >= 1024;
|
||||||
|
const isTablet = windowSize.width >= 768 && windowSize.width < 1024;
|
||||||
|
|
||||||
|
// Web端使用更大的容器宽度
|
||||||
|
const containerMaxWidth = isDesktop ? 1200 : isTablet ? 1000 : 900;
|
||||||
|
|
||||||
const [messages, setMessages] = useState<SystemMessageResponse[]>([]);
|
const [messages, setMessages] = useState<SystemMessageResponse[]>([]);
|
||||||
const [activeType, setActiveType] = useState('all');
|
const [activeType, setActiveType] = useState('all');
|
||||||
@@ -320,7 +338,7 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
|
|||||||
const shouldShowBackButton = onBack && !isWideScreen;
|
const shouldShowBackButton = onBack && !isWideScreen;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.header, isWideScreen && styles.headerWide]}>
|
<View style={[styles.header, isWideScreen ? styles.headerWide : null]}>
|
||||||
{shouldShowBackButton ? (
|
{shouldShowBackButton ? (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.backButton}
|
style={styles.backButton}
|
||||||
@@ -342,21 +360,34 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
|
|||||||
|
|
||||||
// 渲染空状态
|
// 渲染空状态
|
||||||
const renderEmpty = () => (
|
const renderEmpty = () => (
|
||||||
|
<View style={[styles.emptyContainer, isWideScreen && styles.emptyContainerWeb]}>
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="暂无通知"
|
title="暂无通知"
|
||||||
description="关注一些用户来获取通知吧"
|
description="关注一些用户来获取通知吧"
|
||||||
icon="bell-outline"
|
icon="bell-outline"
|
||||||
/>
|
/>
|
||||||
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 如果还未hydrated,显示加载占位符,避免竞态条件导致的渲染问题
|
||||||
|
if (!isHydrated) {
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.container} edges={[]}>
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<ActivityIndicator size="large" color={colors.primary.main} />
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.container} edges={[]}>
|
<SafeAreaView style={styles.container} edges={[]}>
|
||||||
{isWideScreen ? (
|
{isWideScreen ? (
|
||||||
<ResponsiveContainer maxWidth={900}>
|
<ResponsiveContainer maxWidth={containerMaxWidth}>
|
||||||
{/* 头部 - 大屏模式下隐藏返回按钮 */}
|
{/* 头部 - 大屏模式下隐藏返回按钮 */}
|
||||||
{renderHeader()}
|
{renderHeader()}
|
||||||
{/* 分类筛选 */}
|
{/* 分类筛选 */}
|
||||||
<View style={[styles.filterContainer, isWideScreen && styles.filterContainerWide]}>
|
<View style={[styles.filterContainer, isWideScreen && styles.filterContainerWideWeb]}>
|
||||||
{MESSAGE_TYPES.map(type => {
|
{MESSAGE_TYPES.map(type => {
|
||||||
const count = type.key === 'all'
|
const count = type.key === 'all'
|
||||||
? displayMessages.length
|
? displayMessages.length
|
||||||
@@ -518,10 +549,11 @@ const styles = StyleSheet.create({
|
|||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: colors.divider,
|
borderBottomColor: colors.divider,
|
||||||
},
|
},
|
||||||
filterContainerWide: {
|
filterContainerWideWeb: {
|
||||||
paddingHorizontal: spacing.xl,
|
paddingHorizontal: spacing.xl,
|
||||||
paddingVertical: spacing.md,
|
paddingVertical: spacing.md,
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
|
backgroundColor: colors.background.paper,
|
||||||
},
|
},
|
||||||
filterTag: {
|
filterTag: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -596,4 +628,14 @@ const styles = StyleSheet.create({
|
|||||||
loadingMoreText: {
|
loadingMoreText: {
|
||||||
marginLeft: spacing.sm,
|
marginLeft: spacing.sm,
|
||||||
},
|
},
|
||||||
|
emptyContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
emptyContainerWeb: {
|
||||||
|
minHeight: 500,
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingTop: 100,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -483,11 +483,20 @@ const renderLinkSegment = (
|
|||||||
): React.ReactNode => {
|
): React.ReactNode => {
|
||||||
const { onLinkPress, isMe } = props;
|
const { onLinkPress, isMe } = props;
|
||||||
|
|
||||||
const handlePress = () => {
|
const handlePress = async () => {
|
||||||
if (onLinkPress) {
|
if (onLinkPress) {
|
||||||
onLinkPress(data.url);
|
onLinkPress(data.url);
|
||||||
} else {
|
} else {
|
||||||
Linking.openURL(data.url).catch(() => {});
|
try {
|
||||||
|
const supported = await Linking.canOpenURL(data.url);
|
||||||
|
if (supported) {
|
||||||
|
await Linking.openURL(data.url);
|
||||||
|
} else {
|
||||||
|
console.warn('无法打开链接:', data.url);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('打开链接失败:', error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -511,7 +520,13 @@ const renderLinkSegment = (
|
|||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
<Text style={styles.linkUrl} numberOfLines={1}>
|
<Text style={styles.linkUrl} numberOfLines={1}>
|
||||||
{new URL(data.url).hostname}
|
{(() => {
|
||||||
|
try {
|
||||||
|
return new URL(data.url).hostname;
|
||||||
|
} catch {
|
||||||
|
return data.url;
|
||||||
|
}
|
||||||
|
})()}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { View, Text, StyleSheet, TouchableOpacity, Alert } from 'react-native';
|
import { View, Text, StyleSheet, TouchableOpacity, Alert, ScrollView, Pressable } from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { RouteProp, useNavigation, useRoute } from '@react-navigation/native';
|
import { RouteProp, useNavigation, useRoute } from '@react-navigation/native';
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
@@ -64,8 +64,9 @@ const CourseDetailScreen: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.container}>
|
<SafeAreaView style={styles.container}>
|
||||||
<TouchableOpacity style={styles.mask} activeOpacity={1} onPress={() => navigation.goBack()}>
|
<View style={styles.mask}>
|
||||||
<TouchableOpacity activeOpacity={1} style={styles.card} onPress={e => e.stopPropagation()}>
|
<Pressable style={StyleSheet.absoluteFillObject} onPress={() => navigation.goBack()} />
|
||||||
|
<View style={styles.card}>
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<View style={styles.headerLeft}>
|
<View style={styles.headerLeft}>
|
||||||
<View style={styles.iconBadge}>
|
<View style={styles.iconBadge}>
|
||||||
@@ -78,16 +79,17 @@ const CourseDetailScreen: React.FC = () => {
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
style={styles.contentScroll}
|
||||||
|
contentContainerStyle={styles.contentScrollContainer}
|
||||||
|
nestedScrollEnabled
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
<View style={styles.row}>
|
<View style={styles.row}>
|
||||||
<Text style={styles.label}>课程名称</Text>
|
<Text style={styles.label}>课程名称</Text>
|
||||||
<Text style={styles.value} numberOfLines={2}>{course.name}</Text>
|
<Text style={styles.value} numberOfLines={2}>{course.name}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.row}>
|
|
||||||
<Text style={styles.label}>教师</Text>
|
|
||||||
<Text style={styles.value}>{course.teacher || '未填写'}</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={[styles.row, styles.rowLast]}>
|
<View style={[styles.row, styles.rowLast]}>
|
||||||
<Text style={styles.label}>上课时间</Text>
|
<Text style={styles.label}>上课时间</Text>
|
||||||
<View style={styles.timeList}>
|
<View style={styles.timeList}>
|
||||||
@@ -97,6 +99,9 @@ const CourseDetailScreen: React.FC = () => {
|
|||||||
第{formatWeekRanges(item.weeks)}周 · {WEEKDAY_NAMES[item.dayOfWeek]} 第
|
第{formatWeekRanges(item.weeks)}周 · {WEEKDAY_NAMES[item.dayOfWeek]} 第
|
||||||
{getMergedSectionIndex(item.startSection)}节
|
{getMergedSectionIndex(item.startSection)}节
|
||||||
</Text>
|
</Text>
|
||||||
|
<Text style={styles.timeItemSub}>
|
||||||
|
任课教师:{item.teacher || '未填写'}
|
||||||
|
</Text>
|
||||||
{item.location ? <Text style={styles.timeItemSub}>{item.location}</Text> : null}
|
{item.location ? <Text style={styles.timeItemSub}>{item.location}</Text> : null}
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
@@ -109,8 +114,9 @@ const CourseDetailScreen: React.FC = () => {
|
|||||||
<Text style={styles.deleteButtonText}>删除本条课程</Text>
|
<Text style={styles.deleteButtonText}>删除本条课程</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</ScrollView>
|
||||||
</TouchableOpacity>
|
</View>
|
||||||
|
</View>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -130,8 +136,15 @@ const styles = StyleSheet.create({
|
|||||||
borderRadius: borderRadius.xl,
|
borderRadius: borderRadius.xl,
|
||||||
paddingVertical: spacing.lg,
|
paddingVertical: spacing.lg,
|
||||||
paddingHorizontal: spacing.lg,
|
paddingHorizontal: spacing.lg,
|
||||||
|
maxHeight: '82%',
|
||||||
...shadows.lg,
|
...shadows.lg,
|
||||||
},
|
},
|
||||||
|
contentScroll: {
|
||||||
|
maxHeight: '100%',
|
||||||
|
},
|
||||||
|
contentScrollContainer: {
|
||||||
|
paddingBottom: spacing.xs,
|
||||||
|
},
|
||||||
header: {
|
header: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
Modal,
|
Modal,
|
||||||
TextInput,
|
TextInput,
|
||||||
Alert,
|
Alert,
|
||||||
|
ActivityIndicator,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { PanGestureHandler, State } from 'react-native-gesture-handler';
|
import { PanGestureHandler, State } from 'react-native-gesture-handler';
|
||||||
import type { PanGestureHandlerStateChangeEvent } from 'react-native-gesture-handler';
|
import type { PanGestureHandlerStateChangeEvent } from 'react-native-gesture-handler';
|
||||||
@@ -29,6 +30,7 @@ import {
|
|||||||
TimeSlot,
|
TimeSlot,
|
||||||
getCourseColor,
|
getCourseColor,
|
||||||
hasCourseInWeek,
|
hasCourseInWeek,
|
||||||
|
getCurrentWeek,
|
||||||
} from '../../types/schedule';
|
} from '../../types/schedule';
|
||||||
import type { ScheduleStackParamList } from '../../navigation/types';
|
import type { ScheduleStackParamList } from '../../navigation/types';
|
||||||
import { scheduleService } from '../../services/scheduleService';
|
import { scheduleService } from '../../services/scheduleService';
|
||||||
@@ -71,6 +73,13 @@ const getMergedSectionEnd = (mergedSection: number) => mergedSection * 2;
|
|||||||
const SWIPE_THRESHOLD = 18;
|
const SWIPE_THRESHOLD = 18;
|
||||||
|
|
||||||
type RepeatMode = 'single' | 'weekly' | 'odd' | 'even';
|
type RepeatMode = 'single' | 'weekly' | 'odd' | 'even';
|
||||||
|
type CourseDisplayItem = {
|
||||||
|
key: string;
|
||||||
|
course: Course;
|
||||||
|
locationText: string;
|
||||||
|
laneIndex: number;
|
||||||
|
laneCount: number;
|
||||||
|
};
|
||||||
const TOTAL_WEEKS = 20;
|
const TOTAL_WEEKS = 20;
|
||||||
const HEX_COLOR_REGEX = /^#?[0-9A-Fa-f]{6}$/;
|
const HEX_COLOR_REGEX = /^#?[0-9A-Fa-f]{6}$/;
|
||||||
const parseWeekInput = (value: string, fallback: number) => {
|
const parseWeekInput = (value: string, fallback: number) => {
|
||||||
@@ -122,9 +131,17 @@ const getWeekDates = (weekOffset: number = 0) => {
|
|||||||
return getWeekInfo(weekOffset).dates;
|
return getWeekInfo(weekOffset).dates;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 学期起始日期字符串(用于 getCurrentWeek 函数)
|
||||||
|
const SEMESTER_START_DATE = '2026-03-09';
|
||||||
|
|
||||||
|
// 计算当前实际周数
|
||||||
|
const getInitialWeek = (): number => {
|
||||||
|
return getCurrentWeek(SEMESTER_START_DATE, TOTAL_WEEKS);
|
||||||
|
};
|
||||||
|
|
||||||
export const ScheduleScreen: React.FC = () => {
|
export const ScheduleScreen: React.FC = () => {
|
||||||
const navigation = useNavigation<NativeStackNavigationProp<ScheduleStackParamList>>();
|
const navigation = useNavigation<NativeStackNavigationProp<ScheduleStackParamList>>();
|
||||||
const [currentWeek, setCurrentWeek] = useState(1);
|
const [currentWeek, setCurrentWeek] = useState(getInitialWeek);
|
||||||
const [courses, setCourses] = useState<Course[]>([]);
|
const [courses, setCourses] = useState<Course[]>([]);
|
||||||
const [isAddModalVisible, setIsAddModalVisible] = useState(false);
|
const [isAddModalVisible, setIsAddModalVisible] = useState(false);
|
||||||
const [pendingDay, setPendingDay] = useState<number>(0);
|
const [pendingDay, setPendingDay] = useState<number>(0);
|
||||||
@@ -137,9 +154,28 @@ export const ScheduleScreen: React.FC = () => {
|
|||||||
const [startWeekInput, setStartWeekInput] = useState(String(currentWeek));
|
const [startWeekInput, setStartWeekInput] = useState(String(currentWeek));
|
||||||
const [endWeekInput, setEndWeekInput] = useState(String(TOTAL_WEEKS));
|
const [endWeekInput, setEndWeekInput] = useState(String(TOTAL_WEEKS));
|
||||||
const weekScrollRef = useRef<ScrollView | null>(null);
|
const weekScrollRef = useRef<ScrollView | null>(null);
|
||||||
|
// 组件卸载状态标记,用于防止组件卸载后仍调用 setState
|
||||||
|
const isMountedRef = useRef(true);
|
||||||
|
|
||||||
|
// 设置弹窗状态
|
||||||
|
const [isSettingsModalVisible, setIsSettingsModalVisible] = useState(false);
|
||||||
|
// 同步教务系统弹窗状态
|
||||||
|
const [isSyncModalVisible, setIsSyncModalVisible] = useState(false);
|
||||||
|
const [syncUsername, setSyncUsername] = useState('');
|
||||||
|
const [syncPassword, setSyncPassword] = useState('');
|
||||||
|
const [isSyncing, setIsSyncing] = useState(false);
|
||||||
|
|
||||||
|
// 组件卸载时设置标记
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
isMountedRef.current = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
const todayColumnIndex = getTodayColumnIndex();
|
const todayColumnIndex = getTodayColumnIndex();
|
||||||
// currentWeek === 1 对应今天所在的真实周(offset 0),其他周不高亮今日列
|
// 获取当前实际周数,用于判断是否在查看当前周
|
||||||
const isViewingCurrentWeek = currentWeek === 1;
|
const actualCurrentWeek = getInitialWeek();
|
||||||
|
// 只有在查看当前实际周时才高亮今日列
|
||||||
|
const isViewingCurrentWeek = currentWeek === actualCurrentWeek;
|
||||||
const activeTodayColumn = isViewingCurrentWeek ? todayColumnIndex : -1;
|
const activeTodayColumn = isViewingCurrentWeek ? todayColumnIndex : -1;
|
||||||
|
|
||||||
const dayColumnWidth = getDayColumnWidth();
|
const dayColumnWidth = getDayColumnWidth();
|
||||||
@@ -308,7 +344,10 @@ export const ScheduleScreen: React.FC = () => {
|
|||||||
// 渲染周选择器
|
// 渲染周选择器
|
||||||
const renderWeekSelector = () => (
|
const renderWeekSelector = () => (
|
||||||
<View style={styles.weekSelector}>
|
<View style={styles.weekSelector}>
|
||||||
<TouchableOpacity style={styles.settingsButton}>
|
<TouchableOpacity
|
||||||
|
style={styles.settingsButton}
|
||||||
|
onPress={() => setIsSettingsModalVisible(true)}
|
||||||
|
>
|
||||||
<MaterialCommunityIcons name="cog" size={24} color="#FFFFFF" />
|
<MaterialCommunityIcons name="cog" size={24} color="#FFFFFF" />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<ScrollView
|
<ScrollView
|
||||||
@@ -410,26 +449,137 @@ export const ScheduleScreen: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 处理长按课程删除
|
||||||
|
const handleCourseLongPress = useCallback((course: Course) => {
|
||||||
|
const relatedCourses = getRelatedCourses(course);
|
||||||
|
const hasMultiple = relatedCourses.length > 1;
|
||||||
|
|
||||||
|
Alert.alert(
|
||||||
|
'删除课程',
|
||||||
|
`课程:${course.name}`,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: '取消',
|
||||||
|
style: 'cancel',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '删除本节',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: async () => {
|
||||||
|
try {
|
||||||
|
await scheduleService.deleteCourse(course.id);
|
||||||
|
setCourses(prev => prev.filter(c => c.id !== course.id));
|
||||||
|
Alert.alert('成功', '已删除本节课程');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('删除课程失败:', error);
|
||||||
|
Alert.alert('删除失败', '请稍后重试');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...(hasMultiple
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
text: '删除全部',
|
||||||
|
style: 'destructive' as const,
|
||||||
|
onPress: async () => {
|
||||||
|
try {
|
||||||
|
// 使用 Promise.allSettled 批量删除,即使部分失败也能继续
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
relatedCourses.map(course => scheduleService.deleteCourse(course.id))
|
||||||
|
);
|
||||||
|
|
||||||
|
// 统计成功和失败的数量
|
||||||
|
const fulfilledCount = results.filter(r => r.status === 'fulfilled').length;
|
||||||
|
const rejectedCount = results.filter(r => r.status === 'rejected').length;
|
||||||
|
|
||||||
|
// 只移除成功删除的课程
|
||||||
|
const successfullyDeletedIds = new Set(
|
||||||
|
relatedCourses
|
||||||
|
.filter((_, index) => results[index].status === 'fulfilled')
|
||||||
|
.map(c => c.id)
|
||||||
|
);
|
||||||
|
setCourses(prev => prev.filter(c => !successfullyDeletedIds.has(c.id)));
|
||||||
|
|
||||||
|
if (rejectedCount > 0) {
|
||||||
|
Alert.alert(
|
||||||
|
'部分删除成功',
|
||||||
|
`成功删除 ${fulfilledCount} 节课程,${rejectedCount} 节删除失败,请重试`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
Alert.alert('成功', `已删除 ${fulfilledCount} 节课程`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('批量删除课程失败:', error);
|
||||||
|
Alert.alert('删除失败', '请稍后重试');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
],
|
||||||
|
{ cancelable: true }
|
||||||
|
);
|
||||||
|
}, [getRelatedCourses]);
|
||||||
|
|
||||||
|
const buildDayDisplayCourses = useCallback((dayCourses: Course[]): CourseDisplayItem[] => {
|
||||||
|
const groupedByNameAndSlot = new Map<string, Course[]>();
|
||||||
|
|
||||||
|
dayCourses.forEach(course => {
|
||||||
|
// 同名 + 同时间段分组;若出现多条记录,则拆分并排展示
|
||||||
|
const key = `${course.name}|${course.dayOfWeek}|${course.startSection}|${course.endSection}`;
|
||||||
|
const group = groupedByNameAndSlot.get(key);
|
||||||
|
if (group) {
|
||||||
|
group.push(course);
|
||||||
|
} else {
|
||||||
|
groupedByNameAndSlot.set(key, [course]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const items: CourseDisplayItem[] = [];
|
||||||
|
groupedByNameAndSlot.forEach((groupCourses, groupKey) => {
|
||||||
|
groupCourses.forEach((course, index) => {
|
||||||
|
items.push({
|
||||||
|
key: `${groupKey}|${course.id}|${index}`,
|
||||||
|
course,
|
||||||
|
locationText: course.location?.trim() ?? '',
|
||||||
|
laneIndex: index,
|
||||||
|
laneCount: groupCourses.length,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}, []);
|
||||||
|
|
||||||
// 渲染课程卡片
|
// 渲染课程卡片
|
||||||
const renderCourseCard = (course: Course) => {
|
const renderCourseCard = ({ key, course, locationText, laneIndex, laneCount }: CourseDisplayItem) => {
|
||||||
const color = getCourseColor(course.id, course.color);
|
// 颜色仅按课程名计算,确保同名课在 UI 上颜色一致
|
||||||
|
const color = getCourseColor(course.name);
|
||||||
const startMergedSection = getMergedSectionIndex(course.startSection);
|
const startMergedSection = getMergedSectionIndex(course.startSection);
|
||||||
const endMergedSection = getMergedSectionIndex(course.endSection);
|
const endMergedSection = getMergedSectionIndex(course.endSection);
|
||||||
const duration = endMergedSection - startMergedSection + 1;
|
const duration = endMergedSection - startMergedSection + 1;
|
||||||
const top = (startMergedSection - 1) * SECTION_HEIGHT;
|
const top = (startMergedSection - 1) * SECTION_HEIGHT;
|
||||||
const height = duration * SECTION_HEIGHT - 4;
|
const height = duration * SECTION_HEIGHT - 4;
|
||||||
|
const laneGap = 2;
|
||||||
|
const baseLeft = 2;
|
||||||
|
const availableWidth = dayColumnWidth - baseLeft * 2;
|
||||||
|
const cardWidth =
|
||||||
|
laneCount > 1
|
||||||
|
? (availableWidth - laneGap * (laneCount - 1)) / laneCount
|
||||||
|
: availableWidth;
|
||||||
|
const left = baseLeft + laneIndex * (cardWidth + laneGap);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={course.id}
|
key={key}
|
||||||
style={[
|
style={[
|
||||||
styles.courseCard,
|
styles.courseCard,
|
||||||
{
|
{
|
||||||
top,
|
top,
|
||||||
height,
|
height,
|
||||||
backgroundColor: color,
|
backgroundColor: color,
|
||||||
left: 2,
|
left,
|
||||||
right: 2,
|
width: cardWidth,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
@@ -438,14 +588,16 @@ export const ScheduleScreen: React.FC = () => {
|
|||||||
relatedCourses: getRelatedCourses(course),
|
relatedCourses: getRelatedCourses(course),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
onLongPress={() => handleCourseLongPress(course)}
|
||||||
|
delayLongPress={500}
|
||||||
activeOpacity={0.82}
|
activeOpacity={0.82}
|
||||||
>
|
>
|
||||||
<Text style={styles.courseName} numberOfLines={3}>
|
<Text style={styles.courseName} numberOfLines={3}>
|
||||||
{formatCourseName(course.name)}
|
{formatCourseName(course.name)}
|
||||||
</Text>
|
</Text>
|
||||||
{course.location ? (
|
{locationText ? (
|
||||||
<Text style={styles.courseLocation}>
|
<Text style={styles.courseLocation}>
|
||||||
@{course.location}
|
@{locationText}
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -458,6 +610,7 @@ export const ScheduleScreen: React.FC = () => {
|
|||||||
const dayCourses = currentWeekCourses.filter(
|
const dayCourses = currentWeekCourses.filter(
|
||||||
course => course.dayOfWeek === dayOfWeek
|
course => course.dayOfWeek === dayOfWeek
|
||||||
);
|
);
|
||||||
|
const displayCourses = buildDayDisplayCourses(dayCourses);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
@@ -493,7 +646,7 @@ export const ScheduleScreen: React.FC = () => {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{/* 课程卡片 */}
|
{/* 课程卡片 */}
|
||||||
{dayCourses.map(renderCourseCard)}
|
{displayCourses.map(renderCourseCard)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -540,6 +693,163 @@ export const ScheduleScreen: React.FC = () => {
|
|||||||
{/* 课程表主体 */}
|
{/* 课程表主体 */}
|
||||||
{renderScheduleBody()}
|
{renderScheduleBody()}
|
||||||
|
|
||||||
|
{/* 设置弹窗 */}
|
||||||
|
<Modal
|
||||||
|
visible={isSettingsModalVisible}
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={() => setIsSettingsModalVisible(false)}
|
||||||
|
>
|
||||||
|
<View style={styles.addModalMask}>
|
||||||
|
<View style={styles.settingsModalCard}>
|
||||||
|
<Text style={styles.addModalTitle}>设置</Text>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.settingsItem}
|
||||||
|
onPress={() => {
|
||||||
|
setIsSettingsModalVisible(false);
|
||||||
|
setIsSyncModalVisible(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons name="cloud-sync" size={22} color={colors.primary.main} />
|
||||||
|
<Text style={styles.settingsItemText}>同步教务系统</Text>
|
||||||
|
<MaterialCommunityIcons name="chevron-right" size={20} color={colors.text.hint} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.settingsCloseBtn}
|
||||||
|
onPress={() => setIsSettingsModalVisible(false)}
|
||||||
|
>
|
||||||
|
<Text style={styles.settingsCloseText}>关闭</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* 同步教务系统弹窗 */}
|
||||||
|
<Modal
|
||||||
|
visible={isSyncModalVisible}
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={() => {
|
||||||
|
if (!isSyncing) {
|
||||||
|
setIsSyncModalVisible(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={styles.addModalMask}>
|
||||||
|
<View style={styles.addModalCard}>
|
||||||
|
<Text style={styles.addModalTitle}>同步教务系统</Text>
|
||||||
|
<Text style={styles.addModalDesc}>
|
||||||
|
输入教务系统账号密码,自动同步课表数据
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
value={syncUsername}
|
||||||
|
onChangeText={setSyncUsername}
|
||||||
|
placeholder="学号/工号"
|
||||||
|
placeholderTextColor={colors.text.hint}
|
||||||
|
style={styles.addInput}
|
||||||
|
autoCapitalize="none"
|
||||||
|
editable={!isSyncing}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
value={syncPassword}
|
||||||
|
onChangeText={setSyncPassword}
|
||||||
|
placeholder="密码"
|
||||||
|
placeholderTextColor={colors.text.hint}
|
||||||
|
style={styles.addInput}
|
||||||
|
secureTextEntry
|
||||||
|
editable={!isSyncing}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isSyncing && (
|
||||||
|
<View style={styles.syncingContainer}>
|
||||||
|
<ActivityIndicator size="small" color={colors.primary.main} />
|
||||||
|
<Text style={styles.syncingText}>正在同步课表数据...</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={styles.addActions}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.addCancelBtn, isSyncing && styles.addConfirmBtnDisabled]}
|
||||||
|
onPress={() => {
|
||||||
|
if (!isSyncing) {
|
||||||
|
setIsSyncModalVisible(false);
|
||||||
|
setSyncUsername('');
|
||||||
|
setSyncPassword('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
disabled={isSyncing}
|
||||||
|
>
|
||||||
|
<Text style={styles.addCancelText}>取消</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.addConfirmBtn,
|
||||||
|
(!syncUsername.trim() || !syncPassword.trim() || isSyncing) && styles.addConfirmBtnDisabled,
|
||||||
|
]}
|
||||||
|
onPress={async () => {
|
||||||
|
if (!syncUsername.trim() || !syncPassword.trim()) return;
|
||||||
|
|
||||||
|
setIsSyncing(true);
|
||||||
|
try {
|
||||||
|
const result = await scheduleService.syncSchedule({
|
||||||
|
username: syncUsername.trim(),
|
||||||
|
password: syncPassword.trim(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 检查组件是否已卸载
|
||||||
|
if (!isMountedRef.current) return;
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
Alert.alert(
|
||||||
|
'同步成功',
|
||||||
|
`成功同步 ${result.synced_count} 门课程`,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: '确定',
|
||||||
|
onPress: () => {
|
||||||
|
setIsSyncModalVisible(false);
|
||||||
|
setSyncUsername('');
|
||||||
|
setSyncPassword('');
|
||||||
|
loadCourses(); // 刷新课表
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
Alert.alert(
|
||||||
|
'同步失败',
|
||||||
|
result.error_message || result.message || '请检查账号密码是否正确'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('同步课表失败:', error);
|
||||||
|
// 检查组件是否已卸载
|
||||||
|
if (isMountedRef.current) {
|
||||||
|
Alert.alert('同步失败', '网络错误,请稍后重试');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// 检查组件是否已卸载
|
||||||
|
if (isMountedRef.current) {
|
||||||
|
setIsSyncing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
disabled={!syncUsername.trim() || !syncPassword.trim() || isSyncing}
|
||||||
|
>
|
||||||
|
<Text style={styles.addConfirmText}>
|
||||||
|
{isSyncing ? '同步中...' : '开始同步'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
visible={isAddModalVisible}
|
visible={isAddModalVisible}
|
||||||
transparent
|
transparent
|
||||||
@@ -1060,6 +1370,58 @@ const styles = StyleSheet.create({
|
|||||||
color: colors.text.primary,
|
color: colors.text.primary,
|
||||||
backgroundColor: colors.background.default,
|
backgroundColor: colors.background.default,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── 设置弹窗 ──────────────────────────────────────────
|
||||||
|
settingsModalCard: {
|
||||||
|
backgroundColor: colors.background.paper,
|
||||||
|
borderRadius: borderRadius['2xl'],
|
||||||
|
padding: spacing.xl,
|
||||||
|
...shadows.lg,
|
||||||
|
minWidth: 280,
|
||||||
|
},
|
||||||
|
settingsItem: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: spacing.md,
|
||||||
|
paddingHorizontal: spacing.sm,
|
||||||
|
borderRadius: borderRadius.lg,
|
||||||
|
backgroundColor: colors.background.default,
|
||||||
|
marginTop: spacing.md,
|
||||||
|
},
|
||||||
|
settingsItemText: {
|
||||||
|
flex: 1,
|
||||||
|
fontSize: fontSizes.md,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: colors.text.primary,
|
||||||
|
marginLeft: spacing.md,
|
||||||
|
},
|
||||||
|
settingsCloseBtn: {
|
||||||
|
marginTop: spacing.lg,
|
||||||
|
height: 46,
|
||||||
|
borderRadius: borderRadius.full,
|
||||||
|
backgroundColor: colors.background.default,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
settingsCloseText: {
|
||||||
|
fontSize: fontSizes.md,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: colors.text.secondary,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 同步状态 ──────────────────────────────────────────
|
||||||
|
syncingContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginVertical: spacing.md,
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
syncingText: {
|
||||||
|
fontSize: fontSizes.sm,
|
||||||
|
color: colors.text.secondary,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default ScheduleScreen;
|
export default ScheduleScreen;
|
||||||
|
|||||||
@@ -32,7 +32,30 @@ interface CreateScheduleCourseResponse {
|
|||||||
course: ScheduleCourseDTO;
|
course: ScheduleCourseDTO;
|
||||||
}
|
}
|
||||||
|
|
||||||
const toCourse = (dto: ScheduleCourseDTO): Course => ({
|
interface SyncScheduleRequest {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
semester?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SyncScheduleResponse {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
synced_count: number;
|
||||||
|
error_message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toCourse = (dto: ScheduleCourseDTO): Course => {
|
||||||
|
// 调试日志:检查后端返回的 weeks 字段
|
||||||
|
console.log('[ScheduleService] 转换课程 DTO:', {
|
||||||
|
id: dto.id,
|
||||||
|
name: dto.name,
|
||||||
|
day_of_week: dto.day_of_week,
|
||||||
|
weeks: dto.weeks,
|
||||||
|
weeksType: typeof dto.weeks,
|
||||||
|
weeksLength: dto.weeks?.length,
|
||||||
|
});
|
||||||
|
return {
|
||||||
id: dto.id,
|
id: dto.id,
|
||||||
name: dto.name,
|
name: dto.name,
|
||||||
teacher: dto.teacher,
|
teacher: dto.teacher,
|
||||||
@@ -42,12 +65,22 @@ const toCourse = (dto: ScheduleCourseDTO): Course => ({
|
|||||||
endSection: dto.end_section,
|
endSection: dto.end_section,
|
||||||
weeks: dto.weeks ?? [],
|
weeks: dto.weeks ?? [],
|
||||||
color: dto.color,
|
color: dto.color,
|
||||||
});
|
};
|
||||||
|
};
|
||||||
|
|
||||||
class ScheduleService {
|
class ScheduleService {
|
||||||
async getCourses(week?: number): Promise<Course[]> {
|
async getCourses(week?: number): Promise<Course[]> {
|
||||||
const params = week ? { week } : undefined;
|
const params = week ? { week } : undefined;
|
||||||
|
console.log('[ScheduleService] 获取课程列表, params:', params);
|
||||||
const response = await api.get<ScheduleCourseListResponse>('/schedule/courses', params);
|
const response = await api.get<ScheduleCourseListResponse>('/schedule/courses', params);
|
||||||
|
console.log('[ScheduleService] 课程列表响应, 数量:', response.data.list.length);
|
||||||
|
// 打印原始响应数据中的 weeks 字段
|
||||||
|
response.data.list.forEach((item, index) => {
|
||||||
|
console.log(`[ScheduleService] 课程[${index}] ${item.name}:`, {
|
||||||
|
day_of_week: item.day_of_week,
|
||||||
|
weeks: item.weeks,
|
||||||
|
});
|
||||||
|
});
|
||||||
return response.data.list.map(toCourse);
|
return response.data.list.map(toCourse);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,7 +97,14 @@ class ScheduleService {
|
|||||||
async deleteCourse(courseId: string): Promise<void> {
|
async deleteCourse(courseId: string): Promise<void> {
|
||||||
await api.delete(`/schedule/courses/${courseId}`);
|
await api.delete(`/schedule/courses/${courseId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async syncSchedule(req: SyncScheduleRequest): Promise<SyncScheduleResponse> {
|
||||||
|
console.log('[ScheduleService] 开始同步教务系统, username:', req.username);
|
||||||
|
const response = await api.post<SyncScheduleResponse>('/schedule/sync', req);
|
||||||
|
console.log('[ScheduleService] 同步响应:', response.data);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const scheduleService = new ScheduleService();
|
export const scheduleService = new ScheduleService();
|
||||||
export type { CreateScheduleCourseRequest };
|
export type { CreateScheduleCourseRequest, SyncScheduleRequest };
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ function resolveFileInfo(uri: string, providedType?: string): { name: string; ty
|
|||||||
// 上传响应
|
// 上传响应
|
||||||
export interface UploadResponse {
|
export interface UploadResponse {
|
||||||
url: string;
|
url: string;
|
||||||
|
preview_url?: string; // 列表/网格预览图
|
||||||
|
preview_url_large?: string; // 详情页预览图
|
||||||
width?: number;
|
width?: number;
|
||||||
height?: number;
|
height?: number;
|
||||||
size?: number;
|
size?: number;
|
||||||
@@ -162,7 +164,19 @@ class UploadService {
|
|||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const result = await this.uploadImage(file);
|
const result = await this.uploadImage(file);
|
||||||
if (result) {
|
if (result) {
|
||||||
urls.push(result.url);
|
// 将预览图信息合并到 URL 中:url|preview_url|preview_url_large
|
||||||
|
const parts = [result.url];
|
||||||
|
if (result.preview_url) {
|
||||||
|
parts.push(result.preview_url);
|
||||||
|
} else {
|
||||||
|
parts.push(''); // 空占位
|
||||||
|
}
|
||||||
|
if (result.preview_url_large) {
|
||||||
|
parts.push(result.preview_url_large);
|
||||||
|
} else {
|
||||||
|
parts.push(''); // 空占位
|
||||||
|
}
|
||||||
|
urls.push(parts.join('|'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ export interface PostImageDTO {
|
|||||||
id: string;
|
id: string;
|
||||||
url: string;
|
url: string;
|
||||||
thumbnail_url: string;
|
thumbnail_url: string;
|
||||||
|
preview_url: string; // 列表/网格预览图
|
||||||
|
preview_url_large: string; // 详情页预览图
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
}
|
}
|
||||||
|
|||||||
101
src/utils/imageHelper.ts
Normal file
101
src/utils/imageHelper.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
/**
|
||||||
|
* 图片工具函数
|
||||||
|
* 提供图片 URL 选择、格式检测等实用功能
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { PostImageDTO } from '../types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图片显示模式
|
||||||
|
*/
|
||||||
|
export type ImageDisplayMode = 'list' | 'grid' | 'detail';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据显示模式获取预览图 URL
|
||||||
|
* @param image 图片对象
|
||||||
|
* @param mode 显示模式
|
||||||
|
* @returns 图片 URL
|
||||||
|
*/
|
||||||
|
export function getPreviewImageUrl(image: PostImageDTO, mode: ImageDisplayMode): string {
|
||||||
|
// 如果没有预览图,返回原图
|
||||||
|
if (!image) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (mode) {
|
||||||
|
case 'list':
|
||||||
|
case 'grid':
|
||||||
|
// 列表和网格模式使用普通预览图
|
||||||
|
return image.preview_url || image.url || '';
|
||||||
|
case 'detail':
|
||||||
|
// 详情页使用大预览图
|
||||||
|
return image.preview_url_large || image.preview_url || image.url || '';
|
||||||
|
default:
|
||||||
|
return image.url || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检测 WebP 支持并构建 Accept header
|
||||||
|
* @returns Accept header 字符串
|
||||||
|
*/
|
||||||
|
export function getImageAcceptHeader(): string {
|
||||||
|
// 检测浏览器是否支持 WebP
|
||||||
|
// 如果支持,优先请求 WebP
|
||||||
|
return 'image/webp,image/apng,image/*,*/*;q=0.8';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断是否应该使用预览图
|
||||||
|
* @param mode 显示模式
|
||||||
|
* @param hasPreview 是否有预览图
|
||||||
|
* @returns 是否使用预览图
|
||||||
|
*/
|
||||||
|
export function shouldUsePreview(mode: ImageDisplayMode, hasPreview: boolean): boolean {
|
||||||
|
// 列表和网格模式优先使用预览图
|
||||||
|
if (mode === 'list' || mode === 'grid') {
|
||||||
|
return hasPreview;
|
||||||
|
}
|
||||||
|
// 详情页也使用预览图,但点击后加载原图
|
||||||
|
if (mode === 'detail') {
|
||||||
|
return hasPreview;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从图片 URL 提取文件扩展名
|
||||||
|
* @param url 图片 URL
|
||||||
|
* @returns 文件扩展名(如 '.jpg')
|
||||||
|
*/
|
||||||
|
export function getImageExtension(url: string): string {
|
||||||
|
if (!url) return '';
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(url);
|
||||||
|
const pathname = urlObj.pathname;
|
||||||
|
const ext = pathname.match(/\.[0-9a-z]+$/i);
|
||||||
|
return ext ? ext[0].toLowerCase() : '';
|
||||||
|
} catch {
|
||||||
|
// 如果 URL 解析失败,直接从字符串匹配
|
||||||
|
const match = url.match(/\.[0-9a-z]+$/i);
|
||||||
|
return match ? match[0].toLowerCase() : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断是否是 GIF 图片
|
||||||
|
* @param url 图片 URL
|
||||||
|
* @returns 是否是 GIF
|
||||||
|
*/
|
||||||
|
export function isGifImage(url: string): boolean {
|
||||||
|
return getImageExtension(url) === '.gif';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断是否是 WebP 图片
|
||||||
|
* @param url 图片 URL
|
||||||
|
* @returns 是否是 WebP
|
||||||
|
*/
|
||||||
|
export function isWebPImage(url: string): boolean {
|
||||||
|
return getImageExtension(url) === '.webp';
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user