feat(front): 覆盖 front 并完善登录快传入口与中文文案
@@ -7,12 +7,3 @@ GEMINI_API_KEY="MY_GEMINI_API_KEY"
|
|||||||
# AI Studio automatically injects this at runtime with the Cloud Run service URL.
|
# AI Studio automatically injects this at runtime with the Cloud Run service URL.
|
||||||
# Used for self-referential links, OAuth callbacks, and API endpoints.
|
# Used for self-referential links, OAuth callbacks, and API endpoints.
|
||||||
APP_URL="MY_APP_URL"
|
APP_URL="MY_APP_URL"
|
||||||
|
|
||||||
# Optional: direct API base path used by the frontend.
|
|
||||||
VITE_API_BASE_URL="/api"
|
|
||||||
|
|
||||||
# Optional: backend origin used by the Vite dev proxy.
|
|
||||||
VITE_BACKEND_URL="http://localhost:8080"
|
|
||||||
|
|
||||||
# Enable the dev-login button when the backend runs with the dev profile.
|
|
||||||
VITE_ENABLE_DEV_LOGIN="true"
|
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
# Frontend AGENTS
|
|
||||||
|
|
||||||
This directory is a Vite + React + TypeScript frontend. Follow the current split between pages, shared state/helpers, auth context, and reusable UI.
|
|
||||||
|
|
||||||
## Frontend layout
|
|
||||||
|
|
||||||
- `src/pages`: route-level screens and page-scoped state modules.
|
|
||||||
- `src/lib`: API helpers, cache helpers, schedule utilities, shared types, and test files.
|
|
||||||
- `src/auth`: authentication context/provider.
|
|
||||||
- `src/components/layout`: page shell/layout components.
|
|
||||||
- `src/components/ui`: reusable UI primitives.
|
|
||||||
- `src/index.css`: global styles.
|
|
||||||
|
|
||||||
## Real frontend commands
|
|
||||||
|
|
||||||
Run these from `front/`:
|
|
||||||
|
|
||||||
- `npm run dev`
|
|
||||||
- `npm run build`
|
|
||||||
- `npm run preview`
|
|
||||||
- `npm run clean`
|
|
||||||
- `npm run lint`
|
|
||||||
- `npm run test`
|
|
||||||
|
|
||||||
Run this from the repository root for OSS publishing:
|
|
||||||
|
|
||||||
- `node scripts/deploy-front-oss.mjs`
|
|
||||||
- `node scripts/deploy-front-oss.mjs --dry-run`
|
|
||||||
- `node scripts/deploy-front-oss.mjs --skip-build`
|
|
||||||
|
|
||||||
Important:
|
|
||||||
|
|
||||||
- `npm run lint` is the current TypeScript check because it runs `tsc --noEmit`.
|
|
||||||
- There is no separate ESLint script.
|
|
||||||
- There is no separate `typecheck` script beyond `npm run lint`.
|
|
||||||
- OSS publishing uses `scripts/deploy-front-oss.mjs`, which reads credentials from environment variables or the repository root `.env` file, with `.env.oss.local` kept only as a legacy fallback.
|
|
||||||
|
|
||||||
## Frontend rules
|
|
||||||
|
|
||||||
- Keep route behavior in `src/pages` and shared non-UI logic in `src/lib`.
|
|
||||||
- Add or update tests next to the state/helper module they exercise, following the existing `*.test.ts` pattern.
|
|
||||||
- Preserve the current Vite alias usage: `@/*` resolves from the `front/` directory root.
|
|
||||||
- If a change depends on backend API behavior, verify the proxy expectations in `vite.config.ts` before hardcoding URLs.
|
|
||||||
- Use the existing `npm run build`, `npm run test`, and `npm run lint` commands for validation; do not invent a separate frontend verification command.
|
|
||||||
- For release work, let the deployer agent publish `front/dist` through `scripts/deploy-front-oss.mjs` instead of manual object uploads.
|
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
This contains everything you need to run your app locally.
|
This contains everything you need to run your app locally.
|
||||||
|
|
||||||
View your app in AI Studio: https://ai.studio/apps/7dcdc5c7-28c0-4121-959b-77273973e0ef
|
View your app in AI Studio: https://ai.studio/apps/52ed7feb-11e7-46f2-aac1-69c955c09846
|
||||||
|
|
||||||
## Run Locally
|
## Run Locally
|
||||||
|
|
||||||
|
|||||||
101
front/android/.gitignore
vendored
@@ -1,101 +0,0 @@
|
|||||||
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
|
|
||||||
|
|
||||||
# Built application files
|
|
||||||
*.apk
|
|
||||||
*.aar
|
|
||||||
*.ap_
|
|
||||||
*.aab
|
|
||||||
|
|
||||||
# Files for the ART/Dalvik VM
|
|
||||||
*.dex
|
|
||||||
|
|
||||||
# Java class files
|
|
||||||
*.class
|
|
||||||
|
|
||||||
# Generated files
|
|
||||||
bin/
|
|
||||||
gen/
|
|
||||||
out/
|
|
||||||
# Uncomment the following line in case you need and you don't have the release build type files in your app
|
|
||||||
# release/
|
|
||||||
|
|
||||||
# Gradle files
|
|
||||||
.gradle/
|
|
||||||
build/
|
|
||||||
|
|
||||||
# Local configuration file (sdk path, etc)
|
|
||||||
local.properties
|
|
||||||
|
|
||||||
# Proguard folder generated by Eclipse
|
|
||||||
proguard/
|
|
||||||
|
|
||||||
# Log Files
|
|
||||||
*.log
|
|
||||||
|
|
||||||
# Android Studio Navigation editor temp files
|
|
||||||
.navigation/
|
|
||||||
|
|
||||||
# Android Studio captures folder
|
|
||||||
captures/
|
|
||||||
|
|
||||||
# IntelliJ
|
|
||||||
*.iml
|
|
||||||
.idea/workspace.xml
|
|
||||||
.idea/tasks.xml
|
|
||||||
.idea/gradle.xml
|
|
||||||
.idea/assetWizardSettings.xml
|
|
||||||
.idea/dictionaries
|
|
||||||
.idea/libraries
|
|
||||||
# Android Studio 3 in .gitignore file.
|
|
||||||
.idea/caches
|
|
||||||
.idea/modules.xml
|
|
||||||
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
|
|
||||||
.idea/navEditor.xml
|
|
||||||
|
|
||||||
# Keystore files
|
|
||||||
# Uncomment the following lines if you do not want to check your keystore files in.
|
|
||||||
#*.jks
|
|
||||||
#*.keystore
|
|
||||||
|
|
||||||
# External native build folder generated in Android Studio 2.2 and later
|
|
||||||
.externalNativeBuild
|
|
||||||
.cxx/
|
|
||||||
|
|
||||||
# Google Services (e.g. APIs or Firebase)
|
|
||||||
# google-services.json
|
|
||||||
|
|
||||||
# Freeline
|
|
||||||
freeline.py
|
|
||||||
freeline/
|
|
||||||
freeline_project_description.json
|
|
||||||
|
|
||||||
# fastlane
|
|
||||||
fastlane/report.xml
|
|
||||||
fastlane/Preview.html
|
|
||||||
fastlane/screenshots
|
|
||||||
fastlane/test_output
|
|
||||||
fastlane/readme.md
|
|
||||||
|
|
||||||
# Version control
|
|
||||||
vcs.xml
|
|
||||||
|
|
||||||
# lint
|
|
||||||
lint/intermediates/
|
|
||||||
lint/generated/
|
|
||||||
lint/outputs/
|
|
||||||
lint/tmp/
|
|
||||||
# lint/reports/
|
|
||||||
|
|
||||||
# Android Profiling
|
|
||||||
*.hprof
|
|
||||||
|
|
||||||
# Cordova plugins for Capacitor
|
|
||||||
capacitor-cordova-android-plugins
|
|
||||||
|
|
||||||
# Copied web assets
|
|
||||||
app/src/main/assets/public
|
|
||||||
|
|
||||||
# Generated Config files
|
|
||||||
app/src/main/assets/capacitor.config.json
|
|
||||||
app/src/main/assets/capacitor.plugins.json
|
|
||||||
app/src/main/res/xml/config.xml
|
|
||||||
2
front/android/app/.gitignore
vendored
@@ -1,2 +0,0 @@
|
|||||||
/build/*
|
|
||||||
!/build/.npmkeep
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
apply plugin: 'com.android.application'
|
|
||||||
|
|
||||||
def buildTimestamp = new Date()
|
|
||||||
def buildVersionCode = System.getenv('YOYUZH_ANDROID_VERSION_CODE') ?: buildTimestamp.format('yyDDDHHmm')
|
|
||||||
def buildVersionName = System.getenv('YOYUZH_ANDROID_VERSION_NAME') ?: buildTimestamp.format('yyyy.MM.dd.HHmm')
|
|
||||||
|
|
||||||
android {
|
|
||||||
namespace = "xyz.yoyuzh.portal"
|
|
||||||
compileSdk = rootProject.ext.compileSdkVersion
|
|
||||||
defaultConfig {
|
|
||||||
applicationId "xyz.yoyuzh.portal"
|
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
|
||||||
versionCode Integer.parseInt(buildVersionCode)
|
|
||||||
versionName buildVersionName
|
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
|
||||||
aaptOptions {
|
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
|
||||||
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
|
|
||||||
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
buildTypes {
|
|
||||||
release {
|
|
||||||
minifyEnabled false
|
|
||||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
repositories {
|
|
||||||
flatDir{
|
|
||||||
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
|
||||||
implementation fileTree(include: ['*.jar'], dir: 'libs')
|
|
||||||
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
|
||||||
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
|
||||||
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
|
|
||||||
implementation project(':capacitor-android')
|
|
||||||
testImplementation "junit:junit:$junitVersion"
|
|
||||||
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
|
||||||
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
|
||||||
implementation project(':capacitor-cordova-android-plugins')
|
|
||||||
}
|
|
||||||
|
|
||||||
apply from: 'capacitor.build.gradle'
|
|
||||||
|
|
||||||
try {
|
|
||||||
def servicesJSON = file('google-services.json')
|
|
||||||
if (servicesJSON.text) {
|
|
||||||
apply plugin: 'com.google.gms.google-services'
|
|
||||||
}
|
|
||||||
} catch(Exception e) {
|
|
||||||
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
|
||||||
|
|
||||||
android {
|
|
||||||
compileOptions {
|
|
||||||
sourceCompatibility JavaVersion.VERSION_21
|
|
||||||
targetCompatibility JavaVersion.VERSION_21
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
|
||||||
dependencies {
|
|
||||||
implementation project(':capacitor-app')
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
if (hasProperty('postBuildExtras')) {
|
|
||||||
postBuildExtras()
|
|
||||||
}
|
|
||||||
21
front/android/app/proguard-rules.pro
vendored
@@ -1,21 +0,0 @@
|
|||||||
# Add project specific ProGuard rules here.
|
|
||||||
# You can control the set of applied configuration files using the
|
|
||||||
# proguardFiles setting in build.gradle.
|
|
||||||
#
|
|
||||||
# For more details, see
|
|
||||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
|
||||||
|
|
||||||
# If your project uses WebView with JS, uncomment the following
|
|
||||||
# and specify the fully qualified class name to the JavaScript interface
|
|
||||||
# class:
|
|
||||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
|
||||||
# public *;
|
|
||||||
#}
|
|
||||||
|
|
||||||
# Uncomment this to preserve the line number information for
|
|
||||||
# debugging stack traces.
|
|
||||||
#-keepattributes SourceFile,LineNumberTable
|
|
||||||
|
|
||||||
# If you keep the line number information, uncomment this to
|
|
||||||
# hide the original source file name.
|
|
||||||
#-renamesourcefileattribute SourceFile
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
package com.getcapacitor.myapp;
|
|
||||||
|
|
||||||
import static org.junit.Assert.*;
|
|
||||||
|
|
||||||
import android.content.Context;
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
|
||||||
import androidx.test.platform.app.InstrumentationRegistry;
|
|
||||||
import org.junit.Test;
|
|
||||||
import org.junit.runner.RunWith;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Instrumented test, which will execute on an Android device.
|
|
||||||
*
|
|
||||||
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
|
||||||
*/
|
|
||||||
@RunWith(AndroidJUnit4.class)
|
|
||||||
public class ExampleInstrumentedTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void useAppContext() throws Exception {
|
|
||||||
// Context of the app under test.
|
|
||||||
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
|
|
||||||
|
|
||||||
assertEquals("com.getcapacitor.app", appContext.getPackageName());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
|
|
||||||
<application
|
|
||||||
android:allowBackup="true"
|
|
||||||
android:icon="@mipmap/ic_launcher"
|
|
||||||
android:label="@string/app_name"
|
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
|
||||||
android:supportsRtl="true"
|
|
||||||
android:theme="@style/AppTheme">
|
|
||||||
|
|
||||||
<activity
|
|
||||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation|density"
|
|
||||||
android:name=".MainActivity"
|
|
||||||
android:label="@string/title_activity_main"
|
|
||||||
android:theme="@style/AppTheme.NoActionBarLaunch"
|
|
||||||
android:launchMode="singleTask"
|
|
||||||
android:exported="true">
|
|
||||||
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.MAIN" />
|
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
|
||||||
</intent-filter>
|
|
||||||
|
|
||||||
</activity>
|
|
||||||
|
|
||||||
<provider
|
|
||||||
android:name="androidx.core.content.FileProvider"
|
|
||||||
android:authorities="${applicationId}.fileprovider"
|
|
||||||
android:exported="false"
|
|
||||||
android:grantUriPermissions="true">
|
|
||||||
<meta-data
|
|
||||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
|
||||||
android:resource="@xml/file_paths"></meta-data>
|
|
||||||
</provider>
|
|
||||||
</application>
|
|
||||||
|
|
||||||
<!-- Permissions -->
|
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
|
||||||
</manifest>
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
package xyz.yoyuzh.portal;
|
|
||||||
|
|
||||||
import com.getcapacitor.BridgeActivity;
|
|
||||||
|
|
||||||
public class MainActivity extends BridgeActivity {}
|
|
||||||
|
Before Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 9.0 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 17 KiB |
@@ -1,34 +0,0 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
xmlns:aapt="http://schemas.android.com/aapt"
|
|
||||||
android:width="108dp"
|
|
||||||
android:height="108dp"
|
|
||||||
android:viewportHeight="108"
|
|
||||||
android:viewportWidth="108">
|
|
||||||
<path
|
|
||||||
android:fillType="evenOdd"
|
|
||||||
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
|
|
||||||
android:strokeColor="#00000000"
|
|
||||||
android:strokeWidth="1">
|
|
||||||
<aapt:attr name="android:fillColor">
|
|
||||||
<gradient
|
|
||||||
android:endX="78.5885"
|
|
||||||
android:endY="90.9159"
|
|
||||||
android:startX="48.7653"
|
|
||||||
android:startY="61.0927"
|
|
||||||
android:type="linear">
|
|
||||||
<item
|
|
||||||
android:color="#44000000"
|
|
||||||
android:offset="0.0" />
|
|
||||||
<item
|
|
||||||
android:color="#00000000"
|
|
||||||
android:offset="1.0" />
|
|
||||||
</gradient>
|
|
||||||
</aapt:attr>
|
|
||||||
</path>
|
|
||||||
<path
|
|
||||||
android:fillColor="#FFFFFF"
|
|
||||||
android:fillType="nonZero"
|
|
||||||
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
|
|
||||||
android:strokeColor="#00000000"
|
|
||||||
android:strokeWidth="1" />
|
|
||||||
</vector>
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="108dp"
|
|
||||||
android:height="108dp"
|
|
||||||
android:viewportHeight="108"
|
|
||||||
android:viewportWidth="108">
|
|
||||||
<path
|
|
||||||
android:fillColor="#26A69A"
|
|
||||||
android:pathData="M0,0h108v108h-108z" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M9,0L9,108"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,0L19,108"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M29,0L29,108"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M39,0L39,108"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M49,0L49,108"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M59,0L59,108"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M69,0L69,108"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M79,0L79,108"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M89,0L89,108"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M99,0L99,108"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,9L108,9"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,19L108,19"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,29L108,29"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,39L108,39"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,49L108,49"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,59L108,59"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,69L108,69"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,79L108,79"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,89L108,89"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,99L108,99"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,29L89,29"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,39L89,39"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,49L89,49"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,59L89,59"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,69L89,69"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,79L89,79"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M29,19L29,89"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M39,19L39,89"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M49,19L49,89"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M59,19L59,89"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M69,19L69,89"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M79,19L79,89"
|
|
||||||
android:strokeColor="#33FFFFFF"
|
|
||||||
android:strokeWidth="0.8" />
|
|
||||||
</vector>
|
|
||||||
|
Before Width: | Height: | Size: 3.9 KiB |
@@ -1,12 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
tools:context=".MainActivity">
|
|
||||||
|
|
||||||
<WebView
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent" />
|
|
||||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<background android:drawable="@color/ic_launcher_background"/>
|
|
||||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
|
||||||
</adaptive-icon>
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<background android:drawable="@color/ic_launcher_background"/>
|
|
||||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
|
||||||
</adaptive-icon>
|
|
||||||
|
Before Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 9.2 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 16 KiB |
@@ -1,4 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<color name="ic_launcher_background">#FFFFFF</color>
|
|
||||||
</resources>
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<?xml version='1.0' encoding='utf-8'?>
|
|
||||||
<resources>
|
|
||||||
<string name="app_name">YOYUZH</string>
|
|
||||||
<string name="title_activity_main">YOYUZH</string>
|
|
||||||
<string name="package_name">xyz.yoyuzh.portal</string>
|
|
||||||
<string name="custom_url_scheme">xyz.yoyuzh.portal</string>
|
|
||||||
</resources>
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
|
|
||||||
<!-- Base application theme. -->
|
|
||||||
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
|
|
||||||
<!-- Customize your theme here. -->
|
|
||||||
<item name="colorPrimary">@color/colorPrimary</item>
|
|
||||||
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
|
|
||||||
<item name="colorAccent">@color/colorAccent</item>
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
|
|
||||||
<item name="windowActionBar">false</item>
|
|
||||||
<item name="windowNoTitle">true</item>
|
|
||||||
<item name="android:background">@null</item>
|
|
||||||
</style>
|
|
||||||
|
|
||||||
|
|
||||||
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
|
|
||||||
<item name="android:background">@drawable/splash</item>
|
|
||||||
</style>
|
|
||||||
</resources>
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<external-path name="my_images" path="." />
|
|
||||||
<cache-path name="my_cache_images" path="." />
|
|
||||||
</paths>
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
package com.getcapacitor.myapp;
|
|
||||||
|
|
||||||
import static org.junit.Assert.*;
|
|
||||||
|
|
||||||
import org.junit.Test;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Example local unit test, which will execute on the development machine (host).
|
|
||||||
*
|
|
||||||
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
|
||||||
*/
|
|
||||||
public class ExampleUnitTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void addition_isCorrect() throws Exception {
|
|
||||||
assertEquals(4, 2 + 2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
|
||||||
|
|
||||||
buildscript {
|
|
||||||
def googleMirror = 'https://maven.aliyun.com/repository/google'
|
|
||||||
|
|
||||||
repositories {
|
|
||||||
maven { url googleMirror }
|
|
||||||
mavenCentral()
|
|
||||||
}
|
|
||||||
dependencies {
|
|
||||||
classpath 'com.android.tools.build:gradle:8.13.0'
|
|
||||||
|
|
||||||
// NOTE: Do not place your application dependencies here; they belong
|
|
||||||
// in the individual module build.gradle files
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
apply from: "variables.gradle"
|
|
||||||
|
|
||||||
allprojects {
|
|
||||||
def googleMirror = 'https://maven.aliyun.com/repository/google'
|
|
||||||
|
|
||||||
repositories {
|
|
||||||
maven { url googleMirror }
|
|
||||||
mavenCentral()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
task clean(type: Delete) {
|
|
||||||
delete rootProject.buildDir
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
|
||||||
include ':capacitor-android'
|
|
||||||
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
|
|
||||||
|
|
||||||
include ':capacitor-app'
|
|
||||||
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
# Project-wide Gradle settings.
|
|
||||||
|
|
||||||
# IDE (e.g. Android Studio) users:
|
|
||||||
# Gradle settings configured through the IDE *will override*
|
|
||||||
# any settings specified in this file.
|
|
||||||
|
|
||||||
# For more details on how to configure your build environment visit
|
|
||||||
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
|
||||||
|
|
||||||
# Specifies the JVM arguments used for the daemon process.
|
|
||||||
# The setting is particularly useful for tweaking memory settings.
|
|
||||||
org.gradle.jvmargs=-Xmx1536m
|
|
||||||
|
|
||||||
# When configured, Gradle will run in incubating parallel mode.
|
|
||||||
# This option should only be used with decoupled projects. More details, visit
|
|
||||||
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
|
|
||||||
# org.gradle.parallel=true
|
|
||||||
|
|
||||||
# AndroidX package structure to make it clearer which packages are bundled with the
|
|
||||||
# Android operating system, and which are packaged with your app's APK
|
|
||||||
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
|
||||||
android.useAndroidX=true
|
|
||||||
BIN
front/android/gradle/wrapper/gradle-wrapper.jar
vendored
@@ -1,7 +0,0 @@
|
|||||||
distributionBase=GRADLE_USER_HOME
|
|
||||||
distributionPath=wrapper/dists
|
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip
|
|
||||||
networkTimeout=10000
|
|
||||||
validateDistributionUrl=true
|
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
|
||||||
zipStorePath=wrapper/dists
|
|
||||||
251
front/android/gradlew
vendored
@@ -1,251 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
|
|
||||||
#
|
|
||||||
# Copyright © 2015-2021 the original authors.
|
|
||||||
#
|
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
# you may not use this file except in compliance with the License.
|
|
||||||
# You may obtain a copy of the License at
|
|
||||||
#
|
|
||||||
# https://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
#
|
|
||||||
# Unless required by applicable law or agreed to in writing, software
|
|
||||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
# See the License for the specific language governing permissions and
|
|
||||||
# limitations under the License.
|
|
||||||
#
|
|
||||||
# SPDX-License-Identifier: Apache-2.0
|
|
||||||
#
|
|
||||||
|
|
||||||
##############################################################################
|
|
||||||
#
|
|
||||||
# Gradle start up script for POSIX generated by Gradle.
|
|
||||||
#
|
|
||||||
# Important for running:
|
|
||||||
#
|
|
||||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
|
||||||
# noncompliant, but you have some other compliant shell such as ksh or
|
|
||||||
# bash, then to run this script, type that shell name before the whole
|
|
||||||
# command line, like:
|
|
||||||
#
|
|
||||||
# ksh Gradle
|
|
||||||
#
|
|
||||||
# Busybox and similar reduced shells will NOT work, because this script
|
|
||||||
# requires all of these POSIX shell features:
|
|
||||||
# * functions;
|
|
||||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
|
||||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
|
||||||
# * compound commands having a testable exit status, especially «case»;
|
|
||||||
# * various built-in commands including «command», «set», and «ulimit».
|
|
||||||
#
|
|
||||||
# Important for patching:
|
|
||||||
#
|
|
||||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
|
||||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
|
||||||
#
|
|
||||||
# The "traditional" practice of packing multiple parameters into a
|
|
||||||
# space-separated string is a well documented source of bugs and security
|
|
||||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
|
||||||
# options in "$@", and eventually passing that to Java.
|
|
||||||
#
|
|
||||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
|
||||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
|
||||||
# see the in-line comments for details.
|
|
||||||
#
|
|
||||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
|
||||||
# Darwin, MinGW, and NonStop.
|
|
||||||
#
|
|
||||||
# (3) This script is generated from the Groovy template
|
|
||||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
|
||||||
# within the Gradle project.
|
|
||||||
#
|
|
||||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
|
||||||
#
|
|
||||||
##############################################################################
|
|
||||||
|
|
||||||
# Attempt to set APP_HOME
|
|
||||||
|
|
||||||
# Resolve links: $0 may be a link
|
|
||||||
app_path=$0
|
|
||||||
|
|
||||||
# Need this for daisy-chained symlinks.
|
|
||||||
while
|
|
||||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
|
||||||
[ -h "$app_path" ]
|
|
||||||
do
|
|
||||||
ls=$( ls -ld "$app_path" )
|
|
||||||
link=${ls#*' -> '}
|
|
||||||
case $link in #(
|
|
||||||
/*) app_path=$link ;; #(
|
|
||||||
*) app_path=$APP_HOME$link ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
# This is normally unused
|
|
||||||
# shellcheck disable=SC2034
|
|
||||||
APP_BASE_NAME=${0##*/}
|
|
||||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
|
||||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
|
||||||
|
|
||||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
|
||||||
MAX_FD=maximum
|
|
||||||
|
|
||||||
warn () {
|
|
||||||
echo "$*"
|
|
||||||
} >&2
|
|
||||||
|
|
||||||
die () {
|
|
||||||
echo
|
|
||||||
echo "$*"
|
|
||||||
echo
|
|
||||||
exit 1
|
|
||||||
} >&2
|
|
||||||
|
|
||||||
# OS specific support (must be 'true' or 'false').
|
|
||||||
cygwin=false
|
|
||||||
msys=false
|
|
||||||
darwin=false
|
|
||||||
nonstop=false
|
|
||||||
case "$( uname )" in #(
|
|
||||||
CYGWIN* ) cygwin=true ;; #(
|
|
||||||
Darwin* ) darwin=true ;; #(
|
|
||||||
MSYS* | MINGW* ) msys=true ;; #(
|
|
||||||
NONSTOP* ) nonstop=true ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
CLASSPATH="\\\"\\\""
|
|
||||||
|
|
||||||
|
|
||||||
# Determine the Java command to use to start the JVM.
|
|
||||||
if [ -n "$JAVA_HOME" ] ; then
|
|
||||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
|
||||||
# IBM's JDK on AIX uses strange locations for the executables
|
|
||||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
|
||||||
else
|
|
||||||
JAVACMD=$JAVA_HOME/bin/java
|
|
||||||
fi
|
|
||||||
if [ ! -x "$JAVACMD" ] ; then
|
|
||||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
|
||||||
|
|
||||||
Please set the JAVA_HOME variable in your environment to match the
|
|
||||||
location of your Java installation."
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
JAVACMD=java
|
|
||||||
if ! command -v java >/dev/null 2>&1
|
|
||||||
then
|
|
||||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
|
||||||
|
|
||||||
Please set the JAVA_HOME variable in your environment to match the
|
|
||||||
location of your Java installation."
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Increase the maximum file descriptors if we can.
|
|
||||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
|
||||||
case $MAX_FD in #(
|
|
||||||
max*)
|
|
||||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
|
||||||
# shellcheck disable=SC2039,SC3045
|
|
||||||
MAX_FD=$( ulimit -H -n ) ||
|
|
||||||
warn "Could not query maximum file descriptor limit"
|
|
||||||
esac
|
|
||||||
case $MAX_FD in #(
|
|
||||||
'' | soft) :;; #(
|
|
||||||
*)
|
|
||||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
|
||||||
# shellcheck disable=SC2039,SC3045
|
|
||||||
ulimit -n "$MAX_FD" ||
|
|
||||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
|
||||||
esac
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Collect all arguments for the java command, stacking in reverse order:
|
|
||||||
# * args from the command line
|
|
||||||
# * the main class name
|
|
||||||
# * -classpath
|
|
||||||
# * -D...appname settings
|
|
||||||
# * --module-path (only if needed)
|
|
||||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
|
||||||
|
|
||||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
|
||||||
if "$cygwin" || "$msys" ; then
|
|
||||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
|
||||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
|
||||||
|
|
||||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
|
||||||
|
|
||||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
|
||||||
for arg do
|
|
||||||
if
|
|
||||||
case $arg in #(
|
|
||||||
-*) false ;; # don't mess with options #(
|
|
||||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
|
||||||
[ -e "$t" ] ;; #(
|
|
||||||
*) false ;;
|
|
||||||
esac
|
|
||||||
then
|
|
||||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
|
||||||
fi
|
|
||||||
# Roll the args list around exactly as many times as the number of
|
|
||||||
# args, so each arg winds up back in the position where it started, but
|
|
||||||
# possibly modified.
|
|
||||||
#
|
|
||||||
# NB: a `for` loop captures its iteration list before it begins, so
|
|
||||||
# changing the positional parameters here affects neither the number of
|
|
||||||
# iterations, nor the values presented in `arg`.
|
|
||||||
shift # remove old arg
|
|
||||||
set -- "$@" "$arg" # push replacement arg
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
|
|
||||||
|
|
||||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
|
||||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
|
||||||
|
|
||||||
# Collect all arguments for the java command:
|
|
||||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
|
||||||
# and any embedded shellness will be escaped.
|
|
||||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
|
||||||
# treated as '${Hostname}' itself on the command line.
|
|
||||||
|
|
||||||
set -- \
|
|
||||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
|
||||||
-classpath "$CLASSPATH" \
|
|
||||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
|
||||||
"$@"
|
|
||||||
|
|
||||||
# Stop when "xargs" is not available.
|
|
||||||
if ! command -v xargs >/dev/null 2>&1
|
|
||||||
then
|
|
||||||
die "xargs is not available"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Use "xargs" to parse quoted args.
|
|
||||||
#
|
|
||||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
|
||||||
#
|
|
||||||
# In Bash we could simply go:
|
|
||||||
#
|
|
||||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
|
||||||
# set -- "${ARGS[@]}" "$@"
|
|
||||||
#
|
|
||||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
|
||||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
|
||||||
# character that might be a shell metacharacter, then use eval to reverse
|
|
||||||
# that process (while maintaining the separation between arguments), and wrap
|
|
||||||
# the whole thing up as a single "set" statement.
|
|
||||||
#
|
|
||||||
# This will of course break if any of these variables contains a newline or
|
|
||||||
# an unmatched quote.
|
|
||||||
#
|
|
||||||
|
|
||||||
eval "set -- $(
|
|
||||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
|
||||||
xargs -n1 |
|
|
||||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
|
||||||
tr '\n' ' '
|
|
||||||
)" '"$@"'
|
|
||||||
|
|
||||||
exec "$JAVACMD" "$@"
|
|
||||||
94
front/android/gradlew.bat
vendored
@@ -1,94 +0,0 @@
|
|||||||
@rem
|
|
||||||
@rem Copyright 2015 the original author or authors.
|
|
||||||
@rem
|
|
||||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
@rem you may not use this file except in compliance with the License.
|
|
||||||
@rem You may obtain a copy of the License at
|
|
||||||
@rem
|
|
||||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
@rem
|
|
||||||
@rem Unless required by applicable law or agreed to in writing, software
|
|
||||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
@rem See the License for the specific language governing permissions and
|
|
||||||
@rem limitations under the License.
|
|
||||||
@rem
|
|
||||||
@rem SPDX-License-Identifier: Apache-2.0
|
|
||||||
@rem
|
|
||||||
|
|
||||||
@if "%DEBUG%"=="" @echo off
|
|
||||||
@rem ##########################################################################
|
|
||||||
@rem
|
|
||||||
@rem Gradle startup script for Windows
|
|
||||||
@rem
|
|
||||||
@rem ##########################################################################
|
|
||||||
|
|
||||||
@rem Set local scope for the variables with windows NT shell
|
|
||||||
if "%OS%"=="Windows_NT" setlocal
|
|
||||||
|
|
||||||
set DIRNAME=%~dp0
|
|
||||||
if "%DIRNAME%"=="" set DIRNAME=.
|
|
||||||
@rem This is normally unused
|
|
||||||
set APP_BASE_NAME=%~n0
|
|
||||||
set APP_HOME=%DIRNAME%
|
|
||||||
|
|
||||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
|
||||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
|
||||||
|
|
||||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
|
||||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
|
||||||
|
|
||||||
@rem Find java.exe
|
|
||||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
|
||||||
|
|
||||||
set JAVA_EXE=java.exe
|
|
||||||
%JAVA_EXE% -version >NUL 2>&1
|
|
||||||
if %ERRORLEVEL% equ 0 goto execute
|
|
||||||
|
|
||||||
echo. 1>&2
|
|
||||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
|
||||||
echo. 1>&2
|
|
||||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
|
||||||
echo location of your Java installation. 1>&2
|
|
||||||
|
|
||||||
goto fail
|
|
||||||
|
|
||||||
:findJavaFromJavaHome
|
|
||||||
set JAVA_HOME=%JAVA_HOME:"=%
|
|
||||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
|
||||||
|
|
||||||
if exist "%JAVA_EXE%" goto execute
|
|
||||||
|
|
||||||
echo. 1>&2
|
|
||||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
|
||||||
echo. 1>&2
|
|
||||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
|
||||||
echo location of your Java installation. 1>&2
|
|
||||||
|
|
||||||
goto fail
|
|
||||||
|
|
||||||
:execute
|
|
||||||
@rem Setup the command line
|
|
||||||
|
|
||||||
set CLASSPATH=
|
|
||||||
|
|
||||||
|
|
||||||
@rem Execute Gradle
|
|
||||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
|
||||||
|
|
||||||
:end
|
|
||||||
@rem End local scope for the variables with windows NT shell
|
|
||||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
|
||||||
|
|
||||||
:fail
|
|
||||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
|
||||||
rem the _cmd.exe /c_ return code!
|
|
||||||
set EXIT_CODE=%ERRORLEVEL%
|
|
||||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
|
||||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
|
||||||
exit /b %EXIT_CODE%
|
|
||||||
|
|
||||||
:mainEnd
|
|
||||||
if "%OS%"=="Windows_NT" endlocal
|
|
||||||
|
|
||||||
:omega
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
include ':app'
|
|
||||||
include ':capacitor-cordova-android-plugins'
|
|
||||||
project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/')
|
|
||||||
|
|
||||||
apply from: 'capacitor.settings.gradle'
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
ext {
|
|
||||||
minSdkVersion = 24
|
|
||||||
compileSdkVersion = 36
|
|
||||||
targetSdkVersion = 36
|
|
||||||
androidxActivityVersion = '1.11.0'
|
|
||||||
androidxAppCompatVersion = '1.7.1'
|
|
||||||
androidxCoordinatorLayoutVersion = '1.3.0'
|
|
||||||
androidxCoreVersion = '1.17.0'
|
|
||||||
androidxFragmentVersion = '1.8.9'
|
|
||||||
coreSplashScreenVersion = '1.2.0'
|
|
||||||
androidxWebkitVersion = '1.14.0'
|
|
||||||
junitVersion = '4.13.2'
|
|
||||||
androidxJunitVersion = '1.3.0'
|
|
||||||
androidxEspressoCoreVersion = '3.7.0'
|
|
||||||
cordovaAndroidVersion = '14.0.1'
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import type { CapacitorConfig } from '@capacitor/cli';
|
|
||||||
|
|
||||||
const config: CapacitorConfig = {
|
|
||||||
appId: 'xyz.yoyuzh.portal',
|
|
||||||
appName: 'YOYUZH',
|
|
||||||
webDir: 'dist'
|
|
||||||
};
|
|
||||||
|
|
||||||
export default config;
|
|
||||||
@@ -2,11 +2,15 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>优立云盘</title>
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=Outfit:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
|
||||||
|
<title>Stitch Portal</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "Personal Portal",
|
"name": "",
|
||||||
"description": "A unified personal portal for managing files, fast transfer, and games with a glassmorphism design.",
|
"description": "",
|
||||||
"requestFramePermissions": []
|
"requestFramePermissions": []
|
||||||
}
|
}
|
||||||
|
|||||||
3964
front/package-lock.json
generated
@@ -8,35 +8,20 @@
|
|||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"clean": "rm -rf dist",
|
"clean": "rm -rf dist",
|
||||||
"lint": "tsc --noEmit",
|
"lint": "tsc --noEmit"
|
||||||
"test": "node --import tsx --test src/**/*.test.ts"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@capacitor/app": "^8.1.0",
|
|
||||||
"@capacitor/android": "^8.3.0",
|
|
||||||
"@capacitor/cli": "^8.3.0",
|
|
||||||
"@capacitor/core": "^8.3.0",
|
|
||||||
"@emotion/react": "^11.14.0",
|
|
||||||
"@emotion/styled": "^11.14.1",
|
|
||||||
"@google/genai": "^1.29.0",
|
"@google/genai": "^1.29.0",
|
||||||
"@mui/icons-material": "^7.3.9",
|
|
||||||
"@mui/material": "^7.3.9",
|
|
||||||
"@tailwindcss/vite": "^4.1.14",
|
"@tailwindcss/vite": "^4.1.14",
|
||||||
"@types/simple-peer": "^9.11.9",
|
|
||||||
"@vitejs/plugin-react": "^5.0.4",
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
"better-sqlite3": "^12.4.1",
|
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"lucide-react": "^0.546.0",
|
"lucide-react": "^0.546.0",
|
||||||
"motion": "^12.23.24",
|
"motion": "^12.23.24",
|
||||||
"ogl": "^1.0.11",
|
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-admin": "^5.14.4",
|
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-router-dom": "^7.13.1",
|
"react-router-dom": "^7.14.0",
|
||||||
"simple-peer": "^9.11.1",
|
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"vite": "^6.2.0"
|
"vite": "^6.2.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,100 +1,58 @@
|
|||||||
import React, { Suspense } from 'react';
|
import { BrowserRouter, Navigate, Route, Routes, useLocation } from 'react-router-dom';
|
||||||
import { BrowserRouter, HashRouter, Routes, Route, Navigate, useLocation } from 'react-router-dom';
|
import { AnimatePresence, motion } from 'motion/react';
|
||||||
import { Layout } from './components/layout/Layout';
|
import AdminDashboard from './admin/dashboard';
|
||||||
import { useAuth } from './auth/AuthProvider';
|
import AdminFilesList from './admin/files-list';
|
||||||
|
import AdminStoragePoliciesList from './admin/storage-policies-list';
|
||||||
|
import AdminUsersList from './admin/users-list';
|
||||||
|
import Layout from './components/layout/Layout';
|
||||||
|
import MobileLayout from './mobile-components/MobileLayout';
|
||||||
|
import { useIsMobile } from './hooks/useIsMobile';
|
||||||
import Login from './pages/Login';
|
import Login from './pages/Login';
|
||||||
import Overview from './pages/Overview';
|
import Overview from './pages/Overview';
|
||||||
import Files from './pages/Files';
|
|
||||||
import RecycleBin from './pages/RecycleBin';
|
import RecycleBin from './pages/RecycleBin';
|
||||||
|
import Shares from './pages/Shares';
|
||||||
|
import Tasks from './pages/Tasks';
|
||||||
import Transfer from './pages/Transfer';
|
import Transfer from './pages/Transfer';
|
||||||
import FileShare from './pages/FileShare';
|
import FileShare from './pages/FileShare';
|
||||||
import Games from './pages/Games';
|
import FilesPage from './pages/files/FilesPage';
|
||||||
import GamePlayer from './pages/GamePlayer';
|
|
||||||
import { FILE_SHARE_ROUTE_PREFIX } from './lib/file-share';
|
|
||||||
import {
|
|
||||||
getTransferRouterMode,
|
|
||||||
LEGACY_PUBLIC_TRANSFER_ROUTE,
|
|
||||||
PUBLIC_TRANSFER_ROUTE,
|
|
||||||
} from './lib/transfer-links';
|
|
||||||
|
|
||||||
const PortalAdminApp = React.lazy(() => import('./admin/AdminApp'));
|
function AnimatedRoutes({ isMobile }: { isMobile: boolean }) {
|
||||||
|
|
||||||
function LegacyTransferRedirect() {
|
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
return <Navigate to={`${PUBLIC_TRANSFER_ROUTE}${location.search}`} replace />;
|
const AppLayout = isMobile ? MobileLayout : Layout;
|
||||||
}
|
|
||||||
|
|
||||||
function AppRoutes() {
|
|
||||||
const { ready, session } = useAuth();
|
|
||||||
const location = useLocation();
|
|
||||||
const isPublicTransferRoute = location.pathname === PUBLIC_TRANSFER_ROUTE || location.pathname === LEGACY_PUBLIC_TRANSFER_ROUTE;
|
|
||||||
const isPublicFileShareRoute = location.pathname.startsWith(`${FILE_SHARE_ROUTE_PREFIX}/`);
|
|
||||||
|
|
||||||
if (!ready && !isPublicTransferRoute && !isPublicFileShareRoute) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen flex items-center justify-center bg-[#07101D] text-slate-300">
|
|
||||||
正在检查登录状态...
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isAuthenticated = Boolean(session?.token);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<AnimatePresence mode="wait">
|
||||||
<Route
|
<Routes location={location}>
|
||||||
path={PUBLIC_TRANSFER_ROUTE}
|
<Route path="/login" element={<Login />} />
|
||||||
element={isAuthenticated ? <Layout><Transfer /></Layout> : <Transfer />}
|
<Route path="/share/:token" element={<FileShare />} />
|
||||||
/>
|
<Route element={<AppLayout />}>
|
||||||
<Route path={`${FILE_SHARE_ROUTE_PREFIX}/:token`} element={<FileShare />} />
|
<Route path="/" element={<Navigate to="/overview" replace />} />
|
||||||
<Route path={LEGACY_PUBLIC_TRANSFER_ROUTE} element={<LegacyTransferRedirect />} />
|
<Route path="/overview" element={<Overview />} />
|
||||||
<Route
|
<Route path="/files" element={<FilesPage />} />
|
||||||
path="/login"
|
<Route path="/tasks" element={<Tasks />} />
|
||||||
element={isAuthenticated ? <Navigate to="/overview" replace /> : <Login />}
|
<Route path="/shares" element={<Shares />} />
|
||||||
/>
|
<Route path="/recycle-bin" element={<RecycleBin />} />
|
||||||
<Route
|
<Route path="/transfer" element={<Transfer />} />
|
||||||
path="/"
|
<Route path="/admin">
|
||||||
element={isAuthenticated ? <Layout /> : <Navigate to="/login" replace />}
|
<Route index element={<Navigate to="/admin/dashboard" replace />} />
|
||||||
>
|
<Route path="dashboard" element={isMobile ? <Navigate to="/overview" replace /> : <AdminDashboard />} />
|
||||||
<Route index element={<Navigate to="/overview" replace />} />
|
<Route path="users" element={isMobile ? <Navigate to="/overview" replace /> : <AdminUsersList />} />
|
||||||
<Route path="overview" element={<Overview />} />
|
<Route path="files" element={isMobile ? <Navigate to="/overview" replace /> : <AdminFilesList />} />
|
||||||
<Route path="files" element={<Files />} />
|
<Route path="storage-policies" element={isMobile ? <Navigate to="/overview" replace /> : <AdminStoragePoliciesList />} />
|
||||||
<Route path="recycle-bin" element={<RecycleBin />} />
|
</Route>
|
||||||
<Route path="games" element={<Games />} />
|
<Route path="*" element={<Navigate to="/overview" replace />} />
|
||||||
<Route path="games/:gameId" element={<GamePlayer />} />
|
|
||||||
</Route>
|
</Route>
|
||||||
<Route
|
|
||||||
path="/admin/*"
|
|
||||||
element={
|
|
||||||
isAuthenticated ? (
|
|
||||||
<Suspense
|
|
||||||
fallback={
|
|
||||||
<div className="min-h-screen flex items-center justify-center bg-white text-slate-700">
|
|
||||||
正在加载后台管理台...
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<PortalAdminApp />
|
|
||||||
</Suspense>
|
|
||||||
) : (
|
|
||||||
<Navigate to="/login" replace />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="*"
|
|
||||||
element={<Navigate to={isAuthenticated ? '/overview' : '/login'} replace />}
|
|
||||||
/>
|
|
||||||
</Routes>
|
</Routes>
|
||||||
|
</AnimatePresence>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const Router = getTransferRouterMode() === 'hash' ? HashRouter : BrowserRouter;
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Router>
|
<BrowserRouter>
|
||||||
<AppRoutes />
|
<AnimatedRoutes isMobile={isMobile} />
|
||||||
</Router>
|
</BrowserRouter>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
import React, { Suspense } from 'react';
|
|
||||||
import { BrowserRouter, HashRouter, Routes, Route, Navigate, useLocation } from 'react-router-dom';
|
|
||||||
|
|
||||||
import { useAuth } from '@/src/auth/AuthProvider';
|
|
||||||
import { FILE_SHARE_ROUTE_PREFIX } from '@/src/lib/file-share';
|
|
||||||
import { getTransferRouterMode, LEGACY_PUBLIC_TRANSFER_ROUTE, PUBLIC_TRANSFER_ROUTE } from '@/src/lib/transfer-links';
|
|
||||||
|
|
||||||
import { MobileLayout } from './mobile-components/MobileLayout';
|
|
||||||
import MobileLogin from './mobile-pages/MobileLogin';
|
|
||||||
import MobileOverview from './mobile-pages/MobileOverview';
|
|
||||||
import MobileFiles from './mobile-pages/MobileFiles';
|
|
||||||
import MobileTransfer from './mobile-pages/MobileTransfer';
|
|
||||||
import MobileFileShare from './mobile-pages/MobileFileShare';
|
|
||||||
import MobileRecycleBin from './mobile-pages/MobileRecycleBin';
|
|
||||||
import MobileAdminUnavailable from './mobile-pages/MobileAdminUnavailable';
|
|
||||||
|
|
||||||
function LegacyTransferRedirect() {
|
|
||||||
const location = useLocation();
|
|
||||||
return <Navigate to={`${PUBLIC_TRANSFER_ROUTE}${location.search}`} replace />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function MobileAppRoutes() {
|
|
||||||
const { ready, session } = useAuth();
|
|
||||||
const location = useLocation();
|
|
||||||
const isPublicTransferRoute = location.pathname === PUBLIC_TRANSFER_ROUTE || location.pathname === LEGACY_PUBLIC_TRANSFER_ROUTE;
|
|
||||||
const isPublicFileShareRoute = location.pathname.startsWith(`${FILE_SHARE_ROUTE_PREFIX}/`);
|
|
||||||
|
|
||||||
if (!ready && !isPublicTransferRoute && !isPublicFileShareRoute) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-[100dvh] flex items-center justify-center bg-[#07101D] text-slate-300 flex-col gap-4">
|
|
||||||
<span className="w-6 h-6 border-2 border-white/20 border-t-white rounded-full animate-spin" />
|
|
||||||
<span className="text-sm">正在检查登录状态...</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isAuthenticated = Boolean(session?.token);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Routes>
|
|
||||||
<Route
|
|
||||||
path={PUBLIC_TRANSFER_ROUTE}
|
|
||||||
element={isAuthenticated ? <MobileLayout><MobileTransfer /></MobileLayout> : <MobileTransfer />}
|
|
||||||
/>
|
|
||||||
<Route path={`${FILE_SHARE_ROUTE_PREFIX}/:token`} element={<MobileFileShare />} />
|
|
||||||
<Route path={LEGACY_PUBLIC_TRANSFER_ROUTE} element={<LegacyTransferRedirect />} />
|
|
||||||
<Route
|
|
||||||
path="/login"
|
|
||||||
element={isAuthenticated ? <Navigate to="/overview" replace /> : <MobileLogin />}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/"
|
|
||||||
element={isAuthenticated ? <MobileLayout /> : <Navigate to="/login" replace />}
|
|
||||||
>
|
|
||||||
<Route index element={<Navigate to="/overview" replace />} />
|
|
||||||
<Route path="overview" element={<MobileOverview />} />
|
|
||||||
<Route path="files" element={<MobileFiles />} />
|
|
||||||
<Route path="recycle-bin" element={<MobileRecycleBin />} />
|
|
||||||
<Route path="games" element={<Navigate to="/overview" replace />} />
|
|
||||||
</Route>
|
|
||||||
|
|
||||||
<Route path="/games/:gameId" element={<Navigate to={isAuthenticated ? '/overview' : '/login'} replace />} />
|
|
||||||
|
|
||||||
{/* Admin dashboard is not mobile-optimized in this phase yet, show stub page */}
|
|
||||||
<Route
|
|
||||||
path="/admin/*"
|
|
||||||
element={isAuthenticated ? <MobileAdminUnavailable /> : <Navigate to="/login" replace />}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Route
|
|
||||||
path="*"
|
|
||||||
element={<Navigate to={isAuthenticated ? '/overview' : '/login'} replace />}
|
|
||||||
/>
|
|
||||||
</Routes>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function MobileApp() {
|
|
||||||
const Router = getTransferRouterMode() === 'hash' ? HashRouter : BrowserRouter;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Router>
|
|
||||||
<MobileAppRoutes />
|
|
||||||
</Router>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import FolderOutlined from '@mui/icons-material/FolderOutlined';
|
|
||||||
import GroupsOutlined from '@mui/icons-material/GroupsOutlined';
|
|
||||||
import StorageRounded from '@mui/icons-material/StorageRounded';
|
|
||||||
import { Admin, Resource } from 'react-admin';
|
|
||||||
|
|
||||||
import { portalAdminAuthProvider } from './auth-provider';
|
|
||||||
import { portalAdminDataProvider } from './data-provider';
|
|
||||||
import { PortalAdminDashboard } from './dashboard';
|
|
||||||
import { PortalAdminFilesList } from './files-list';
|
|
||||||
import { PortalAdminStoragePoliciesList } from './storage-policies-list';
|
|
||||||
import { PortalAdminUsersList } from './users-list';
|
|
||||||
|
|
||||||
export default function PortalAdminApp() {
|
|
||||||
return (
|
|
||||||
<Admin
|
|
||||||
authProvider={portalAdminAuthProvider}
|
|
||||||
basename="/admin"
|
|
||||||
dashboard={PortalAdminDashboard}
|
|
||||||
dataProvider={portalAdminDataProvider}
|
|
||||||
disableTelemetry
|
|
||||||
requireAuth
|
|
||||||
title="YOYUZH Admin"
|
|
||||||
>
|
|
||||||
<Resource
|
|
||||||
name="users"
|
|
||||||
icon={GroupsOutlined}
|
|
||||||
list={PortalAdminUsersList}
|
|
||||||
options={{ label: '用户资源' }}
|
|
||||||
recordRepresentation="username"
|
|
||||||
/>
|
|
||||||
<Resource
|
|
||||||
name="files"
|
|
||||||
icon={FolderOutlined}
|
|
||||||
list={PortalAdminFilesList}
|
|
||||||
options={{ label: '文件资源' }}
|
|
||||||
recordRepresentation="filename"
|
|
||||||
/>
|
|
||||||
<Resource
|
|
||||||
name="storagePolicies"
|
|
||||||
icon={StorageRounded}
|
|
||||||
list={PortalAdminStoragePoliciesList}
|
|
||||||
options={{ label: '存储策略' }}
|
|
||||||
recordRepresentation="name"
|
|
||||||
/>
|
|
||||||
</Admin>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import test from 'node:test';
|
|
||||||
|
|
||||||
import type { AuthSession } from '@/src/lib/types';
|
|
||||||
|
|
||||||
import { buildAdminIdentity, hasAdminSession, portalAdminAuthProvider } from './auth-provider';
|
|
||||||
|
|
||||||
const session: AuthSession = {
|
|
||||||
token: 'token-123',
|
|
||||||
refreshToken: 'refresh-123',
|
|
||||||
user: {
|
|
||||||
id: 7,
|
|
||||||
username: 'alice',
|
|
||||||
email: 'alice@example.com',
|
|
||||||
createdAt: '2026-03-19T15:00:00',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
test('hasAdminSession returns true only when a token is present', () => {
|
|
||||||
assert.equal(hasAdminSession(session), true);
|
|
||||||
assert.equal(hasAdminSession({...session, token: ''}), false);
|
|
||||||
assert.equal(hasAdminSession(null), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('buildAdminIdentity maps the portal session user to react-admin identity', () => {
|
|
||||||
assert.deepEqual(buildAdminIdentity(session), {
|
|
||||||
id: '7',
|
|
||||||
fullName: 'alice',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('checkError keeps the session when admin API returns 403', async () => {
|
|
||||||
await assert.doesNotReject(() => portalAdminAuthProvider.checkError?.({status: 403}));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('checkError rejects when admin API returns 401', async () => {
|
|
||||||
await assert.rejects(() => portalAdminAuthProvider.checkError?.({status: 401}));
|
|
||||||
});
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
import type { AuthProvider, UserIdentity } from 'react-admin';
|
|
||||||
|
|
||||||
import { clearStoredSession, readStoredSession } from '@/src/lib/session';
|
|
||||||
import type { AuthSession } from '@/src/lib/types';
|
|
||||||
|
|
||||||
export function hasAdminSession(session: AuthSession | null | undefined) {
|
|
||||||
return Boolean(session?.token?.trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildAdminIdentity(session: AuthSession): UserIdentity {
|
|
||||||
return {
|
|
||||||
id: String(session.user.id),
|
|
||||||
fullName: session.user.username,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const portalAdminAuthProvider: AuthProvider = {
|
|
||||||
login: async () => {
|
|
||||||
throw new Error('请先使用门户登录页完成登录');
|
|
||||||
},
|
|
||||||
logout: async () => {
|
|
||||||
clearStoredSession();
|
|
||||||
return '/login';
|
|
||||||
},
|
|
||||||
checkAuth: async () => {
|
|
||||||
if (!hasAdminSession(readStoredSession())) {
|
|
||||||
throw new Error('当前没有可用登录状态');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
checkError: async (error) => {
|
|
||||||
const status = error?.status;
|
|
||||||
if (status === 401) {
|
|
||||||
clearStoredSession();
|
|
||||||
throw new Error('登录状态已失效');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status === 403) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
getIdentity: async () => {
|
|
||||||
const session = readStoredSession();
|
|
||||||
if (!session) {
|
|
||||||
throw new Error('当前没有可用登录状态');
|
|
||||||
}
|
|
||||||
|
|
||||||
return buildAdminIdentity(session);
|
|
||||||
},
|
|
||||||
getPermissions: async () => [],
|
|
||||||
};
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import test from 'node:test';
|
|
||||||
|
|
||||||
import {
|
|
||||||
buildRequestLineChartModel,
|
|
||||||
buildRequestLineChartXAxisPoints,
|
|
||||||
formatMetricValue,
|
|
||||||
getInviteCodePanelState,
|
|
||||||
parseStorageLimitInput,
|
|
||||||
} from './dashboard-state';
|
|
||||||
|
|
||||||
test('getInviteCodePanelState returns a copyable invite code when summary contains one', () => {
|
|
||||||
assert.deepEqual(
|
|
||||||
getInviteCodePanelState({
|
|
||||||
totalUsers: 12,
|
|
||||||
totalFiles: 34,
|
|
||||||
totalStorageBytes: 0,
|
|
||||||
downloadTrafficBytes: 0,
|
|
||||||
requestCount: 0,
|
|
||||||
transferUsageBytes: 0,
|
|
||||||
offlineTransferStorageBytes: 0,
|
|
||||||
offlineTransferStorageLimitBytes: 0,
|
|
||||||
dailyActiveUsers: [],
|
|
||||||
requestTimeline: [],
|
|
||||||
inviteCode: ' AbCd1234 ',
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
inviteCode: 'AbCd1234',
|
|
||||||
canCopy: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('getInviteCodePanelState falls back to a placeholder when summary has no invite code', () => {
|
|
||||||
assert.deepEqual(
|
|
||||||
getInviteCodePanelState({
|
|
||||||
totalUsers: 12,
|
|
||||||
totalFiles: 34,
|
|
||||||
totalStorageBytes: 0,
|
|
||||||
downloadTrafficBytes: 0,
|
|
||||||
requestCount: 0,
|
|
||||||
transferUsageBytes: 0,
|
|
||||||
offlineTransferStorageBytes: 0,
|
|
||||||
offlineTransferStorageLimitBytes: 0,
|
|
||||||
dailyActiveUsers: [],
|
|
||||||
requestTimeline: [],
|
|
||||||
inviteCode: ' ',
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
inviteCode: '未生成',
|
|
||||||
canCopy: false,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('formatMetricValue formats byte metrics with binary units', () => {
|
|
||||||
assert.equal(formatMetricValue(1536, 'bytes'), '1.5 KB');
|
|
||||||
assert.equal(formatMetricValue(50 * 1024 * 1024 * 1024, 'bytes'), '50 GB');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('formatMetricValue formats count metrics with locale separators', () => {
|
|
||||||
assert.equal(formatMetricValue(1234567, 'count'), '1,234,567');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('parseStorageLimitInput accepts common storage unit inputs', () => {
|
|
||||||
assert.equal(parseStorageLimitInput('20GB'), 20 * 1024 * 1024 * 1024);
|
|
||||||
assert.equal(parseStorageLimitInput('512 mb'), 512 * 1024 * 1024);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('parseStorageLimitInput rejects invalid or non-positive inputs', () => {
|
|
||||||
assert.equal(parseStorageLimitInput('0GB'), null);
|
|
||||||
assert.equal(parseStorageLimitInput('abc'), null);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('buildRequestLineChartModel converts hourly request data into chart coordinates', () => {
|
|
||||||
const model = buildRequestLineChartModel([
|
|
||||||
{ hour: 0, label: '00:00', requestCount: 0 },
|
|
||||||
{ hour: 1, label: '01:00', requestCount: 30 },
|
|
||||||
{ hour: 2, label: '02:00', requestCount: 60 },
|
|
||||||
{ hour: 3, label: '03:00', requestCount: 15 },
|
|
||||||
]);
|
|
||||||
|
|
||||||
assert.equal(model.points.length, 4);
|
|
||||||
assert.equal(model.points[0]?.x, 0);
|
|
||||||
assert.equal(model.points[0]?.y, 100);
|
|
||||||
assert.equal(model.points[2]?.y, 0);
|
|
||||||
assert.equal(model.points[3]?.x, 100);
|
|
||||||
assert.equal(model.maxValue, 60);
|
|
||||||
assert.equal(model.linePath, 'M 0 100 L 33.333 50 L 66.667 0 L 100 75');
|
|
||||||
assert.deepEqual(model.yAxisTicks, [0, 15, 30, 45, 60]);
|
|
||||||
assert.equal(model.peakPoint?.label, '02:00');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('buildRequestLineChartModel stretches only the available hours across the chart width', () => {
|
|
||||||
const model = buildRequestLineChartModel([
|
|
||||||
{ hour: 0, label: '00:00', requestCount: 2 },
|
|
||||||
{ hour: 1, label: '01:00', requestCount: 4 },
|
|
||||||
{ hour: 2, label: '02:00', requestCount: 3 },
|
|
||||||
{ hour: 3, label: '03:00', requestCount: 6 },
|
|
||||||
{ hour: 4, label: '04:00', requestCount: 5 },
|
|
||||||
{ hour: 5, label: '05:00', requestCount: 1 },
|
|
||||||
{ hour: 6, label: '06:00', requestCount: 2 },
|
|
||||||
{ hour: 7, label: '07:00', requestCount: 4 },
|
|
||||||
]);
|
|
||||||
|
|
||||||
assert.equal(model.points[0]?.x, 0);
|
|
||||||
assert.equal(model.points.at(-1)?.x, 100);
|
|
||||||
assert.equal(model.points.length, 8);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('buildRequestLineChartXAxisPoints only shows elapsed-hour labels plus start and end', () => {
|
|
||||||
const model = buildRequestLineChartModel([
|
|
||||||
{ hour: 0, label: '00:00', requestCount: 2 },
|
|
||||||
{ hour: 1, label: '01:00', requestCount: 4 },
|
|
||||||
{ hour: 2, label: '02:00', requestCount: 3 },
|
|
||||||
{ hour: 3, label: '03:00', requestCount: 6 },
|
|
||||||
{ hour: 4, label: '04:00', requestCount: 5 },
|
|
||||||
{ hour: 5, label: '05:00', requestCount: 1 },
|
|
||||||
{ hour: 6, label: '06:00', requestCount: 2 },
|
|
||||||
{ hour: 7, label: '07:00', requestCount: 4 },
|
|
||||||
]);
|
|
||||||
|
|
||||||
assert.deepEqual(
|
|
||||||
buildRequestLineChartXAxisPoints(model.points).map((point) => point.label),
|
|
||||||
['00:00', '06:00', '07:00'],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
import type { AdminRequestTimelinePoint, AdminSummary } from '@/src/lib/types';
|
|
||||||
|
|
||||||
export interface InviteCodePanelState {
|
|
||||||
inviteCode: string;
|
|
||||||
canCopy: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RequestLineChartPoint extends AdminRequestTimelinePoint {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RequestLineChartModel {
|
|
||||||
points: RequestLineChartPoint[];
|
|
||||||
linePath: string;
|
|
||||||
areaPath: string;
|
|
||||||
yAxisTicks: number[];
|
|
||||||
maxValue: number;
|
|
||||||
peakPoint: RequestLineChartPoint | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
type MetricValueKind = 'bytes' | 'count';
|
|
||||||
|
|
||||||
const BYTE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
|
||||||
const REQUEST_CHART_X_AXIS_HOURS = [0, 6, 12, 18, 23];
|
|
||||||
|
|
||||||
export function formatMetricValue(value: number, kind: MetricValueKind): string {
|
|
||||||
if (kind === 'count') {
|
|
||||||
return new Intl.NumberFormat('en-US').format(value);
|
|
||||||
}
|
|
||||||
if (value <= 0) {
|
|
||||||
return '0 B';
|
|
||||||
}
|
|
||||||
|
|
||||||
const unitIndex = Math.min(Math.floor(Math.log(value) / Math.log(1024)), BYTE_UNITS.length - 1);
|
|
||||||
const unitValue = value / 1024 ** unitIndex;
|
|
||||||
const formatted = unitValue >= 10 || unitIndex === 0 ? unitValue.toFixed(0) : unitValue.toFixed(1);
|
|
||||||
return `${formatted} ${BYTE_UNITS[unitIndex]}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseStorageLimitInput(value: string): number | null {
|
|
||||||
const normalized = value.trim().toLowerCase();
|
|
||||||
const matched = normalized.match(/^(\d+(?:\.\d+)?)\s*(b|kb|mb|gb|tb|pb)?$/);
|
|
||||||
if (!matched) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const amount = Number.parseFloat(matched[1] ?? '0');
|
|
||||||
if (!Number.isFinite(amount) || amount <= 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const unit = matched[2] ?? 'b';
|
|
||||||
const multiplier = unit === 'pb'
|
|
||||||
? 1024 ** 5
|
|
||||||
: unit === 'tb'
|
|
||||||
? 1024 ** 4
|
|
||||||
: unit === 'gb'
|
|
||||||
? 1024 ** 3
|
|
||||||
: unit === 'mb'
|
|
||||||
? 1024 ** 2
|
|
||||||
: unit === 'kb'
|
|
||||||
? 1024
|
|
||||||
: 1;
|
|
||||||
return Math.floor(amount * multiplier);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildRequestLineChartModel(timeline: AdminRequestTimelinePoint[]): RequestLineChartModel {
|
|
||||||
if (timeline.length === 0) {
|
|
||||||
return {
|
|
||||||
points: [],
|
|
||||||
linePath: '',
|
|
||||||
areaPath: '',
|
|
||||||
yAxisTicks: [0, 1, 2, 3, 4],
|
|
||||||
maxValue: 0,
|
|
||||||
peakPoint: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxValue = Math.max(...timeline.map((point) => point.requestCount), 0);
|
|
||||||
const scaleMax = maxValue > 0 ? maxValue : 1;
|
|
||||||
const lastIndex = Math.max(timeline.length - 1, 1);
|
|
||||||
const points = timeline.map((point, index) => ({
|
|
||||||
...point,
|
|
||||||
x: roundChartValue((index / lastIndex) * 100),
|
|
||||||
y: roundChartValue(100 - (point.requestCount / scaleMax) * 100),
|
|
||||||
}));
|
|
||||||
const linePath = points
|
|
||||||
.map((point, index) => `${index === 0 ? 'M' : 'L'} ${formatChartNumber(point.x)} ${formatChartNumber(point.y)}`)
|
|
||||||
.join(' ');
|
|
||||||
|
|
||||||
return {
|
|
||||||
points,
|
|
||||||
linePath,
|
|
||||||
areaPath: linePath ? `${linePath} L 100 100 L 0 100 Z` : '',
|
|
||||||
yAxisTicks: buildYAxisTicks(maxValue),
|
|
||||||
maxValue,
|
|
||||||
peakPoint: points.reduce<RequestLineChartPoint | null>((peak, point) => {
|
|
||||||
if (!peak || point.requestCount > peak.requestCount) {
|
|
||||||
return point;
|
|
||||||
}
|
|
||||||
return peak;
|
|
||||||
}, null),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildRequestLineChartXAxisPoints(points: RequestLineChartPoint[]): RequestLineChartPoint[] {
|
|
||||||
if (points.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const firstHour = points[0]?.hour ?? 0;
|
|
||||||
const lastHour = points.at(-1)?.hour ?? firstHour;
|
|
||||||
const visibleHours = new Set<number>([firstHour, lastHour]);
|
|
||||||
for (const hour of REQUEST_CHART_X_AXIS_HOURS) {
|
|
||||||
if (hour > firstHour && hour < lastHour) {
|
|
||||||
visibleHours.add(hour);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return points.filter((point) => visibleHours.has(point.hour));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getInviteCodePanelState(summary: AdminSummary | null | undefined): InviteCodePanelState {
|
|
||||||
const inviteCode = summary?.inviteCode?.trim() ?? '';
|
|
||||||
if (!inviteCode) {
|
|
||||||
return {
|
|
||||||
inviteCode: '未生成',
|
|
||||||
canCopy: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
inviteCode,
|
|
||||||
canCopy: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildYAxisTicks(maxValue: number): number[] {
|
|
||||||
if (maxValue <= 0) {
|
|
||||||
return [0, 1, 2, 3, 4];
|
|
||||||
}
|
|
||||||
return Array.from({ length: 5 }, (_, index) => roundChartValue((maxValue / 4) * index));
|
|
||||||
}
|
|
||||||
|
|
||||||
function roundChartValue(value: number): number {
|
|
||||||
return Math.round(value * 1000) / 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatChartNumber(value: number): string {
|
|
||||||
const rounded = roundChartValue(value);
|
|
||||||
return Number.isInteger(rounded) ? `${rounded}` : rounded.toFixed(3).replace(/0+$/, '').replace(/\.$/, '');
|
|
||||||
}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import test from 'node:test';
|
|
||||||
|
|
||||||
import type { AdminFile, PageResponse } from '@/src/lib/types';
|
|
||||||
|
|
||||||
import {
|
|
||||||
buildAdminListPath,
|
|
||||||
buildFilesListPath,
|
|
||||||
buildStoragePoliciesListPath,
|
|
||||||
mapFilesListResponse,
|
|
||||||
} from './data-provider';
|
|
||||||
|
|
||||||
test('buildFilesListPath maps react-admin pagination to the backend files list query', () => {
|
|
||||||
assert.equal(
|
|
||||||
buildFilesListPath({
|
|
||||||
pagination: {
|
|
||||||
page: 3,
|
|
||||||
perPage: 25,
|
|
||||||
},
|
|
||||||
filter: {},
|
|
||||||
}),
|
|
||||||
'/admin/files?page=2&size=25',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('buildFilesListPath includes file and owner search filters when present', () => {
|
|
||||||
assert.equal(
|
|
||||||
buildFilesListPath({
|
|
||||||
pagination: {
|
|
||||||
page: 1,
|
|
||||||
perPage: 25,
|
|
||||||
},
|
|
||||||
filter: {
|
|
||||||
query: 'report',
|
|
||||||
ownerQuery: 'alice',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
'/admin/files?page=0&size=25&query=report&ownerQuery=alice',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('mapFilesListResponse preserves list items and total count', () => {
|
|
||||||
const payload: PageResponse<AdminFile> = {
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
filename: 'hello.txt',
|
|
||||||
path: '/',
|
|
||||||
size: 12,
|
|
||||||
contentType: 'text/plain',
|
|
||||||
directory: false,
|
|
||||||
createdAt: '2026-03-19T15:00:00',
|
|
||||||
ownerId: 7,
|
|
||||||
ownerUsername: 'alice',
|
|
||||||
ownerEmail: 'alice@example.com',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
total: 1,
|
|
||||||
page: 0,
|
|
||||||
size: 25,
|
|
||||||
};
|
|
||||||
|
|
||||||
assert.deepEqual(mapFilesListResponse(payload), {
|
|
||||||
data: payload.items,
|
|
||||||
total: 1,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('buildAdminListPath maps generic admin resources to backend paging queries', () => {
|
|
||||||
assert.equal(
|
|
||||||
buildAdminListPath('users', {
|
|
||||||
pagination: {
|
|
||||||
page: 2,
|
|
||||||
perPage: 20,
|
|
||||||
},
|
|
||||||
filter: {},
|
|
||||||
}),
|
|
||||||
'/admin/users?page=1&size=20',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('buildAdminListPath includes the user search query when present', () => {
|
|
||||||
assert.equal(
|
|
||||||
buildAdminListPath('users', {
|
|
||||||
pagination: {
|
|
||||||
page: 1,
|
|
||||||
perPage: 25,
|
|
||||||
},
|
|
||||||
filter: {
|
|
||||||
query: 'alice',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
'/admin/users?page=0&size=25&query=alice',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('buildAdminListPath rejects the removed school snapshots resource', () => {
|
|
||||||
assert.throws(
|
|
||||||
() =>
|
|
||||||
buildAdminListPath('schoolSnapshots', {
|
|
||||||
pagination: {
|
|
||||||
page: 1,
|
|
||||||
perPage: 50,
|
|
||||||
},
|
|
||||||
filter: {},
|
|
||||||
}),
|
|
||||||
/schoolSnapshots/,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('buildStoragePoliciesListPath maps react-admin pagination to the backend storage policies list query', () => {
|
|
||||||
assert.equal(
|
|
||||||
buildStoragePoliciesListPath({
|
|
||||||
pagination: {
|
|
||||||
page: 2,
|
|
||||||
perPage: 10,
|
|
||||||
},
|
|
||||||
filter: {},
|
|
||||||
}),
|
|
||||||
'/admin/storage-policies?page=1&size=10',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
import type { DataProvider, GetListParams, GetListResult, Identifier } from 'react-admin';
|
|
||||||
|
|
||||||
import { apiRequest } from '@/src/lib/api';
|
|
||||||
import type {
|
|
||||||
AdminFile,
|
|
||||||
AdminStoragePolicy,
|
|
||||||
AdminUser,
|
|
||||||
PageResponse,
|
|
||||||
} from '@/src/lib/types';
|
|
||||||
|
|
||||||
const FILES_RESOURCE = 'files';
|
|
||||||
const STORAGE_POLICIES_RESOURCE = 'storagePolicies';
|
|
||||||
const USERS_RESOURCE = 'users';
|
|
||||||
|
|
||||||
function createUnsupportedError(resource: string, action: string) {
|
|
||||||
return new Error(`当前管理台暂未为资源 "${resource}" 实现 ${action} 操作`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureSupportedResource(resource: string, action: string) {
|
|
||||||
if (![FILES_RESOURCE, STORAGE_POLICIES_RESOURCE, USERS_RESOURCE].includes(resource)) {
|
|
||||||
throw createUnsupportedError(resource, action);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeFilterValue(value: unknown) {
|
|
||||||
return typeof value === 'string' ? value.trim() : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildAdminListPath(resource: string, params: Pick<GetListParams, 'pagination' | 'filter'>) {
|
|
||||||
const page = Math.max(0, params.pagination.page - 1);
|
|
||||||
const size = Math.max(1, params.pagination.perPage);
|
|
||||||
const query = normalizeFilterValue(params.filter?.query);
|
|
||||||
|
|
||||||
if (resource === USERS_RESOURCE) {
|
|
||||||
return `/admin/users?page=${page}&size=${size}${query ? `&query=${encodeURIComponent(query)}` : ''}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw createUnsupportedError(resource, 'list');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildFilesListPath(params: Pick<GetListParams, 'pagination' | 'filter'>) {
|
|
||||||
const page = Math.max(0, params.pagination.page - 1);
|
|
||||||
const size = Math.max(1, params.pagination.perPage);
|
|
||||||
const query = normalizeFilterValue(params.filter?.query);
|
|
||||||
const ownerQuery = normalizeFilterValue(params.filter?.ownerQuery);
|
|
||||||
const search = new URLSearchParams({
|
|
||||||
page: String(page),
|
|
||||||
size: String(size),
|
|
||||||
});
|
|
||||||
if (query) {
|
|
||||||
search.set('query', query);
|
|
||||||
}
|
|
||||||
if (ownerQuery) {
|
|
||||||
search.set('ownerQuery', ownerQuery);
|
|
||||||
}
|
|
||||||
return `/admin/files?${search.toString()}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildStoragePoliciesListPath(params: Pick<GetListParams, 'pagination' | 'filter'>) {
|
|
||||||
const page = Math.max(0, params.pagination.page - 1);
|
|
||||||
const size = Math.max(1, params.pagination.perPage);
|
|
||||||
return `/admin/storage-policies?page=${page}&size=${size}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mapFilesListResponse(
|
|
||||||
payload: PageResponse<AdminFile>,
|
|
||||||
): GetListResult<AdminFile> {
|
|
||||||
return {
|
|
||||||
data: payload.items,
|
|
||||||
total: payload.total,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteFile(id: Identifier) {
|
|
||||||
await apiRequest(`/admin/files/${id}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export const portalAdminDataProvider: DataProvider = {
|
|
||||||
getList: async (resource, params) => {
|
|
||||||
ensureSupportedResource(resource, 'list');
|
|
||||||
|
|
||||||
if (resource === FILES_RESOURCE) {
|
|
||||||
const payload = await apiRequest<PageResponse<AdminFile>>(buildFilesListPath(params));
|
|
||||||
return mapFilesListResponse(payload) as GetListResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (resource === USERS_RESOURCE) {
|
|
||||||
const payload = await apiRequest<PageResponse<AdminUser>>(buildAdminListPath(resource, params));
|
|
||||||
return {
|
|
||||||
data: payload.items,
|
|
||||||
total: payload.total,
|
|
||||||
} as GetListResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (resource === STORAGE_POLICIES_RESOURCE) {
|
|
||||||
const payload = await apiRequest<AdminStoragePolicy[]>(buildStoragePoliciesListPath(params));
|
|
||||||
return {
|
|
||||||
data: payload,
|
|
||||||
total: payload.length,
|
|
||||||
} as GetListResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw createUnsupportedError(resource, 'list');
|
|
||||||
},
|
|
||||||
getOne: async (resource) => {
|
|
||||||
ensureSupportedResource(resource, 'getOne');
|
|
||||||
throw createUnsupportedError(resource, 'getOne');
|
|
||||||
},
|
|
||||||
getMany: async (resource) => {
|
|
||||||
ensureSupportedResource(resource, 'getMany');
|
|
||||||
throw createUnsupportedError(resource, 'getMany');
|
|
||||||
},
|
|
||||||
getManyReference: async (resource) => {
|
|
||||||
ensureSupportedResource(resource, 'getManyReference');
|
|
||||||
throw createUnsupportedError(resource, 'getManyReference');
|
|
||||||
},
|
|
||||||
update: async (resource) => {
|
|
||||||
ensureSupportedResource(resource, 'update');
|
|
||||||
throw createUnsupportedError(resource, 'update');
|
|
||||||
},
|
|
||||||
updateMany: async (resource) => {
|
|
||||||
ensureSupportedResource(resource, 'updateMany');
|
|
||||||
throw createUnsupportedError(resource, 'updateMany');
|
|
||||||
},
|
|
||||||
create: async (resource) => {
|
|
||||||
ensureSupportedResource(resource, 'create');
|
|
||||||
throw createUnsupportedError(resource, 'create');
|
|
||||||
},
|
|
||||||
delete: async (resource, params) => {
|
|
||||||
if (resource !== FILES_RESOURCE) {
|
|
||||||
throw createUnsupportedError(resource, 'delete');
|
|
||||||
}
|
|
||||||
await deleteFile(params.id);
|
|
||||||
const fallbackRecord = { id: params.id } as typeof params.previousData;
|
|
||||||
return {
|
|
||||||
data: (params.previousData ?? fallbackRecord) as typeof params.previousData,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
deleteMany: async (resource, params) => {
|
|
||||||
if (resource !== FILES_RESOURCE) {
|
|
||||||
throw createUnsupportedError(resource, 'deleteMany');
|
|
||||||
}
|
|
||||||
await Promise.all(params.ids.map((id) => deleteFile(id)));
|
|
||||||
return {
|
|
||||||
data: params.ids,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,69 +1,181 @@
|
|||||||
import { Chip } from '@mui/material';
|
import { useEffect, useState } from 'react';
|
||||||
import {
|
import { RefreshCw, Search, Trash2, Folder, FileText, ChevronRight } from 'lucide-react';
|
||||||
Datagrid,
|
import { motion } from 'motion/react';
|
||||||
DateField,
|
import { cn } from '@/src/lib/utils';
|
||||||
DeleteWithConfirmButton,
|
import { deleteAdminFile, listAdminFiles, type AdminFile } from '@/src/lib/admin';
|
||||||
FunctionField,
|
import { formatBytes, formatDateTime } from '@/src/lib/format';
|
||||||
List,
|
|
||||||
RefreshButton,
|
|
||||||
SearchInput,
|
|
||||||
TextField,
|
|
||||||
TopToolbar,
|
|
||||||
} from 'react-admin';
|
|
||||||
|
|
||||||
import type { AdminFile } from '@/src/lib/types';
|
const container = {
|
||||||
|
hidden: { opacity: 0 },
|
||||||
function FilesListActions() {
|
show: {
|
||||||
return (
|
opacity: 1,
|
||||||
<TopToolbar>
|
transition: {
|
||||||
<RefreshButton />
|
staggerChildren: 0.05
|
||||||
</TopToolbar>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatFileSize(size: number) {
|
|
||||||
if (size >= 1024 * 1024) {
|
|
||||||
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
|
||||||
}
|
}
|
||||||
if (size >= 1024) {
|
|
||||||
return `${(size / 1024).toFixed(1)} KB`;
|
|
||||||
}
|
}
|
||||||
return `${size} B`;
|
};
|
||||||
}
|
|
||||||
|
const itemVariants = {
|
||||||
|
hidden: { y: 10, opacity: 0 },
|
||||||
|
show: { y: 0, opacity: 1 }
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AdminFilesList() {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [ownerQuery, setOwnerQuery] = useState('');
|
||||||
|
const [files, setFiles] = useState<AdminFile[]>([]);
|
||||||
|
|
||||||
|
async function loadFiles(nextQuery = query, nextOwnerQuery = ownerQuery) {
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const result = await listAdminFiles(0, 100, nextQuery, nextOwnerQuery);
|
||||||
|
setFiles(result.items);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : '加载文件失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadFiles();
|
||||||
|
}, []);
|
||||||
|
|
||||||
export function PortalAdminFilesList() {
|
|
||||||
return (
|
return (
|
||||||
<List
|
<motion.div
|
||||||
actions={<FilesListActions />}
|
initial={{ opacity: 0 }}
|
||||||
filters={[
|
animate={{ opacity: 1 }}
|
||||||
<SearchInput key="query" source="query" alwaysOn placeholder="搜索文件名或路径" />,
|
exit={{ opacity: 0 }}
|
||||||
<SearchInput key="ownerQuery" source="ownerQuery" placeholder="搜索所属用户" />,
|
className="flex h-full flex-col p-8 text-gray-900 dark:text-gray-100 overflow-y-auto"
|
||||||
]}
|
|
||||||
perPage={25}
|
|
||||||
resource="files"
|
|
||||||
title="文件管理"
|
|
||||||
sort={{ field: 'createdAt', order: 'DESC' }}
|
|
||||||
>
|
>
|
||||||
<Datagrid bulkActionButtons={false} rowClick={false}>
|
<div className="mb-10 flex items-center justify-between">
|
||||||
<TextField source="id" label="ID" />
|
<div>
|
||||||
<TextField source="filename" label="文件名" />
|
<h1 className="text-4xl font-black tracking-tight animate-text-reveal text-gray-900 dark:text-white">全站审计</h1>
|
||||||
<TextField source="path" label="路径" />
|
<p className="mt-3 text-[10px] font-black uppercase tracking-[0.2em] opacity-40">全站对象索引 / 审计日志</p>
|
||||||
<TextField source="ownerUsername" label="所属用户" />
|
</div>
|
||||||
<TextField source="ownerEmail" label="用户邮箱" />
|
<button
|
||||||
<FunctionField<AdminFile>
|
type="button"
|
||||||
label="类型"
|
onClick={() => {
|
||||||
render={(record) =>
|
setLoading(true);
|
||||||
record.directory ? <Chip label="目录" size="small" /> : <Chip label="文件" size="small" variant="outlined" />
|
void loadFiles();
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-3 px-6 py-3 rounded-lg glass-panel hover:bg-white/40 transition-all font-black text-[11px] uppercase tracking-widest"
|
||||||
|
>
|
||||||
|
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
|
||||||
|
刷新索引
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-10 grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||||
|
<div className="relative group">
|
||||||
|
<Search className="absolute left-5 top-1/2 h-4 w-4 -translate-y-1/2 opacity-30 group-focus-within:text-blue-500 transition-colors" />
|
||||||
|
<input
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
setLoading(true);
|
||||||
|
void loadFiles(event.currentTarget.value, ownerQuery);
|
||||||
}
|
}
|
||||||
|
}}
|
||||||
|
placeholder="搜索文件名或路径...(回车)"
|
||||||
|
className="w-full rounded-lg glass-panel bg-white/10 py-5 pl-14 pr-6 outline-none border border-white/10 focus:border-blue-500/50 focus:ring-4 focus:ring-blue-500/10 transition-all font-black text-[11px] uppercase tracking-widest placeholder:opacity-20"
|
||||||
/>
|
/>
|
||||||
<FunctionField<AdminFile>
|
</div>
|
||||||
label="大小"
|
<div className="relative group">
|
||||||
render={(record) => (record.directory ? '-' : formatFileSize(record.size))}
|
<Search className="absolute left-5 top-1/2 h-4 w-4 -translate-y-1/2 opacity-30 group-focus-within:text-blue-500 transition-colors" />
|
||||||
|
<input
|
||||||
|
value={ownerQuery}
|
||||||
|
onChange={(event) => setOwnerQuery(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
setLoading(true);
|
||||||
|
void loadFiles(query, event.currentTarget.value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="搜索所属用户...(回车)"
|
||||||
|
className="w-full rounded-lg glass-panel bg-white/10 py-5 pl-14 pr-6 outline-none border border-white/10 focus:border-blue-500/50 focus:ring-4 focus:ring-blue-500/10 transition-all font-black text-[11px] uppercase tracking-widest placeholder:opacity-20"
|
||||||
/>
|
/>
|
||||||
<TextField source="contentType" label="Content-Type" emptyText="-" />
|
</div>
|
||||||
<DateField source="createdAt" label="创建时间" showTime />
|
</div>
|
||||||
<DeleteWithConfirmButton mutationMode="pessimistic" label="删除" confirmTitle="删除文件" confirmContent="确认删除该文件吗?" />
|
|
||||||
</Datagrid>
|
{error ? <div className="mb-8 rounded-lg bg-red-500/10 border border-red-500/20 px-6 py-4 text-xs text-red-600 font-bold backdrop-blur-md uppercase tracking-widest">{error}</div> : null}
|
||||||
</List>
|
|
||||||
|
<div className="flex-1 min-h-0">
|
||||||
|
{loading && files.length === 0 ? (
|
||||||
|
<div className="glass-panel-no-hover rounded-lg px-4 py-16 text-center text-[10px] font-black uppercase tracking-widest opacity-40">正在扫描全站文件...</div>
|
||||||
|
) : (
|
||||||
|
<div className="glass-panel-no-hover rounded-lg overflow-hidden shadow-3xl border border-white/10">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-white/10">
|
||||||
|
<thead className="bg-white/10 dark:bg-black/40">
|
||||||
|
<tr>
|
||||||
|
<th className="px-8 py-5 text-left text-[9px] font-black uppercase tracking-[0.2em] opacity-40">文件</th>
|
||||||
|
<th className="px-8 py-5 text-left text-[9px] font-black uppercase tracking-[0.2em] opacity-40">所属用户</th>
|
||||||
|
<th className="px-8 py-5 text-left text-[9px] font-black uppercase tracking-[0.2em] opacity-40">大小</th>
|
||||||
|
<th className="px-8 py-5 text-left text-[9px] font-black uppercase tracking-[0.2em] opacity-40">创建时间</th>
|
||||||
|
<th className="px-8 py-5 text-right text-[9px] font-black uppercase tracking-[0.2em] opacity-40">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<motion.tbody
|
||||||
|
variants={container}
|
||||||
|
initial="hidden"
|
||||||
|
animate="show"
|
||||||
|
className="divide-y divide-white/10 dark:divide-white/5"
|
||||||
|
>
|
||||||
|
{files.map((file) => (
|
||||||
|
<motion.tr key={file.id} variants={itemVariants} className="hover:bg-white/10 dark:hover:bg-white/5 transition-colors group">
|
||||||
|
<td className="px-8 py-5">
|
||||||
|
<div className="text-[12px] font-black tracking-tight uppercase group-hover:text-blue-500 transition-colors uppercase">{file.filename}</div>
|
||||||
|
<div className="mt-1 text-[9px] opacity-30 font-black uppercase tracking-widest truncate max-w-xs">{file.path}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-8 py-5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[10px] font-black text-blue-500 uppercase tracking-widest">{file.ownerUsername || file.ownerEmail}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-8 py-5">
|
||||||
|
<div className="text-[11px] font-black uppercase tracking-widest">{file.directory ? '-' : formatBytes(file.size)}</div>
|
||||||
|
<div className="text-[9px] opacity-20 font-black tracking-[0.2em] uppercase mt-1">
|
||||||
|
{file.directory ? '目录' : '文件'}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-8 py-5 text-[10px] font-bold opacity-30 tracking-tighter uppercase">
|
||||||
|
{formatDateTime(file.createdAt)}
|
||||||
|
</td>
|
||||||
|
<td className="px-8 py-5 text-right">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={async () => {
|
||||||
|
if (!window.confirm(`确认物理擦除 ${file.filename} 吗?此操作将触发硬件级销毁。`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await deleteAdminFile(file.id);
|
||||||
|
await loadFiles();
|
||||||
|
}}
|
||||||
|
className="p-2.5 rounded-lg glass-panel hover:bg-red-600 hover:text-white text-red-500 border border-white/10 transition-all opacity-0 group-hover:opacity-100 shadow-sm"
|
||||||
|
title="彻底删除"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</motion.tr>
|
||||||
|
))}
|
||||||
|
{files.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="px-8 py-20 text-center text-[10px] font-black uppercase tracking-widest opacity-30">
|
||||||
|
没有匹配的文件
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</motion.tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,98 +1,384 @@
|
|||||||
import { Chip, Stack } from '@mui/material';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { ArrowRightLeft, Edit2, Play, Plus, RefreshCw, Square } from 'lucide-react';
|
||||||
|
import { motion, AnimatePresence } from 'motion/react';
|
||||||
import {
|
import {
|
||||||
BooleanField,
|
createStorageMigration,
|
||||||
Datagrid,
|
createStoragePolicy,
|
||||||
DateField,
|
getStoragePolicies,
|
||||||
FunctionField,
|
updateStoragePolicy,
|
||||||
List,
|
updateStoragePolicyStatus,
|
||||||
RefreshButton,
|
type AdminStoragePolicy,
|
||||||
TextField,
|
type StoragePolicyCapabilities,
|
||||||
TopToolbar,
|
type StoragePolicyUpsertPayload,
|
||||||
} from 'react-admin';
|
} from '@/src/lib/admin-storage-policies';
|
||||||
|
import { formatBytes } from '@/src/lib/format';
|
||||||
|
import { cn } from '@/src/lib/utils';
|
||||||
|
|
||||||
import type { AdminStoragePolicy, StoragePolicyCapabilities } from '@/src/lib/types';
|
function createDefaultCapabilities(maxObjectSize = 1024 * 1024 * 1024): StoragePolicyCapabilities {
|
||||||
|
return {
|
||||||
const CAPABILITY_LABELS: Array<{ key: keyof StoragePolicyCapabilities; label: string }> = [
|
directUpload: false,
|
||||||
{ key: 'directUpload', label: '直传' },
|
multipartUpload: false,
|
||||||
{ key: 'multipartUpload', label: '分片' },
|
signedDownloadUrl: false,
|
||||||
{ key: 'signedDownloadUrl', label: '签名下载' },
|
serverProxyDownload: true,
|
||||||
{ key: 'serverProxyDownload', label: '服务端下载' },
|
thumbnailNative: false,
|
||||||
{ key: 'thumbnailNative', label: '原生缩略图' },
|
friendlyDownloadName: false,
|
||||||
{ key: 'friendlyDownloadName', label: '友好文件名' },
|
requiresCors: false,
|
||||||
{ key: 'requiresCors', label: 'CORS' },
|
supportsInternalEndpoint: false,
|
||||||
{ key: 'supportsInternalEndpoint', label: '内网 endpoint' },
|
maxObjectSize,
|
||||||
];
|
};
|
||||||
|
|
||||||
function StoragePoliciesListActions() {
|
|
||||||
return (
|
|
||||||
<TopToolbar>
|
|
||||||
<RefreshButton />
|
|
||||||
</TopToolbar>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatFileSize(size: number) {
|
function buildInitialForm(policy?: AdminStoragePolicy): StoragePolicyUpsertPayload {
|
||||||
if (size >= 1024 * 1024 * 1024) {
|
if (policy) {
|
||||||
return `${(size / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
return {
|
||||||
|
name: policy.name,
|
||||||
|
type: policy.type,
|
||||||
|
bucketName: policy.bucketName ?? '',
|
||||||
|
endpoint: policy.endpoint ?? '',
|
||||||
|
region: policy.region ?? '',
|
||||||
|
privateBucket: policy.privateBucket,
|
||||||
|
prefix: policy.prefix ?? '',
|
||||||
|
credentialMode: policy.credentialMode,
|
||||||
|
maxSizeBytes: policy.maxSizeBytes,
|
||||||
|
capabilities: policy.capabilities,
|
||||||
|
enabled: policy.enabled,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
if (size >= 1024 * 1024) {
|
|
||||||
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
return {
|
||||||
}
|
name: '',
|
||||||
if (size >= 1024) {
|
type: 'LOCAL',
|
||||||
return `${(size / 1024).toFixed(1)} KB`;
|
bucketName: '',
|
||||||
}
|
endpoint: '',
|
||||||
return `${size} B`;
|
region: '',
|
||||||
|
privateBucket: false,
|
||||||
|
prefix: '',
|
||||||
|
credentialMode: 'NONE',
|
||||||
|
maxSizeBytes: 1024 * 1024 * 1024,
|
||||||
|
capabilities: createDefaultCapabilities(),
|
||||||
|
enabled: true,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderCapabilities(capabilities: StoragePolicyCapabilities) {
|
export default function AdminStoragePoliciesList() {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [policies, setPolicies] = useState<AdminStoragePolicy[]>([]);
|
||||||
|
const [editingPolicy, setEditingPolicy] = useState<AdminStoragePolicy | null>(null);
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [form, setForm] = useState<StoragePolicyUpsertPayload>(buildInitialForm());
|
||||||
|
|
||||||
|
async function loadPolicies() {
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
setPolicies(await getStoragePolicies());
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : '加载存储策略失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadPolicies();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function savePolicy() {
|
||||||
|
try {
|
||||||
|
if (editingPolicy) {
|
||||||
|
await updateStoragePolicy(editingPolicy.id, form);
|
||||||
|
} else {
|
||||||
|
await createStoragePolicy(form);
|
||||||
|
}
|
||||||
|
setShowForm(false);
|
||||||
|
setEditingPolicy(null);
|
||||||
|
setForm(buildInitialForm());
|
||||||
|
await loadPolicies();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : '保存策略失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack direction="row" flexWrap="wrap" gap={0.5}>
|
<motion.div
|
||||||
{CAPABILITY_LABELS.map(({ key, label }) => {
|
initial={{ opacity: 0 }}
|
||||||
const enabled = capabilities[key] === true;
|
animate={{ opacity: 1 }}
|
||||||
return (
|
exit={{ opacity: 0 }}
|
||||||
<Chip
|
className="flex h-full flex-col p-8 text-gray-900 dark:text-gray-100 overflow-y-auto"
|
||||||
key={key}
|
>
|
||||||
color={enabled ? 'success' : 'default'}
|
<div className="mb-10 flex flex-wrap items-center justify-between gap-4">
|
||||||
label={`${label}${enabled ? '开' : '关'}`}
|
<div>
|
||||||
size="small"
|
<h1 className="text-4xl font-black tracking-tight animate-text-reveal">存储策略</h1>
|
||||||
variant={enabled ? 'filled' : 'outlined'}
|
<p className="mt-3 text-[10px] font-black uppercase tracking-[0.2em] opacity-40">资源分发与存储节点映射</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setLoading(true);
|
||||||
|
void loadPolicies();
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 px-5 py-3 rounded-lg glass-panel hover:bg-white/40 transition-all font-black text-[11px] uppercase tracking-widest"
|
||||||
|
>
|
||||||
|
<RefreshCw className={cn("h-3.5 w-3.5", loading && "animate-spin")} />
|
||||||
|
刷新
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingPolicy(null);
|
||||||
|
setForm(buildInitialForm());
|
||||||
|
setShowForm(true);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 px-6 py-3 rounded-lg bg-blue-600 text-white font-black text-[11px] uppercase tracking-[0.15em] shadow-lg hover:bg-blue-500 hover:scale-[1.02] active:scale-[0.98] transition-all"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
新建策略
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? <div className="mb-8 rounded-lg bg-red-500/10 border border-red-500/20 px-6 py-4 text-xs text-red-600 dark:text-red-400 font-bold backdrop-blur-md">{error}</div> : null}
|
||||||
|
|
||||||
|
<div className="flex-1 min-h-0">
|
||||||
|
{loading ? (
|
||||||
|
<div className="glass-panel rounded-lg px-4 py-16 text-center text-[10px] font-black uppercase tracking-widest opacity-40">正在读取存储策略...</div>
|
||||||
|
) : (
|
||||||
|
<div className="glass-panel rounded-lg overflow-hidden shadow-xl border-white/20">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-white/10 text-sm">
|
||||||
|
<thead className="bg-white/10 dark:bg-black/40 font-black uppercase tracking-[0.15em] text-[9px] opacity-40">
|
||||||
|
<tr>
|
||||||
|
<th className="px-8 py-5 text-left">名称</th>
|
||||||
|
<th className="px-8 py-5 text-left">后端类型</th>
|
||||||
|
<th className="px-8 py-5 text-left">访问端点</th>
|
||||||
|
<th className="px-8 py-5 text-left">状态</th>
|
||||||
|
<th className="px-8 py-5 text-left">对象上限</th>
|
||||||
|
<th className="px-8 py-5 text-right">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-white/10 dark:divide-white/5">
|
||||||
|
{policies.map((policy) => (
|
||||||
|
<tr key={policy.id} className="hover:bg-white/10 dark:hover:bg-white/5 transition-colors group">
|
||||||
|
<td className="px-8 py-5">
|
||||||
|
<div className="flex items-center gap-2 font-black text-[13px] tracking-tight">
|
||||||
|
{policy.name}
|
||||||
|
{policy.defaultPolicy ? (
|
||||||
|
<span className="rounded-sm bg-blue-500/20 text-blue-600 dark:text-blue-400 px-1.5 py-0.5 text-[8px] border border-blue-500/20 uppercase tracking-widest font-black">默认</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] font-bold opacity-30 mt-1 tracking-tighter">PID::{policy.id}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-8 py-5">
|
||||||
|
<span className="font-black text-[10px] uppercase tracking-widest opacity-60 bg-white/10 px-2 py-0.5 rounded-sm">{policy.type}</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-8 py-5">
|
||||||
|
<div className="truncate max-w-[180px] font-bold opacity-60 text-[11px] tracking-tight">{policy.endpoint || '-'}</div>
|
||||||
|
<div className="text-[9px] font-black text-blue-500 uppercase tracking-tighter mt-0.5">{policy.bucketName || '私有根路径'}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-8 py-5">
|
||||||
|
<span className={cn(
|
||||||
|
"inline-flex items-center gap-1.5 rounded-sm px-2 py-1 text-[9px] font-black uppercase tracking-widest border",
|
||||||
|
policy.enabled
|
||||||
|
? "bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/20"
|
||||||
|
: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20"
|
||||||
|
)}>
|
||||||
|
<span className={cn("w-1.5 h-1.5 rounded-full", policy.enabled ? "bg-green-500 animate-pulse" : "bg-red-500")}></span>
|
||||||
|
{policy.enabled ? '启用' : '停用'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-8 py-5 font-black opacity-60 text-xs tracking-tighter">
|
||||||
|
{formatBytes(policy.maxSizeBytes)}
|
||||||
|
</td>
|
||||||
|
<td className="px-8 py-5 text-right">
|
||||||
|
<div className="flex justify-end gap-2.5 opacity-40 group-hover:opacity-100 transition-opacity">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingPolicy(policy);
|
||||||
|
setForm(buildInitialForm(policy));
|
||||||
|
setShowForm(true);
|
||||||
|
}}
|
||||||
|
className="p-2 rounded-lg glass-panel hover:bg-white/40 text-gray-500 border-white/20 transition-all"
|
||||||
|
title="编辑策略"
|
||||||
|
>
|
||||||
|
<Edit2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
{!policy.defaultPolicy ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={async () => {
|
||||||
|
await updateStoragePolicyStatus(policy.id, !policy.enabled);
|
||||||
|
await loadPolicies();
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"p-2 rounded-lg glass-panel border-white/20 transition-all",
|
||||||
|
policy.enabled ? "text-amber-500 hover:bg-amber-500/10" : "text-green-500 hover:bg-green-500/10"
|
||||||
|
)}
|
||||||
|
title={policy.enabled ? '停用' : '启用'}
|
||||||
|
>
|
||||||
|
{policy.enabled ? <Square className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={async () => {
|
||||||
|
const targetId = window.prompt('请输入迁移目标策略 ID:');
|
||||||
|
if (!targetId) return;
|
||||||
|
await createStorageMigration(policy.id, Number(targetId));
|
||||||
|
window.alert('已创建迁移任务');
|
||||||
|
}}
|
||||||
|
className="p-2 rounded-lg glass-panel hover:bg-blue-500/10 text-blue-500 border-white/20 transition-all"
|
||||||
|
title="发起迁移"
|
||||||
|
>
|
||||||
|
<ArrowRightLeft className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showForm ? (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-md px-4 py-8 overflow-y-auto mt-0">
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.95, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
className="w-full max-w-2xl glass-panel-no-hover rounded-lg p-12 shadow-2xl border-white/20"
|
||||||
|
>
|
||||||
|
<h2 className="mb-10 text-3xl font-black tracking-tighter uppercase">{editingPolicy ? '编辑策略' : '新建策略'}</h2>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] font-black uppercase tracking-[0.2em] opacity-40 ml-1">策略名称</label>
|
||||||
|
<input
|
||||||
|
value={form.name}
|
||||||
|
onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))}
|
||||||
|
className="w-full rounded-lg glass-panel bg-white/10 p-4 outline-none border-white/10 focus:border-blue-500/50 transition-all font-bold text-sm"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] font-black uppercase tracking-[0.2em] opacity-40 ml-1">驱动协议</label>
|
||||||
|
<select
|
||||||
|
value={form.type}
|
||||||
|
onChange={(event) => setForm((current) => ({ ...current, type: event.target.value as StoragePolicyUpsertPayload['type'] }))}
|
||||||
|
className="w-full rounded-lg glass-panel bg-white/10 p-4 outline-none border-white/10 focus:border-blue-500/50 transition-all font-bold text-sm"
|
||||||
|
>
|
||||||
|
<option value="LOCAL">本地文件系统</option>
|
||||||
|
<option value="S3_COMPATIBLE">S3 兼容接口</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 md:col-span-2">
|
||||||
|
<label className="text-[10px] font-black uppercase tracking-[0.2em] opacity-40 ml-1">端点地址</label>
|
||||||
|
<input
|
||||||
|
value={form.endpoint || ''}
|
||||||
|
onChange={(event) => setForm((current) => ({ ...current, endpoint: event.target.value }))}
|
||||||
|
className="w-full rounded-lg glass-panel bg-white/10 p-4 outline-none border-white/10 focus:border-blue-500/50 transition-all font-bold text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] font-black uppercase tracking-[0.2em] opacity-40 ml-1">桶名称</label>
|
||||||
|
<input
|
||||||
|
value={form.bucketName || ''}
|
||||||
|
onChange={(event) => setForm((current) => ({ ...current, bucketName: event.target.value }))}
|
||||||
|
className="w-full rounded-lg glass-panel bg-white/10 p-4 outline-none border-white/10 focus:border-blue-500/50 transition-all font-bold text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] font-black uppercase tracking-[0.2em] opacity-40 ml-1">对象大小上限(字节)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={form.maxSizeBytes}
|
||||||
|
onChange={(event) => setForm((current) => ({ ...current, maxSizeBytes: Number(event.target.value), capabilities: { ...current.capabilities, maxObjectSize: Number(event.target.value) } }))}
|
||||||
|
className="w-full rounded-lg glass-panel bg-white/10 p-4 outline-none border-white/10 focus:border-blue-500/50 transition-all font-bold text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-10 grid grid-cols-2 gap-4 text-[9px] font-black uppercase tracking-widest md:grid-cols-4">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
['privateBucket', '私有桶'],
|
||||||
|
['enabled', '启用'],
|
||||||
|
['capabilities.directUpload', '直传'],
|
||||||
|
['capabilities.multipartUpload', '分片上传'],
|
||||||
|
['capabilities.signedDownloadUrl', '签名下载'],
|
||||||
|
['capabilities.serverProxyDownload', '代理下载'],
|
||||||
|
['capabilities.requiresCors', '需要 CORS'],
|
||||||
|
['capabilities.supportsInternalEndpoint', '内网端点'],
|
||||||
|
] as const
|
||||||
|
).map(([key, label]) => {
|
||||||
|
const checked =
|
||||||
|
key === 'privateBucket'
|
||||||
|
? form.privateBucket
|
||||||
|
: key === 'enabled'
|
||||||
|
? form.enabled
|
||||||
|
: form.capabilities[key.replace('capabilities.', '') as keyof StoragePolicyCapabilities];
|
||||||
|
const checkedBoolean = Boolean(checked);
|
||||||
|
return (
|
||||||
|
<label key={key} className={cn(
|
||||||
|
"flex items-center gap-3 p-3 rounded-lg hover:bg-white/10 transition-all cursor-pointer border border-transparent group",
|
||||||
|
checkedBoolean ? "bg-white/5 border-white/10" : "opacity-30"
|
||||||
|
)}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checkedBoolean}
|
||||||
|
onChange={(event) => {
|
||||||
|
const nextValue = event.target.checked;
|
||||||
|
if (key === 'privateBucket') {
|
||||||
|
setForm((current) => ({ ...current, privateBucket: nextValue }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key === 'enabled') {
|
||||||
|
setForm((current) => ({ ...current, enabled: nextValue }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const capabilityKey = key.replace('capabilities.', '') as keyof StoragePolicyCapabilities;
|
||||||
|
setForm((current) => ({
|
||||||
|
...current,
|
||||||
|
capabilities: {
|
||||||
|
...current.capabilities,
|
||||||
|
[capabilityKey]: nextValue,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
className="w-4 h-4 rounded-sm border-white/20 bg-white/10 text-blue-600 focus:ring-0"
|
||||||
|
/>
|
||||||
|
<span className={cn("transition-colors", checked ? "text-blue-500" : "")}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Stack>
|
</div>
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PortalAdminStoragePoliciesList() {
|
<div className="mt-12 flex justify-end gap-3">
|
||||||
return (
|
<button
|
||||||
<List
|
type="button"
|
||||||
actions={<StoragePoliciesListActions />}
|
onClick={() => {
|
||||||
perPage={25}
|
setShowForm(false);
|
||||||
resource="storagePolicies"
|
setEditingPolicy(null);
|
||||||
title="存储策略"
|
}}
|
||||||
sort={{ field: 'id', order: 'ASC' }}
|
className="px-8 py-4 rounded-lg glass-panel hover:bg-white/40 text-[11px] font-black uppercase tracking-widest transition-all"
|
||||||
>
|
>
|
||||||
<Datagrid bulkActionButtons={false} rowClick={false}>
|
取消
|
||||||
<TextField source="id" label="ID" />
|
</button>
|
||||||
<TextField source="name" label="名称" />
|
<button
|
||||||
<TextField source="type" label="类型" />
|
type="button"
|
||||||
<TextField source="bucketName" label="Bucket" emptyText="-" />
|
onClick={() => void savePolicy()}
|
||||||
<TextField source="endpoint" label="Endpoint" emptyText="-" />
|
className="px-10 py-4 rounded-lg bg-blue-600 text-white text-[11px] font-black uppercase tracking-widest shadow-xl hover:bg-blue-500 hover:scale-[1.02] active:scale-[0.98] transition-all"
|
||||||
<TextField source="region" label="Region" emptyText="-" />
|
>
|
||||||
<TextField source="prefix" label="Prefix" emptyText="-" />
|
保存
|
||||||
<TextField source="credentialMode" label="凭证模式" />
|
</button>
|
||||||
<BooleanField source="enabled" label="启用" />
|
</div>
|
||||||
<BooleanField source="defaultPolicy" label="默认" />
|
</motion.div>
|
||||||
<FunctionField<AdminStoragePolicy>
|
</div>
|
||||||
label="容量上限"
|
) : null}
|
||||||
render={(record) => formatFileSize(record.maxSizeBytes)}
|
</motion.div>
|
||||||
/>
|
|
||||||
<FunctionField<AdminStoragePolicy>
|
|
||||||
label="能力"
|
|
||||||
render={(record) => renderCapabilities(record.capabilities)}
|
|
||||||
/>
|
|
||||||
<DateField source="updatedAt" label="更新时间" showTime />
|
|
||||||
</Datagrid>
|
|
||||||
</List>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,305 +1,260 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Button, Chip, Stack } from '@mui/material';
|
import { Ban, KeyRound, RefreshCw, Search, Shield, Upload, Mail, Phone, ChevronRight } from 'lucide-react';
|
||||||
|
import { motion } from 'motion/react';
|
||||||
|
import { cn } from '@/src/lib/utils';
|
||||||
import {
|
import {
|
||||||
Datagrid,
|
getAdminUsers,
|
||||||
DateField,
|
resetUserPassword,
|
||||||
FunctionField,
|
updateUserMaxUploadSize,
|
||||||
List,
|
updateUserPassword,
|
||||||
SearchInput,
|
updateUserRole,
|
||||||
TextField,
|
updateUserStatus,
|
||||||
TopToolbar,
|
updateUserStorageQuota,
|
||||||
RefreshButton,
|
type AdminUser,
|
||||||
useNotify,
|
} from '@/src/lib/admin-users';
|
||||||
useRefresh,
|
import { formatBytes, formatDateTime } from '@/src/lib/format';
|
||||||
} from 'react-admin';
|
|
||||||
|
|
||||||
import { apiRequest } from '@/src/lib/api';
|
const container = {
|
||||||
import type { AdminPasswordResetResponse, AdminUser, AdminUserRole } from '@/src/lib/types';
|
hidden: { opacity: 0 },
|
||||||
|
show: {
|
||||||
const USER_ROLE_OPTIONS: AdminUserRole[] = ['USER', 'MODERATOR', 'ADMIN'];
|
opacity: 1,
|
||||||
|
transition: {
|
||||||
function formatLimitSize(bytes: number) {
|
staggerChildren: 0.05
|
||||||
if (bytes <= 0) {
|
|
||||||
return '0 B';
|
|
||||||
}
|
}
|
||||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
||||||
const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
|
|
||||||
const value = bytes / 1024 ** index;
|
|
||||||
return `${value >= 10 || index === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[index]}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseLimitInput(value: string): number | null {
|
|
||||||
const normalized = value.trim().toLowerCase();
|
|
||||||
const matched = normalized.match(/^(\d+(?:\.\d+)?)\s*(b|kb|mb|gb|tb)?$/);
|
|
||||||
if (!matched) {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
const amount = Number.parseFloat(matched[1] ?? '0');
|
};
|
||||||
if (!Number.isFinite(amount) || amount <= 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const unit = matched[2] ?? 'b';
|
|
||||||
const multiplier = unit === 'tb'
|
|
||||||
? 1024 ** 4
|
|
||||||
: unit === 'gb'
|
|
||||||
? 1024 ** 3
|
|
||||||
: unit === 'mb'
|
|
||||||
? 1024 ** 2
|
|
||||||
: unit === 'kb'
|
|
||||||
? 1024
|
|
||||||
: 1;
|
|
||||||
return Math.floor(amount * multiplier);
|
|
||||||
}
|
|
||||||
|
|
||||||
function UsersListActions() {
|
const itemVariants = {
|
||||||
return (
|
hidden: { y: 10, opacity: 0 },
|
||||||
<TopToolbar>
|
show: { y: 0, opacity: 1 }
|
||||||
<RefreshButton />
|
};
|
||||||
</TopToolbar>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatStorageUsage(usedBytes: number, quotaBytes: number) {
|
export default function AdminUsersList() {
|
||||||
return `${formatLimitSize(usedBytes)} / ${formatLimitSize(quotaBytes)}`;
|
const [loading, setLoading] = useState(true);
|
||||||
}
|
const [error, setError] = useState('');
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||||
|
|
||||||
function AdminUserActions({ record }: { record: AdminUser }) {
|
async function loadUsers(nextQuery = query) {
|
||||||
const notify = useNotify();
|
setError('');
|
||||||
const refresh = useRefresh();
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
|
|
||||||
async function handleRoleAssign() {
|
|
||||||
const input = window.prompt('请输入角色:USER / MODERATOR / ADMIN', record.role);
|
|
||||||
if (!input) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const role = input.trim().toUpperCase() as AdminUserRole;
|
|
||||||
if (!USER_ROLE_OPTIONS.includes(role)) {
|
|
||||||
notify('角色必须是 USER、MODERATOR 或 ADMIN', { type: 'warning' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setBusy(true);
|
|
||||||
try {
|
try {
|
||||||
await apiRequest(`/admin/users/${record.id}/role`, {
|
const result = await getAdminUsers(0, 100, nextQuery);
|
||||||
method: 'PATCH',
|
setUsers(result.items);
|
||||||
body: { role },
|
} catch (err) {
|
||||||
});
|
setError(err instanceof Error ? err.message : '加载用户失败');
|
||||||
notify(`已将 ${record.username} 设为 ${role}`, { type: 'success' });
|
|
||||||
refresh();
|
|
||||||
} catch (error) {
|
|
||||||
notify(error instanceof Error ? error.message : '角色更新失败', { type: 'error' });
|
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleToggleBan() {
|
useEffect(() => {
|
||||||
const nextBanned = !record.banned;
|
void loadUsers();
|
||||||
const confirmed = window.confirm(
|
}, []);
|
||||||
nextBanned ? `确认封禁用户 ${record.username} 吗?` : `确认解封用户 ${record.username} 吗?`,
|
|
||||||
);
|
|
||||||
if (!confirmed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setBusy(true);
|
async function mutate(action: () => Promise<unknown>) {
|
||||||
try {
|
try {
|
||||||
await apiRequest(`/admin/users/${record.id}/status`, {
|
await action();
|
||||||
method: 'PATCH',
|
await loadUsers();
|
||||||
body: { banned: nextBanned },
|
} catch (err) {
|
||||||
});
|
setError(err instanceof Error ? err.message : '操作失败');
|
||||||
notify(nextBanned ? '用户已封禁' : '用户已解封', { type: 'success' });
|
|
||||||
refresh();
|
|
||||||
} catch (error) {
|
|
||||||
notify(error instanceof Error ? error.message : '状态更新失败', { type: 'error' });
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSetPassword() {
|
|
||||||
const newPassword = window.prompt(
|
|
||||||
'请输入新密码。密码至少8位,且必须包含大写字母。',
|
|
||||||
);
|
|
||||||
if (!newPassword) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setBusy(true);
|
|
||||||
try {
|
|
||||||
await apiRequest(`/admin/users/${record.id}/password`, {
|
|
||||||
method: 'PUT',
|
|
||||||
body: { newPassword },
|
|
||||||
});
|
|
||||||
notify('密码已更新,旧 refresh token 已失效', { type: 'success' });
|
|
||||||
} catch (error) {
|
|
||||||
notify(error instanceof Error ? error.message : '密码更新失败', { type: 'error' });
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSetStorageQuota() {
|
|
||||||
const input = window.prompt(
|
|
||||||
`请输入新的存储上限(支持 B/KB/MB/GB/TB,当前 ${formatLimitSize(record.storageQuotaBytes)})`,
|
|
||||||
`${Math.floor(record.storageQuotaBytes / 1024 / 1024 / 1024)}GB`,
|
|
||||||
);
|
|
||||||
if (!input) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const storageQuotaBytes = parseLimitInput(input);
|
|
||||||
if (!storageQuotaBytes) {
|
|
||||||
notify('输入格式不正确,请输入例如 20GB 或 21474836480', { type: 'warning' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setBusy(true);
|
|
||||||
try {
|
|
||||||
await apiRequest(`/admin/users/${record.id}/storage-quota`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
body: { storageQuotaBytes },
|
|
||||||
});
|
|
||||||
notify(`存储上限已更新为 ${formatLimitSize(storageQuotaBytes)}`, { type: 'success' });
|
|
||||||
refresh();
|
|
||||||
} catch (error) {
|
|
||||||
notify(error instanceof Error ? error.message : '存储上限更新失败', { type: 'error' });
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSetMaxUploadSize() {
|
|
||||||
const input = window.prompt(
|
|
||||||
`请输入单文件最大上传大小(支持 B/KB/MB/GB/TB,当前 ${formatLimitSize(record.maxUploadSizeBytes)})`,
|
|
||||||
`${Math.max(1, Math.floor(record.maxUploadSizeBytes / 1024 / 1024))}MB`,
|
|
||||||
);
|
|
||||||
if (!input) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const maxUploadSizeBytes = parseLimitInput(input);
|
|
||||||
if (!maxUploadSizeBytes) {
|
|
||||||
notify('输入格式不正确,请输入例如 500MB 或 524288000', { type: 'warning' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setBusy(true);
|
|
||||||
try {
|
|
||||||
await apiRequest(`/admin/users/${record.id}/max-upload-size`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
body: { maxUploadSizeBytes },
|
|
||||||
});
|
|
||||||
notify(`单文件上限已更新为 ${formatLimitSize(maxUploadSizeBytes)}`, { type: 'success' });
|
|
||||||
refresh();
|
|
||||||
} catch (error) {
|
|
||||||
notify(error instanceof Error ? error.message : '单文件上限更新失败', { type: 'error' });
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleResetPassword() {
|
|
||||||
const confirmed = window.confirm(`确认重置 ${record.username} 的密码吗?`);
|
|
||||||
if (!confirmed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setBusy(true);
|
|
||||||
try {
|
|
||||||
const result = await apiRequest<AdminPasswordResetResponse>(`/admin/users/${record.id}/password/reset`, {
|
|
||||||
method: 'POST',
|
|
||||||
});
|
|
||||||
notify('已生成临时密码,请立即复制并安全发送给用户', { type: 'success' });
|
|
||||||
window.prompt(`用户 ${record.username} 的临时密码如下,请复制保存`, result.temporaryPassword);
|
|
||||||
} catch (error) {
|
|
||||||
notify(error instanceof Error ? error.message : '密码重置失败', { type: 'error' });
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack direction="row" spacing={0.75} useFlexGap flexWrap="wrap">
|
<motion.div
|
||||||
<Button size="small" variant="outlined" disabled={busy} sx={{ minWidth: 'auto', px: 1 }} onClick={() => void handleRoleAssign()}>
|
initial={{ opacity: 0 }}
|
||||||
角色分配
|
animate={{ opacity: 1 }}
|
||||||
</Button>
|
exit={{ opacity: 0 }}
|
||||||
<Button size="small" variant="outlined" disabled={busy} sx={{ minWidth: 'auto', px: 1 }} onClick={() => void handleSetPassword()}>
|
className="flex h-full flex-col p-8 text-gray-900 dark:text-gray-100 overflow-y-auto"
|
||||||
修改密码
|
|
||||||
</Button>
|
|
||||||
<Button size="small" variant="outlined" disabled={busy} sx={{ minWidth: 'auto', px: 1 }} onClick={() => void handleResetPassword()}>
|
|
||||||
重置密码
|
|
||||||
</Button>
|
|
||||||
<Button size="small" variant="outlined" disabled={busy} sx={{ minWidth: 'auto', px: 1 }} onClick={() => void handleSetStorageQuota()}>
|
|
||||||
存储上限
|
|
||||||
</Button>
|
|
||||||
<Button size="small" variant="outlined" disabled={busy} sx={{ minWidth: 'auto', px: 1 }} onClick={() => void handleSetMaxUploadSize()}>
|
|
||||||
单文件上限
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
variant={record.banned ? 'contained' : 'outlined'}
|
|
||||||
color={record.banned ? 'success' : 'warning'}
|
|
||||||
disabled={busy}
|
|
||||||
sx={{ minWidth: 'auto', px: 1 }}
|
|
||||||
onClick={() => void handleToggleBan()}
|
|
||||||
>
|
>
|
||||||
{record.banned ? '解封' : '封禁'}
|
<div className="mb-10 flex items-center justify-between">
|
||||||
</Button>
|
<div>
|
||||||
</Stack>
|
<h1 className="text-4xl font-black tracking-tight animate-text-reveal text-gray-900 dark:text-white">身份管理</h1>
|
||||||
);
|
<p className="mt-3 text-[10px] font-black uppercase tracking-[0.2em] opacity-40">用户权限 / 身份档案</p>
|
||||||
}
|
</div>
|
||||||
|
<button
|
||||||
export function PortalAdminUsersList() {
|
type="button"
|
||||||
return (
|
onClick={() => {
|
||||||
<List
|
setLoading(true);
|
||||||
actions={<UsersListActions />}
|
void loadUsers();
|
||||||
filters={[<SearchInput key="query" source="query" alwaysOn placeholder="搜索用户名、邮箱或手机号" />]}
|
|
||||||
perPage={25}
|
|
||||||
resource="users"
|
|
||||||
title="用户管理"
|
|
||||||
sort={{ field: 'createdAt', order: 'DESC' }}
|
|
||||||
>
|
|
||||||
<Datagrid
|
|
||||||
bulkActionButtons={false}
|
|
||||||
rowClick={false}
|
|
||||||
size="small"
|
|
||||||
sx={{
|
|
||||||
'& .RaDatagrid-table th, & .RaDatagrid-table td': {
|
|
||||||
px: 1,
|
|
||||||
py: 0.75,
|
|
||||||
},
|
|
||||||
}}
|
}}
|
||||||
|
className="flex items-center gap-3 px-6 py-3 rounded-lg glass-panel hover:bg-white/40 transition-all font-black text-[11px] uppercase tracking-widest"
|
||||||
>
|
>
|
||||||
<TextField source="id" label="ID" />
|
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
|
||||||
<TextField source="username" label="用户名" />
|
刷新列表
|
||||||
<TextField source="email" label="邮箱" />
|
</button>
|
||||||
<TextField source="phoneNumber" label="手机号" emptyText="-" />
|
</div>
|
||||||
<FunctionField<AdminUser>
|
|
||||||
label="存储使用"
|
<div className="mb-10 group">
|
||||||
render={(record) => formatStorageUsage(record.usedStorageBytes, record.storageQuotaBytes)}
|
<div className="relative">
|
||||||
/>
|
<Search className="absolute left-5 top-1/2 h-4 w-4 -translate-y-1/2 opacity-30 group-focus-within:text-blue-500 transition-colors" />
|
||||||
<FunctionField<AdminUser>
|
<input
|
||||||
label="单文件上限"
|
value={query}
|
||||||
render={(record) => formatLimitSize(record.maxUploadSizeBytes)}
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
/>
|
onKeyDown={(event) => {
|
||||||
<FunctionField<AdminUser>
|
if (event.key === 'Enter') {
|
||||||
label="角色"
|
setLoading(true);
|
||||||
render={(record) => <Chip label={record.role} size="small" color={record.role === 'ADMIN' ? 'primary' : 'default'} />}
|
void loadUsers(event.currentTarget.value);
|
||||||
/>
|
}
|
||||||
<FunctionField<AdminUser>
|
}}
|
||||||
label="状态"
|
placeholder="搜索用户名、邮箱或手机号...(回车)"
|
||||||
render={(record) => (
|
className="w-full rounded-lg glass-panel bg-white/10 py-5 pl-14 pr-6 outline-none border border-white/10 focus:border-blue-500/50 focus:ring-4 focus:ring-blue-500/10 transition-all font-black text-[11px] uppercase tracking-widest placeholder:opacity-20"
|
||||||
<Chip
|
|
||||||
label={record.banned ? '已封禁' : '正常'}
|
|
||||||
size="small"
|
|
||||||
color={record.banned ? 'warning' : 'success'}
|
|
||||||
variant={record.banned ? 'filled' : 'outlined'}
|
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? <div className="mb-8 rounded-lg bg-red-500/10 border border-red-500/20 px-6 py-4 text-xs text-red-600 font-bold backdrop-blur-md uppercase tracking-widest">{error}</div> : null}
|
||||||
|
|
||||||
|
<div className="flex-1 min-h-0">
|
||||||
|
{loading && users.length === 0 ? (
|
||||||
|
<div className="glass-panel-no-hover rounded-lg px-4 py-16 text-center text-[10px] font-black uppercase tracking-widest opacity-40">正在查询用户数据...</div>
|
||||||
|
) : (
|
||||||
|
<div className="glass-panel-no-hover rounded-lg overflow-hidden shadow-3xl border border-white/10">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-white/10">
|
||||||
|
<thead className="bg-white/10 dark:bg-black/40">
|
||||||
|
<tr>
|
||||||
|
<th className="px-8 py-5 text-left text-[9px] font-black uppercase tracking-[0.2em] opacity-40">用户信息</th>
|
||||||
|
<th className="px-8 py-5 text-left text-[9px] font-black uppercase tracking-[0.2em] opacity-40">角色</th>
|
||||||
|
<th className="px-8 py-5 text-left text-[9px] font-black uppercase tracking-[0.2em] opacity-40">状态</th>
|
||||||
|
<th className="px-8 py-5 text-left text-[9px] font-black uppercase tracking-[0.2em] opacity-40">资源配额</th>
|
||||||
|
<th className="px-8 py-5 text-left text-[9px] font-black uppercase tracking-[0.2em] opacity-40">注册时间</th>
|
||||||
|
<th className="px-8 py-5 text-right text-[9px] font-black uppercase tracking-[0.2em] opacity-40">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<motion.tbody
|
||||||
|
variants={container}
|
||||||
|
initial="hidden"
|
||||||
|
animate="show"
|
||||||
|
className="divide-y divide-white/10 dark:divide-white/5"
|
||||||
|
>
|
||||||
|
{users.map((user) => (
|
||||||
|
<motion.tr key={user.id} variants={itemVariants} className="hover:bg-white/10 dark:hover:bg-white/5 transition-colors group">
|
||||||
|
<td className="px-8 py-5">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="h-10 w-10 rounded-lg bg-blue-500/10 flex items-center justify-center font-black text-blue-500 border border-blue-500/20 shadow-inner">
|
||||||
|
{user.username.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[12px] font-black tracking-tight uppercase">{user.username}</div>
|
||||||
|
<div className="text-[10px] opacity-40 font-bold flex items-center gap-1.5 mt-0.5"><Mail className="h-3 w-3" /> {user.email}</div>
|
||||||
|
{user.phoneNumber ? <div className="mt-1 text-[9px] font-black opacity-20 tracking-widest flex items-center gap-1.5"><Phone className="h-3 w-3" />{user.phoneNumber}</div> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-8 py-5">
|
||||||
|
<span className={cn(
|
||||||
|
"inline-flex items-center gap-2 rounded-sm px-2 py-0.5 text-[9px] font-black uppercase tracking-widest border shadow-inner",
|
||||||
|
user.role === 'ADMIN'
|
||||||
|
? "bg-purple-500/10 text-purple-500 border-purple-500/20"
|
||||||
|
: "bg-blue-500/10 text-blue-500 border-blue-500/20"
|
||||||
|
)}>
|
||||||
|
<Shield className="h-3 w-3" />
|
||||||
|
{user.role}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-8 py-5">
|
||||||
|
<span className={cn(
|
||||||
|
"inline-flex items-center gap-2 rounded-sm px-2 py-0.5 text-[9px] font-black uppercase tracking-widest border",
|
||||||
|
user.banned
|
||||||
|
? "bg-red-500/10 text-red-500 border-red-500/20"
|
||||||
|
: "bg-green-500/10 text-green-500 border-green-500/20"
|
||||||
|
)}>
|
||||||
|
{user.banned ? '已禁用' : '正常'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-8 py-5">
|
||||||
|
<div className="text-[10px] font-black uppercase tracking-tight">
|
||||||
|
{formatBytes(user.usedStorageBytes)} / <span className="opacity-30">{formatBytes(user.storageQuotaBytes)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 h-1 w-full max-w-[120px] rounded-full bg-white/10 overflow-hidden">
|
||||||
|
<motion.div
|
||||||
|
initial={{ width: 0 }}
|
||||||
|
animate={{ width: `${Math.min(100, (user.usedStorageBytes / user.storageQuotaBytes) * 100)}%` }}
|
||||||
|
className="h-full bg-blue-500 shadow-[0_0_8px_rgba(59,130,246,0.5)]"
|
||||||
|
></motion.div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-[9px] font-bold opacity-30 uppercase tracking-widest">
|
||||||
|
上传上限:{formatBytes(user.maxUploadSizeBytes)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-8 py-5 text-[10px] font-bold opacity-30 tracking-tighter uppercase">
|
||||||
|
{formatDateTime(user.createdAt)}
|
||||||
|
</td>
|
||||||
|
<td className="px-8 py-5 text-right">
|
||||||
|
<div className="flex justify-end gap-2 opacity-30 group-hover:opacity-100 transition-opacity">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
void mutate(async () => {
|
||||||
|
const nextRole = window.prompt('设置角色:USER 或 ADMIN', user.role);
|
||||||
|
if (!nextRole || (nextRole !== 'USER' && nextRole !== 'ADMIN')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await updateUserRole(user.id, nextRole);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="p-2.5 rounded-lg glass-panel hover:bg-blue-600 hover:text-white text-blue-500 transition-all border-white/10"
|
||||||
|
title="修改角色"
|
||||||
|
>
|
||||||
|
<Shield className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
void mutate(async () => {
|
||||||
|
const nextQuota = window.prompt('设置存储配额(字节)', String(user.storageQuotaBytes));
|
||||||
|
if (!nextQuota) return;
|
||||||
|
await updateUserStorageQuota(user.id, Number(nextQuota));
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="p-2.5 rounded-lg glass-panel hover:bg-blue-600 hover:text-white text-blue-500 transition-all border-white/10"
|
||||||
|
title="修改配额"
|
||||||
|
>
|
||||||
|
<Upload className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
void mutate(async () => {
|
||||||
|
const newPassword = window.prompt('设置新密码');
|
||||||
|
if (!newPassword) return;
|
||||||
|
await updateUserPassword(user.id, newPassword);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="p-2.5 rounded-lg glass-panel hover:bg-amber-500 hover:text-white text-amber-500 transition-all border-white/10"
|
||||||
|
title="重置密码"
|
||||||
|
>
|
||||||
|
<KeyRound className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void mutate(() => updateUserStatus(user.id, !user.banned))}
|
||||||
|
className={cn(
|
||||||
|
"p-2.5 rounded-lg glass-panel border border-white/10 transition-all",
|
||||||
|
user.banned ? "hover:bg-green-500 hover:text-white text-green-500" : "hover:bg-red-500 hover:text-white text-red-500"
|
||||||
)}
|
)}
|
||||||
/>
|
title={user.banned ? '恢复账号' : '禁用账号'}
|
||||||
<DateField source="createdAt" label="创建时间" showTime />
|
>
|
||||||
<FunctionField<AdminUser> label="操作" render={(record) => <AdminUserActions record={record} />} />
|
<Ban className="h-4 w-4" />
|
||||||
</Datagrid>
|
</button>
|
||||||
</List>
|
</div>
|
||||||
|
</td>
|
||||||
|
</motion.tr>
|
||||||
|
))}
|
||||||
|
{users.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="px-8 py-20 text-center text-[10px] font-black uppercase tracking-widest opacity-30">
|
||||||
|
暂无用户记录
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : null}
|
||||||
|
</motion.tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,200 +0,0 @@
|
|||||||
import React, { createContext, useContext, useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
import { apiRequest } from '@/src/lib/api';
|
|
||||||
import { fetchAdminAccessStatus } from './admin-access';
|
|
||||||
import {
|
|
||||||
clearStoredSession,
|
|
||||||
createSession,
|
|
||||||
readStoredSession,
|
|
||||||
saveStoredSession,
|
|
||||||
SESSION_EVENT_NAME,
|
|
||||||
} from '@/src/lib/session';
|
|
||||||
import type { AuthResponse, AuthSession, UserProfile } from '@/src/lib/types';
|
|
||||||
|
|
||||||
interface LoginPayload {
|
|
||||||
username: string;
|
|
||||||
password: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AuthContextValue {
|
|
||||||
ready: boolean;
|
|
||||||
session: AuthSession | null;
|
|
||||||
user: UserProfile | null;
|
|
||||||
isAdmin: boolean;
|
|
||||||
login: (payload: LoginPayload) => Promise<void>;
|
|
||||||
devLogin: (username?: string) => Promise<void>;
|
|
||||||
logout: () => void;
|
|
||||||
refreshProfile: () => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
|
||||||
|
|
||||||
function buildSession(auth: AuthResponse): AuthSession {
|
|
||||||
return createSession(auth);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|
||||||
const [session, setSession] = useState<AuthSession | null>(() => readStoredSession());
|
|
||||||
const [ready, setReady] = useState(false);
|
|
||||||
const [isAdmin, setIsAdmin] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const syncSession = () => {
|
|
||||||
setSession(readStoredSession());
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('storage', syncSession);
|
|
||||||
window.addEventListener(SESSION_EVENT_NAME, syncSession);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('storage', syncSession);
|
|
||||||
window.removeEventListener(SESSION_EVENT_NAME, syncSession);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let active = true;
|
|
||||||
|
|
||||||
async function hydrate() {
|
|
||||||
const storedSession = readStoredSession();
|
|
||||||
if (!storedSession) {
|
|
||||||
if (active) {
|
|
||||||
setSession(null);
|
|
||||||
setReady(true);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const user = await apiRequest<UserProfile>('/user/profile');
|
|
||||||
if (!active) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextSession = {
|
|
||||||
...storedSession,
|
|
||||||
user,
|
|
||||||
};
|
|
||||||
saveStoredSession(nextSession);
|
|
||||||
setSession(nextSession);
|
|
||||||
} catch {
|
|
||||||
clearStoredSession();
|
|
||||||
if (active) {
|
|
||||||
setSession(null);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (active) {
|
|
||||||
setReady(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
hydrate();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
active = false;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let active = true;
|
|
||||||
|
|
||||||
async function syncAdminAccess() {
|
|
||||||
if (!session?.token) {
|
|
||||||
if (active) {
|
|
||||||
setIsAdmin(false);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const allowed = await fetchAdminAccessStatus();
|
|
||||||
if (active) {
|
|
||||||
setIsAdmin(allowed);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
if (active) {
|
|
||||||
setIsAdmin(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
syncAdminAccess();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
active = false;
|
|
||||||
};
|
|
||||||
}, [session?.token]);
|
|
||||||
|
|
||||||
async function refreshProfile() {
|
|
||||||
const currentSession = readStoredSession();
|
|
||||||
if (!currentSession) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await apiRequest<UserProfile>('/user/profile');
|
|
||||||
const nextSession = {
|
|
||||||
...currentSession,
|
|
||||||
user,
|
|
||||||
};
|
|
||||||
saveStoredSession(nextSession);
|
|
||||||
setSession(nextSession);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function login(payload: LoginPayload) {
|
|
||||||
const auth = await apiRequest<AuthResponse>('/auth/login', {
|
|
||||||
method: 'POST',
|
|
||||||
body: payload,
|
|
||||||
});
|
|
||||||
const nextSession = buildSession(auth);
|
|
||||||
saveStoredSession(nextSession);
|
|
||||||
setSession(nextSession);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function devLogin(username?: string) {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (username?.trim()) {
|
|
||||||
params.set('username', username.trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
const auth = await apiRequest<AuthResponse>(
|
|
||||||
`/auth/dev-login${params.size ? `?${params.toString()}` : ''}`,
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const nextSession = buildSession(auth);
|
|
||||||
saveStoredSession(nextSession);
|
|
||||||
setSession(nextSession);
|
|
||||||
}
|
|
||||||
|
|
||||||
function logout() {
|
|
||||||
clearStoredSession();
|
|
||||||
setSession(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AuthContext.Provider
|
|
||||||
value={{
|
|
||||||
ready,
|
|
||||||
session,
|
|
||||||
user: session?.user || null,
|
|
||||||
isAdmin,
|
|
||||||
login,
|
|
||||||
devLogin,
|
|
||||||
logout,
|
|
||||||
refreshProfile,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</AuthContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAuth() {
|
|
||||||
const context = useContext(AuthContext);
|
|
||||||
if (!context) {
|
|
||||||
throw new Error('useAuth must be used inside AuthProvider');
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import test from 'node:test';
|
|
||||||
|
|
||||||
import { ApiError } from '@/src/lib/api';
|
|
||||||
|
|
||||||
import { fetchAdminAccessStatus } from './admin-access';
|
|
||||||
|
|
||||||
test('fetchAdminAccessStatus returns true when the admin summary request succeeds', async () => {
|
|
||||||
const request = async () => ({
|
|
||||||
totalUsers: 1,
|
|
||||||
totalFiles: 2,
|
|
||||||
totalStorageBytes: 0,
|
|
||||||
downloadTrafficBytes: 0,
|
|
||||||
requestCount: 0,
|
|
||||||
transferUsageBytes: 0,
|
|
||||||
offlineTransferStorageBytes: 0,
|
|
||||||
offlineTransferStorageLimitBytes: 0,
|
|
||||||
dailyActiveUsers: [],
|
|
||||||
requestTimeline: [],
|
|
||||||
inviteCode: 'invite-code',
|
|
||||||
});
|
|
||||||
|
|
||||||
await assert.doesNotReject(async () => {
|
|
||||||
const allowed = await fetchAdminAccessStatus(request);
|
|
||||||
assert.equal(allowed, true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('fetchAdminAccessStatus returns false when the server rejects the user with 403', async () => {
|
|
||||||
const request = async () => {
|
|
||||||
throw new ApiError('没有后台权限', 403);
|
|
||||||
};
|
|
||||||
|
|
||||||
const allowed = await fetchAdminAccessStatus(request);
|
|
||||||
assert.equal(allowed, false);
|
|
||||||
});
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { ApiError, apiRequest } from '@/src/lib/api';
|
|
||||||
import type { AdminSummary } from '@/src/lib/types';
|
|
||||||
|
|
||||||
type AdminSummaryRequest = () => Promise<AdminSummary>;
|
|
||||||
|
|
||||||
export async function fetchAdminAccessStatus(
|
|
||||||
request: AdminSummaryRequest = () => apiRequest<AdminSummary>('/admin/summary'),
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
await request();
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof ApiError && error.status === 403) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
73
front/src/components/ThemeProvider.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import React, { createContext, useContext, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
type Theme = 'dark' | 'light' | 'system';
|
||||||
|
|
||||||
|
type ThemeProviderProps = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
defaultTheme?: Theme;
|
||||||
|
storageKey?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ThemeProviderState = {
|
||||||
|
theme: Theme;
|
||||||
|
setTheme: (theme: Theme) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialState: ThemeProviderState = {
|
||||||
|
theme: 'system',
|
||||||
|
setTheme: () => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
|
||||||
|
|
||||||
|
export function ThemeProvider({
|
||||||
|
children,
|
||||||
|
defaultTheme = 'system',
|
||||||
|
storageKey = 'vite-ui-theme',
|
||||||
|
...props
|
||||||
|
}: ThemeProviderProps) {
|
||||||
|
const [theme, setTheme] = useState<Theme>(
|
||||||
|
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root = window.document.documentElement;
|
||||||
|
|
||||||
|
root.classList.remove('light', 'dark');
|
||||||
|
|
||||||
|
if (theme === 'system') {
|
||||||
|
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)')
|
||||||
|
.matches
|
||||||
|
? 'dark'
|
||||||
|
: 'light';
|
||||||
|
|
||||||
|
root.classList.add(systemTheme);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
root.classList.add(theme);
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
const value = {
|
||||||
|
theme,
|
||||||
|
setTheme: (theme: Theme) => {
|
||||||
|
localStorage.setItem(storageKey, theme);
|
||||||
|
setTheme(theme);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeProviderContext.Provider {...props} value={value}>
|
||||||
|
{children}
|
||||||
|
</ThemeProviderContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useTheme = () => {
|
||||||
|
const context = useContext(ThemeProviderContext);
|
||||||
|
|
||||||
|
if (context === undefined)
|
||||||
|
throw new Error('useTheme must be used within a ThemeProvider');
|
||||||
|
|
||||||
|
return context;
|
||||||
|
};
|
||||||
17
front/src/components/ThemeToggle.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { Moon, Sun } from 'lucide-react';
|
||||||
|
import { useTheme } from './ThemeProvider';
|
||||||
|
|
||||||
|
export function ThemeToggle() {
|
||||||
|
const { theme, setTheme } = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}
|
||||||
|
className="p-2 rounded-xl glass-panel hover:bg-white/40 dark:hover:bg-white/10 transition-all border border-white/20"
|
||||||
|
aria-label="Toggle theme"
|
||||||
|
>
|
||||||
|
<Sun className="h-5 w-5 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||||
|
<Moon className="absolute h-5 w-5 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100 top-2 left-2" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import test from 'node:test';
|
|
||||||
|
|
||||||
import { getVisibleNavItems } from './Layout';
|
|
||||||
|
|
||||||
test('getVisibleNavItems exposes the transfer entry instead of the school entry', () => {
|
|
||||||
const visibleItems = getVisibleNavItems(false);
|
|
||||||
const visiblePaths: string[] = visibleItems.map((item) => item.path);
|
|
||||||
|
|
||||||
assert.equal(visiblePaths.includes('/transfer'), true);
|
|
||||||
assert.equal(visiblePaths.some((path) => path === '/school'), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('getVisibleNavItems hides the admin entry for non-admin users', () => {
|
|
||||||
assert.equal(getVisibleNavItems(false).some((item) => item.path === '/admin'), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('getVisibleNavItems keeps the admin entry for admin users', () => {
|
|
||||||
assert.equal(getVisibleNavItems(true).some((item) => item.path === '/admin'), true);
|
|
||||||
});
|
|
||||||
@@ -1,627 +1,121 @@
|
|||||||
import React, { ReactNode, useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
|
import { Outlet, NavLink, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Gamepad2,
|
HardDrive,
|
||||||
FolderOpen,
|
|
||||||
Key,
|
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
|
ListTodo,
|
||||||
LogOut,
|
LogOut,
|
||||||
Mail,
|
|
||||||
Send,
|
Send,
|
||||||
Settings,
|
Settings,
|
||||||
Shield,
|
Share2,
|
||||||
Smartphone,
|
Trash2,
|
||||||
X,
|
Sun,
|
||||||
|
Moon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { AnimatePresence, motion } from 'motion/react';
|
|
||||||
|
|
||||||
import { useAuth } from '@/src/auth/AuthProvider';
|
|
||||||
import { apiBinaryUploadRequest, apiDownload, apiRequest, apiUploadRequest } from '@/src/lib/api';
|
|
||||||
import { createSession, readStoredSession, saveStoredSession } from '@/src/lib/session';
|
|
||||||
import type { AuthResponse, InitiateUploadResponse, UserProfile } from '@/src/lib/types';
|
|
||||||
import { cn } from '@/src/lib/utils';
|
import { cn } from '@/src/lib/utils';
|
||||||
import { Button } from '@/src/components/ui/button';
|
import { logout } from '@/src/lib/auth';
|
||||||
import { Input } from '@/src/components/ui/input';
|
import { getSession, type PortalSession } from '@/src/lib/session';
|
||||||
|
import { useTheme } from '../ThemeProvider';
|
||||||
|
|
||||||
import { buildAccountDraft, getRoleLabel, shouldLoadAvatarWithAuth } from './account-utils';
|
export default function Layout() {
|
||||||
import { UploadProgressPanel } from './UploadProgressPanel';
|
const location = useLocation();
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
|
||||||
{ name: '总览', path: '/overview', icon: LayoutDashboard },
|
|
||||||
{ name: '网盘', path: '/files', icon: FolderOpen },
|
|
||||||
{ name: '快传', path: '/transfer', icon: Send },
|
|
||||||
{ name: '游戏', path: '/games', icon: Gamepad2 },
|
|
||||||
{ name: '后台', path: '/admin', icon: Shield },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
type ActiveModal = 'security' | 'settings' | null;
|
|
||||||
|
|
||||||
export function getVisibleNavItems(isAdmin: boolean) {
|
|
||||||
return NAV_ITEMS.filter((item) => isAdmin || item.path !== '/admin');
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LayoutProps {
|
|
||||||
children?: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Layout({ children }: LayoutProps = {}) {
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { isAdmin, logout, refreshProfile, user } = useAuth();
|
const [session, setSession] = useState<PortalSession | null>(() => getSession());
|
||||||
const navItems = getVisibleNavItems(isAdmin);
|
const { theme, setTheme } = useTheme();
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
|
|
||||||
const [avatarPreviewUrl, setAvatarPreviewUrl] = useState<string | null>(null);
|
|
||||||
const [selectedAvatarFile, setSelectedAvatarFile] = useState<File | null>(null);
|
|
||||||
const [avatarSourceUrl, setAvatarSourceUrl] = useState<string | null>(user?.avatarUrl ?? null);
|
|
||||||
const [profileDraft, setProfileDraft] = useState(() =>
|
|
||||||
buildAccountDraft(
|
|
||||||
user ?? {
|
|
||||||
id: 0,
|
|
||||||
username: '',
|
|
||||||
email: '',
|
|
||||||
createdAt: '',
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const [currentPassword, setCurrentPassword] = useState('');
|
|
||||||
const [newPassword, setNewPassword] = useState('');
|
|
||||||
const [confirmPassword, setConfirmPassword] = useState('');
|
|
||||||
const [profileMessage, setProfileMessage] = useState('');
|
|
||||||
const [passwordMessage, setPasswordMessage] = useState('');
|
|
||||||
const [profileError, setProfileError] = useState('');
|
|
||||||
const [passwordError, setPasswordError] = useState('');
|
|
||||||
const [profileSubmitting, setProfileSubmitting] = useState(false);
|
|
||||||
const [passwordSubmitting, setPasswordSubmitting] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user) {
|
const handleSessionChange = (event: Event) => {
|
||||||
return;
|
const customEvent = event as CustomEvent<PortalSession | null>;
|
||||||
}
|
setSession(customEvent.detail ?? getSession());
|
||||||
setProfileDraft(buildAccountDraft(user));
|
};
|
||||||
}, [user]);
|
window.addEventListener('portal-session-changed', handleSessionChange);
|
||||||
|
return () => window.removeEventListener('portal-session-changed', handleSessionChange);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!avatarPreviewUrl) {
|
if (!session && location.pathname !== '/transfer') {
|
||||||
return undefined;
|
navigate('/login', { replace: true });
|
||||||
}
|
}
|
||||||
|
}, [location.pathname, navigate, session]);
|
||||||
|
|
||||||
return () => {
|
const navItems = [
|
||||||
URL.revokeObjectURL(avatarPreviewUrl);
|
{ to: '/overview', icon: LayoutDashboard, label: '概览' },
|
||||||
};
|
{ to: '/files', icon: HardDrive, label: '网盘' },
|
||||||
}, [avatarPreviewUrl]);
|
{ to: '/tasks', icon: ListTodo, label: '任务' },
|
||||||
|
{ to: '/shares', icon: Share2, label: '分享' },
|
||||||
useEffect(() => {
|
{ to: '/recycle-bin', icon: Trash2, label: '回收站' },
|
||||||
let active = true;
|
{ to: '/transfer', icon: Send, label: '快传' },
|
||||||
let objectUrl: string | null = null;
|
...(session?.user.role === 'ADMIN'
|
||||||
|
? [{ to: '/admin/dashboard', icon: Settings, label: '后台' }]
|
||||||
async function syncAvatar() {
|
: []),
|
||||||
if (!user?.avatarUrl) {
|
];
|
||||||
if (active) {
|
|
||||||
setAvatarSourceUrl(null);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!shouldLoadAvatarWithAuth(user.avatarUrl)) {
|
|
||||||
if (active) {
|
|
||||||
setAvatarSourceUrl(user.avatarUrl);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await apiDownload(user.avatarUrl);
|
|
||||||
const blob = await response.blob();
|
|
||||||
objectUrl = URL.createObjectURL(blob);
|
|
||||||
if (active) {
|
|
||||||
setAvatarSourceUrl(objectUrl);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
if (active) {
|
|
||||||
setAvatarSourceUrl(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void syncAvatar();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
active = false;
|
|
||||||
if (objectUrl) {
|
|
||||||
URL.revokeObjectURL(objectUrl);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [user?.avatarUrl]);
|
|
||||||
|
|
||||||
const displayName = useMemo(() => {
|
|
||||||
if (!user) {
|
|
||||||
return '账户';
|
|
||||||
}
|
|
||||||
return user.displayName || user.username;
|
|
||||||
}, [user]);
|
|
||||||
|
|
||||||
const email = user?.email || '暂无邮箱';
|
|
||||||
const phoneNumber = user?.phoneNumber || '未设置手机号';
|
|
||||||
const roleLabel = getRoleLabel(user?.role);
|
|
||||||
const avatarFallback = (displayName || 'Y').charAt(0).toUpperCase();
|
|
||||||
const displayedAvatarUrl = avatarPreviewUrl || avatarSourceUrl;
|
|
||||||
|
|
||||||
const handleLogout = () => {
|
|
||||||
logout();
|
|
||||||
navigate('/login');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAvatarClick = () => {
|
|
||||||
fileInputRef.current?.click();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const file = event.target.files?.[0];
|
|
||||||
if (!file) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSelectedAvatarFile(file);
|
|
||||||
setAvatarPreviewUrl((current) => {
|
|
||||||
if (current) {
|
|
||||||
URL.revokeObjectURL(current);
|
|
||||||
}
|
|
||||||
return URL.createObjectURL(file);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleProfileDraftChange = (field: keyof typeof profileDraft, value: string) => {
|
|
||||||
setProfileDraft((current) => ({
|
|
||||||
...current,
|
|
||||||
[field]: value,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeModal = () => {
|
|
||||||
setActiveModal(null);
|
|
||||||
setProfileMessage('');
|
|
||||||
setProfileError('');
|
|
||||||
setPasswordMessage('');
|
|
||||||
setPasswordError('');
|
|
||||||
};
|
|
||||||
|
|
||||||
const persistSessionUser = (nextProfile: UserProfile) => {
|
|
||||||
const currentSession = readStoredSession();
|
|
||||||
if (!currentSession) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
saveStoredSession({
|
|
||||||
...currentSession,
|
|
||||||
user: nextProfile,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const uploadAvatar = async (file: File) => {
|
|
||||||
const initiated = await apiRequest<InitiateUploadResponse>('/user/avatar/upload/initiate', {
|
|
||||||
method: 'POST',
|
|
||||||
body: {
|
|
||||||
filename: file.name,
|
|
||||||
contentType: file.type || 'image/png',
|
|
||||||
size: file.size,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (initiated.direct) {
|
|
||||||
try {
|
|
||||||
await apiBinaryUploadRequest(initiated.uploadUrl, {
|
|
||||||
method: initiated.method,
|
|
||||||
headers: initiated.headers,
|
|
||||||
body: file,
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('file', file);
|
|
||||||
await apiUploadRequest<void>(`/user/avatar/upload?storageName=${encodeURIComponent(initiated.storageName)}`, {
|
|
||||||
body: formData,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('file', file);
|
|
||||||
await apiUploadRequest<void>(initiated.uploadUrl, {
|
|
||||||
body: formData,
|
|
||||||
method: initiated.method === 'PUT' ? 'PUT' : 'POST',
|
|
||||||
headers: initiated.headers,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextProfile = await apiRequest<UserProfile>('/user/avatar/upload/complete', {
|
|
||||||
method: 'POST',
|
|
||||||
body: {
|
|
||||||
filename: file.name,
|
|
||||||
contentType: file.type || 'image/png',
|
|
||||||
size: file.size,
|
|
||||||
storageName: initiated.storageName,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
persistSessionUser(nextProfile);
|
|
||||||
return nextProfile;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaveProfile = async () => {
|
|
||||||
setProfileSubmitting(true);
|
|
||||||
setProfileMessage('');
|
|
||||||
setProfileError('');
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (selectedAvatarFile) {
|
|
||||||
await uploadAvatar(selectedAvatarFile);
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextProfile = await apiRequest<UserProfile>('/user/profile', {
|
|
||||||
method: 'PUT',
|
|
||||||
body: {
|
|
||||||
displayName: profileDraft.displayName.trim(),
|
|
||||||
email: profileDraft.email.trim(),
|
|
||||||
phoneNumber: profileDraft.phoneNumber.trim(),
|
|
||||||
bio: profileDraft.bio,
|
|
||||||
preferredLanguage: profileDraft.preferredLanguage,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
persistSessionUser(nextProfile);
|
|
||||||
|
|
||||||
await refreshProfile();
|
|
||||||
setSelectedAvatarFile(null);
|
|
||||||
setAvatarPreviewUrl((current) => {
|
|
||||||
if (current) {
|
|
||||||
URL.revokeObjectURL(current);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
setProfileMessage('账户资料已保存');
|
|
||||||
} catch (error) {
|
|
||||||
setProfileError(error instanceof Error ? error.message : '账户资料保存失败');
|
|
||||||
} finally {
|
|
||||||
setProfileSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleChangePassword = async () => {
|
|
||||||
setPasswordMessage('');
|
|
||||||
setPasswordError('');
|
|
||||||
|
|
||||||
if (newPassword !== confirmPassword) {
|
|
||||||
setPasswordError('两次输入的新密码不一致');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setPasswordSubmitting(true);
|
|
||||||
try {
|
|
||||||
const auth = await apiRequest<AuthResponse>('/user/password', {
|
|
||||||
method: 'POST',
|
|
||||||
body: {
|
|
||||||
currentPassword,
|
|
||||||
newPassword,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const currentSession = readStoredSession();
|
|
||||||
if (currentSession) {
|
|
||||||
saveStoredSession({
|
|
||||||
...currentSession,
|
|
||||||
...createSession(auth),
|
|
||||||
user: auth.user,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
setCurrentPassword('');
|
|
||||||
setNewPassword('');
|
|
||||||
setConfirmPassword('');
|
|
||||||
setPasswordMessage('密码已更新,当前登录态已同步刷新');
|
|
||||||
} catch (error) {
|
|
||||||
setPasswordError(error instanceof Error ? error.message : '密码修改失败');
|
|
||||||
} finally {
|
|
||||||
setPasswordSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex bg-[#07101D] text-white overflow-hidden w-full h-screen">
|
<div className="flex h-screen w-full bg-aurora text-gray-900 dark:text-gray-100 overflow-hidden">
|
||||||
<aside className="h-full w-16 md:w-56 flex flex-col shrink-0 border-r border-white/10 bg-[#0f172a]/50">
|
{/* Sidebar */}
|
||||||
<div className="h-14 flex items-center md:px-4 justify-center md:justify-start border-b border-white/10">
|
<aside className="w-68 flex-shrink-0 border-r border-white/20 dark:border-white/10 bg-white/40 dark:bg-black/40 backdrop-blur-2xl flex flex-col z-20 shadow-xl">
|
||||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-[#336EFF] to-blue-400 flex items-center justify-center shadow-lg shadow-[#336EFF]/20 shrink-0">
|
<div className="h-24 flex items-center justify-between px-8 border-b border-white/10">
|
||||||
<span className="text-white font-bold text-lg leading-none">Y</span>
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-blue-600 rounded-lg flex items-center justify-center text-white font-black shadow-lg text-lg tracking-tighter">P</div>
|
||||||
|
<span className="text-2xl font-black tracking-tight uppercase">门户</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden md:flex flex-col ml-3">
|
<button
|
||||||
<span className="text-white font-bold text-sm tracking-wider">YOYUZH.XYZ</span>
|
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
|
||||||
|
className="p-2.5 rounded-lg glass-panel hover:bg-white/50 transition-all font-bold"
|
||||||
|
>
|
||||||
|
{theme === 'dark' ? <Sun className="w-5 h-5 text-yellow-300" /> : <Moon className="w-5 h-5 text-gray-700" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-b border-white/10 px-8 py-6">
|
||||||
|
<div className="text-sm font-black uppercase tracking-[0.2em] opacity-70 mb-1">当前账号</div>
|
||||||
|
<div className="text-sm font-black truncate">
|
||||||
|
{session?.user.displayName || session?.user.username || '游客用户'}
|
||||||
|
</div>
|
||||||
|
<div className="truncate text-sm font-bold opacity-80 dark:opacity-90 flex items-center gap-1.5 mt-2 uppercase tracking-tight">
|
||||||
|
<span className="inline-block w-1.5 h-1.5 rounded-full bg-green-500 shadow-[0_0_10px_rgba(34,197,94,0.6)] animate-pulse"></span>
|
||||||
|
{session?.user.email || '未登录'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="flex-1 flex flex-col gap-2 p-2 relative overflow-y-auto overflow-x-hidden">
|
<nav className="flex-1 overflow-y-auto py-8 px-5 space-y-1.5">
|
||||||
|
<div className="px-3 mb-2 text-xs font-black uppercase tracking-[0.3em] opacity-70">主要功能</div>
|
||||||
{navItems.map((item) => (
|
{navItems.map((item) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={item.path}
|
key={item.to}
|
||||||
to={item.path}
|
to={item.to}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
cn(
|
cn(
|
||||||
'flex items-center gap-3 px-0 md:px-4 justify-center md:justify-start h-10 rounded-xl text-sm font-medium transition-all duration-200 relative overflow-hidden group',
|
"flex items-center gap-3 px-4 py-3.5 rounded-lg text-sm font-black uppercase tracking-widest transition-all duration-300 group",
|
||||||
isActive ? 'text-white shadow-md shadow-[#336EFF]/20' : 'text-slate-400 hover:text-white hover:bg-white/5',
|
isActive
|
||||||
|
? "glass-panel-no-hover bg-white/60 dark:bg-white/10 shadow-lg text-blue-600 dark:text-blue-400 border-white/40"
|
||||||
|
: "text-gray-700 dark:text-gray-200 hover:bg-white/30 dark:hover:bg-white/5 hover:translate-x-1"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{({ isActive }) => (
|
<item.icon className={cn("h-4 w-4 transition-colors group-hover:text-blue-500")} />
|
||||||
<>
|
{item.label}
|
||||||
{isActive && <div className="absolute inset-0 bg-[#336EFF] opacity-100 z-0" />}
|
|
||||||
<item.icon className="w-[18px] h-[18px] relative z-10 shrink-0" />
|
|
||||||
<span className="relative z-10 hidden md:block">{item.name}</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="p-4 border-t border-white/10 shrink-0 flex flex-col gap-2 relative">
|
<div className="border-t border-white/10 p-6">
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveModal('settings')}
|
type="button"
|
||||||
className="w-full flex items-center justify-center md:justify-start gap-3 p-2 rounded-xl text-slate-400 hover:text-white hover:bg-white/5 transition-colors"
|
onClick={() => {
|
||||||
|
logout();
|
||||||
|
navigate('/login');
|
||||||
|
}}
|
||||||
|
className="flex w-full items-center gap-3 rounded-lg px-4 py-4 text-sm font-black uppercase tracking-[0.2em] text-gray-700 dark:text-gray-200 hover:text-red-500 transition-all hover:bg-white/20 dark:hover:bg-white/5"
|
||||||
>
|
>
|
||||||
<div className="w-8 h-8 rounded-full border border-white/10 flex items-center justify-center bg-slate-800 text-slate-300 relative z-10 overflow-hidden shrink-0">
|
<LogOut className="h-4 w-4 opacity-60" />
|
||||||
{displayedAvatarUrl ? (
|
退出登录
|
||||||
<img src={displayedAvatarUrl} alt="Avatar" className="w-full h-full object-cover" />
|
|
||||||
) : (
|
|
||||||
<span className="text-xs font-semibold">{avatarFallback}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="hidden md:block flex-1 min-w-0 text-left">
|
|
||||||
<p className="text-sm font-medium text-white truncate">{displayName}</p>
|
|
||||||
<p className="text-xs text-slate-400 truncate">{email}</p>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleLogout}
|
|
||||||
className="w-full flex items-center justify-center md:justify-start gap-3 md:px-4 h-10 rounded-xl text-sm text-red-400 hover:bg-red-500/10 hover:text-red-300 transition-colors"
|
|
||||||
>
|
|
||||||
<LogOut className="w-[18px] h-[18px]" />
|
|
||||||
<span className="hidden md:block font-medium">退出登录</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
<main className="relative flex min-w-0 flex-1 flex-col overflow-hidden z-10">
|
||||||
<main className="flex-1 flex flex-col min-w-0 h-full relative overflow-y-auto">
|
<Outlet />
|
||||||
{children ?? <Outlet />}
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<UploadProgressPanel />
|
|
||||||
|
|
||||||
<AnimatePresence>
|
|
||||||
{activeModal === 'security' && (
|
|
||||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
|
||||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
|
||||||
className="bg-[#0f172a] border border-white/10 rounded-2xl shadow-2xl w-full max-w-lg overflow-hidden flex flex-col max-h-[80vh]"
|
|
||||||
>
|
|
||||||
<div className="p-5 border-b border-white/10 flex justify-between items-center bg-white/5">
|
|
||||||
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
|
||||||
<Shield className="w-5 h-5 text-emerald-400" />
|
|
||||||
安全中心
|
|
||||||
</h3>
|
|
||||||
<button onClick={closeModal} className="text-slate-400 hover:text-white transition-colors p-1 rounded-md hover:bg-white/10">
|
|
||||||
<X className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="p-6 overflow-y-auto space-y-6">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="p-4 rounded-xl bg-white/5 border border-white/10 space-y-4">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="w-10 h-10 rounded-full bg-blue-500/20 flex items-center justify-center">
|
|
||||||
<Key className="w-5 h-5 text-blue-400" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-white">登录密码</p>
|
|
||||||
<p className="text-xs text-slate-400 mt-0.5">密码修改后会刷新当前登录凭据并使旧 refresh token 失效</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-3">
|
|
||||||
<Input
|
|
||||||
type="password"
|
|
||||||
placeholder="当前密码"
|
|
||||||
value={currentPassword}
|
|
||||||
onChange={(event) => setCurrentPassword(event.target.value)}
|
|
||||||
className="bg-black/20 border-white/10"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
type="password"
|
|
||||||
placeholder="新密码"
|
|
||||||
value={newPassword}
|
|
||||||
onChange={(event) => setNewPassword(event.target.value)}
|
|
||||||
className="bg-black/20 border-white/10"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
type="password"
|
|
||||||
placeholder="确认新密码"
|
|
||||||
value={confirmPassword}
|
|
||||||
onChange={(event) => setConfirmPassword(event.target.value)}
|
|
||||||
className="bg-black/20 border-white/10"
|
|
||||||
/>
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<Button variant="outline" disabled={passwordSubmitting} onClick={() => void handleChangePassword()}>
|
|
||||||
{passwordSubmitting ? '保存中...' : '修改'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between p-4 rounded-xl bg-white/5 border border-white/10">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="w-10 h-10 rounded-full bg-emerald-500/20 flex items-center justify-center">
|
|
||||||
<Smartphone className="w-5 h-5 text-emerald-400" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-white">手机绑定</p>
|
|
||||||
<p className="text-xs text-slate-400 mt-0.5">当前手机号:{phoneNumber}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="border-white/10 hover:bg-white/10 text-slate-300"
|
|
||||||
onClick={() => setActiveModal('settings')}
|
|
||||||
>
|
|
||||||
更改
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between p-4 rounded-xl bg-white/5 border border-white/10">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="w-10 h-10 rounded-full bg-purple-500/20 flex items-center justify-center">
|
|
||||||
<Mail className="w-5 h-5 text-purple-400" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-white">邮箱绑定</p>
|
|
||||||
<p className="text-xs text-slate-400 mt-0.5">当前邮箱:{email}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="border-white/10 hover:bg-white/10 text-slate-300"
|
|
||||||
onClick={() => setActiveModal('settings')}
|
|
||||||
>
|
|
||||||
更改
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{passwordError && <p className="text-sm text-rose-300">{passwordError}</p>}
|
|
||||||
{passwordMessage && <p className="text-sm text-emerald-300">{passwordMessage}</p>}
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeModal === 'settings' && (
|
|
||||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
|
||||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
|
||||||
className="bg-[#0f172a] border border-white/10 rounded-2xl shadow-2xl w-full max-w-lg overflow-hidden flex flex-col max-h-[80vh]"
|
|
||||||
>
|
|
||||||
<div className="p-5 border-b border-white/10 flex justify-between items-center bg-white/5">
|
|
||||||
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
|
||||||
<Settings className="w-5 h-5 text-[#336EFF]" />
|
|
||||||
账户设置
|
|
||||||
</h3>
|
|
||||||
<button onClick={closeModal} className="text-slate-400 hover:text-white transition-colors p-1 rounded-md hover:bg-white/10">
|
|
||||||
<X className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="p-6 overflow-y-auto space-y-6">
|
|
||||||
<div className="flex items-center gap-6 pb-6 border-b border-white/10">
|
|
||||||
<div className="relative group cursor-pointer" onClick={handleAvatarClick}>
|
|
||||||
<div className="w-20 h-20 rounded-full bg-gradient-to-br from-[#336EFF] to-blue-400 flex items-center justify-center text-2xl font-bold text-white shadow-lg overflow-hidden">
|
|
||||||
{displayedAvatarUrl ? <img src={displayedAvatarUrl} alt="Avatar" className="w-full h-full object-cover" /> : avatarFallback}
|
|
||||||
</div>
|
|
||||||
<div className="absolute inset-0 bg-black/50 rounded-full opacity-0 group-hover:opacity-100 flex items-center justify-center transition-opacity">
|
|
||||||
<span className="text-xs text-white">{selectedAvatarFile ? '等待保存' : '更换头像'}</span>
|
|
||||||
</div>
|
|
||||||
<input type="file" ref={fileInputRef} onChange={handleFileChange} accept="image/*" className="hidden" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 space-y-1">
|
|
||||||
<h4 className="text-lg font-medium text-white">{displayName}</h4>
|
|
||||||
<p className="text-sm text-slate-400">{roleLabel}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-slate-300">昵称</label>
|
|
||||||
<Input
|
|
||||||
value={profileDraft.displayName}
|
|
||||||
onChange={(event) => handleProfileDraftChange('displayName', event.target.value)}
|
|
||||||
className="bg-black/20 border-white/10 text-white focus-visible:ring-[#336EFF]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-slate-300">邮箱</label>
|
|
||||||
<Input
|
|
||||||
type="email"
|
|
||||||
value={profileDraft.email}
|
|
||||||
onChange={(event) => handleProfileDraftChange('email', event.target.value)}
|
|
||||||
className="bg-black/20 border-white/10 text-white focus-visible:ring-[#336EFF]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-slate-300">手机号</label>
|
|
||||||
<Input
|
|
||||||
type="tel"
|
|
||||||
value={profileDraft.phoneNumber}
|
|
||||||
onChange={(event) => handleProfileDraftChange('phoneNumber', event.target.value)}
|
|
||||||
className="bg-black/20 border-white/10 text-white focus-visible:ring-[#336EFF]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-slate-300">个人简介</label>
|
|
||||||
<textarea
|
|
||||||
className="w-full min-h-[100px] rounded-md bg-black/20 border border-white/10 text-white p-3 text-sm focus:outline-none focus:ring-2 focus:ring-[#336EFF] resize-none"
|
|
||||||
value={profileDraft.bio}
|
|
||||||
onChange={(event) => handleProfileDraftChange('bio', event.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-slate-300">语言偏好</label>
|
|
||||||
<select
|
|
||||||
className="w-full rounded-md bg-black/20 border border-white/10 text-white p-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-[#336EFF] appearance-none"
|
|
||||||
value={profileDraft.preferredLanguage}
|
|
||||||
onChange={(event) => handleProfileDraftChange('preferredLanguage', event.target.value)}
|
|
||||||
>
|
|
||||||
<option value="zh-CN">简体中文</option>
|
|
||||||
<option value="en-US">English</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between p-4 rounded-xl bg-white/5 border border-white/10">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="w-10 h-10 rounded-full bg-blue-500/20 flex items-center justify-center">
|
|
||||||
<Key className="w-5 h-5 text-blue-400" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-white">安全中心</p>
|
|
||||||
<p className="text-xs text-slate-400 mt-0.5">修改密码及账号保护设置</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="border-white/10 hover:bg-white/10 text-slate-300"
|
|
||||||
onClick={() => {
|
|
||||||
setActiveModal('security');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
管理
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{profileError && <p className="text-sm text-rose-300">{profileError}</p>}
|
|
||||||
{profileMessage && <p className="text-sm text-emerald-300">{profileMessage}</p>}
|
|
||||||
|
|
||||||
<div className="pt-4 flex justify-end gap-3">
|
|
||||||
<Button variant="outline" onClick={closeModal} className="border-white/10 hover:bg-white/10 text-slate-300">
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
<Button variant="default" disabled={profileSubmitting} onClick={() => void handleSaveProfile()}>
|
|
||||||
{profileSubmitting ? '保存中...' : '保存更改'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import { afterEach, test } from 'node:test';
|
|
||||||
import React from 'react';
|
|
||||||
import { renderToStaticMarkup } from 'react-dom/server';
|
|
||||||
|
|
||||||
import { createUploadTask } from '@/src/pages/files-upload';
|
|
||||||
import {
|
|
||||||
clearFilesUploads,
|
|
||||||
replaceFilesUploads,
|
|
||||||
resetFilesUploadStoreForTests,
|
|
||||||
setFilesUploadPanelOpen,
|
|
||||||
} from '@/src/pages/files-upload-store';
|
|
||||||
|
|
||||||
import { UploadProgressPanel } from './UploadProgressPanel';
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
resetFilesUploadStoreForTests();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('mobile upload progress panel renders as a top summary card instead of a bottom desktop panel', () => {
|
|
||||||
replaceFilesUploads([
|
|
||||||
createUploadTask(new File(['demo'], 'demo.txt', { type: 'text/plain' }), []),
|
|
||||||
]);
|
|
||||||
setFilesUploadPanelOpen(false);
|
|
||||||
|
|
||||||
const html = renderToStaticMarkup(
|
|
||||||
React.createElement(UploadProgressPanel, {
|
|
||||||
variant: 'mobile',
|
|
||||||
className: 'top-offset-anchor',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
clearFilesUploads();
|
|
||||||
|
|
||||||
assert.match(html, /top-offset-anchor/);
|
|
||||||
assert.match(html, /已在后台上传 1 项/);
|
|
||||||
assert.doesNotMatch(html, /bottom-6/);
|
|
||||||
});
|
|
||||||
@@ -1,220 +0,0 @@
|
|||||||
import { AnimatePresence, motion } from 'motion/react';
|
|
||||||
import { Ban, CheckCircle2, ChevronDown, ChevronUp, FileUp, TriangleAlert, UploadCloud, X } from 'lucide-react';
|
|
||||||
|
|
||||||
import { FileTypeIcon, getFileTypeTheme } from '@/src/components/ui/FileTypeIcon';
|
|
||||||
import { ellipsizeFileName } from '@/src/lib/file-name';
|
|
||||||
import { cn } from '@/src/lib/utils';
|
|
||||||
import {
|
|
||||||
cancelFilesUploadTask,
|
|
||||||
clearFilesUploads,
|
|
||||||
toggleFilesUploadPanelOpen,
|
|
||||||
useFilesUploadStore,
|
|
||||||
} from '@/src/pages/files-upload-store';
|
|
||||||
import type { UploadTask } from '@/src/pages/files-upload';
|
|
||||||
|
|
||||||
export type UploadProgressPanelVariant = 'desktop' | 'mobile';
|
|
||||||
|
|
||||||
export function getUploadProgressSummary(uploads: UploadTask[]) {
|
|
||||||
const uploadingCount = uploads.filter((task) => task.status === 'uploading').length;
|
|
||||||
const completedCount = uploads.filter((task) => task.status === 'completed').length;
|
|
||||||
const errorCount = uploads.filter((task) => task.status === 'error').length;
|
|
||||||
const cancelledCount = uploads.filter((task) => task.status === 'cancelled').length;
|
|
||||||
const uploadingTasks = uploads.filter((task) => task.status === 'uploading');
|
|
||||||
const activeProgress = uploadingTasks.length > 0
|
|
||||||
? Math.round(uploadingTasks.reduce((sum, task) => sum + task.progress, 0) / uploadingTasks.length)
|
|
||||||
: uploads.length > 0 && completedCount === uploads.length
|
|
||||||
? 100
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
if (uploadingCount > 0) {
|
|
||||||
return {
|
|
||||||
title: `已在后台上传 ${uploadingCount} 项`,
|
|
||||||
detail: `${completedCount}/${uploads.length} 已完成 · ${activeProgress}%`,
|
|
||||||
progress: activeProgress,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (errorCount > 0) {
|
|
||||||
return {
|
|
||||||
title: `上传结束,${errorCount} 项失败`,
|
|
||||||
detail: `${completedCount}/${uploads.length} 已完成`,
|
|
||||||
progress: activeProgress,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cancelledCount > 0) {
|
|
||||||
return {
|
|
||||||
title: '上传已停止',
|
|
||||||
detail: `${completedCount}/${uploads.length} 已完成`,
|
|
||||||
progress: activeProgress,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
title: `上传已完成 ${completedCount} 项`,
|
|
||||||
detail: `${completedCount}/${uploads.length} 已完成`,
|
|
||||||
progress: activeProgress,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UploadProgressPanelProps {
|
|
||||||
className?: string;
|
|
||||||
variant?: UploadProgressPanelVariant;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function UploadProgressPanel({
|
|
||||||
className,
|
|
||||||
variant = 'desktop',
|
|
||||||
}: UploadProgressPanelProps = {}) {
|
|
||||||
const { uploads, isUploadPanelOpen } = useFilesUploadStore();
|
|
||||||
|
|
||||||
if (uploads.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const summary = getUploadProgressSummary(uploads);
|
|
||||||
const isMobile = variant === 'mobile';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AnimatePresence>
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 50, scale: 0.95 }}
|
|
||||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
|
||||||
exit={{ opacity: 0, y: 50, scale: 0.95 }}
|
|
||||||
className={cn(
|
|
||||||
'z-50 flex flex-col overflow-hidden border border-white/10 bg-[#0f172a]/95 backdrop-blur-xl',
|
|
||||||
isMobile
|
|
||||||
? 'w-full rounded-2xl shadow-[0_16px_40px_rgba(15,23,42,0.28)]'
|
|
||||||
: 'fixed bottom-6 right-6 w-[min(24rem,calc(100vw-2rem))] rounded-xl shadow-2xl',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="flex cursor-pointer items-center justify-between border-b border-white/10 bg-white/5 px-4 py-3 transition-colors hover:bg-white/10"
|
|
||||||
onClick={() => toggleFilesUploadPanelOpen()}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<UploadCloud className="h-4 w-4 text-[#336EFF]" />
|
|
||||||
<div className="flex min-w-0 flex-col">
|
|
||||||
<span className="text-sm font-medium text-white">
|
|
||||||
{isMobile ? summary.title : `上传进度 (${uploads.filter((task) => task.status === 'completed').length}/${uploads.length})`}
|
|
||||||
</span>
|
|
||||||
{isMobile ? (
|
|
||||||
<span className="text-[11px] text-slate-400">{summary.detail}</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
{isMobile ? (
|
|
||||||
<span className="rounded-full bg-[#336EFF]/15 px-2 py-1 text-[11px] font-medium text-[#8fb0ff]">
|
|
||||||
{summary.progress}%
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
<button type="button" className="rounded p-1 text-slate-400 transition-colors hover:bg-white/10 hover:text-white">
|
|
||||||
{isUploadPanelOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronUp className="h-4 w-4" />}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="rounded p-1 text-slate-400 transition-colors hover:bg-white/10 hover:text-white"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
clearFilesUploads();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AnimatePresence initial={false}>
|
|
||||||
{isUploadPanelOpen && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ height: 0 }}
|
|
||||||
animate={{ height: 'auto' }}
|
|
||||||
exit={{ height: 0 }}
|
|
||||||
className={cn(isMobile ? 'max-h-64 overflow-y-auto' : 'max-h-80 overflow-y-auto')}
|
|
||||||
>
|
|
||||||
<div className="space-y-1 p-2">
|
|
||||||
{uploads.map((task) => (
|
|
||||||
<div
|
|
||||||
key={task.id}
|
|
||||||
className={cn(
|
|
||||||
'group relative overflow-hidden rounded-lg p-3 transition-colors hover:bg-white/5',
|
|
||||||
task.status === 'error' && 'bg-rose-500/5',
|
|
||||||
task.status === 'cancelled' && 'bg-amber-500/5',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{task.status === 'uploading' && (
|
|
||||||
<div
|
|
||||||
className="absolute inset-y-0 left-0 bg-[#336EFF]/10 transition-all duration-300 ease-out"
|
|
||||||
style={{ width: `${task.progress}%` }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="relative z-10 flex items-start gap-3">
|
|
||||||
<FileTypeIcon type={task.type} size="sm" className="mt-0.5" />
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<p className="truncate text-sm font-medium text-slate-200" title={task.fileName}>
|
|
||||||
{ellipsizeFileName(task.fileName, 30)}
|
|
||||||
</p>
|
|
||||||
<div className="shrink-0 flex items-center gap-2">
|
|
||||||
{task.status === 'uploading' ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="rounded-md border border-amber-500/30 bg-amber-500/10 px-2 py-0.5 text-[11px] font-medium text-amber-200 transition-colors hover:bg-amber-500/20"
|
|
||||||
onClick={() => {
|
|
||||||
cancelFilesUploadTask(task.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
{task.status === 'completed' ? (
|
|
||||||
<CheckCircle2 className="h-[18px] w-[18px] text-emerald-400" />
|
|
||||||
) : task.status === 'cancelled' ? (
|
|
||||||
<Ban className="h-[18px] w-[18px] text-amber-300" />
|
|
||||||
) : task.status === 'error' ? (
|
|
||||||
<TriangleAlert className="h-[18px] w-[18px] text-rose-400" />
|
|
||||||
) : (
|
|
||||||
<FileUp className="h-[18px] w-[18px] animate-pulse text-[#78A1FF]" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs">
|
|
||||||
<span className={cn('rounded-full px-2 py-1 font-medium', getFileTypeTheme(task.type).badgeClassName)}>
|
|
||||||
{task.typeLabel}
|
|
||||||
</span>
|
|
||||||
<span className="truncate text-slate-500">上传至: {task.destination}</span>
|
|
||||||
</div>
|
|
||||||
{task.noticeMessage && (
|
|
||||||
<p className="mt-2 truncate text-xs text-amber-300">{task.noticeMessage}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{task.status === 'uploading' && (
|
|
||||||
<div className="mt-2 flex items-center justify-between text-xs">
|
|
||||||
<span className="font-medium text-[#336EFF]">{Math.round(task.progress)}%</span>
|
|
||||||
<span className="font-mono text-slate-400">{task.speed}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{task.status === 'completed' && (
|
|
||||||
<p className="mt-2 text-xs text-emerald-400">上传完成</p>
|
|
||||||
)}
|
|
||||||
{task.status === 'cancelled' && (
|
|
||||||
<p className="mt-2 text-xs text-amber-300">已取消上传</p>
|
|
||||||
)}
|
|
||||||
{task.status === 'error' && (
|
|
||||||
<p className="mt-2 truncate text-xs text-rose-400">{task.errorMessage ?? '上传失败,请稍后重试'}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</motion.div>
|
|
||||||
</AnimatePresence>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import test from 'node:test';
|
|
||||||
|
|
||||||
import type { UserProfile } from '@/src/lib/types';
|
|
||||||
|
|
||||||
import { buildAccountDraft, getRoleLabel, shouldLoadAvatarWithAuth } from './account-utils';
|
|
||||||
|
|
||||||
test('buildAccountDraft prefers display name and fills fallback values', () => {
|
|
||||||
const profile: UserProfile = {
|
|
||||||
id: 1,
|
|
||||||
username: 'alice',
|
|
||||||
displayName: 'Alice',
|
|
||||||
email: 'alice@example.com',
|
|
||||||
bio: null,
|
|
||||||
preferredLanguage: null,
|
|
||||||
role: 'USER',
|
|
||||||
createdAt: '2026-03-19T17:00:00',
|
|
||||||
};
|
|
||||||
|
|
||||||
assert.deepEqual(buildAccountDraft(profile), {
|
|
||||||
displayName: 'Alice',
|
|
||||||
email: 'alice@example.com',
|
|
||||||
phoneNumber: '',
|
|
||||||
bio: '',
|
|
||||||
preferredLanguage: 'zh-CN',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('getRoleLabel maps backend roles to readable chinese labels', () => {
|
|
||||||
assert.equal(getRoleLabel('ADMIN'), '管理员');
|
|
||||||
assert.equal(getRoleLabel('MODERATOR'), '协管员');
|
|
||||||
assert.equal(getRoleLabel('USER'), '普通用户');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('shouldLoadAvatarWithAuth only treats relative avatar urls as protected resources', () => {
|
|
||||||
assert.equal(shouldLoadAvatarWithAuth('/api/user/avatar/content?v=1'), true);
|
|
||||||
assert.equal(shouldLoadAvatarWithAuth('https://cdn.example.com/avatar.png?sig=1'), false);
|
|
||||||
assert.equal(shouldLoadAvatarWithAuth(null), false);
|
|
||||||
});
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import type { AdminUserRole, UserProfile } from '@/src/lib/types';
|
|
||||||
|
|
||||||
export interface AccountDraft {
|
|
||||||
displayName: string;
|
|
||||||
email: string;
|
|
||||||
phoneNumber: string;
|
|
||||||
bio: string;
|
|
||||||
preferredLanguage: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildAccountDraft(profile: UserProfile): AccountDraft {
|
|
||||||
return {
|
|
||||||
displayName: profile.displayName || profile.username,
|
|
||||||
email: profile.email,
|
|
||||||
phoneNumber: profile.phoneNumber || '',
|
|
||||||
bio: profile.bio || '',
|
|
||||||
preferredLanguage: profile.preferredLanguage || 'zh-CN',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getRoleLabel(role: AdminUserRole | undefined) {
|
|
||||||
switch (role) {
|
|
||||||
case 'ADMIN':
|
|
||||||
return '管理员';
|
|
||||||
case 'MODERATOR':
|
|
||||||
return '协管员';
|
|
||||||
default:
|
|
||||||
return '普通用户';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function shouldLoadAvatarWithAuth(avatarUrl: string | null | undefined) {
|
|
||||||
return Boolean(avatarUrl && avatarUrl.startsWith('/'));
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import React, { ReactNode } from 'react';
|
|
||||||
import { cn } from '@/src/lib/utils';
|
|
||||||
|
|
||||||
interface AppPageShellProps {
|
|
||||||
toolbar: ReactNode;
|
|
||||||
rail?: ReactNode;
|
|
||||||
inspector?: ReactNode;
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AppPageShell({ toolbar, rail, inspector, children }: AppPageShellProps) {
|
|
||||||
return (
|
|
||||||
<div className="flex-1 flex flex-col min-w-0 h-full overflow-hidden relative z-10 w-full bg-[#07101D]">
|
|
||||||
{/* Top Toolbar */}
|
|
||||||
<header className="h-14 shrink-0 border-b border-white/10 bg-[#0f172a]/70 flex items-center px-4 w-full z-20 backdrop-blur-xl">
|
|
||||||
{toolbar}
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* 3-Zone Content Segment */}
|
|
||||||
<div className="flex-1 flex min-h-0 w-full overflow-hidden">
|
|
||||||
{/* Nav Rail (e.g. Directory Tree) */}
|
|
||||||
{rail && (
|
|
||||||
<div className="w-64 shrink-0 border-r border-white/10 bg-[#0f172a]/20 h-full overflow-y-auto">
|
|
||||||
{rail}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Center Main Pane */}
|
|
||||||
<main className="flex-1 min-w-0 h-full overflow-y-auto bg-transparent relative">
|
|
||||||
{children}
|
|
||||||
</main>
|
|
||||||
|
|
||||||
{/* Inspector Panel (e.g. File Details) */}
|
|
||||||
{inspector && (
|
|
||||||
<div className="w-72 shrink-0 border-l border-white/10 bg-[#0f172a]/20 h-full overflow-y-auto hidden lg:block">
|
|
||||||
{inspector}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
import type { LucideIcon } from 'lucide-react';
|
|
||||||
import {
|
|
||||||
AppWindow,
|
|
||||||
BookOpenText,
|
|
||||||
Database,
|
|
||||||
FileArchive,
|
|
||||||
FileAudio2,
|
|
||||||
FileBadge2,
|
|
||||||
FileCode2,
|
|
||||||
FileImage,
|
|
||||||
FileSpreadsheet,
|
|
||||||
FileText,
|
|
||||||
FileVideoCamera,
|
|
||||||
Folder,
|
|
||||||
Presentation,
|
|
||||||
SwatchBook,
|
|
||||||
Type,
|
|
||||||
} from 'lucide-react';
|
|
||||||
|
|
||||||
import type { FileTypeKind } from '@/src/lib/file-type';
|
|
||||||
import { cn } from '@/src/lib/utils';
|
|
||||||
|
|
||||||
type FileTypeIconSize = 'sm' | 'md' | 'lg';
|
|
||||||
|
|
||||||
interface FileTypeTheme {
|
|
||||||
badgeClassName: string;
|
|
||||||
icon: LucideIcon;
|
|
||||||
iconClassName: string;
|
|
||||||
surfaceClassName: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const FILE_TYPE_THEMES: Record<FileTypeKind, FileTypeTheme> = {
|
|
||||||
folder: {
|
|
||||||
icon: Folder,
|
|
||||||
iconClassName: 'text-[#78A1FF]',
|
|
||||||
surfaceClassName: 'border border-[#336EFF]/25 bg-[linear-gradient(135deg,rgba(51,110,255,0.24),rgba(15,23,42,0.2))] shadow-[0_16px_30px_-22px_rgba(51,110,255,0.95)]',
|
|
||||||
badgeClassName: 'border border-[#336EFF]/20 bg-[#336EFF]/10 text-[#93B4FF]',
|
|
||||||
},
|
|
||||||
image: {
|
|
||||||
icon: FileImage,
|
|
||||||
iconClassName: 'text-cyan-300',
|
|
||||||
surfaceClassName: 'border border-cyan-400/20 bg-[linear-gradient(135deg,rgba(34,211,238,0.18),rgba(15,23,42,0.18))] shadow-[0_16px_30px_-22px_rgba(34,211,238,0.8)]',
|
|
||||||
badgeClassName: 'border border-cyan-400/15 bg-cyan-400/10 text-cyan-200',
|
|
||||||
},
|
|
||||||
pdf: {
|
|
||||||
icon: FileBadge2,
|
|
||||||
iconClassName: 'text-rose-300',
|
|
||||||
surfaceClassName: 'border border-rose-400/20 bg-[linear-gradient(135deg,rgba(251,113,133,0.18),rgba(15,23,42,0.18))] shadow-[0_16px_30px_-22px_rgba(251,113,133,0.78)]',
|
|
||||||
badgeClassName: 'border border-rose-400/15 bg-rose-400/10 text-rose-200',
|
|
||||||
},
|
|
||||||
word: {
|
|
||||||
icon: FileText,
|
|
||||||
iconClassName: 'text-sky-300',
|
|
||||||
surfaceClassName: 'border border-sky-400/20 bg-[linear-gradient(135deg,rgba(56,189,248,0.18),rgba(15,23,42,0.18))] shadow-[0_16px_30px_-22px_rgba(56,189,248,0.8)]',
|
|
||||||
badgeClassName: 'border border-sky-400/15 bg-sky-400/10 text-sky-200',
|
|
||||||
},
|
|
||||||
spreadsheet: {
|
|
||||||
icon: FileSpreadsheet,
|
|
||||||
iconClassName: 'text-emerald-300',
|
|
||||||
surfaceClassName: 'border border-emerald-400/20 bg-[linear-gradient(135deg,rgba(52,211,153,0.18),rgba(15,23,42,0.18))] shadow-[0_16px_30px_-22px_rgba(52,211,153,0.82)]',
|
|
||||||
badgeClassName: 'border border-emerald-400/15 bg-emerald-400/10 text-emerald-200',
|
|
||||||
},
|
|
||||||
presentation: {
|
|
||||||
icon: Presentation,
|
|
||||||
iconClassName: 'text-amber-300',
|
|
||||||
surfaceClassName: 'border border-amber-400/20 bg-[linear-gradient(135deg,rgba(251,191,36,0.18),rgba(15,23,42,0.18))] shadow-[0_16px_30px_-22px_rgba(251,191,36,0.82)]',
|
|
||||||
badgeClassName: 'border border-amber-400/15 bg-amber-400/10 text-amber-100',
|
|
||||||
},
|
|
||||||
archive: {
|
|
||||||
icon: FileArchive,
|
|
||||||
iconClassName: 'text-orange-300',
|
|
||||||
surfaceClassName: 'border border-orange-400/20 bg-[linear-gradient(135deg,rgba(251,146,60,0.18),rgba(15,23,42,0.18))] shadow-[0_16px_30px_-22px_rgba(251,146,60,0.8)]',
|
|
||||||
badgeClassName: 'border border-orange-400/15 bg-orange-400/10 text-orange-100',
|
|
||||||
},
|
|
||||||
video: {
|
|
||||||
icon: FileVideoCamera,
|
|
||||||
iconClassName: 'text-fuchsia-300',
|
|
||||||
surfaceClassName: 'border border-fuchsia-400/20 bg-[linear-gradient(135deg,rgba(232,121,249,0.18),rgba(15,23,42,0.18))] shadow-[0_16px_30px_-22px_rgba(232,121,249,0.78)]',
|
|
||||||
badgeClassName: 'border border-fuchsia-400/15 bg-fuchsia-400/10 text-fuchsia-100',
|
|
||||||
},
|
|
||||||
audio: {
|
|
||||||
icon: FileAudio2,
|
|
||||||
iconClassName: 'text-teal-300',
|
|
||||||
surfaceClassName: 'border border-teal-400/20 bg-[linear-gradient(135deg,rgba(45,212,191,0.18),rgba(15,23,42,0.18))] shadow-[0_16px_30px_-22px_rgba(45,212,191,0.8)]',
|
|
||||||
badgeClassName: 'border border-teal-400/15 bg-teal-400/10 text-teal-100',
|
|
||||||
},
|
|
||||||
design: {
|
|
||||||
icon: SwatchBook,
|
|
||||||
iconClassName: 'text-pink-300',
|
|
||||||
surfaceClassName: 'border border-pink-400/20 bg-[linear-gradient(135deg,rgba(244,114,182,0.18),rgba(15,23,42,0.18))] shadow-[0_16px_30px_-22px_rgba(244,114,182,0.8)]',
|
|
||||||
badgeClassName: 'border border-pink-400/15 bg-pink-400/10 text-pink-100',
|
|
||||||
},
|
|
||||||
font: {
|
|
||||||
icon: Type,
|
|
||||||
iconClassName: 'text-lime-300',
|
|
||||||
surfaceClassName: 'border border-lime-400/20 bg-[linear-gradient(135deg,rgba(163,230,53,0.18),rgba(15,23,42,0.18))] shadow-[0_16px_30px_-22px_rgba(163,230,53,0.8)]',
|
|
||||||
badgeClassName: 'border border-lime-400/15 bg-lime-400/10 text-lime-100',
|
|
||||||
},
|
|
||||||
application: {
|
|
||||||
icon: AppWindow,
|
|
||||||
iconClassName: 'text-violet-300',
|
|
||||||
surfaceClassName: 'border border-violet-400/20 bg-[linear-gradient(135deg,rgba(167,139,250,0.18),rgba(15,23,42,0.18))] shadow-[0_16px_30px_-22px_rgba(167,139,250,0.82)]',
|
|
||||||
badgeClassName: 'border border-violet-400/15 bg-violet-400/10 text-violet-100',
|
|
||||||
},
|
|
||||||
ebook: {
|
|
||||||
icon: BookOpenText,
|
|
||||||
iconClassName: 'text-yellow-200',
|
|
||||||
surfaceClassName: 'border border-yellow-300/20 bg-[linear-gradient(135deg,rgba(253,224,71,0.16),rgba(15,23,42,0.18))] shadow-[0_16px_30px_-22px_rgba(253,224,71,0.7)]',
|
|
||||||
badgeClassName: 'border border-yellow-300/15 bg-yellow-300/10 text-yellow-100',
|
|
||||||
},
|
|
||||||
code: {
|
|
||||||
icon: FileCode2,
|
|
||||||
iconClassName: 'text-cyan-200',
|
|
||||||
surfaceClassName: 'border border-cyan-300/20 bg-[linear-gradient(135deg,rgba(103,232,249,0.16),rgba(15,23,42,0.18))] shadow-[0_16px_30px_-22px_rgba(103,232,249,0.72)]',
|
|
||||||
badgeClassName: 'border border-cyan-300/15 bg-cyan-300/10 text-cyan-100',
|
|
||||||
},
|
|
||||||
text: {
|
|
||||||
icon: FileText,
|
|
||||||
iconClassName: 'text-slate-200',
|
|
||||||
surfaceClassName: 'border border-slate-400/20 bg-[linear-gradient(135deg,rgba(148,163,184,0.18),rgba(15,23,42,0.18))] shadow-[0_16px_30px_-22px_rgba(148,163,184,0.55)]',
|
|
||||||
badgeClassName: 'border border-slate-400/15 bg-slate-400/10 text-slate-200',
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
icon: Database,
|
|
||||||
iconClassName: 'text-indigo-300',
|
|
||||||
surfaceClassName: 'border border-indigo-400/20 bg-[linear-gradient(135deg,rgba(129,140,248,0.18),rgba(15,23,42,0.18))] shadow-[0_16px_30px_-22px_rgba(129,140,248,0.8)]',
|
|
||||||
badgeClassName: 'border border-indigo-400/15 bg-indigo-400/10 text-indigo-100',
|
|
||||||
},
|
|
||||||
document: {
|
|
||||||
icon: FileText,
|
|
||||||
iconClassName: 'text-slate-100',
|
|
||||||
surfaceClassName: 'border border-white/10 bg-[linear-gradient(135deg,rgba(148,163,184,0.14),rgba(15,23,42,0.18))] shadow-[0_16px_30px_-22px_rgba(15,23,42,0.9)]',
|
|
||||||
badgeClassName: 'border border-white/10 bg-white/10 text-slate-200',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const CONTAINER_SIZES: Record<FileTypeIconSize, string> = {
|
|
||||||
sm: 'h-10 w-10 rounded-xl',
|
|
||||||
md: 'h-12 w-12 rounded-2xl',
|
|
||||||
lg: 'h-16 w-16 rounded-[1.35rem]',
|
|
||||||
};
|
|
||||||
|
|
||||||
const ICON_SIZES: Record<FileTypeIconSize, string> = {
|
|
||||||
sm: 'h-[18px] w-[18px]',
|
|
||||||
md: 'h-[22px] w-[22px]',
|
|
||||||
lg: 'h-8 w-8',
|
|
||||||
};
|
|
||||||
|
|
||||||
export function getFileTypeTheme(type: FileTypeKind): FileTypeTheme {
|
|
||||||
return FILE_TYPE_THEMES[type] ?? FILE_TYPE_THEMES.document;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FileTypeIcon({
|
|
||||||
type,
|
|
||||||
size = 'md',
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
type: FileTypeKind;
|
|
||||||
size?: FileTypeIconSize;
|
|
||||||
className?: string;
|
|
||||||
}) {
|
|
||||||
const theme = getFileTypeTheme(type);
|
|
||||||
const Icon = theme.icon;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'flex shrink-0 items-center justify-center backdrop-blur-sm',
|
|
||||||
CONTAINER_SIZES[size],
|
|
||||||
theme.surfaceClassName,
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Icon className={cn(ICON_SIZES[size], theme.iconClassName)} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,234 +0,0 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
import { AnimatePresence, motion } from 'motion/react';
|
|
||||||
import { ChevronLeft, ChevronRight, Folder, Loader2, X } from 'lucide-react';
|
|
||||||
import { createPortal } from 'react-dom';
|
|
||||||
|
|
||||||
import { apiRequest } from '@/src/lib/api';
|
|
||||||
import { getParentNetdiskPath, joinNetdiskPath, splitNetdiskPath } from '@/src/lib/netdisk-paths';
|
|
||||||
import type { FileMetadata, PageResponse } from '@/src/lib/types';
|
|
||||||
|
|
||||||
import { Button } from './button';
|
|
||||||
|
|
||||||
interface NetdiskPathPickerModalProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
title: string;
|
|
||||||
description?: string;
|
|
||||||
initialPath?: string;
|
|
||||||
confirmLabel: string;
|
|
||||||
confirmPathPreview?: (path: string) => string;
|
|
||||||
onClose: () => void;
|
|
||||||
onConfirm: (path: string) => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function NetdiskPathPickerModal({
|
|
||||||
isOpen,
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
initialPath = '/',
|
|
||||||
confirmLabel,
|
|
||||||
confirmPathPreview,
|
|
||||||
onClose,
|
|
||||||
onConfirm,
|
|
||||||
}: NetdiskPathPickerModalProps) {
|
|
||||||
const [currentPath, setCurrentPath] = useState(initialPath);
|
|
||||||
const [folders, setFolders] = useState<FileMetadata[]>([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [confirming, setConfirming] = useState(false);
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setCurrentPath(initialPath);
|
|
||||||
setError('');
|
|
||||||
}, [initialPath, isOpen]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let active = true;
|
|
||||||
setLoading(true);
|
|
||||||
setError('');
|
|
||||||
|
|
||||||
void apiRequest<PageResponse<FileMetadata>>(
|
|
||||||
`/files/list?path=${encodeURIComponent(currentPath)}&page=0&size=100`,
|
|
||||||
)
|
|
||||||
.then((response) => {
|
|
||||||
if (!active) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setFolders(response.items.filter((item) => item.directory));
|
|
||||||
})
|
|
||||||
.catch((requestError) => {
|
|
||||||
if (!active) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setFolders([]);
|
|
||||||
setError(requestError instanceof Error ? requestError.message : '读取网盘目录失败');
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
if (active) {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
active = false;
|
|
||||||
};
|
|
||||||
}, [currentPath, isOpen]);
|
|
||||||
|
|
||||||
async function handleConfirm() {
|
|
||||||
setConfirming(true);
|
|
||||||
setError('');
|
|
||||||
|
|
||||||
try {
|
|
||||||
await onConfirm(currentPath);
|
|
||||||
onClose();
|
|
||||||
} catch (requestError) {
|
|
||||||
setError(requestError instanceof Error ? requestError.message : '保存目录失败');
|
|
||||||
} finally {
|
|
||||||
setConfirming(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const pathSegments = splitNetdiskPath(currentPath);
|
|
||||||
const previewPath = confirmPathPreview ? confirmPathPreview(currentPath) : currentPath;
|
|
||||||
if (typeof document === 'undefined') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return createPortal(
|
|
||||||
<AnimatePresence>
|
|
||||||
{isOpen ? (
|
|
||||||
<div className="fixed inset-0 z-[130] overflow-y-auto bg-black/50 p-4 backdrop-blur-sm sm:p-6">
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
|
||||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
|
||||||
className="mx-auto my-4 flex w-full max-w-lg flex-col overflow-hidden rounded-2xl border border-white/10 bg-[#0f172a] shadow-2xl sm:my-8 max-h-[calc(100vh-2rem)] sm:max-h-[calc(100vh-3rem)]"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between border-b border-white/10 bg-white/5 px-5 py-4">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-semibold text-white">{title}</h3>
|
|
||||||
{description ? <p className="mt-1 text-xs text-slate-400">{description}</p> : null}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
className="rounded-md p-1 text-slate-400 transition-colors hover:bg-white/10 hover:text-white"
|
|
||||||
>
|
|
||||||
<X className="h-5 w-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 space-y-4 overflow-y-auto p-5">
|
|
||||||
<div className="rounded-xl border border-white/10 bg-black/20 p-4">
|
|
||||||
<div className="flex items-center justify-between gap-4">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">当前目录</p>
|
|
||||||
<div className="mt-2 flex flex-wrap items-center gap-1 text-sm text-slate-200">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="rounded px-1 py-0.5 hover:bg-white/10"
|
|
||||||
onClick={() => setCurrentPath('/')}
|
|
||||||
>
|
|
||||||
网盘
|
|
||||||
</button>
|
|
||||||
{pathSegments.map((segment, index) => (
|
|
||||||
<React.Fragment key={`${segment}-${index}`}>
|
|
||||||
<ChevronRight className="h-3.5 w-3.5 text-slate-500" />
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="rounded px-1 py-0.5 hover:bg-white/10"
|
|
||||||
onClick={() => setCurrentPath(joinNetdiskPath(pathSegments.slice(0, index + 1)))}
|
|
||||||
>
|
|
||||||
{segment}
|
|
||||||
</button>
|
|
||||||
</React.Fragment>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="border-white/10 text-slate-200 hover:bg-white/10"
|
|
||||||
disabled={currentPath === '/'}
|
|
||||||
onClick={() => setCurrentPath(getParentNetdiskPath(currentPath))}
|
|
||||||
>
|
|
||||||
<ChevronLeft className="mr-1 h-4 w-4" />
|
|
||||||
返回上级
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<p className="mt-3 text-xs text-emerald-300">将存入: {previewPath}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-xl border border-white/10 bg-black/20">
|
|
||||||
<div className="border-b border-white/10 px-4 py-3 text-sm font-medium text-slate-200">选择目标文件夹</div>
|
|
||||||
<div className="max-h-72 overflow-y-auto p-3 sm:max-h-80">
|
|
||||||
{loading ? (
|
|
||||||
<div className="flex items-center justify-center gap-2 px-4 py-10 text-sm text-slate-400">
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
正在加载目录...
|
|
||||||
</div>
|
|
||||||
) : folders.length === 0 ? (
|
|
||||||
<div className="px-4 py-10 text-center text-sm text-slate-500">这个目录下没有更多子文件夹,当前目录也可以直接使用。</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{folders.map((folder) => {
|
|
||||||
const nextPath = folder.path;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={folder.id}
|
|
||||||
type="button"
|
|
||||||
className="flex w-full items-center gap-3 rounded-xl border border-white/5 bg-white/[0.03] px-4 py-3 text-left transition-colors hover:border-white/10 hover:bg-white/[0.06]"
|
|
||||||
onClick={() => setCurrentPath(nextPath)}
|
|
||||||
>
|
|
||||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-[#336EFF]/10">
|
|
||||||
<Folder className="h-4 w-4 text-[#336EFF]" />
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<p className="truncate text-sm font-medium text-slate-100">{folder.filename}</p>
|
|
||||||
<p className="truncate text-xs text-slate-500">{nextPath}</p>
|
|
||||||
</div>
|
|
||||||
<ChevronRight className="h-4 w-4 text-slate-500" />
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<div className="rounded-xl border border-rose-500/20 bg-rose-500/10 px-4 py-3 text-sm text-rose-200">{error}</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-3">
|
|
||||||
<Button type="button" variant="outline" className="border-white/10 text-slate-300 hover:bg-white/10" onClick={onClose} disabled={confirming}>
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
<Button type="button" onClick={() => void handleConfirm()} disabled={confirming || loading}>
|
|
||||||
{confirming ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
处理中...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
confirmLabel
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</AnimatePresence>
|
|
||||||
,
|
|
||||||
document.body,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import React, { ReactNode } from 'react';
|
|
||||||
|
|
||||||
interface PageToolbarProps {
|
|
||||||
title: ReactNode;
|
|
||||||
actions?: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PageToolbar({ title, actions }: PageToolbarProps) {
|
|
||||||
return (
|
|
||||||
<div className="flex w-full items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{typeof title === 'string' ? (
|
|
||||||
<h2 className="text-lg font-semibold text-white tracking-tight">{title}</h2>
|
|
||||||
) : (
|
|
||||||
title
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{actions && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{actions}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import { cn } from "@/src/lib/utils"
|
|
||||||
|
|
||||||
export interface ButtonProps
|
|
||||||
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|
||||||
variant?: "default" | "outline" | "ghost" | "glass"
|
|
||||||
size?: "default" | "sm" | "lg" | "icon"
|
|
||||||
}
|
|
||||||
|
|
||||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|
||||||
({ className, variant = "default", size = "default", ...props }, ref) => {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
className={cn(
|
|
||||||
"inline-flex items-center justify-center whitespace-nowrap rounded-xl text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
|
||||||
{
|
|
||||||
"bg-[#336EFF] text-white hover:bg-[#2958cc] shadow-md shadow-[#336EFF]/20": variant === "default",
|
|
||||||
"border border-white/20 bg-transparent hover:bg-white/10 text-white": variant === "outline",
|
|
||||||
"hover:bg-white/10 text-white": variant === "ghost",
|
|
||||||
"glass-panel hover:bg-white/10 text-white": variant === "glass",
|
|
||||||
"h-10 px-4 py-2": size === "default",
|
|
||||||
"h-9 rounded-lg px-3": size === "sm",
|
|
||||||
"h-11 rounded-xl px-8": size === "lg",
|
|
||||||
"h-10 w-10": size === "icon",
|
|
||||||
},
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
ref={ref}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
Button.displayName = "Button"
|
|
||||||
|
|
||||||
export { Button }
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import test from 'node:test';
|
|
||||||
import { renderToStaticMarkup } from 'react-dom/server';
|
|
||||||
|
|
||||||
import { Card } from './card';
|
|
||||||
|
|
||||||
test('Card applies the shared elevated shadow styling', () => {
|
|
||||||
const html = renderToStaticMarkup(<Card>demo</Card>);
|
|
||||||
|
|
||||||
assert.match(html, /shadow-\[0_12px_32px_rgba\(15,23,42,0\.18\)\]/);
|
|
||||||
});
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import { cn } from "@/src/lib/utils"
|
|
||||||
|
|
||||||
const Card = React.forwardRef<
|
|
||||||
HTMLDivElement,
|
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"glass-panel rounded-2xl text-white shadow-[0_12px_32px_rgba(15,23,42,0.18)]",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
Card.displayName = "Card"
|
|
||||||
|
|
||||||
const CardHeader = React.forwardRef<
|
|
||||||
HTMLDivElement,
|
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
CardHeader.displayName = "CardHeader"
|
|
||||||
|
|
||||||
const CardTitle = React.forwardRef<
|
|
||||||
HTMLParagraphElement,
|
|
||||||
React.HTMLAttributes<HTMLHeadingElement>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<h3
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"text-lg font-semibold leading-none tracking-tight",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
CardTitle.displayName = "CardTitle"
|
|
||||||
|
|
||||||
const CardDescription = React.forwardRef<
|
|
||||||
HTMLParagraphElement,
|
|
||||||
React.HTMLAttributes<HTMLParagraphElement>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<p
|
|
||||||
ref={ref}
|
|
||||||
className={cn("text-sm text-slate-400", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
CardDescription.displayName = "CardDescription"
|
|
||||||
|
|
||||||
const CardContent = React.forwardRef<
|
|
||||||
HTMLDivElement,
|
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
|
||||||
))
|
|
||||||
CardContent.displayName = "CardContent"
|
|
||||||
|
|
||||||
const CardFooter = React.forwardRef<
|
|
||||||
HTMLDivElement,
|
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
className={cn("flex items-center p-6 pt-0", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
CardFooter.displayName = "CardFooter"
|
|
||||||
|
|
||||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import { cn } from "@/src/lib/utils"
|
|
||||||
|
|
||||||
export interface InputProps
|
|
||||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
|
||||||
|
|
||||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|
||||||
({ className, type, ...props }, ref) => {
|
|
||||||
return (
|
|
||||||
<input
|
|
||||||
type={type}
|
|
||||||
className={cn(
|
|
||||||
"flex h-11 w-full rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-sm text-white ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-slate-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#336EFF] disabled:cursor-not-allowed disabled:opacity-50 transition-colors",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
ref={ref}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
Input.displayName = "Input"
|
|
||||||
|
|
||||||
export { Input }
|
|
||||||
20
front/src/hooks/useIsMobile.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
export function useIsMobile() {
|
||||||
|
const [isMobile, setIsMobile] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const checkIsMobile = () => {
|
||||||
|
// Determine mobile based on aspect ratio (width / height < 1) or width < 768
|
||||||
|
const isPortrait = window.innerWidth / window.innerHeight < 1;
|
||||||
|
const isSmallScreen = window.innerWidth < 768;
|
||||||
|
setIsMobile(isPortrait || isSmallScreen);
|
||||||
|
};
|
||||||
|
|
||||||
|
checkIsMobile();
|
||||||
|
window.addEventListener('resize', checkIsMobile);
|
||||||
|
return () => window.removeEventListener('resize', checkIsMobile);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return isMobile;
|
||||||
|
}
|
||||||
@@ -1,113 +1,146 @@
|
|||||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
|
|
||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
|
--color-aurora-1: #c4d9ff;
|
||||||
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace;
|
--color-aurora-2: #ffd1eb;
|
||||||
|
--color-aurora-3: #fff1c4;
|
||||||
--color-bg-base: #07101D;
|
--color-aurora-4: #c4fff2;
|
||||||
--color-primary: #336EFF;
|
--color-aurora-5: #e0c4ff;
|
||||||
--color-primary-hover: #2958cc;
|
|
||||||
|
|
||||||
--color-text-primary: #FFFFFF;
|
|
||||||
--color-text-secondary: #94A3B8; /* slate-400 */
|
|
||||||
--color-text-tertiary: rgba(255, 255, 255, 0.3);
|
|
||||||
|
|
||||||
--color-glass-bg: rgba(255, 255, 255, 0.045);
|
|
||||||
--color-glass-border: rgba(255, 255, 255, 0.1);
|
|
||||||
--color-glass-hover: rgba(255, 255, 255, 0.07);
|
|
||||||
--color-glass-active: rgba(255, 255, 255, 0.11);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
@layer base {
|
||||||
--app-safe-area-top: max(env(safe-area-inset-top, 0px), var(--safe-area-inset-top, 0px));
|
:root {
|
||||||
--app-safe-area-bottom: max(env(safe-area-inset-bottom, 0px), var(--safe-area-inset-bottom, 0px));
|
--background: 210 40% 98%;
|
||||||
}
|
--foreground: 222.2 84% 4.9%;
|
||||||
|
--glass-bg: rgba(255, 255, 255, 0.4);
|
||||||
|
--glass-border: rgba(255, 255, 255, 0.2);
|
||||||
|
--glass-hover: rgba(255, 255, 255, 0.5);
|
||||||
|
--glass-inner-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.1), 0 8px 32px 0 rgba(31, 38, 135, 0.07);
|
||||||
|
}
|
||||||
|
|
||||||
html,
|
.dark {
|
||||||
body,
|
--background: 222.2 84% 4.9%;
|
||||||
#root {
|
--foreground: 210 40% 98%;
|
||||||
width: 100%;
|
--glass-bg: rgba(15, 23, 42, 0.4);
|
||||||
min-height: 100%;
|
--glass-border: rgba(255, 255, 255, 0.05);
|
||||||
height: 100%;
|
--glass-hover: rgba(15, 23, 42, 0.6);
|
||||||
margin: 0;
|
--glass-inner-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.05), 0 8px 32px 0 rgba(0, 0, 0, 0.3);
|
||||||
padding: 0;
|
}
|
||||||
background-color: var(--color-bg-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background-color: var(--color-bg-base);
|
font-family: 'Inter', sans-serif;
|
||||||
color: var(--color-text-primary);
|
color: hsl(var(--foreground));
|
||||||
font-family: var(--font-sans);
|
overflow: hidden;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
overflow-x: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Custom Scrollbar */
|
|
||||||
::-webkit-scrollbar {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
}
|
|
||||||
::-webkit-scrollbar-track {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
::-webkit-scrollbar-thumb {
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Glassmorphism utilities */
|
|
||||||
.glass-panel {
|
|
||||||
background: var(--color-glass-bg);
|
|
||||||
backdrop-filter: blur(16px);
|
|
||||||
-webkit-backdrop-filter: blur(16px);
|
|
||||||
border: 1px solid var(--color-glass-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.glass-panel-hover:hover {
|
|
||||||
background: var(--color-glass-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.glass-panel-active {
|
|
||||||
background: var(--color-glass-active);
|
|
||||||
}
|
|
||||||
|
|
||||||
.safe-area-pt {
|
|
||||||
padding-top: var(--app-safe-area-top);
|
|
||||||
}
|
|
||||||
|
|
||||||
.safe-area-pb {
|
|
||||||
padding-bottom: var(--app-safe-area-bottom);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Animations */
|
|
||||||
@keyframes blob {
|
|
||||||
0% {
|
|
||||||
transform: translate(0px, 0px) scale(1);
|
|
||||||
}
|
}
|
||||||
33% {
|
|
||||||
transform: translate(30px, -50px) scale(1.1);
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
font-family: 'Outfit', sans-serif;
|
||||||
}
|
}
|
||||||
66% {
|
|
||||||
transform: translate(-20px, 20px) scale(0.9);
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
.bg-aurora {
|
||||||
|
background-color: #c4d9ff;
|
||||||
|
background-image:
|
||||||
|
radial-gradient(at 0% 0%, #ffd1eb 0px, transparent 50%),
|
||||||
|
radial-gradient(at 100% 0%, #fff1c4 0px, transparent 50%),
|
||||||
|
radial-gradient(at 100% 100%, #c4fff2 0px, transparent 50%),
|
||||||
|
radial-gradient(at 0% 100%, #e0c4ff 0px, transparent 50%),
|
||||||
|
radial-gradient(at 50% 50%, #f1f5f9 0px, transparent 50%);
|
||||||
|
background-size: 200% 200%;
|
||||||
|
animation: aurora 45s ease infinite alternate;
|
||||||
}
|
}
|
||||||
100% {
|
|
||||||
transform: translate(0px, 0px) scale(1);
|
.dark .bg-aurora {
|
||||||
|
background-color: #020617;
|
||||||
|
background-image:
|
||||||
|
radial-gradient(at 0% 0%, #171717 0px, transparent 50%),
|
||||||
|
radial-gradient(at 100% 0%, #0c0a09 0px, transparent 50%),
|
||||||
|
radial-gradient(at 100% 100%, #0f172a 0px, transparent 50%),
|
||||||
|
radial-gradient(at 0% 100%, #1e1b4b 0px, transparent 50%),
|
||||||
|
radial-gradient(at 50% 50%, #020617 0px, transparent 50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-panel {
|
||||||
|
background-color: var(--glass-bg);
|
||||||
|
backdrop-filter: blur(24px) saturate(180%);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
box-shadow: var(--glass-inner-shadow);
|
||||||
|
border-radius: 8px; /* 8px Professional Radius */
|
||||||
|
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-panel:hover {
|
||||||
|
background-color: var(--glass-hover);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: var(--glass-inner-shadow), 0 12px 40px -10px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-panel-no-hover {
|
||||||
|
background-color: var(--glass-bg);
|
||||||
|
backdrop-filter: blur(24px) saturate(180%);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
box-shadow: var(--glass-inner-shadow);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animations */
|
||||||
|
.animate-page-entry {
|
||||||
|
animation: page-entry 1s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-text-reveal {
|
||||||
|
animation: text-reveal 1.5s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||||
|
background-clip: text;
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-scan {
|
||||||
|
animation: scan 4s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stagger-1 { animation-delay: 0.1s; }
|
||||||
|
.stagger-2 { animation-delay: 0.2s; }
|
||||||
|
.stagger-3 { animation-delay: 0.3s; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes aurora {
|
||||||
|
0% { background-position: 0% 0%; }
|
||||||
|
50% { background-position: 100% 100%; }
|
||||||
|
100% { background-position: 0% 100%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes page-entry {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px) scale(1.02);
|
||||||
|
filter: blur(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
filter: blur(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.animate-blob {
|
@keyframes text-reveal {
|
||||||
animation: blob 10s infinite alternate ease-in-out;
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
letter-spacing: 0.4em;
|
||||||
|
filter: blur(12px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
letter-spacing: normal;
|
||||||
|
filter: blur(0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.animation-delay-2000 {
|
@keyframes scan {
|
||||||
animation-delay: 2s;
|
0% { transform: translateY(-100%); opacity: 0; }
|
||||||
}
|
10% { opacity: 0.5; }
|
||||||
|
90% { opacity: 0.5; }
|
||||||
.animation-delay-4000 {
|
100% { transform: translateY(120vh); opacity: 0; }
|
||||||
animation-delay: 4s;
|
|
||||||
}
|
}
|
||||||
|
|||||||
77
front/src/lib/admin-storage-policies.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { fetchApi } from './api';
|
||||||
|
|
||||||
|
export type StoragePolicyCapabilities = {
|
||||||
|
directUpload: boolean;
|
||||||
|
multipartUpload: boolean;
|
||||||
|
signedDownloadUrl: boolean;
|
||||||
|
serverProxyDownload: boolean;
|
||||||
|
thumbnailNative: boolean;
|
||||||
|
friendlyDownloadName: boolean;
|
||||||
|
requiresCors: boolean;
|
||||||
|
supportsInternalEndpoint: boolean;
|
||||||
|
maxObjectSize: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminStoragePolicy = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
type: 'LOCAL' | 'S3_COMPATIBLE';
|
||||||
|
bucketName: string | null;
|
||||||
|
endpoint: string | null;
|
||||||
|
region: string | null;
|
||||||
|
privateBucket: boolean;
|
||||||
|
prefix: string | null;
|
||||||
|
credentialMode: 'NONE' | 'STATIC' | 'DOGECLOUD_TEMP';
|
||||||
|
maxSizeBytes: number;
|
||||||
|
capabilities: StoragePolicyCapabilities;
|
||||||
|
enabled: boolean;
|
||||||
|
defaultPolicy: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StoragePolicyUpsertPayload = {
|
||||||
|
name: string;
|
||||||
|
type: AdminStoragePolicy['type'];
|
||||||
|
bucketName?: string;
|
||||||
|
endpoint?: string;
|
||||||
|
region?: string;
|
||||||
|
privateBucket: boolean;
|
||||||
|
prefix?: string;
|
||||||
|
credentialMode: AdminStoragePolicy['credentialMode'];
|
||||||
|
maxSizeBytes: number;
|
||||||
|
capabilities: StoragePolicyCapabilities;
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getStoragePolicies() {
|
||||||
|
return fetchApi<AdminStoragePolicy[]>('/admin/storage-policies');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createStoragePolicy(policyData: StoragePolicyUpsertPayload) {
|
||||||
|
return fetchApi<AdminStoragePolicy>('/admin/storage-policies', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(policyData),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateStoragePolicy(policyId: number, policyData: StoragePolicyUpsertPayload) {
|
||||||
|
return fetchApi<AdminStoragePolicy>(`/admin/storage-policies/${policyId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(policyData),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateStoragePolicyStatus(policyId: number, enabled: boolean) {
|
||||||
|
return fetchApi<AdminStoragePolicy>(`/admin/storage-policies/${policyId}/status`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ enabled }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createStorageMigration(sourcePolicyId: number, targetPolicyId: number) {
|
||||||
|
return fetchApi('/admin/storage-policies/migrations', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ sourcePolicyId, targetPolicyId }),
|
||||||
|
});
|
||||||
|
}
|
||||||
69
front/src/lib/admin-users.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { fetchApi } from './api';
|
||||||
|
import type { PageResponse } from './files';
|
||||||
|
|
||||||
|
export type AdminUser = {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
phoneNumber: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
role: 'USER' | 'ADMIN';
|
||||||
|
banned: boolean;
|
||||||
|
usedStorageBytes: number;
|
||||||
|
storageQuotaBytes: number;
|
||||||
|
maxUploadSizeBytes: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminPasswordResetResponse = {
|
||||||
|
temporaryPassword: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getAdminUsers(page = 0, size = 50, query = '') {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
page: String(page),
|
||||||
|
size: String(size),
|
||||||
|
query,
|
||||||
|
});
|
||||||
|
return fetchApi<PageResponse<AdminUser>>(`/admin/users?${params.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateUserRole(userId: number, role: AdminUser['role']) {
|
||||||
|
return fetchApi<AdminUser>(`/admin/users/${userId}/role`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ role }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateUserStatus(userId: number, banned: boolean) {
|
||||||
|
return fetchApi<AdminUser>(`/admin/users/${userId}/status`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ banned }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateUserPassword(userId: number, newPassword: string) {
|
||||||
|
return fetchApi<AdminUser>(`/admin/users/${userId}/password`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ newPassword }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateUserStorageQuota(userId: number, storageQuotaBytes: number) {
|
||||||
|
return fetchApi<AdminUser>(`/admin/users/${userId}/storage-quota`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ storageQuotaBytes }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateUserMaxUploadSize(userId: number, maxUploadSizeBytes: number) {
|
||||||
|
return fetchApi<AdminUser>(`/admin/users/${userId}/max-upload-size`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ maxUploadSizeBytes }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetUserPassword(userId: number) {
|
||||||
|
return fetchApi<AdminPasswordResetResponse>(`/admin/users/${userId}/password/reset`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
}
|
||||||
56
front/src/lib/admin.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { fetchApi } from './api';
|
||||||
|
import type { PageResponse } from './files';
|
||||||
|
|
||||||
|
export type AdminSummary = {
|
||||||
|
totalUsers: number;
|
||||||
|
totalFiles: number;
|
||||||
|
totalStorageBytes: number;
|
||||||
|
downloadTrafficBytes: number;
|
||||||
|
requestCount: number;
|
||||||
|
transferUsageBytes: number;
|
||||||
|
offlineTransferStorageBytes: number;
|
||||||
|
offlineTransferStorageLimitBytes: number;
|
||||||
|
dailyActiveUsers: Array<{
|
||||||
|
date: string;
|
||||||
|
count: number;
|
||||||
|
usernames: string[];
|
||||||
|
}>;
|
||||||
|
requestTimeline: Array<{
|
||||||
|
hour: number;
|
||||||
|
requestCount: number;
|
||||||
|
}>;
|
||||||
|
inviteCode: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminFile = {
|
||||||
|
id: number;
|
||||||
|
filename: string;
|
||||||
|
path: string;
|
||||||
|
size: number;
|
||||||
|
contentType: string;
|
||||||
|
directory: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
ownerId: number;
|
||||||
|
ownerUsername: string;
|
||||||
|
ownerEmail: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getAdminSummary() {
|
||||||
|
return fetchApi<AdminSummary>('/admin/summary');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listAdminFiles(page = 0, size = 50, query = '', ownerQuery = '') {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
page: String(page),
|
||||||
|
size: String(size),
|
||||||
|
query,
|
||||||
|
ownerQuery,
|
||||||
|
});
|
||||||
|
return fetchApi<PageResponse<AdminFile>>(`/admin/files?${params.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAdminFile(fileId: number) {
|
||||||
|
return fetchApi<void>(`/admin/files/${fileId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,646 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import { afterEach, beforeEach, test } from 'node:test';
|
|
||||||
|
|
||||||
import {
|
|
||||||
YOYUZH_CLIENT_ID_HEADER,
|
|
||||||
apiBinaryUploadRequest,
|
|
||||||
apiRequest,
|
|
||||||
apiUploadRequest,
|
|
||||||
apiV2Request,
|
|
||||||
resolveYoyuzhClientId,
|
|
||||||
shouldRetryRequest,
|
|
||||||
toNetworkApiError,
|
|
||||||
} from './api';
|
|
||||||
import { clearStoredSession, readStoredSession, saveStoredSession } from './session';
|
|
||||||
|
|
||||||
class MemoryStorage implements Storage {
|
|
||||||
private store = new Map<string, string>();
|
|
||||||
|
|
||||||
get length() {
|
|
||||||
return this.store.size;
|
|
||||||
}
|
|
||||||
|
|
||||||
clear() {
|
|
||||||
this.store.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
getItem(key: string) {
|
|
||||||
return this.store.has(key) ? this.store.get(key)! : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
key(index: number) {
|
|
||||||
return Array.from(this.store.keys())[index] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
removeItem(key: string) {
|
|
||||||
this.store.delete(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
setItem(key: string, value: string) {
|
|
||||||
this.store.set(key, value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const originalFetch = globalThis.fetch;
|
|
||||||
const originalStorage = globalThis.localStorage;
|
|
||||||
const originalXMLHttpRequest = globalThis.XMLHttpRequest;
|
|
||||||
const originalLocation = globalThis.location;
|
|
||||||
|
|
||||||
class FakeXMLHttpRequest {
|
|
||||||
static latest: FakeXMLHttpRequest | null = null;
|
|
||||||
|
|
||||||
method = '';
|
|
||||||
url = '';
|
|
||||||
requestBody: Document | XMLHttpRequestBodyInit | null = null;
|
|
||||||
responseText = '';
|
|
||||||
status = 200;
|
|
||||||
headers = new Map<string, string>();
|
|
||||||
responseHeaders = new Map<string, string>();
|
|
||||||
onload: null | (() => void) = null;
|
|
||||||
onerror: null | (() => void) = null;
|
|
||||||
onabort: null | (() => void) = null;
|
|
||||||
aborted = false;
|
|
||||||
|
|
||||||
upload = {
|
|
||||||
addEventListener: (type: string, listener: EventListenerOrEventListenerObject) => {
|
|
||||||
if (type !== 'progress') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.progressListeners.push(listener);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
private progressListeners: EventListenerOrEventListenerObject[] = [];
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
FakeXMLHttpRequest.latest = this;
|
|
||||||
}
|
|
||||||
|
|
||||||
open(method: string, url: string) {
|
|
||||||
this.method = method;
|
|
||||||
this.url = url;
|
|
||||||
}
|
|
||||||
|
|
||||||
setRequestHeader(name: string, value: string) {
|
|
||||||
this.headers.set(name.toLowerCase(), value);
|
|
||||||
}
|
|
||||||
|
|
||||||
getResponseHeader(name: string) {
|
|
||||||
return this.responseHeaders.get(name) ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
send(body: Document | XMLHttpRequestBodyInit | null) {
|
|
||||||
this.requestBody = body;
|
|
||||||
}
|
|
||||||
|
|
||||||
abort() {
|
|
||||||
this.aborted = true;
|
|
||||||
this.onabort?.();
|
|
||||||
}
|
|
||||||
|
|
||||||
triggerProgress(loaded: number, total: number) {
|
|
||||||
const event = {
|
|
||||||
lengthComputable: true,
|
|
||||||
loaded,
|
|
||||||
total,
|
|
||||||
} as ProgressEvent<EventTarget>;
|
|
||||||
|
|
||||||
for (const listener of this.progressListeners) {
|
|
||||||
if (typeof listener === 'function') {
|
|
||||||
listener(event);
|
|
||||||
} else {
|
|
||||||
listener.handleEvent(event);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
respond(body: unknown, status = 200, contentType = 'application/json') {
|
|
||||||
this.status = status;
|
|
||||||
this.responseText = typeof body === 'string' ? body : JSON.stringify(body);
|
|
||||||
this.responseHeaders.set('content-type', contentType);
|
|
||||||
this.onload?.();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
Object.defineProperty(globalThis, 'localStorage', {
|
|
||||||
configurable: true,
|
|
||||||
value: new MemoryStorage(),
|
|
||||||
});
|
|
||||||
Object.defineProperty(globalThis, 'XMLHttpRequest', {
|
|
||||||
configurable: true,
|
|
||||||
value: FakeXMLHttpRequest,
|
|
||||||
});
|
|
||||||
FakeXMLHttpRequest.latest = null;
|
|
||||||
clearStoredSession();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
globalThis.fetch = originalFetch;
|
|
||||||
Object.defineProperty(globalThis, 'localStorage', {
|
|
||||||
configurable: true,
|
|
||||||
value: originalStorage,
|
|
||||||
});
|
|
||||||
Object.defineProperty(globalThis, 'XMLHttpRequest', {
|
|
||||||
configurable: true,
|
|
||||||
value: originalXMLHttpRequest,
|
|
||||||
});
|
|
||||||
Object.defineProperty(globalThis, 'location', {
|
|
||||||
configurable: true,
|
|
||||||
value: originalLocation,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('apiRequest attaches bearer token and unwraps response payload', async () => {
|
|
||||||
let request: Request | URL | string | undefined;
|
|
||||||
saveStoredSession({
|
|
||||||
token: 'token-123',
|
|
||||||
refreshToken: 'refresh-123',
|
|
||||||
user: {
|
|
||||||
id: 1,
|
|
||||||
username: 'tester',
|
|
||||||
email: 'tester@example.com',
|
|
||||||
createdAt: '2026-03-14T10:00:00',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
globalThis.fetch = async (input, init) => {
|
|
||||||
request =
|
|
||||||
input instanceof Request
|
|
||||||
? input
|
|
||||||
: new Request(new URL(String(input), 'http://localhost'), init);
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
code: 0,
|
|
||||||
msg: 'success',
|
|
||||||
data: {
|
|
||||||
ok: true,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const payload = await apiRequest<{ok: boolean}>('/files/recent');
|
|
||||||
|
|
||||||
assert.deepEqual(payload, {ok: true});
|
|
||||||
assert.ok(request instanceof Request);
|
|
||||||
assert.equal(request.headers.get('Authorization'), 'Bearer token-123');
|
|
||||||
assert.equal(request.headers.get('X-Yoyuzh-Client'), 'desktop');
|
|
||||||
assert.equal(request.headers.get(YOYUZH_CLIENT_ID_HEADER), resolveYoyuzhClientId());
|
|
||||||
assert.equal(request.url, 'http://localhost/api/files/recent');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('apiV2Request prefixes v2 paths and attaches a stable client id header', async () => {
|
|
||||||
let request: Request | URL | string | undefined;
|
|
||||||
|
|
||||||
globalThis.fetch = async (input, init) => {
|
|
||||||
request =
|
|
||||||
input instanceof Request
|
|
||||||
? input
|
|
||||||
: new Request(new URL(String(input), 'http://localhost'), init);
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
code: 0,
|
|
||||||
msg: 'success',
|
|
||||||
data: {
|
|
||||||
status: 'ok',
|
|
||||||
apiVersion: 'v2',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const payload = await apiV2Request<{status: string; apiVersion: string}>('/site/ping');
|
|
||||||
|
|
||||||
assert.deepEqual(payload, {status: 'ok', apiVersion: 'v2'});
|
|
||||||
assert.ok(request instanceof Request);
|
|
||||||
assert.equal(request.url, 'http://localhost/api/v2/site/ping');
|
|
||||||
assert.equal(request.headers.get(YOYUZH_CLIENT_ID_HEADER), resolveYoyuzhClientId());
|
|
||||||
});
|
|
||||||
|
|
||||||
test('resolveYoyuzhClientId reuses the same generated client id for later requests', () => {
|
|
||||||
const firstClientId = resolveYoyuzhClientId();
|
|
||||||
const secondClientId = resolveYoyuzhClientId();
|
|
||||||
|
|
||||||
assert.equal(secondClientId, firstClientId);
|
|
||||||
assert.match(firstClientId, /^yoyuzh-client-[a-zA-Z0-9-]+$/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('apiRequest uses the production api origin inside the Capacitor localhost shell', async () => {
|
|
||||||
let request: Request | URL | string | undefined;
|
|
||||||
Object.defineProperty(globalThis, 'location', {
|
|
||||||
configurable: true,
|
|
||||||
value: new URL('http://localhost'),
|
|
||||||
});
|
|
||||||
|
|
||||||
globalThis.fetch = async (input, init) => {
|
|
||||||
request =
|
|
||||||
input instanceof Request
|
|
||||||
? input
|
|
||||||
: new Request(new URL(String(input), 'https://fallback.example.com'), init);
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
code: 0,
|
|
||||||
msg: 'success',
|
|
||||||
data: {
|
|
||||||
ok: true,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
await apiRequest<{ok: boolean}>('/files/recent');
|
|
||||||
|
|
||||||
assert.ok(request instanceof Request);
|
|
||||||
assert.equal(request.url, 'https://api.yoyuzh.xyz/api/files/recent');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('apiRequest uses the production api origin inside the Capacitor https localhost shell', async () => {
|
|
||||||
let request: Request | URL | string | undefined;
|
|
||||||
Object.defineProperty(globalThis, 'location', {
|
|
||||||
configurable: true,
|
|
||||||
value: new URL('https://localhost'),
|
|
||||||
});
|
|
||||||
|
|
||||||
globalThis.fetch = async (input, init) => {
|
|
||||||
request =
|
|
||||||
input instanceof Request
|
|
||||||
? input
|
|
||||||
: new Request(new URL(String(input), 'https://fallback.example.com'), init);
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
code: 0,
|
|
||||||
msg: 'success',
|
|
||||||
data: {
|
|
||||||
ok: true,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
await apiRequest<{ok: boolean}>('/files/recent');
|
|
||||||
|
|
||||||
assert.ok(request instanceof Request);
|
|
||||||
assert.equal(request.url, 'https://api.yoyuzh.xyz/api/files/recent');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('apiRequest throws backend message on business error', async () => {
|
|
||||||
globalThis.fetch = async () =>
|
|
||||||
new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
code: 40101,
|
|
||||||
msg: 'login required',
|
|
||||||
data: null,
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
await assert.rejects(() => apiRequest('/user/profile'), /login required/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('network login failures are retried a limited number of times for auth login', () => {
|
|
||||||
const error = new TypeError('Failed to fetch');
|
|
||||||
|
|
||||||
assert.equal(shouldRetryRequest('/auth/login', {method: 'POST'}, error, 0), true);
|
|
||||||
assert.equal(shouldRetryRequest('/auth/login', {method: 'POST'}, error, 1), true);
|
|
||||||
assert.equal(shouldRetryRequest('/auth/login', {method: 'POST'}, error, 2), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('network register failures are not retried automatically', () => {
|
|
||||||
const error = new TypeError('Failed to fetch');
|
|
||||||
|
|
||||||
assert.equal(shouldRetryRequest('/auth/register', {method: 'POST'}, error, 0), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('network get failures are retried up to two times after the first attempt', () => {
|
|
||||||
const error = new TypeError('Failed to fetch');
|
|
||||||
|
|
||||||
assert.equal(shouldRetryRequest('/files/list', {method: 'GET'}, error, 0), true);
|
|
||||||
assert.equal(shouldRetryRequest('/files/list', {method: 'GET'}, error, 1), true);
|
|
||||||
assert.equal(shouldRetryRequest('/files/list', {method: 'GET'}, error, 2), true);
|
|
||||||
assert.equal(shouldRetryRequest('/files/list', {method: 'GET'}, error, 3), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('network rename failures are retried once for idempotent file rename requests', () => {
|
|
||||||
const error = new TypeError('Failed to fetch');
|
|
||||||
|
|
||||||
assert.equal(shouldRetryRequest('/files/32/rename', {method: 'PATCH'}, error, 0), true);
|
|
||||||
assert.equal(shouldRetryRequest('/files/32/rename', {method: 'PATCH'}, error, 1), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('network fetch failures are converted to readable api errors', () => {
|
|
||||||
const apiError = toNetworkApiError(new TypeError('Failed to fetch'));
|
|
||||||
|
|
||||||
assert.equal(apiError.status, 0);
|
|
||||||
assert.match(apiError.message, /网络连接异常|Failed to fetch/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('apiUploadRequest attaches auth header and forwards upload progress', async () => {
|
|
||||||
saveStoredSession({
|
|
||||||
token: 'token-456',
|
|
||||||
refreshToken: 'refresh-456',
|
|
||||||
user: {
|
|
||||||
id: 2,
|
|
||||||
username: 'uploader',
|
|
||||||
email: 'uploader@example.com',
|
|
||||||
createdAt: '2026-03-18T10:00:00',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const progressCalls: Array<{loaded: number; total: number}> = [];
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('file', new Blob(['hello']), 'hello.txt');
|
|
||||||
|
|
||||||
const uploadPromise = apiUploadRequest<{id: number}>('/files/upload?path=%2F', {
|
|
||||||
body: formData,
|
|
||||||
onProgress: (progress) => {
|
|
||||||
progressCalls.push(progress);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const request = FakeXMLHttpRequest.latest;
|
|
||||||
assert.ok(request);
|
|
||||||
assert.equal(request.method, 'POST');
|
|
||||||
assert.equal(request.url, '/api/files/upload?path=%2F');
|
|
||||||
assert.equal(request.headers.get('authorization'), 'Bearer token-456');
|
|
||||||
assert.equal(request.headers.get('accept'), 'application/json');
|
|
||||||
assert.equal(request.headers.get(YOYUZH_CLIENT_ID_HEADER.toLowerCase()), resolveYoyuzhClientId());
|
|
||||||
assert.equal(request.requestBody, formData);
|
|
||||||
|
|
||||||
request.triggerProgress(128, 512);
|
|
||||||
request.triggerProgress(512, 512);
|
|
||||||
request.respond({
|
|
||||||
code: 0,
|
|
||||||
msg: 'success',
|
|
||||||
data: {
|
|
||||||
id: 7,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const payload = await uploadPromise;
|
|
||||||
assert.deepEqual(payload, {id: 7});
|
|
||||||
assert.deepEqual(progressCalls, [
|
|
||||||
{loaded: 128, total: 512},
|
|
||||||
{loaded: 512, total: 512},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('apiBinaryUploadRequest sends raw file body to signed upload url', async () => {
|
|
||||||
const progressCalls: Array<{loaded: number; total: number}> = [];
|
|
||||||
const fileBody = new Blob(['hello-oss']);
|
|
||||||
|
|
||||||
const uploadPromise = apiBinaryUploadRequest('https://upload.example.com/object', {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'text/plain',
|
|
||||||
'x-oss-meta-test': '1',
|
|
||||||
},
|
|
||||||
body: fileBody,
|
|
||||||
onProgress: (progress) => {
|
|
||||||
progressCalls.push(progress);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const request = FakeXMLHttpRequest.latest;
|
|
||||||
assert.ok(request);
|
|
||||||
assert.equal(request.method, 'PUT');
|
|
||||||
assert.equal(request.url, 'https://upload.example.com/object');
|
|
||||||
assert.equal(request.headers.get('content-type'), 'text/plain');
|
|
||||||
assert.equal(request.headers.get('x-oss-meta-test'), '1');
|
|
||||||
assert.equal(request.requestBody, fileBody);
|
|
||||||
|
|
||||||
request.triggerProgress(64, 128);
|
|
||||||
request.triggerProgress(128, 128);
|
|
||||||
request.responseHeaders.set('etag', '"etag-1"');
|
|
||||||
request.respond('', 200, 'text/plain');
|
|
||||||
|
|
||||||
const payload = await uploadPromise;
|
|
||||||
assert.deepEqual(payload, {
|
|
||||||
status: 200,
|
|
||||||
headers: {},
|
|
||||||
});
|
|
||||||
assert.deepEqual(progressCalls, [
|
|
||||||
{loaded: 64, total: 128},
|
|
||||||
{loaded: 128, total: 128},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('apiBinaryUploadRequest returns requested response headers', async () => {
|
|
||||||
const uploadPromise = apiBinaryUploadRequest('https://upload.example.com/object', {
|
|
||||||
method: 'PUT',
|
|
||||||
body: new Blob(['hello-oss']),
|
|
||||||
responseHeaders: ['etag'],
|
|
||||||
});
|
|
||||||
|
|
||||||
const request = FakeXMLHttpRequest.latest;
|
|
||||||
assert.ok(request);
|
|
||||||
request.responseHeaders.set('etag', '"etag-part-2"');
|
|
||||||
request.respond('', 200, 'text/plain');
|
|
||||||
|
|
||||||
const payload = await uploadPromise;
|
|
||||||
assert.deepEqual(payload, {
|
|
||||||
status: 200,
|
|
||||||
headers: {
|
|
||||||
etag: '"etag-part-2"',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('apiUploadRequest supports aborting a single upload task', async () => {
|
|
||||||
const controller = new AbortController();
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('file', new Blob(['hello']), 'hello.txt');
|
|
||||||
|
|
||||||
const uploadPromise = apiUploadRequest<{id: number}>('/files/upload?path=%2F', {
|
|
||||||
body: formData,
|
|
||||||
signal: controller.signal,
|
|
||||||
});
|
|
||||||
|
|
||||||
const request = FakeXMLHttpRequest.latest;
|
|
||||||
assert.ok(request);
|
|
||||||
|
|
||||||
controller.abort();
|
|
||||||
|
|
||||||
await assert.rejects(uploadPromise, /上传已取消/);
|
|
||||||
assert.equal(request.aborted, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('apiRequest refreshes expired access token once and retries the original request', async () => {
|
|
||||||
const calls: Array<{url: string; authorization: string | null; body: string | null}> = [];
|
|
||||||
saveStoredSession({
|
|
||||||
token: 'expired-token',
|
|
||||||
refreshToken: 'refresh-1',
|
|
||||||
user: {
|
|
||||||
id: 3,
|
|
||||||
username: 'alice',
|
|
||||||
email: 'alice@example.com',
|
|
||||||
createdAt: '2026-03-18T10:00:00',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
globalThis.fetch = async (input, init) => {
|
|
||||||
const url = String(input);
|
|
||||||
const headers = new Headers(init?.headers);
|
|
||||||
calls.push({
|
|
||||||
url,
|
|
||||||
authorization: headers.get('Authorization'),
|
|
||||||
body: typeof init?.body === 'string' ? init.body : null,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (url.endsWith('/user/profile') && calls.length === 1) {
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
code: 1001,
|
|
||||||
msg: '用户未登录',
|
|
||||||
data: null,
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 401,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url.endsWith('/auth/refresh')) {
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
code: 0,
|
|
||||||
msg: 'success',
|
|
||||||
data: {
|
|
||||||
token: 'new-access-token',
|
|
||||||
accessToken: 'new-access-token',
|
|
||||||
refreshToken: 'refresh-2',
|
|
||||||
user: {
|
|
||||||
id: 3,
|
|
||||||
username: 'alice',
|
|
||||||
email: 'alice@example.com',
|
|
||||||
createdAt: '2026-03-18T10:00:00',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
code: 0,
|
|
||||||
msg: 'success',
|
|
||||||
data: {
|
|
||||||
id: 3,
|
|
||||||
username: 'alice',
|
|
||||||
email: 'alice@example.com',
|
|
||||||
createdAt: '2026-03-18T10:00:00',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const profile = await apiRequest<{id: number; username: string}>('/user/profile');
|
|
||||||
|
|
||||||
assert.equal(profile.username, 'alice');
|
|
||||||
assert.equal(calls.length, 3);
|
|
||||||
assert.equal(calls[0]?.authorization, 'Bearer expired-token');
|
|
||||||
assert.equal(calls[1]?.url, '/api/auth/refresh');
|
|
||||||
assert.equal(calls[2]?.authorization, 'Bearer new-access-token');
|
|
||||||
assert.deepEqual(JSON.parse(calls[1]?.body || '{}'), {refreshToken: 'refresh-1'});
|
|
||||||
assert.deepEqual(readStoredSession(), {
|
|
||||||
token: 'new-access-token',
|
|
||||||
refreshToken: 'refresh-2',
|
|
||||||
user: {
|
|
||||||
id: 3,
|
|
||||||
username: 'alice',
|
|
||||||
email: 'alice@example.com',
|
|
||||||
createdAt: '2026-03-18T10:00:00',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('apiRequest clears session when refresh fails after a 401 response', async () => {
|
|
||||||
let callCount = 0;
|
|
||||||
saveStoredSession({
|
|
||||||
token: 'expired-token',
|
|
||||||
refreshToken: 'refresh-1',
|
|
||||||
user: {
|
|
||||||
id: 5,
|
|
||||||
username: 'bob',
|
|
||||||
email: 'bob@example.com',
|
|
||||||
createdAt: '2026-03-18T10:00:00',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
globalThis.fetch = async (input) => {
|
|
||||||
callCount += 1;
|
|
||||||
const url = String(input);
|
|
||||||
|
|
||||||
if (url.endsWith('/auth/refresh')) {
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
code: 1001,
|
|
||||||
msg: '刷新令牌已过期',
|
|
||||||
data: null,
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 401,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
code: 1001,
|
|
||||||
msg: '用户未登录',
|
|
||||||
data: null,
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 401,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
await assert.rejects(() => apiRequest('/user/profile'), /用户未登录/);
|
|
||||||
assert.equal(callCount, 2);
|
|
||||||
assert.equal(readStoredSession(), null);
|
|
||||||
});
|
|
||||||
@@ -1,662 +1,192 @@
|
|||||||
import type { AuthResponse } from './types';
|
import { clearSession, getSession, setSession, type PortalSession } from './session';
|
||||||
import { PORTAL_CLIENT_HEADER, resolvePortalClientType } from './app-shell';
|
|
||||||
import { clearStoredSession, createSession, readStoredSession, saveStoredSession } from './session';
|
|
||||||
|
|
||||||
interface ApiEnvelope<T> {
|
const CLIENT_HEADER = 'X-Yoyuzh-Client';
|
||||||
|
const CLIENT_ID_HEADER = 'X-Yoyuzh-Client-Id';
|
||||||
|
const CLIENT_TYPE = 'desktop';
|
||||||
|
|
||||||
|
type ApiEnvelope<T> = {
|
||||||
code: number;
|
code: number;
|
||||||
msg: string;
|
msg: string;
|
||||||
data: T;
|
data: T;
|
||||||
}
|
};
|
||||||
|
|
||||||
interface ApiRequestInit extends Omit<RequestInit, 'body'> {
|
|
||||||
body?: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ApiUploadRequestInit {
|
|
||||||
body: FormData;
|
|
||||||
headers?: HeadersInit;
|
|
||||||
method?: 'POST' | 'PUT' | 'PATCH';
|
|
||||||
onProgress?: (progress: {loaded: number; total: number}) => void;
|
|
||||||
signal?: AbortSignal;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ApiBinaryUploadRequestInit {
|
|
||||||
body: Blob;
|
|
||||||
headers?: HeadersInit;
|
|
||||||
method?: 'PUT' | 'POST';
|
|
||||||
onProgress?: (progress: {loaded: number; total: number}) => void;
|
|
||||||
responseHeaders?: string[];
|
|
||||||
signal?: AbortSignal;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ApiBinaryUploadResponse {
|
|
||||||
status: number;
|
|
||||||
headers: Record<string, string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const AUTH_REFRESH_PATH = '/auth/refresh';
|
|
||||||
const DEFAULT_API_BASE_URL = '/api';
|
|
||||||
const DEFAULT_CAPACITOR_API_ORIGIN = 'https://api.yoyuzh.xyz';
|
|
||||||
const YOYUZH_CLIENT_ID_STORAGE_KEY = 'yoyuzh.clientId';
|
|
||||||
|
|
||||||
export const YOYUZH_CLIENT_ID_HEADER = 'X-Yoyuzh-Client-Id';
|
|
||||||
|
|
||||||
let refreshRequestPromise: Promise<boolean> | null = null;
|
|
||||||
let fallbackClientId: string | null = null;
|
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
code?: number;
|
|
||||||
status: number;
|
status: number;
|
||||||
isNetworkError: boolean;
|
code: number;
|
||||||
|
|
||||||
constructor(message: string, status = 500, code?: number) {
|
constructor(message: string, status: number, code = -1) {
|
||||||
super(message);
|
super(message);
|
||||||
this.name = 'ApiError';
|
this.name = 'ApiError';
|
||||||
this.status = status;
|
this.status = status;
|
||||||
this.code = code;
|
this.code = code;
|
||||||
this.isNetworkError = status === 0;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isNetworkFailure(error: unknown) {
|
export type FetchApiOptions = RequestInit & {
|
||||||
return error instanceof TypeError || error instanceof DOMException;
|
auth?: boolean;
|
||||||
}
|
rawResponse?: boolean;
|
||||||
|
retryOnAuthFailure?: boolean;
|
||||||
function sleep(ms: number) {
|
};
|
||||||
return new Promise((resolve) => {
|
|
||||||
setTimeout(resolve, ms);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function getRetryDelayMs(attempt: number) {
|
|
||||||
const schedule = [500, 1200, 2200];
|
|
||||||
return schedule[Math.min(attempt, schedule.length - 1)];
|
|
||||||
}
|
|
||||||
|
|
||||||
function getMaxRetryAttempts(path: string, init: ApiRequestInit = {}) {
|
|
||||||
const method = (init.method || 'GET').toUpperCase();
|
|
||||||
|
|
||||||
if (method === 'POST' && path === '/auth/login') {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (method === 'PATCH' && /^\/files\/\d+\/rename$/.test(path)) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (method === 'GET' || method === 'HEAD' || method === 'OPTIONS') {
|
|
||||||
return 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getRetryDelayForRequest(path: string, init: ApiRequestInit = {}, attempt: number) {
|
|
||||||
const method = (init.method || 'GET').toUpperCase();
|
|
||||||
|
|
||||||
if (method === 'POST' && path === '/auth/login') {
|
|
||||||
const loginSchedule = [350, 800];
|
|
||||||
return loginSchedule[Math.min(attempt, loginSchedule.length - 1)];
|
|
||||||
}
|
|
||||||
|
|
||||||
return getRetryDelayMs(attempt);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveRuntimeLocation() {
|
|
||||||
if (typeof globalThis.location !== 'undefined') {
|
|
||||||
return globalThis.location;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
return window.location;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isCapacitorLocalhostOrigin(location: Location | URL | null) {
|
|
||||||
if (!location) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const protocol = location.protocol || '';
|
|
||||||
const hostname = location.hostname || '';
|
|
||||||
const port = location.port || '';
|
|
||||||
|
|
||||||
if (protocol === 'capacitor:') {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isLocalhostHost = hostname === 'localhost' || hostname === '127.0.0.1';
|
|
||||||
const isCapacitorLocalScheme = protocol === 'http:' || protocol === 'https:';
|
|
||||||
|
|
||||||
return isCapacitorLocalScheme && isLocalhostHost && port === '';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getApiBaseUrl() {
|
export function getApiBaseUrl() {
|
||||||
const configuredBaseUrl = import.meta.env?.VITE_API_BASE_URL?.replace(/\/$/, '');
|
return '/api';
|
||||||
if (configuredBaseUrl) {
|
|
||||||
return configuredBaseUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isCapacitorLocalhostOrigin(resolveRuntimeLocation())) {
|
|
||||||
return `${DEFAULT_CAPACITOR_API_ORIGIN}${DEFAULT_API_BASE_URL}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return DEFAULT_API_BASE_URL;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveUrl(path: string) {
|
export function getClientId() {
|
||||||
if (/^https?:\/\//.test(path)) {
|
const storageKey = 'portal-client-id';
|
||||||
return path;
|
const existing = window.localStorage.getItem(storageKey);
|
||||||
|
if (existing) {
|
||||||
|
return existing;
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
const generated = `web-${crypto.randomUUID()}`;
|
||||||
return `${getApiBaseUrl()}${normalizedPath}`;
|
window.localStorage.setItem(storageKey, generated);
|
||||||
|
return generated;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizePath(path: string) {
|
function buildUrl(endpoint: string) {
|
||||||
return path.startsWith('/') ? path : `/${path}`;
|
if (/^https?:\/\//.test(endpoint)) {
|
||||||
}
|
return endpoint;
|
||||||
|
|
||||||
function resolveV2Path(path: string) {
|
|
||||||
const normalizedPath = normalizePath(path);
|
|
||||||
return normalizedPath.startsWith('/v2/') ? normalizedPath : `/v2${normalizedPath}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function shouldAttachPortalClientHeader(path: string) {
|
|
||||||
return !/^https?:\/\//.test(path);
|
|
||||||
}
|
|
||||||
|
|
||||||
function shouldAttachYoyuzhClientIdHeader(path: string) {
|
|
||||||
return !/^https?:\/\//.test(path);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createYoyuzhClientId() {
|
|
||||||
const randomId =
|
|
||||||
typeof globalThis.crypto?.randomUUID === 'function'
|
|
||||||
? globalThis.crypto.randomUUID()
|
|
||||||
: Math.random().toString(36).slice(2);
|
|
||||||
return `yoyuzh-client-${randomId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveYoyuzhClientId() {
|
|
||||||
if (typeof globalThis.localStorage === 'undefined') {
|
|
||||||
fallbackClientId ??= createYoyuzhClientId();
|
|
||||||
return fallbackClientId;
|
|
||||||
}
|
}
|
||||||
|
if (endpoint.startsWith('/api/')) {
|
||||||
const storedClientId = globalThis.localStorage.getItem(YOYUZH_CLIENT_ID_STORAGE_KEY);
|
return endpoint;
|
||||||
if (storedClientId) {
|
|
||||||
return storedClientId;
|
|
||||||
}
|
}
|
||||||
|
if (endpoint.startsWith('/')) {
|
||||||
const clientId = createYoyuzhClientId();
|
return `${getApiBaseUrl()}${endpoint}`;
|
||||||
globalThis.localStorage.setItem(YOYUZH_CLIENT_ID_STORAGE_KEY, clientId);
|
}
|
||||||
return clientId;
|
return `${getApiBaseUrl()}/${endpoint}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldAttemptTokenRefresh(path: string) {
|
function looksLikeQuestionMarks(message: string | null | undefined) {
|
||||||
const normalizedPath = normalizePath(path);
|
if (!message) {
|
||||||
return ![
|
|
||||||
'/auth/login',
|
|
||||||
'/auth/register',
|
|
||||||
'/auth/dev-login',
|
|
||||||
AUTH_REFRESH_PATH,
|
|
||||||
].includes(normalizedPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildRequestBody(body: ApiRequestInit['body']) {
|
|
||||||
if (body == null) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
body instanceof FormData ||
|
|
||||||
body instanceof Blob ||
|
|
||||||
body instanceof URLSearchParams ||
|
|
||||||
typeof body === 'string' ||
|
|
||||||
body instanceof ArrayBuffer
|
|
||||||
) {
|
|
||||||
return body;
|
|
||||||
}
|
|
||||||
|
|
||||||
return JSON.stringify(body);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshAccessToken() {
|
|
||||||
const currentSession = readStoredSession();
|
|
||||||
if (!currentSession?.refreshToken) {
|
|
||||||
clearStoredSession();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (refreshRequestPromise) {
|
|
||||||
return refreshRequestPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
refreshRequestPromise = (async () => {
|
|
||||||
try {
|
|
||||||
const headers = new Headers({
|
|
||||||
Accept: 'application/json',
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
});
|
|
||||||
headers.set(PORTAL_CLIENT_HEADER, resolvePortalClientType());
|
|
||||||
|
|
||||||
const response = await fetch(resolveUrl(AUTH_REFRESH_PATH), {
|
|
||||||
method: 'POST',
|
|
||||||
headers,
|
|
||||||
body: JSON.stringify({
|
|
||||||
refreshToken: currentSession.refreshToken,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const contentType = response.headers.get('content-type') || '';
|
|
||||||
if (!response.ok || !contentType.includes('application/json')) {
|
|
||||||
clearStoredSession();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = (await response.json()) as ApiEnvelope<AuthResponse>;
|
|
||||||
if (payload.code !== 0 || !payload.data) {
|
|
||||||
clearStoredSession();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
saveStoredSession({
|
|
||||||
...currentSession,
|
|
||||||
...createSession(payload.data),
|
|
||||||
user: payload.data.user ?? currentSession.user,
|
|
||||||
});
|
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
|
||||||
clearStoredSession();
|
|
||||||
return false;
|
|
||||||
} finally {
|
|
||||||
refreshRequestPromise = null;
|
|
||||||
}
|
}
|
||||||
})();
|
|
||||||
|
|
||||||
return refreshRequestPromise;
|
const trimmed = message.trim();
|
||||||
|
return trimmed.length === 0 || /^[?锛焆]+$/.test(trimmed);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function parseApiError(response: Response) {
|
function resolveFriendlyMessage(code: number, status: number, message: string) {
|
||||||
const contentType = response.headers.get('content-type') || '';
|
if ((code === 1001 || status === 401) && looksLikeQuestionMarks(message)) {
|
||||||
if (!contentType.includes('application/json')) {
|
return '未登录或登录已过期,请先登录。';
|
||||||
return new ApiError(`请求失败 (${response.status})`, response.status);
|
|
||||||
}
|
}
|
||||||
|
if ((code === 1002 || status === 403) && looksLikeQuestionMarks(message)) {
|
||||||
const payload = (await response.json()) as ApiEnvelope<null>;
|
return '没有权限访问该页面。';
|
||||||
return new ApiError(payload.msg || `请求失败 (${response.status})`, response.status, payload.code);
|
}
|
||||||
|
if (looksLikeQuestionMarks(message)) {
|
||||||
|
return `请求失败(HTTP ${status})`;
|
||||||
|
}
|
||||||
|
return message;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toNetworkApiError(error: unknown) {
|
async function parseResponse<T>(response: Response): Promise<T> {
|
||||||
const fallbackMessage = '网络连接异常,请稍后重试';
|
const contentType = response.headers.get('content-type') ?? '';
|
||||||
const message = error instanceof Error && error.message ? error.message : fallbackMessage;
|
|
||||||
return new ApiError(message === 'Failed to fetch' ? fallbackMessage : message, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
function toUploadAbortApiError() {
|
|
||||||
return new ApiError('上传已取消', 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function shouldRetryRequest(
|
|
||||||
path: string,
|
|
||||||
init: ApiRequestInit = {},
|
|
||||||
error: unknown,
|
|
||||||
attempt: number,
|
|
||||||
) {
|
|
||||||
if (!isNetworkFailure(error)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return attempt <= getMaxRetryAttempts(path, init);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function performRequest(path: string, init: ApiRequestInit = {}, allowRefresh = true): Promise<Response> {
|
|
||||||
const session = readStoredSession();
|
|
||||||
const headers = new Headers(init.headers);
|
|
||||||
const requestBody = buildRequestBody(init.body);
|
|
||||||
|
|
||||||
if (session?.token) {
|
|
||||||
headers.set('Authorization', `Bearer ${session.token}`);
|
|
||||||
}
|
|
||||||
if (shouldAttachPortalClientHeader(path) && !headers.has(PORTAL_CLIENT_HEADER)) {
|
|
||||||
headers.set(PORTAL_CLIENT_HEADER, resolvePortalClientType());
|
|
||||||
}
|
|
||||||
if (shouldAttachYoyuzhClientIdHeader(path) && !headers.has(YOYUZH_CLIENT_ID_HEADER)) {
|
|
||||||
headers.set(YOYUZH_CLIENT_ID_HEADER, resolveYoyuzhClientId());
|
|
||||||
}
|
|
||||||
if (requestBody && !(requestBody instanceof FormData) && !headers.has('Content-Type')) {
|
|
||||||
headers.set('Content-Type', 'application/json');
|
|
||||||
}
|
|
||||||
if (!headers.has('Accept')) {
|
|
||||||
headers.set('Accept', 'application/json');
|
|
||||||
}
|
|
||||||
|
|
||||||
let response: Response;
|
|
||||||
let lastError: unknown;
|
|
||||||
|
|
||||||
for (let attempt = 0; attempt <= 3; attempt += 1) {
|
|
||||||
try {
|
|
||||||
response = await fetch(resolveUrl(path), {
|
|
||||||
...init,
|
|
||||||
headers,
|
|
||||||
body: requestBody,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
} catch (error) {
|
|
||||||
lastError = error;
|
|
||||||
if (!shouldRetryRequest(path, init, error, attempt)) {
|
|
||||||
throw toNetworkApiError(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
await sleep(getRetryDelayForRequest(path, init, attempt));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response!) {
|
|
||||||
throw toNetworkApiError(lastError);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.status === 401 && allowRefresh && shouldAttemptTokenRefresh(path)) {
|
|
||||||
const refreshed = await refreshAccessToken();
|
|
||||||
if (refreshed) {
|
|
||||||
return performRequest(path, init, false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.status === 401) {
|
|
||||||
clearStoredSession();
|
|
||||||
}
|
|
||||||
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function apiRequest<T>(path: string, init?: ApiRequestInit) {
|
|
||||||
const response = await performRequest(path, init);
|
|
||||||
const contentType = response.headers.get('content-type') || '';
|
|
||||||
|
|
||||||
if (!contentType.includes('application/json')) {
|
if (!contentType.includes('application/json')) {
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new ApiError(`请求失败 (${response.status})`, response.status);
|
throw new ApiError(`请求失败(HTTP ${response.status})`, response.status);
|
||||||
}
|
}
|
||||||
return undefined as T;
|
return undefined as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = (await response.json()) as ApiEnvelope<T>;
|
const payload = (await response.json()) as ApiEnvelope<T> | T;
|
||||||
if (!response.ok || payload.code !== 0) {
|
|
||||||
throw new ApiError(payload.msg || `请求失败 (${response.status})`, response.status, payload.code);
|
|
||||||
}
|
|
||||||
|
|
||||||
return payload.data;
|
if (
|
||||||
}
|
typeof payload === 'object' &&
|
||||||
|
payload !== null &&
|
||||||
export function apiV2Request<T>(path: string, init?: ApiRequestInit) {
|
'code' in payload &&
|
||||||
return apiRequest<T>(resolveV2Path(path), init);
|
'msg' in payload &&
|
||||||
}
|
'data' in payload
|
||||||
|
) {
|
||||||
function apiUploadRequestInternal<T>(path: string, init: ApiUploadRequestInit, allowRefresh: boolean): Promise<T> {
|
const envelope = payload as ApiEnvelope<T>;
|
||||||
const session = readStoredSession();
|
if (envelope.code !== 0) {
|
||||||
const headers = new Headers(init.headers);
|
throw new ApiError(
|
||||||
|
resolveFriendlyMessage(envelope.code, response.status, envelope.msg),
|
||||||
if (session?.token) {
|
response.status,
|
||||||
headers.set('Authorization', `Bearer ${session.token}`);
|
envelope.code,
|
||||||
}
|
|
||||||
if (shouldAttachPortalClientHeader(path) && !headers.has(PORTAL_CLIENT_HEADER)) {
|
|
||||||
headers.set(PORTAL_CLIENT_HEADER, resolvePortalClientType());
|
|
||||||
}
|
|
||||||
if (shouldAttachYoyuzhClientIdHeader(path) && !headers.has(YOYUZH_CLIENT_ID_HEADER)) {
|
|
||||||
headers.set(YOYUZH_CLIENT_ID_HEADER, resolveYoyuzhClientId());
|
|
||||||
}
|
|
||||||
if (!headers.has('Accept')) {
|
|
||||||
headers.set('Accept', 'application/json');
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise<T>((resolve, reject) => {
|
|
||||||
const xhr = new XMLHttpRequest();
|
|
||||||
let settled = false;
|
|
||||||
|
|
||||||
const detachAbortSignal = () => {
|
|
||||||
init.signal?.removeEventListener('abort', handleAbortSignal);
|
|
||||||
};
|
|
||||||
|
|
||||||
const resolveOnce = (value: T | PromiseLike<T>) => {
|
|
||||||
if (settled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
settled = true;
|
|
||||||
detachAbortSignal();
|
|
||||||
resolve(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const rejectOnce = (error: unknown) => {
|
|
||||||
if (settled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
settled = true;
|
|
||||||
detachAbortSignal();
|
|
||||||
reject(error);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAbortSignal = () => {
|
|
||||||
if (settled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
xhr.abort();
|
|
||||||
rejectOnce(toUploadAbortApiError());
|
|
||||||
};
|
|
||||||
|
|
||||||
if (init.signal?.aborted) {
|
|
||||||
rejectOnce(toUploadAbortApiError());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
xhr.open(init.method || 'POST', resolveUrl(path));
|
|
||||||
|
|
||||||
headers.forEach((value, key) => {
|
|
||||||
xhr.setRequestHeader(key, value);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (init.onProgress) {
|
|
||||||
xhr.upload.addEventListener('progress', (event) => {
|
|
||||||
if (!event.lengthComputable) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
init.onProgress?.({
|
|
||||||
loaded: event.loaded,
|
|
||||||
total: event.total,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
xhr.onerror = () => {
|
|
||||||
if (init.signal?.aborted) {
|
|
||||||
rejectOnce(toUploadAbortApiError());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
rejectOnce(toNetworkApiError(new TypeError('Failed to fetch')));
|
|
||||||
};
|
|
||||||
|
|
||||||
xhr.onabort = () => {
|
|
||||||
rejectOnce(toUploadAbortApiError());
|
|
||||||
};
|
|
||||||
|
|
||||||
xhr.onload = () => {
|
|
||||||
const contentType = xhr.getResponseHeader('content-type') || '';
|
|
||||||
|
|
||||||
if (xhr.status === 401 && allowRefresh && shouldAttemptTokenRefresh(path)) {
|
|
||||||
refreshAccessToken()
|
|
||||||
.then((refreshed) => {
|
|
||||||
if (refreshed) {
|
|
||||||
resolveOnce(apiUploadRequestInternal<T>(path, init, false));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
clearStoredSession();
|
|
||||||
rejectOnce(new ApiError('登录状态已失效,请重新登录', 401));
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
clearStoredSession();
|
|
||||||
rejectOnce(error instanceof ApiError ? error : toNetworkApiError(error));
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!contentType.includes('application/json')) {
|
|
||||||
if (xhr.status >= 200 && xhr.status < 300) {
|
|
||||||
resolveOnce(undefined as T);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
rejectOnce(new ApiError(`请求失败 (${xhr.status})`, xhr.status));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = JSON.parse(xhr.responseText) as ApiEnvelope<T>;
|
|
||||||
if (xhr.status < 200 || xhr.status >= 300 || payload.code !== 0) {
|
|
||||||
if (xhr.status === 401) {
|
|
||||||
clearStoredSession();
|
|
||||||
}
|
|
||||||
rejectOnce(new ApiError(payload.msg || `请求失败 (${xhr.status})`, xhr.status, payload.code));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
resolveOnce(payload.data);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (init.signal) {
|
|
||||||
init.signal.addEventListener('abort', handleAbortSignal, {once: true});
|
|
||||||
}
|
|
||||||
|
|
||||||
xhr.send(init.body);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function apiUploadRequest<T>(path: string, init: ApiUploadRequestInit): Promise<T> {
|
|
||||||
return apiUploadRequestInternal<T>(path, init, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function apiBinaryUploadRequest(path: string, init: ApiBinaryUploadRequestInit) {
|
|
||||||
const headers = new Headers(init.headers);
|
|
||||||
|
|
||||||
return new Promise<ApiBinaryUploadResponse>((resolve, reject) => {
|
|
||||||
const xhr = new XMLHttpRequest();
|
|
||||||
let settled = false;
|
|
||||||
|
|
||||||
const detachAbortSignal = () => {
|
|
||||||
init.signal?.removeEventListener('abort', handleAbortSignal);
|
|
||||||
};
|
|
||||||
|
|
||||||
const resolveOnce = (value: ApiBinaryUploadResponse) => {
|
|
||||||
if (settled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
settled = true;
|
|
||||||
detachAbortSignal();
|
|
||||||
resolve(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const rejectOnce = (error: unknown) => {
|
|
||||||
if (settled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
settled = true;
|
|
||||||
detachAbortSignal();
|
|
||||||
reject(error);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAbortSignal = () => {
|
|
||||||
if (settled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
xhr.abort();
|
|
||||||
rejectOnce(toUploadAbortApiError());
|
|
||||||
};
|
|
||||||
|
|
||||||
if (init.signal?.aborted) {
|
|
||||||
rejectOnce(toUploadAbortApiError());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
xhr.open(init.method || 'PUT', resolveUrl(path));
|
|
||||||
|
|
||||||
headers.forEach((value, key) => {
|
|
||||||
xhr.setRequestHeader(key, value);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (init.onProgress) {
|
|
||||||
xhr.upload.addEventListener('progress', (event) => {
|
|
||||||
if (!event.lengthComputable) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
init.onProgress?.({
|
|
||||||
loaded: event.loaded,
|
|
||||||
total: event.total,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
xhr.onerror = () => {
|
|
||||||
if (init.signal?.aborted) {
|
|
||||||
rejectOnce(toUploadAbortApiError());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
rejectOnce(toNetworkApiError(new TypeError('Failed to fetch')));
|
|
||||||
};
|
|
||||||
|
|
||||||
xhr.onabort = () => {
|
|
||||||
rejectOnce(toUploadAbortApiError());
|
|
||||||
};
|
|
||||||
|
|
||||||
xhr.onload = () => {
|
|
||||||
if (xhr.status >= 200 && xhr.status < 300) {
|
|
||||||
const responseHeaders = Object.fromEntries(
|
|
||||||
(init.responseHeaders ?? [])
|
|
||||||
.map((headerName) => {
|
|
||||||
const value = xhr.getResponseHeader(headerName);
|
|
||||||
return [headerName.toLowerCase(), value];
|
|
||||||
})
|
|
||||||
.filter((entry): entry is [string, string] => Boolean(entry[1])),
|
|
||||||
);
|
);
|
||||||
resolveOnce({
|
|
||||||
status: xhr.status,
|
|
||||||
headers: responseHeaders,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
return envelope.data;
|
||||||
rejectOnce(new ApiError(`请求失败 (${xhr.status})`, xhr.status));
|
|
||||||
};
|
|
||||||
|
|
||||||
if (init.signal) {
|
|
||||||
init.signal.addEventListener('abort', handleAbortSignal, {once: true});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
xhr.send(init.body);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function apiDownload(path: string, init: ApiRequestInit = {}) {
|
|
||||||
const headers = new Headers(init.headers);
|
|
||||||
headers.set('Accept', '*/*');
|
|
||||||
|
|
||||||
const response = await performRequest(path, {
|
|
||||||
...init,
|
|
||||||
headers,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw await parseApiError(response);
|
throw new ApiError(`请求失败(HTTP ${response.status})`, response.status);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response;
|
return payload as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAccessToken(session: PortalSession) {
|
||||||
|
const response = await fetch(buildUrl('/auth/refresh'), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
[CLIENT_HEADER]: CLIENT_TYPE,
|
||||||
|
[CLIENT_ID_HEADER]: getClientId(),
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ refreshToken: session.refreshToken }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const refreshed = await parseResponse<PortalSession>(response);
|
||||||
|
const nextSession: PortalSession = {
|
||||||
|
...session,
|
||||||
|
...refreshed,
|
||||||
|
};
|
||||||
|
setSession(nextSession);
|
||||||
|
return nextSession;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchApi<T = unknown>(endpoint: string, options: FetchApiOptions = {}) {
|
||||||
|
const {
|
||||||
|
auth = true,
|
||||||
|
rawResponse = false,
|
||||||
|
retryOnAuthFailure = true,
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
...rest
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const session = getSession();
|
||||||
|
const resolvedHeaders = new Headers(headers ?? {});
|
||||||
|
resolvedHeaders.set(CLIENT_HEADER, CLIENT_TYPE);
|
||||||
|
resolvedHeaders.set(CLIENT_ID_HEADER, getClientId());
|
||||||
|
|
||||||
|
const isFormData = typeof FormData !== 'undefined' && body instanceof FormData;
|
||||||
|
if (!isFormData && body != null && !resolvedHeaders.has('Content-Type')) {
|
||||||
|
resolvedHeaders.set('Content-Type', 'application/json');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (auth && session?.accessToken) {
|
||||||
|
resolvedHeaders.set('Authorization', `Bearer ${session.accessToken}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(buildUrl(endpoint), {
|
||||||
|
...rest,
|
||||||
|
headers: resolvedHeaders,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
|
||||||
|
if ((response.status === 401 || response.status === 403) && auth && session?.refreshToken && retryOnAuthFailure) {
|
||||||
|
try {
|
||||||
|
const refreshed = await refreshAccessToken(session);
|
||||||
|
return fetchApi<T>(endpoint, {
|
||||||
|
...options,
|
||||||
|
retryOnAuthFailure: false,
|
||||||
|
headers: {
|
||||||
|
...(headers ?? {}),
|
||||||
|
Authorization: `Bearer ${refreshed.accessToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
clearSession();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rawResponse) {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new ApiError(`请求失败(HTTP ${response.status})`, response.status);
|
||||||
|
}
|
||||||
|
return response as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseResponse<T>(response);
|
||||||
}
|
}
|
||||||
|
|||||||