feat(front): 覆盖 front 并完善登录快传入口与中文文案

This commit is contained in:
yoyuzh
2026-04-10 01:09:06 +08:00
parent 99e00cd7f7
commit 12005cc606
210 changed files with 4860 additions and 23900 deletions

View File

@@ -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"

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -1,2 +0,0 @@
/build/*
!/build/.npmkeep

View File

@@ -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")
}

View File

@@ -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()
}

View File

@@ -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

View File

@@ -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());
}
}

View File

@@ -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>

View File

@@ -1,5 +0,0 @@
package xyz.yoyuzh.portal;
import com.getcapacitor.BridgeActivity;
public class MainActivity extends BridgeActivity {}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -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>

View File

@@ -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>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
</resources>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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);
}
}

View File

@@ -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
}

View File

@@ -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')

View File

@@ -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

Binary file not shown.

View File

@@ -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
View File

@@ -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" "$@"

View File

@@ -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

View File

@@ -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'

View File

@@ -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'
}

View File

@@ -1,9 +0,0 @@
import type { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = {
appId: 'xyz.yoyuzh.portal',
appName: 'YOYUZH',
webDir: 'dist'
};
export default config;

View File

@@ -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>

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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"
}, },

View File

@@ -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>
); );
} }

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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}));
});

View File

@@ -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 () => [],
};

View File

@@ -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'],
);
});

View File

@@ -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(/\.$/, '');
}

File diff suppressed because it is too large Load Diff

View File

@@ -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',
);
});

View File

@@ -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,
};
},
};

View File

@@ -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 },
show: {
opacity: 1,
transition: {
staggerChildren: 0.05
}
}
};
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();
}, []);
function FilesListActions() {
return ( return (
<TopToolbar> <motion.div
<RefreshButton /> initial={{ opacity: 0 }}
</TopToolbar> animate={{ opacity: 1 }}
); exit={{ opacity: 0 }}
} className="flex h-full flex-col p-8 text-gray-900 dark:text-gray-100 overflow-y-auto"
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`;
}
export function PortalAdminFilesList() {
return (
<List
actions={<FilesListActions />}
filters={[
<SearchInput key="query" source="query" alwaysOn placeholder="搜索文件名或路径" />,
<SearchInput key="ownerQuery" source="ownerQuery" placeholder="搜索所属用户" />,
]}
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>
); );
} }

View File

@@ -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 {
directUpload: false,
multipartUpload: false,
signedDownloadUrl: false,
serverProxyDownload: true,
thumbnailNative: false,
friendlyDownloadName: false,
requiresCors: false,
supportsInternalEndpoint: false,
maxObjectSize,
};
}
const CAPABILITY_LABELS: Array<{ key: keyof StoragePolicyCapabilities; label: string }> = [ function buildInitialForm(policy?: AdminStoragePolicy): StoragePolicyUpsertPayload {
{ key: 'directUpload', label: '直传' }, if (policy) {
{ key: 'multipartUpload', label: '分片' }, return {
{ key: 'signedDownloadUrl', label: '签名下载' }, name: policy.name,
{ key: 'serverProxyDownload', label: '服务端下载' }, type: policy.type,
{ key: 'thumbnailNative', label: '原生缩略图' }, bucketName: policy.bucketName ?? '',
{ key: 'friendlyDownloadName', label: '友好文件名' }, endpoint: policy.endpoint ?? '',
{ key: 'requiresCors', label: 'CORS' }, region: policy.region ?? '',
{ key: 'supportsInternalEndpoint', label: '内网 endpoint' }, privateBucket: policy.privateBucket,
]; prefix: policy.prefix ?? '',
credentialMode: policy.credentialMode,
maxSizeBytes: policy.maxSizeBytes,
capabilities: policy.capabilities,
enabled: policy.enabled,
};
}
return {
name: '',
type: 'LOCAL',
bucketName: '',
endpoint: '',
region: '',
privateBucket: false,
prefix: '',
credentialMode: 'NONE',
maxSizeBytes: 1024 * 1024 * 1024,
capabilities: createDefaultCapabilities(),
enabled: true,
};
}
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 : '保存策略失败');
}
}
function StoragePoliciesListActions() {
return ( return (
<TopToolbar> <motion.div
<RefreshButton /> initial={{ opacity: 0 }}
</TopToolbar> animate={{ opacity: 1 }}
); exit={{ opacity: 0 }}
} className="flex h-full flex-col p-8 text-gray-900 dark:text-gray-100 overflow-y-auto"
>
<div className="mb-10 flex flex-wrap items-center justify-between gap-4">
<div>
<h1 className="text-4xl font-black tracking-tight animate-text-reveal"></h1>
<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>
function formatFileSize(size: number) { {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}
if (size >= 1024 * 1024 * 1024) {
return `${(size / (1024 * 1024 * 1024)).toFixed(1)} GB`;
}
if (size >= 1024 * 1024) {
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
}
if (size >= 1024) {
return `${(size / 1024).toFixed(1)} KB`;
}
return `${size} B`;
}
function renderCapabilities(capabilities: StoragePolicyCapabilities) { <div className="flex-1 min-h-0">
return ( {loading ? (
<Stack direction="row" flexWrap="wrap" gap={0.5}> <div className="glass-panel rounded-lg px-4 py-16 text-center text-[10px] font-black uppercase tracking-widest opacity-40">...</div>
{CAPABILITY_LABELS.map(({ key, label }) => { ) : (
const enabled = capabilities[key] === true; <div className="glass-panel rounded-lg overflow-hidden shadow-xl border-white/20">
return ( <div className="overflow-x-auto">
<Chip <table className="min-w-full divide-y divide-white/10 text-sm">
key={key} <thead className="bg-white/10 dark:bg-black/40 font-black uppercase tracking-[0.15em] text-[9px] opacity-40">
color={enabled ? 'success' : 'default'} <tr>
label={`${label}${enabled ? '开' : '关'}`} <th className="px-8 py-5 text-left"></th>
size="small" <th className="px-8 py-5 text-left"></th>
variant={enabled ? 'filled' : 'outlined'} <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>
); );
} }

View File

@@ -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: {
opacity: 1,
transition: {
staggerChildren: 0.05
}
}
};
const USER_ROLE_OPTIONS: AdminUserRole[] = ['USER', 'MODERATOR', 'ADMIN']; const itemVariants = {
hidden: { y: 10, opacity: 0 },
show: { y: 0, opacity: 1 }
};
function formatLimitSize(bytes: number) { export default function AdminUsersList() {
if (bytes <= 0) { const [loading, setLoading] = useState(true);
return '0 B'; const [error, setError] = useState('');
} const [query, setQuery] = useState('');
const units = ['B', 'KB', 'MB', 'GB', 'TB']; const [users, setUsers] = useState<AdminUser[]>([]);
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 { async function loadUsers(nextQuery = query) {
const normalized = value.trim().toLowerCase(); setError('');
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() {
return (
<TopToolbar>
<RefreshButton />
</TopToolbar>
);
}
function formatStorageUsage(usedBytes: number, quotaBytes: number) {
return `${formatLimitSize(usedBytes)} / ${formatLimitSize(quotaBytes)}`;
}
function AdminUserActions({ record }: { record: AdminUser }) {
const notify = useNotify();
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>
); );
} }

View File

@@ -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;
}

View File

@@ -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);
});

View File

@@ -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;
}
}

View 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;
};

View 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>
);
}

View File

@@ -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);
});

View File

@@ -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>
); );
} }

View File

@@ -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/);
});

View File

@@ -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>
);
}

View File

@@ -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);
});

View File

@@ -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('/'));
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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,
);
}

View File

@@ -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>
);
}

View File

@@ -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 }

View File

@@ -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\)\]/);
});

View File

@@ -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 }

View File

@@ -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 }

View 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;
}

View File

@@ -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);
} }
@layer base {
:root { :root {
--app-safe-area-top: max(env(safe-area-inset-top, 0px), var(--safe-area-inset-top, 0px)); --background: 210 40% 98%;
--app-safe-area-bottom: max(env(safe-area-inset-bottom, 0px), var(--safe-area-inset-bottom, 0px)); --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 */ h1, h2, h3, h4, h5, h6 {
::-webkit-scrollbar { font-family: 'Outfit', sans-serif;
width: 8px; }
height: 8px;
} }
::-webkit-scrollbar-track {
background: transparent; @layer utilities {
} .bg-aurora {
::-webkit-scrollbar-thumb { background-color: #c4d9ff;
background: rgba(255, 255, 255, 0.1); background-image:
border-radius: 4px; radial-gradient(at 0% 0%, #ffd1eb 0px, transparent 50%),
} radial-gradient(at 100% 0%, #fff1c4 0px, transparent 50%),
::-webkit-scrollbar-thumb:hover { radial-gradient(at 100% 100%, #c4fff2 0px, transparent 50%),
background: rgba(255, 255, 255, 0.2); 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;
}
.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%);
} }
/* Glassmorphism utilities */
.glass-panel { .glass-panel {
background: var(--color-glass-bg); background-color: var(--glass-bg);
backdrop-filter: blur(16px); backdrop-filter: blur(24px) saturate(180%);
-webkit-backdrop-filter: blur(16px); border: 1px solid var(--glass-border);
border: 1px solid var(--color-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:hover { .glass-panel:hover {
background: var(--color-glass-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-active { .glass-panel-no-hover {
background: var(--color-glass-active); background-color: var(--glass-bg);
} backdrop-filter: blur(24px) saturate(180%);
border: 1px solid var(--glass-border);
.safe-area-pt { box-shadow: var(--glass-inner-shadow);
padding-top: var(--app-safe-area-top); border-radius: 8px;
}
.safe-area-pb {
padding-bottom: var(--app-safe-area-bottom);
} }
/* Animations */ /* Animations */
@keyframes blob { .animate-page-entry {
0% { animation: page-entry 1s cubic-bezier(0.16, 1, 0.3, 1) forwards;
transform: translate(0px, 0px) scale(1);
} }
33% {
transform: translate(30px, -50px) scale(1.1); .animate-text-reveal {
animation: text-reveal 1.5s cubic-bezier(0.16, 1, 0.3, 1) forwards;
background-clip: text;
-webkit-background-clip: text;
} }
66% {
transform: translate(-20px, 20px) scale(0.9); .animate-scan {
animation: scan 4s linear infinite;
} }
100% {
transform: translate(0px, 0px) scale(1); .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;
} }

View 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 }),
});
}

View 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
View 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',
});
}

View File

@@ -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);
});

View File

@@ -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())) { export function getClientId() {
return `${DEFAULT_CAPACITOR_API_ORIGIN}${DEFAULT_API_BASE_URL}`; const storageKey = 'portal-client-id';
const existing = window.localStorage.getItem(storageKey);
if (existing) {
return existing;
} }
return DEFAULT_API_BASE_URL; const generated = `web-${crypto.randomUUID()}`;
window.localStorage.setItem(storageKey, generated);
return generated;
} }
function resolveUrl(path: string) { function buildUrl(endpoint: string) {
if (/^https?:\/\//.test(path)) { if (/^https?:\/\//.test(endpoint)) {
return path; return endpoint;
}
if (endpoint.startsWith('/api/')) {
return endpoint;
}
if (endpoint.startsWith('/')) {
return `${getApiBaseUrl()}${endpoint}`;
}
return `${getApiBaseUrl()}/${endpoint}`;
} }
const normalizedPath = path.startsWith('/') ? path : `/${path}`; function looksLikeQuestionMarks(message: string | null | undefined) {
return `${getApiBaseUrl()}${normalizedPath}`; if (!message) {
}
function normalizePath(path: string) {
return path.startsWith('/') ? path : `/${path}`;
}
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;
}
const storedClientId = globalThis.localStorage.getItem(YOYUZH_CLIENT_ID_STORAGE_KEY);
if (storedClientId) {
return storedClientId;
}
const clientId = createYoyuzhClientId();
globalThis.localStorage.setItem(YOYUZH_CLIENT_ID_STORAGE_KEY, clientId);
return clientId;
}
function shouldAttemptTokenRefresh(path: string) {
const normalizedPath = normalizePath(path);
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;
} }
async function parseApiError(response: Response) { const trimmed = message.trim();
const contentType = response.headers.get('content-type') || ''; return trimmed.length === 0 || /^[?锛焆]+$/.test(trimmed);
if (!contentType.includes('application/json')) {
return new ApiError(`请求失败 (${response.status})`, response.status);
} }
const payload = (await response.json()) as ApiEnvelope<null>; function resolveFriendlyMessage(code: number, status: number, message: string) {
return new ApiError(payload.msg || `请求失败 (${response.status})`, response.status, payload.code); if ((code === 1001 || status === 401) && looksLikeQuestionMarks(message)) {
return '未登录或登录已过期,请先登录。';
}
if ((code === 1002 || status === 403) && looksLikeQuestionMarks(message)) {
return '没有权限访问该页面。';
}
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);
} }

Some files were not shown because too many files have changed in this diff Show More