feat: add startup scripts and fix application configuration
- Add start_app.sh script for one-click application startup - Add STARTUP_GUIDE.md with detailed startup instructions - Add init_and_start.sh alternative startup script - Fix JpaAuditingConfig to remove duplicate @EnableJpaRepositories - Update application-dev.yml to use environment variables for database config - Fix TenantContextFilterTest to use proper MockFilterChain implementation - Fix OrderAuditIntegrationTest to use correct TenantScope constructor - Fix OrderCacheIntegrationTest to use correct method names and return types - Add tmpl-admin frontend project - Update OrderControllerTest with correct method mappings and return codes Co-authored-by: Qwen-Coder <[email protected]>
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
# Java Service Template - Quick Start Guide
|
||||
|
||||
This guide explains how to initialize the database, establish Flyway baseline, and start the application.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before running the application, ensure you have:
|
||||
|
||||
1. **Java 21** installed
|
||||
2. **Maven** installed
|
||||
3. **PostgreSQL** server running locally (or configure external access)
|
||||
4. **Redis** server running locally (or configure external access)
|
||||
|
||||
## Quick Start Script
|
||||
|
||||
The project includes a script to automate the entire setup process:
|
||||
|
||||
### Using the Start Script
|
||||
|
||||
```bash
|
||||
# Make the script executable (if not already done)
|
||||
chmod +x start_app.sh
|
||||
|
||||
# Run the complete setup and start the application
|
||||
./start_app.sh
|
||||
```
|
||||
|
||||
This script will:
|
||||
1. Check PostgreSQL connectivity
|
||||
2. Build the application using Maven
|
||||
3. Create the database named `java_template`
|
||||
4. Establish Flyway baseline and run migrations
|
||||
5. Start the application
|
||||
|
||||
### Manual Steps (Alternative)
|
||||
|
||||
If you prefer to run the steps manually:
|
||||
|
||||
#### 1. Build the Application
|
||||
```bash
|
||||
mvn clean package -DskipTests
|
||||
```
|
||||
|
||||
#### 2. Set Up Database
|
||||
|
||||
Make sure PostgreSQL is running, then:
|
||||
|
||||
```bash
|
||||
# Connect to PostgreSQL as superuser
|
||||
psql -h localhost -p 5432 -U postgres
|
||||
|
||||
# Create the database
|
||||
CREATE DATABASE java_template;
|
||||
\q
|
||||
```
|
||||
|
||||
#### 3. Run the Application with Flyway
|
||||
|
||||
```bash
|
||||
# The application will automatically run Flyway migrations on startup
|
||||
java -jar tmpl-api/target/tmpl-api-0.1.0-SNAPSHOT.jar \
|
||||
-Dspring.profiles.active=dev \
|
||||
-DDB_HOST=localhost \
|
||||
-DDB_PORT=5432 \
|
||||
-DDB_NAME=java_template \
|
||||
-DDB_USER=postgres \
|
||||
-DDB_PASS=postgres
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
The application uses the following default configuration:
|
||||
|
||||
- **Database**: PostgreSQL at `localhost:5432`, database name `java_template`
|
||||
- **Database User**: `postgres` with password `postgres`
|
||||
- **Redis**: At `localhost:6379`
|
||||
- **Application Port**: `8080`
|
||||
- **Management Port**: `8080` (in dev profile, merged with app port)
|
||||
|
||||
These can be overridden using environment variables:
|
||||
- `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASS`
|
||||
- `REDIS_HOST`, `REDIS_PORT`
|
||||
|
||||
## Database Migrations
|
||||
|
||||
The application includes Flyway migrations:
|
||||
- `V1__init.sql`: Basic schema initialization
|
||||
- `V2__create_order.sql`: Creates the order table with audit columns
|
||||
|
||||
Flyway will automatically apply these migrations on application startup.
|
||||
|
||||
## Accessing the Application
|
||||
|
||||
Once started, the application will be available at:
|
||||
- **Main endpoint**: http://localhost:8080
|
||||
- **API Documentation**: http://localhost:8080/swagger-ui.html
|
||||
- **Health check**: http://localhost:8080/actuator/health
|
||||
- **Metrics**: http://localhost:8080/actuator/prometheus
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues:
|
||||
|
||||
1. **PostgreSQL not accessible**: Make sure PostgreSQL server is running and accessible
|
||||
2. **Database permissions**: Ensure the database user has CREATE privileges
|
||||
3. **Port conflicts**: Check if ports 8080 and 6379 are available
|
||||
4. **Connection timeouts**: Verify network connectivity to database and Redis
|
||||
|
||||
### Environment Variables:
|
||||
|
||||
You can customize the configuration using environment variables:
|
||||
|
||||
```bash
|
||||
export DB_HOST=localhost
|
||||
export DB_PORT=5432
|
||||
export DB_NAME=java_template
|
||||
export DB_USER=postgres
|
||||
export DB_PASS=postgres
|
||||
export REDIS_HOST=localhost
|
||||
export REDIS_PORT=6379
|
||||
```
|
||||
|
||||
Then run the application normally.
|
||||
Executable
+198
@@ -0,0 +1,198 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Java Service Template - Initialization and Startup Script
|
||||
# This script initializes the database, establishes Flyway baseline, and starts the application
|
||||
|
||||
set -e # Exit immediately if a command exits with a non-zero status
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
echo_color() {
|
||||
local color=$1
|
||||
local message=$2
|
||||
echo -e "${color}${message}${NC}"
|
||||
}
|
||||
|
||||
# Configuration
|
||||
DB_NAME="java_template"
|
||||
DB_USER="${DB_USER:-postgres}"
|
||||
DB_PASS="${DB_PASS:-postgres}"
|
||||
DB_HOST="${DB_HOST:-localhost}"
|
||||
DB_PORT="${DB_PORT:-5432}"
|
||||
|
||||
APP_JAR="tmpl-api/target/tmpl-api-0.1.0-SNAPSHOT.jar"
|
||||
|
||||
echo_color $YELLOW "Starting Java Service Template initialization..."
|
||||
|
||||
# Function to check if PostgreSQL is running
|
||||
check_postgres() {
|
||||
if ! command -v psql &> /dev/null; then
|
||||
echo_color $RED "psql command not found. Please install PostgreSQL client."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! pg_isready -h "$DB_HOST" -p "$DB_PORT" 2>/dev/null; then
|
||||
echo_color $RED "PostgreSQL server is not running at $DB_HOST:$DB_PORT"
|
||||
echo_color $YELLOW "Please start PostgreSQL server before running this script."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to create database
|
||||
create_database() {
|
||||
echo_color $YELLOW "Creating database: $DB_NAME"
|
||||
|
||||
# Check if database already exists
|
||||
if PGPASSWORD="$DB_PASS" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -lqt | cut -d \| -f 1 | grep -qw "$DB_NAME"; then
|
||||
echo_color $GREEN "Database $DB_NAME already exists, skipping creation."
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Create database
|
||||
PGPASSWORD="$DB_PASS" createdb -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -O "$DB_USER" "$DB_NAME"
|
||||
echo_color $GREEN "Database $DB_NAME created successfully."
|
||||
}
|
||||
|
||||
# Function to establish Flyway baseline
|
||||
establish_flyway_baseline() {
|
||||
echo_color $YELLOW "Establishing Flyway baseline..."
|
||||
|
||||
# Check if flyway schema history table exists
|
||||
if PGPASSWORD="$DB_PASS" psql -h "$DB_HOST" -p "$DB_PORT" -d "$DB_NAME" -U "$DB_USER" -tAc "SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'flyway_schema_history');" | grep -q 't'; then
|
||||
echo_color $GREEN "Flyway schema history table already exists, skipping baseline."
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Run the application with Flyway baseline enabled
|
||||
echo_color $YELLOW "Running application to establish Flyway baseline..."
|
||||
|
||||
# Temporarily set baseline on migrate to true
|
||||
JAVA_OPTS="-Dspring.flyway.baseline-on-migrate=true -Dspring.flyway.baseline-version=0 -Dspring.profiles.active=dev"
|
||||
JAVA_OPTS="$JAVA_OPTS -DDB_HOST=$DB_HOST -DDB_PORT=$DB_PORT -DDB_NAME=$DB_NAME -DDB_USER=$DB_USER -DDB_PASS=$DB_PASS"
|
||||
|
||||
# Run the application briefly to establish baseline
|
||||
timeout 30s java $JAVA_OPTS -jar $APP_JAR || true
|
||||
|
||||
echo_color $GREEN "Flyway baseline established."
|
||||
}
|
||||
|
||||
# Function to run migrations
|
||||
run_migrations() {
|
||||
echo_color $YELLOW "Running database migrations..."
|
||||
|
||||
# Run the application to apply migrations
|
||||
JAVA_OPTS="-Dspring.profiles.active=dev"
|
||||
JAVA_OPTS="$JAVA_OPTS -DDB_HOST=$DB_HOST -DDB_PORT=$DB_PORT -DDB_NAME=$DB_NAME -DDB_USER=$DB_USER -DDB_PASS=$DB_PASS"
|
||||
|
||||
# Run the application briefly to apply migrations
|
||||
timeout 30s java $JAVA_OPTS -jar $APP_JAR || true
|
||||
|
||||
echo_color $GREEN "Database migrations applied."
|
||||
}
|
||||
|
||||
# Function to build the application
|
||||
build_application() {
|
||||
echo_color $YELLOW "Building the application..."
|
||||
|
||||
if [ ! -f "pom.xml" ]; then
|
||||
echo_color $RED "pom.xml not found in current directory!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mvn clean package -DskipTests
|
||||
|
||||
if [ ! -f "$APP_JAR" ]; then
|
||||
echo_color $RED "Application JAR not found at $APP_JAR after build!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo_color $GREEN "Application built successfully."
|
||||
}
|
||||
|
||||
# Function to start the application
|
||||
start_application() {
|
||||
echo_color $YELLOW "Starting the application..."
|
||||
|
||||
# Set environment variables for the application
|
||||
export DB_HOST=$DB_HOST
|
||||
export DB_PORT=$DB_PORT
|
||||
export DB_NAME=$DB_NAME
|
||||
export DB_USER=$DB_USER
|
||||
export DB_PASS=$DB_PASS
|
||||
|
||||
# Start the application in the background
|
||||
JAVA_OPTS="-Dspring.profiles.active=dev"
|
||||
JAVA_OPTS="$JAVA_OPTS -DDB_HOST=$DB_HOST -DDB_PORT=$DB_PORT -DDB_NAME=$DB_NAME -DDB_USER=$DB_USER -DDB_PASS=$DB_PASS"
|
||||
|
||||
echo_color $GREEN "Starting application with command: java $JAVA_OPTS -jar $APP_JAR"
|
||||
java $JAVA_OPTS -jar $APP_JAR &
|
||||
|
||||
APP_PID=$!
|
||||
echo $APP_PID > app.pid
|
||||
echo_color $GREEN "Application started with PID: $APP_PID"
|
||||
echo_color $GREEN "Application is available at: http://localhost:8080"
|
||||
echo_color $GREEN "Actuator endpoints available at: http://localhost:8080/actuator"
|
||||
|
||||
# Wait for the application to finish
|
||||
wait $APP_PID
|
||||
}
|
||||
|
||||
# Main execution
|
||||
main() {
|
||||
echo_color $YELLOW "Step 1: Checking prerequisites..."
|
||||
check_postgres
|
||||
|
||||
echo_color $YELLOW "Step 2: Building application..."
|
||||
build_application
|
||||
|
||||
echo_color $YELLOW "Step 3: Creating database..."
|
||||
create_database
|
||||
|
||||
echo_color $YELLOW "Step 4: Establishing Flyway baseline..."
|
||||
establish_flyway_baseline
|
||||
|
||||
echo_color $YELLOW "Step 5: Running migrations..."
|
||||
run_migrations
|
||||
|
||||
echo_color $YELLOW "Step 6: Starting application..."
|
||||
start_application
|
||||
}
|
||||
|
||||
# Parse command line arguments
|
||||
case "${1:-full}" in
|
||||
"init-only")
|
||||
echo_color $YELLOW "Initializing database and establishing Flyway baseline only..."
|
||||
check_postgres
|
||||
create_database
|
||||
establish_flyway_baseline
|
||||
run_migrations
|
||||
echo_color $GREEN "Database initialization completed!"
|
||||
;;
|
||||
"start-only")
|
||||
echo_color $YELLOW "Starting application only..."
|
||||
if [ ! -f "$APP_JAR" ]; then
|
||||
echo_color $RED "Application JAR not found. Please build first or run 'full'."
|
||||
exit 1
|
||||
fi
|
||||
start_application
|
||||
;;
|
||||
"build-only")
|
||||
echo_color $YELLOW "Building application only..."
|
||||
build_application
|
||||
;;
|
||||
"full"|"")
|
||||
main
|
||||
;;
|
||||
*)
|
||||
echo_color $RED "Usage: $0 [full|init-only|start-only|build-only]"
|
||||
echo_color $YELLOW " full (default): Initialize database and start application"
|
||||
echo_color $YELLOW " init-only: Initialize database and establish Flyway baseline only"
|
||||
echo_color $YELLOW " start-only: Start application only (requires built JAR)"
|
||||
echo_color $YELLOW " build-only: Build application only"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
Executable
+153
@@ -0,0 +1,153 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Java Service Template - Quick Start Script
|
||||
# Initializes database 'java_template', establishes Flyway baseline, and starts the application
|
||||
|
||||
set -e # Exit immediately if a command exits with a non-zero status
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
echo_color() {
|
||||
local color=$1
|
||||
local message=$2
|
||||
echo -e "${color}${message}${NC}"
|
||||
}
|
||||
|
||||
# Check if another instance is already running
|
||||
if pgrep -f "java.*tmpl-api.*SNAPSHOT.jar" > /dev/null; then
|
||||
echo_color $RED "Another instance of the application is already running!"
|
||||
echo_color $YELLOW "Please stop the existing process before starting a new one."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Configuration - Use environment variables with defaults
|
||||
PORT="${PORT:-48080}"
|
||||
DB_NAME="${DB_NAME:-java_template}"
|
||||
DB_USER="${DB_USER:-postgres}"
|
||||
DB_PASS="${DB_PASS:-postgres}"
|
||||
DB_HOST="${DB_HOST:-localhost}"
|
||||
DB_PORT="${DB_PORT:-5432}"
|
||||
|
||||
APP_JAR="tmpl-api/target/tmpl-api-0.1.0-SNAPSHOT.jar"
|
||||
|
||||
echo_color $YELLOW "Starting Java Service Template setup..."
|
||||
echo_color $YELLOW "Using port: $PORT"
|
||||
|
||||
# Function to check if PostgreSQL is running
|
||||
check_postgres() {
|
||||
echo_color $YELLOW "Checking PostgreSQL connectivity..."
|
||||
|
||||
if ! command -v psql &> /dev/null; then
|
||||
echo_color $RED "psql command not found. Please install PostgreSQL client."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! pg_isready -h "$DB_HOST" -p "$DB_PORT" 2>/dev/null; then
|
||||
echo_color $RED "PostgreSQL server is not running at $DB_HOST:$DB_PORT"
|
||||
echo_color $YELLOW "Please start PostgreSQL server before running this script."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo_color $GREEN "✓ PostgreSQL is accessible"
|
||||
}
|
||||
|
||||
# Function to create database
|
||||
create_database() {
|
||||
echo_color $YELLOW "Creating database: $DB_NAME"
|
||||
|
||||
# Check if database already exists
|
||||
if PGPASSWORD="$DB_PASS" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -lqt | cut -d \| -f 1 | grep -qw "$DB_NAME"; then
|
||||
echo_color $GREEN "✓ Database $DB_NAME already exists"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Create database
|
||||
PGPASSWORD="$DB_PASS" createdb -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -O "$DB_USER" "$DB_NAME"
|
||||
echo_color $GREEN "✓ Database $DB_NAME created successfully"
|
||||
}
|
||||
|
||||
# Function to build the application
|
||||
build_application() {
|
||||
echo_color $YELLOW "Building the application..."
|
||||
|
||||
if [ ! -f "pom.xml" ]; then
|
||||
echo_color $RED "✗ Pom.xml not found in current directory!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mvn clean package -DskipTests
|
||||
|
||||
if [ ! -f "$APP_JAR" ]; then
|
||||
echo_color $RED "✗ Application JAR not found at $APP_JAR after build!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo_color $GREEN "✓ Application built successfully"
|
||||
}
|
||||
|
||||
# Function to establish Flyway baseline and run migrations
|
||||
setup_flyway() {
|
||||
echo_color $YELLOW "Setting up Flyway migrations..."
|
||||
|
||||
# Run the application once to let Flyway handle the database setup
|
||||
# This will create the flyway_schema_history table and apply migrations
|
||||
JAVA_OPTS="-Dspring.profiles.active=dev,redis -Dspring.main.allow-bean-definition-overriding=true"
|
||||
JAVA_OPTS="$JAVA_OPTS -DDB_HOST=$DB_HOST -DDB_PORT=$DB_PORT -DDB_NAME=$DB_NAME -DDB_USER=$DB_USER -DDB_PASS=$DB_PASS"
|
||||
|
||||
echo_color $YELLOW "Running application to initialize database with Flyway..."
|
||||
|
||||
# Run the application briefly to let Flyway do its job
|
||||
# Use a random port to avoid conflicts and ensure quick startup/shutdown
|
||||
timeout 30s java $JAVA_OPTS -Dserver.port=0 -Dmanagement.server.port=0 -jar $APP_JAR || true
|
||||
# The || true ensures the script continues even if timeout kills the process
|
||||
|
||||
echo_color $GREEN "✓ Flyway setup completed (baseline and migrations applied)"
|
||||
}
|
||||
|
||||
# Function to start the application properly
|
||||
start_application() {
|
||||
echo_color $YELLOW "Starting the application..."
|
||||
|
||||
# Set environment variables for the application
|
||||
export DB_HOST=$DB_HOST
|
||||
export DB_PORT=$DB_PORT
|
||||
export DB_NAME=$DB_NAME
|
||||
export DB_USER=$DB_USER
|
||||
export DB_PASS=$DB_PASS
|
||||
|
||||
# Start the application with the specified port
|
||||
JAVA_OPTS="-Dserver.port=$PORT -Dmanagement.server.port=$PORT"
|
||||
JAVA_OPTS="$JAVA_OPTS -Dspring.profiles.active=dev,redis -Dspring.main.allow-bean-definition-overriding=true"
|
||||
JAVA_OPTS="$JAVA_OPTS -DDB_HOST=$DB_HOST -DDB_PORT=$DB_PORT -DDB_NAME=$DB_NAME -DDB_USER=$DB_USER -DDB_PASS=$DB_PASS"
|
||||
|
||||
echo_color $GREEN "✓ Starting application with command: java $JAVA_OPTS -jar $APP_JAR"
|
||||
echo_color $GREEN "✓ Application will be available at: http://localhost:$PORT"
|
||||
echo_color $GREEN "✓ Health endpoint: http://localhost:$PORT/actuator/health"
|
||||
|
||||
java $JAVA_OPTS -jar $APP_JAR
|
||||
}
|
||||
|
||||
# Main execution sequence
|
||||
main() {
|
||||
echo_color $YELLOW "=== Step 1: Checking prerequisites ==="
|
||||
check_postgres
|
||||
|
||||
echo_color $YELLOW "=== Step 2: Building application ==="
|
||||
build_application
|
||||
|
||||
echo_color $YELLOW "=== Step 3: Creating database '$DB_NAME' ==="
|
||||
create_database
|
||||
|
||||
echo_color $YELLOW "=== Step 4: Setting up Flyway (baseline and migrations) ==="
|
||||
setup_flyway
|
||||
|
||||
echo_color $YELLOW "=== Step 5: Starting application ==="
|
||||
start_application
|
||||
}
|
||||
|
||||
# Run the main function
|
||||
main
|
||||
@@ -0,0 +1,5 @@
|
||||
# 开发环境后端地址。Vite proxy 会读取这些值作为 target。
|
||||
# 如需覆盖,创建 .env.development.local(不入 Git)。
|
||||
VITE_API_BASE_URL=http://localhost:8080
|
||||
VITE_ACTUATOR_BASE_URL=http://localhost:9090
|
||||
VITE_ENABLE_MSW=true
|
||||
@@ -0,0 +1,5 @@
|
||||
# 生产环境:前端与后端同域部署(通过 Nginx/网关转发 /api 和 /actuator),
|
||||
# 因此 baseURL 为空,所有请求走相对路径。Vite proxy 仅在 dev server 生效。
|
||||
VITE_API_BASE_URL=
|
||||
VITE_ACTUATOR_BASE_URL=
|
||||
VITE_ENABLE_MSW=false
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 120,
|
||||
"tabWidth": 2,
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import js from '@eslint/js';
|
||||
import tseslint from 'typescript-eslint';
|
||||
import vue from 'eslint-plugin-vue';
|
||||
|
||||
export default [
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
...vue.configs['flat/recommended'],
|
||||
{
|
||||
files: ['**/*.{ts,vue}'],
|
||||
rules: {
|
||||
'vue/multi-word-component-names': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
},
|
||||
},
|
||||
{
|
||||
ignores: ['dist/', 'node_modules/', 'public/mockServiceWorker.js', 'src/auto-imports.d.ts', 'src/components.d.ts'],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>tmpl-admin</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+5433
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "tmpl-admin",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --fix",
|
||||
"format": "prettier --write src/",
|
||||
"new-page": "node scripts/new-page.mjs",
|
||||
"msw:init": "msw init public/"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.0",
|
||||
"pinia": "^2.3.0",
|
||||
"axios": "^1.7.9",
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
"echarts": "^5.6.0",
|
||||
"vue-echarts": "^7.0.3",
|
||||
"lucide-vue-next": "^0.468.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"vite": "^6.0.7",
|
||||
"vue-tsc": "^2.2.0",
|
||||
"typescript": "^5.7.3",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"postcss": "^8.4.49",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"unplugin-auto-import": "^0.18.6",
|
||||
"unplugin-vue-components": "^0.27.5",
|
||||
"eslint": "^9.17.0",
|
||||
"typescript-eslint": "^8.19.1",
|
||||
"eslint-plugin-vue": "^9.32.0",
|
||||
"prettier": "^3.4.2",
|
||||
"msw": "^2.7.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,349 @@
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
|
||||
/**
|
||||
* Mock Service Worker.
|
||||
* @see https://github.com/mswjs/msw
|
||||
* - Please do NOT modify this file.
|
||||
*/
|
||||
|
||||
const PACKAGE_VERSION = '2.12.10'
|
||||
const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
|
||||
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
|
||||
const activeClientIds = new Set()
|
||||
|
||||
addEventListener('install', function () {
|
||||
self.skipWaiting()
|
||||
})
|
||||
|
||||
addEventListener('activate', function (event) {
|
||||
event.waitUntil(self.clients.claim())
|
||||
})
|
||||
|
||||
addEventListener('message', async function (event) {
|
||||
const clientId = Reflect.get(event.source || {}, 'id')
|
||||
|
||||
if (!clientId || !self.clients) {
|
||||
return
|
||||
}
|
||||
|
||||
const client = await self.clients.get(clientId)
|
||||
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
|
||||
const allClients = await self.clients.matchAll({
|
||||
type: 'window',
|
||||
})
|
||||
|
||||
switch (event.data) {
|
||||
case 'KEEPALIVE_REQUEST': {
|
||||
sendToClient(client, {
|
||||
type: 'KEEPALIVE_RESPONSE',
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'INTEGRITY_CHECK_REQUEST': {
|
||||
sendToClient(client, {
|
||||
type: 'INTEGRITY_CHECK_RESPONSE',
|
||||
payload: {
|
||||
packageVersion: PACKAGE_VERSION,
|
||||
checksum: INTEGRITY_CHECKSUM,
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'MOCK_ACTIVATE': {
|
||||
activeClientIds.add(clientId)
|
||||
|
||||
sendToClient(client, {
|
||||
type: 'MOCKING_ENABLED',
|
||||
payload: {
|
||||
client: {
|
||||
id: client.id,
|
||||
frameType: client.frameType,
|
||||
},
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'CLIENT_CLOSED': {
|
||||
activeClientIds.delete(clientId)
|
||||
|
||||
const remainingClients = allClients.filter((client) => {
|
||||
return client.id !== clientId
|
||||
})
|
||||
|
||||
// Unregister itself when there are no more clients
|
||||
if (remainingClients.length === 0) {
|
||||
self.registration.unregister()
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
addEventListener('fetch', function (event) {
|
||||
const requestInterceptedAt = Date.now()
|
||||
|
||||
// Bypass navigation requests.
|
||||
if (event.request.mode === 'navigate') {
|
||||
return
|
||||
}
|
||||
|
||||
// Opening the DevTools triggers the "only-if-cached" request
|
||||
// that cannot be handled by the worker. Bypass such requests.
|
||||
if (
|
||||
event.request.cache === 'only-if-cached' &&
|
||||
event.request.mode !== 'same-origin'
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
// Bypass all requests when there are no active clients.
|
||||
// Prevents the self-unregistered worked from handling requests
|
||||
// after it's been terminated (still remains active until the next reload).
|
||||
if (activeClientIds.size === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const requestId = crypto.randomUUID()
|
||||
event.respondWith(handleRequest(event, requestId, requestInterceptedAt))
|
||||
})
|
||||
|
||||
/**
|
||||
* @param {FetchEvent} event
|
||||
* @param {string} requestId
|
||||
* @param {number} requestInterceptedAt
|
||||
*/
|
||||
async function handleRequest(event, requestId, requestInterceptedAt) {
|
||||
const client = await resolveMainClient(event)
|
||||
const requestCloneForEvents = event.request.clone()
|
||||
const response = await getResponse(
|
||||
event,
|
||||
client,
|
||||
requestId,
|
||||
requestInterceptedAt,
|
||||
)
|
||||
|
||||
// Send back the response clone for the "response:*" life-cycle events.
|
||||
// Ensure MSW is active and ready to handle the message, otherwise
|
||||
// this message will pend indefinitely.
|
||||
if (client && activeClientIds.has(client.id)) {
|
||||
const serializedRequest = await serializeRequest(requestCloneForEvents)
|
||||
|
||||
// Clone the response so both the client and the library could consume it.
|
||||
const responseClone = response.clone()
|
||||
|
||||
sendToClient(
|
||||
client,
|
||||
{
|
||||
type: 'RESPONSE',
|
||||
payload: {
|
||||
isMockedResponse: IS_MOCKED_RESPONSE in response,
|
||||
request: {
|
||||
id: requestId,
|
||||
...serializedRequest,
|
||||
},
|
||||
response: {
|
||||
type: responseClone.type,
|
||||
status: responseClone.status,
|
||||
statusText: responseClone.statusText,
|
||||
headers: Object.fromEntries(responseClone.headers.entries()),
|
||||
body: responseClone.body,
|
||||
},
|
||||
},
|
||||
},
|
||||
responseClone.body ? [serializedRequest.body, responseClone.body] : [],
|
||||
)
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the main client for the given event.
|
||||
* Client that issues a request doesn't necessarily equal the client
|
||||
* that registered the worker. It's with the latter the worker should
|
||||
* communicate with during the response resolving phase.
|
||||
* @param {FetchEvent} event
|
||||
* @returns {Promise<Client | undefined>}
|
||||
*/
|
||||
async function resolveMainClient(event) {
|
||||
const client = await self.clients.get(event.clientId)
|
||||
|
||||
if (activeClientIds.has(event.clientId)) {
|
||||
return client
|
||||
}
|
||||
|
||||
if (client?.frameType === 'top-level') {
|
||||
return client
|
||||
}
|
||||
|
||||
const allClients = await self.clients.matchAll({
|
||||
type: 'window',
|
||||
})
|
||||
|
||||
return allClients
|
||||
.filter((client) => {
|
||||
// Get only those clients that are currently visible.
|
||||
return client.visibilityState === 'visible'
|
||||
})
|
||||
.find((client) => {
|
||||
// Find the client ID that's recorded in the
|
||||
// set of clients that have registered the worker.
|
||||
return activeClientIds.has(client.id)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {FetchEvent} event
|
||||
* @param {Client | undefined} client
|
||||
* @param {string} requestId
|
||||
* @param {number} requestInterceptedAt
|
||||
* @returns {Promise<Response>}
|
||||
*/
|
||||
async function getResponse(event, client, requestId, requestInterceptedAt) {
|
||||
// Clone the request because it might've been already used
|
||||
// (i.e. its body has been read and sent to the client).
|
||||
const requestClone = event.request.clone()
|
||||
|
||||
function passthrough() {
|
||||
// Cast the request headers to a new Headers instance
|
||||
// so the headers can be manipulated with.
|
||||
const headers = new Headers(requestClone.headers)
|
||||
|
||||
// Remove the "accept" header value that marked this request as passthrough.
|
||||
// This prevents request alteration and also keeps it compliant with the
|
||||
// user-defined CORS policies.
|
||||
const acceptHeader = headers.get('accept')
|
||||
if (acceptHeader) {
|
||||
const values = acceptHeader.split(',').map((value) => value.trim())
|
||||
const filteredValues = values.filter(
|
||||
(value) => value !== 'msw/passthrough',
|
||||
)
|
||||
|
||||
if (filteredValues.length > 0) {
|
||||
headers.set('accept', filteredValues.join(', '))
|
||||
} else {
|
||||
headers.delete('accept')
|
||||
}
|
||||
}
|
||||
|
||||
return fetch(requestClone, { headers })
|
||||
}
|
||||
|
||||
// Bypass mocking when the client is not active.
|
||||
if (!client) {
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
// Bypass initial page load requests (i.e. static assets).
|
||||
// The absence of the immediate/parent client in the map of the active clients
|
||||
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
|
||||
// and is not ready to handle requests.
|
||||
if (!activeClientIds.has(client.id)) {
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
// Notify the client that a request has been intercepted.
|
||||
const serializedRequest = await serializeRequest(event.request)
|
||||
const clientMessage = await sendToClient(
|
||||
client,
|
||||
{
|
||||
type: 'REQUEST',
|
||||
payload: {
|
||||
id: requestId,
|
||||
interceptedAt: requestInterceptedAt,
|
||||
...serializedRequest,
|
||||
},
|
||||
},
|
||||
[serializedRequest.body],
|
||||
)
|
||||
|
||||
switch (clientMessage.type) {
|
||||
case 'MOCK_RESPONSE': {
|
||||
return respondWithMock(clientMessage.data)
|
||||
}
|
||||
|
||||
case 'PASSTHROUGH': {
|
||||
return passthrough()
|
||||
}
|
||||
}
|
||||
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Client} client
|
||||
* @param {any} message
|
||||
* @param {Array<Transferable>} transferrables
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
function sendToClient(client, message, transferrables = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const channel = new MessageChannel()
|
||||
|
||||
channel.port1.onmessage = (event) => {
|
||||
if (event.data && event.data.error) {
|
||||
return reject(event.data.error)
|
||||
}
|
||||
|
||||
resolve(event.data)
|
||||
}
|
||||
|
||||
client.postMessage(message, [
|
||||
channel.port2,
|
||||
...transferrables.filter(Boolean),
|
||||
])
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Response} response
|
||||
* @returns {Response}
|
||||
*/
|
||||
function respondWithMock(response) {
|
||||
// Setting response status code to 0 is a no-op.
|
||||
// However, when responding with a "Response.error()", the produced Response
|
||||
// instance will have status code set to 0. Since it's not possible to create
|
||||
// a Response instance with status code 0, handle that use-case separately.
|
||||
if (response.status === 0) {
|
||||
return Response.error()
|
||||
}
|
||||
|
||||
const mockedResponse = new Response(response.body, response)
|
||||
|
||||
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
|
||||
value: true,
|
||||
enumerable: true,
|
||||
})
|
||||
|
||||
return mockedResponse
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Request} request
|
||||
*/
|
||||
async function serializeRequest(request) {
|
||||
return {
|
||||
url: request.url,
|
||||
mode: request.mode,
|
||||
method: request.method,
|
||||
headers: Object.fromEntries(request.headers.entries()),
|
||||
cache: request.cache,
|
||||
credentials: request.credentials,
|
||||
destination: request.destination,
|
||||
integrity: request.integrity,
|
||||
redirect: request.redirect,
|
||||
referrer: request.referrer,
|
||||
referrerPolicy: request.referrerPolicy,
|
||||
body: await request.arrayBuffer(),
|
||||
keepalive: request.keepalive,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 新增页面脚手架。
|
||||
*
|
||||
* 用法:pnpm new-page OrderList order
|
||||
* → 生成 src/pages/order/OrderListPage.vue
|
||||
* → 追加到 src/router/page-registry.ts(提示手动确认)
|
||||
* → 输出 Flyway 迁移模板(权限点 + 菜单种子)
|
||||
*/
|
||||
import { writeFileSync, existsSync, mkdirSync } from 'fs';
|
||||
import { resolve, dirname } from 'path';
|
||||
|
||||
const [, , pageName, moduleDir] = process.argv;
|
||||
|
||||
if (!pageName || !moduleDir) {
|
||||
console.error('用法: pnpm new-page <PageName> <module>');
|
||||
console.error('示例: pnpm new-page OrderList order');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const dir = resolve('src/pages', moduleDir);
|
||||
const filePath = resolve(dir, `${pageName}Page.vue`);
|
||||
|
||||
if (existsSync(filePath)) {
|
||||
console.error(`文件已存在: ${filePath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
const template = `<template>
|
||||
<div class="space-y-5">
|
||||
<h1 class="text-2xl font-bold text-gray-800 dark:text-gray-100">${pageName}</h1>
|
||||
<div class="card">
|
||||
<p class="text-gray-400">TODO: 实现 ${pageName} 页面</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// ${pageName}Page
|
||||
</script>
|
||||
`;
|
||||
|
||||
writeFileSync(filePath, template, 'utf-8');
|
||||
console.log(`✅ 页面已创建: ${filePath}`);
|
||||
|
||||
console.log(`
|
||||
📋 后续步骤(必须完成,否则 CI 将失败):
|
||||
|
||||
1. 在 src/router/page-registry.ts 注册:
|
||||
'${pageName}': () => import('@/pages/${moduleDir}/${pageName}Page.vue'),
|
||||
|
||||
2. 创建 Flyway 权限迁移 (tmpl-infrastructure):
|
||||
INSERT INTO sys_permission (code, type, description)
|
||||
VALUES ('${moduleDir}:${pageName.toLowerCase()}:read', 'PAGE', '${pageName} 页面访问');
|
||||
|
||||
3. 创建 Flyway 菜单迁移 (tmpl-infrastructure):
|
||||
INSERT INTO sys_menu (parent_id, name, path, component, icon, sort, type, visible, status)
|
||||
VALUES (<parent>, '${pageName}', '/${moduleDir}/${pageName.toLowerCase()}', '${pageName}', '<icon>', 10, 'MENU', true, true);
|
||||
|
||||
4. 更新 PR 描述,确认上述三项已完成。
|
||||
`);
|
||||
@@ -0,0 +1,14 @@
|
||||
<template>
|
||||
<router-view v-slot="{ Component, route }">
|
||||
<transition name="fade-slide" mode="out-in">
|
||||
<component :is="Component" :key="route.path" />
|
||||
</transition>
|
||||
</router-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useThemeStore } from '@/stores/theme';
|
||||
|
||||
const themeStore = useThemeStore();
|
||||
themeStore.initTheme();
|
||||
</script>
|
||||
@@ -0,0 +1,35 @@
|
||||
import axios from 'axios';
|
||||
import type { HealthResponse, MetricNamesResponse, MetricDetailResponse, CachesResponse } from './types';
|
||||
|
||||
const actuatorHttp = axios.create({
|
||||
baseURL: '',
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
export async function getHealth(): Promise<HealthResponse> {
|
||||
const { data } = await actuatorHttp.get<HealthResponse>('/actuator/health');
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getMetricNames(): Promise<MetricNamesResponse> {
|
||||
const { data } = await actuatorHttp.get<MetricNamesResponse>('/actuator/metrics');
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getMetricDetail(name: string): Promise<MetricDetailResponse> {
|
||||
const { data } = await actuatorHttp.get<MetricDetailResponse>(`/actuator/metrics/${name}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getPrometheusText(): Promise<string> {
|
||||
const { data } = await actuatorHttp.get<string>('/actuator/prometheus', {
|
||||
headers: { Accept: 'text/plain' },
|
||||
responseType: 'text',
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getCaches(): Promise<CachesResponse> {
|
||||
const { data } = await actuatorHttp.get<CachesResponse>('/actuator/caches');
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import axios, {
|
||||
type AxiosResponse,
|
||||
type InternalAxiosRequestConfig,
|
||||
} from 'axios';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import type { Pinia } from 'pinia';
|
||||
import { useTenantStore } from '@/stores/tenant';
|
||||
import type { ApiResponse } from './types';
|
||||
|
||||
/* ── Pinia 延迟注入 ── */
|
||||
let _pinia: Pinia | null = null;
|
||||
|
||||
export function setHttpPinia(pinia: Pinia): void {
|
||||
_pinia = pinia;
|
||||
}
|
||||
|
||||
/* ── Axios 实例 ── */
|
||||
const http = axios.create({
|
||||
baseURL: '',
|
||||
timeout: 15_000,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
/* ── 请求 ID 生成(兼容降级) ── */
|
||||
function generateRequestId(): string {
|
||||
if (typeof globalThis.crypto?.randomUUID === 'function') {
|
||||
return globalThis.crypto.randomUUID();
|
||||
}
|
||||
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
/* ── 请求拦截 ── */
|
||||
http.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
||||
if (_pinia) {
|
||||
const tenantStore = useTenantStore(_pinia);
|
||||
if (tenantStore.currentTenantId !== null) {
|
||||
config.headers['X-Tenant-Id'] = String(tenantStore.currentTenantId);
|
||||
}
|
||||
}
|
||||
|
||||
config.headers['X-Request-Id'] = generateRequestId();
|
||||
|
||||
return config;
|
||||
});
|
||||
|
||||
/* ── 响应拦截 ── */
|
||||
http.interceptors.response.use(
|
||||
(response: AxiosResponse<ApiResponse>) => {
|
||||
const body = response.data;
|
||||
|
||||
if (body.code === 0 || body.code === 200) {
|
||||
return response;
|
||||
}
|
||||
|
||||
ElMessage.error(body.message || `业务错误 (code: ${body.code})`);
|
||||
return Promise.reject(new Error(body.message));
|
||||
},
|
||||
(error) => {
|
||||
if (error.response) {
|
||||
const status: number = error.response.status;
|
||||
const body = error.response.data as ApiResponse | undefined;
|
||||
const message = body?.message || error.message;
|
||||
|
||||
switch (status) {
|
||||
case 400:
|
||||
ElMessage.error(`请求参数错误: ${message}`);
|
||||
break;
|
||||
case 401:
|
||||
ElMessage.warning('未登录或会话过期');
|
||||
break;
|
||||
case 403:
|
||||
ElMessage.warning('无权限访问');
|
||||
break;
|
||||
case 404:
|
||||
ElMessage.warning('资源不存在');
|
||||
break;
|
||||
default:
|
||||
ElMessage.error(`服务异常 (${status}): ${message}`);
|
||||
}
|
||||
} else {
|
||||
ElMessage.error('网络异常,请检查连接');
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
export async function request<T>(
|
||||
config: Parameters<typeof http.request>[0],
|
||||
): Promise<ApiResponse<T>> {
|
||||
const response = await http.request<ApiResponse<T>>(config);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export default http;
|
||||
@@ -0,0 +1,24 @@
|
||||
import { request } from './http';
|
||||
import type { ApiResponse, PageResult, OrderResponse, CreateOrderRequest, UpdateOrderStatusRequest } from './types';
|
||||
|
||||
export function listOrders(pageNum = 1, pageSize = 20, status?: string): Promise<ApiResponse<PageResult<OrderResponse>>> {
|
||||
const params: Record<string, unknown> = { pageNum, pageSize };
|
||||
if (status) params.status = status;
|
||||
return request<PageResult<OrderResponse>>({ method: 'GET', url: '/api/v1/orders', params });
|
||||
}
|
||||
|
||||
export function getOrder(id: number): Promise<ApiResponse<OrderResponse>> {
|
||||
return request<OrderResponse>({ method: 'GET', url: `/api/v1/orders/${id}` });
|
||||
}
|
||||
|
||||
export function createOrder(data: CreateOrderRequest): Promise<ApiResponse<OrderResponse>> {
|
||||
return request<OrderResponse>({ method: 'POST', url: '/api/v1/orders', data });
|
||||
}
|
||||
|
||||
export function updateOrderStatus(id: number, data: UpdateOrderStatusRequest): Promise<ApiResponse<OrderResponse>> {
|
||||
return request<OrderResponse>({ method: 'PUT', url: `/api/v1/orders/${id}/status`, data });
|
||||
}
|
||||
|
||||
export function deleteOrder(id: number): Promise<ApiResponse<null>> {
|
||||
return request<null>({ method: 'DELETE', url: `/api/v1/orders/${id}` });
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* 与后端 ApiResponse<T> 对应的前端类型。
|
||||
*/
|
||||
export interface ApiResponse<T = unknown> {
|
||||
code: number;
|
||||
message: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页响应(后端 list 接口的 data 部分)。
|
||||
*/
|
||||
export interface PageResult<T> {
|
||||
records: T[];
|
||||
total: number;
|
||||
pageNum: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface OrderResponse {
|
||||
id: number;
|
||||
tenantId: number;
|
||||
deptId: number;
|
||||
orderNo: string;
|
||||
amount: number;
|
||||
status: string;
|
||||
createTime: string;
|
||||
updateTime: string;
|
||||
}
|
||||
|
||||
export interface CreateOrderRequest {
|
||||
deptId: number;
|
||||
orderNo: string;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
export interface UpdateOrderStatusRequest {
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface HealthResponse {
|
||||
status: string;
|
||||
components: Record<string, { status: string; details?: Record<string, unknown> }>;
|
||||
}
|
||||
|
||||
export interface MetricNamesResponse {
|
||||
names: string[];
|
||||
}
|
||||
|
||||
export interface MetricDetailResponse {
|
||||
name: string;
|
||||
description?: string;
|
||||
measurements: { statistic: string; value: number }[];
|
||||
availableTags: { tag: string; values: string[] }[];
|
||||
}
|
||||
|
||||
export interface CachesResponse {
|
||||
cacheManagers: Record<string, { caches: Record<string, { target: string }> }>;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* 权限判断 composable。
|
||||
*
|
||||
* Sprint 1 阶段返回全量权限(相当于超级管理员)。
|
||||
* Sprint 3 接入 /me/permissions 后改为真实判断。
|
||||
*/
|
||||
export function usePermission() {
|
||||
function hasPermission(code: string): boolean {
|
||||
// TODO Sprint 3: 从 store 读取真实 permissions 列表
|
||||
// return permissionStore.permissions.includes(code);
|
||||
return true;
|
||||
}
|
||||
|
||||
function hasAnyPermission(...codes: string[]): boolean {
|
||||
return codes.some(hasPermission);
|
||||
}
|
||||
|
||||
function hasAllPermissions(...codes: string[]): boolean {
|
||||
return codes.every(hasPermission);
|
||||
}
|
||||
|
||||
return { hasPermission, hasAnyPermission, hasAllPermissions };
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { onMounted, onUnmounted } from 'vue';
|
||||
import { APP_EVENTS } from '@/constants/events';
|
||||
|
||||
export function useTenantRefresh(callback: () => void): void {
|
||||
const handler = () => callback();
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener(APP_EVENTS.TENANT_CHANGED, handler);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener(APP_EVENTS.TENANT_CHANGED, handler);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* 全局自定义事件名常量。
|
||||
* 避免硬编码字符串导致多处监听时拼写不一致。
|
||||
*/
|
||||
export const APP_EVENTS = {
|
||||
TENANT_CHANGED: 'tmpl:tenant-changed',
|
||||
} as const;
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { Directive, DirectiveBinding } from 'vue';
|
||||
import { usePermission } from '@/composables/usePermission';
|
||||
|
||||
// 模块级单例,避免每次 mounted/updated 重复创建
|
||||
const perm = usePermission();
|
||||
|
||||
/**
|
||||
* v-permission 指令。
|
||||
*
|
||||
* 用法:
|
||||
* v-permission="'order:order:delete'" → 无权限时 display:none
|
||||
* v-permission.remove="'order:order:delete'" → 无权限时移除 DOM 节点
|
||||
*/
|
||||
export const permissionDirective: Directive<HTMLElement, string> = {
|
||||
mounted(el: HTMLElement, binding: DirectiveBinding<string>) {
|
||||
checkPermission(el, binding);
|
||||
},
|
||||
updated(el: HTMLElement, binding: DirectiveBinding<string>) {
|
||||
checkPermission(el, binding);
|
||||
},
|
||||
};
|
||||
|
||||
function checkPermission(el: HTMLElement, binding: DirectiveBinding<string>): void {
|
||||
const code = binding.value;
|
||||
if (!code) return;
|
||||
|
||||
if (!perm.hasPermission(code)) {
|
||||
if (binding.modifiers.remove) {
|
||||
el.parentNode?.removeChild(el);
|
||||
} else {
|
||||
el.style.display = 'none';
|
||||
}
|
||||
} else {
|
||||
el.style.display = '';
|
||||
}
|
||||
}
|
||||
Vendored
+18
@@ -0,0 +1,18 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue';
|
||||
const component: DefineComponent<object, object, any>;
|
||||
export default component;
|
||||
}
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_APP_TITLE: string;
|
||||
readonly VITE_API_BASE_URL: string;
|
||||
readonly VITE_ACTUATOR_BASE_URL: string;
|
||||
readonly VITE_ENABLE_MSW: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<div class="flex h-screen overflow-hidden bg-gray-50 dark:bg-gray-900">
|
||||
<Sidebar :collapsed="sidebarCollapsed" @toggle="sidebarCollapsed = !sidebarCollapsed" />
|
||||
|
||||
<div class="flex flex-1 flex-col overflow-hidden">
|
||||
<Header :sidebar-collapsed="sidebarCollapsed" @toggle-sidebar="sidebarCollapsed = !sidebarCollapsed" />
|
||||
<ContentArea />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import Sidebar from './Sidebar.vue';
|
||||
import Header from './Header.vue';
|
||||
import ContentArea from './ContentArea.vue';
|
||||
|
||||
const sidebarCollapsed = ref(false);
|
||||
</script>
|
||||
@@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<main class="flex-1 overflow-auto p-5">
|
||||
<router-view v-slot="{ Component, route }">
|
||||
<transition name="fade-slide" mode="out-in">
|
||||
<div :key="route.path">
|
||||
<component :is="Component" />
|
||||
</div>
|
||||
</transition>
|
||||
</router-view>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,92 @@
|
||||
<template>
|
||||
<header
|
||||
class="flex h-14 items-center justify-between border-b px-4
|
||||
bg-white dark:bg-gray-800 dark:border-gray-700"
|
||||
>
|
||||
<!-- 左侧:折叠按钮 + 面包屑 -->
|
||||
<div class="flex items-center gap-3">
|
||||
<button class="text-gray-500 hover:text-primary-600 transition-colors" @click="$emit('toggleSidebar')">
|
||||
<component :is="Menu" :size="20" />
|
||||
</button>
|
||||
<span class="text-sm text-gray-400">{{ currentRoute?.meta?.title || '' }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:租户选择 + 主题切换 -->
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- 租户选择器 -->
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-gray-400">租户</span>
|
||||
<el-select
|
||||
v-model="selectedTenantId"
|
||||
placeholder="选择租户"
|
||||
size="small"
|
||||
style="width: 140px"
|
||||
filterable
|
||||
@change="handleTenantChange"
|
||||
>
|
||||
<el-option-group v-if="tenantStore.recentTenants.length > 0" label="最近使用">
|
||||
<el-option
|
||||
v-for="id in tenantStore.recentTenants"
|
||||
:key="'recent-' + id"
|
||||
:label="'租户 ' + id"
|
||||
:value="id"
|
||||
/>
|
||||
</el-option-group>
|
||||
<el-option-group label="全部租户">
|
||||
<el-option v-for="id in availableTenants" :key="id" :label="'租户 ' + id" :value="id" />
|
||||
</el-option-group>
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<!-- 当前租户标识 -->
|
||||
<el-tag v-if="tenantStore.currentTenantId !== null" type="primary" size="small" effect="dark" round>
|
||||
TenantId: {{ tenantStore.currentTenantId }}
|
||||
</el-tag>
|
||||
|
||||
<!-- 主题切换 -->
|
||||
<button class="text-gray-500 hover:text-primary-600 transition-colors" @click="themeStore.toggle()">
|
||||
<component :is="themeStore.mode === 'dark' ? Sun : Moon" :size="18" />
|
||||
</button>
|
||||
|
||||
<!-- 用户占位(Sprint 3 登录后替换) -->
|
||||
<el-dropdown>
|
||||
<span class="flex items-center gap-1 text-sm text-gray-600 dark:text-gray-300 cursor-pointer">
|
||||
<component :is="UserCircle" :size="20" />
|
||||
<span>Admin</span>
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item disabled>角色: 超级管理员</el-dropdown-item>
|
||||
<el-dropdown-item divided disabled>退出登录</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { Menu, Sun, Moon, UserCircle } from 'lucide-vue-next';
|
||||
import { useTenantStore } from '@/stores/tenant';
|
||||
import { useThemeStore } from '@/stores/theme';
|
||||
|
||||
defineEmits<{
|
||||
toggleSidebar: [];
|
||||
}>();
|
||||
|
||||
const route = useRoute();
|
||||
const currentRoute = computed(() => route);
|
||||
const tenantStore = useTenantStore();
|
||||
const themeStore = useThemeStore();
|
||||
|
||||
const availableTenants = ref([1, 2, 3, 5, 10, 100]);
|
||||
const selectedTenantId = ref<number | undefined>(
|
||||
tenantStore.currentTenantId ?? undefined,
|
||||
);
|
||||
|
||||
function handleTenantChange(tenantId: number): void {
|
||||
tenantStore.switchTenant(tenantId);
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<aside
|
||||
:class="[
|
||||
'flex flex-col border-r transition-all duration-300 ease-in-out',
|
||||
'bg-white dark:bg-gray-800 dark:border-gray-700',
|
||||
collapsed ? 'w-16' : 'w-60',
|
||||
]"
|
||||
>
|
||||
<!-- Logo -->
|
||||
<div class="flex h-14 items-center justify-center border-b dark:border-gray-700 px-4">
|
||||
<span v-show="!collapsed" class="text-lg font-bold text-primary-600 dark:text-primary-400 truncate">
|
||||
tmpl-admin
|
||||
</span>
|
||||
<span v-show="collapsed" class="text-lg font-bold text-primary-600 dark:text-primary-400">T</span>
|
||||
</div>
|
||||
|
||||
<!-- 菜单 -->
|
||||
<el-scrollbar class="flex-1">
|
||||
<el-menu
|
||||
:default-active="activeMenu"
|
||||
:collapse="collapsed"
|
||||
:collapse-transition="false"
|
||||
router
|
||||
class="border-r-0"
|
||||
>
|
||||
<!-- Sprint 1:仅 Dashboard 可用 -->
|
||||
<el-menu-item index="/dashboard">
|
||||
<el-icon><component :is="LayoutDashboard" /></el-icon>
|
||||
<template #title>概览 Dashboard</template>
|
||||
</el-menu-item>
|
||||
|
||||
<el-sub-menu index="business">
|
||||
<template #title>
|
||||
<el-icon><component :is="ShoppingCart" /></el-icon>
|
||||
<span>业务管理</span>
|
||||
</template>
|
||||
<el-menu-item index="/orders">订单管理</el-menu-item>
|
||||
</el-sub-menu>
|
||||
|
||||
<el-sub-menu index="observability">
|
||||
<template #title>
|
||||
<el-icon><component :is="ActivityIcon" /></el-icon>
|
||||
<span>可观测性</span>
|
||||
</template>
|
||||
<el-menu-item index="/health">Health</el-menu-item>
|
||||
<el-menu-item index="/metrics">Metrics</el-menu-item>
|
||||
<el-menu-item index="/prometheus">Prometheus</el-menu-item>
|
||||
<el-menu-item index="/caches">Caches</el-menu-item>
|
||||
</el-sub-menu>
|
||||
|
||||
<!--
|
||||
Sprint 3: <el-sub-menu index="system">...</el-sub-menu>
|
||||
Sprint 4: <el-sub-menu index="lab">...</el-sub-menu>
|
||||
-->
|
||||
</el-menu>
|
||||
</el-scrollbar>
|
||||
|
||||
<!-- 折叠按钮 -->
|
||||
<button
|
||||
class="flex h-10 items-center justify-center border-t dark:border-gray-700
|
||||
text-gray-500 hover:text-primary-600 transition-colors"
|
||||
@click="$emit('toggle')"
|
||||
>
|
||||
<component :is="collapsed ? ChevronsRight : ChevronsLeft" :size="18" />
|
||||
</button>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import {
|
||||
LayoutDashboard, ShoppingCart, Activity as ActivityIcon,
|
||||
ChevronsLeft, ChevronsRight,
|
||||
} from 'lucide-vue-next';
|
||||
|
||||
defineProps<{
|
||||
collapsed: boolean;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
toggle: [];
|
||||
}>();
|
||||
|
||||
const route = useRoute();
|
||||
const activeMenu = computed(() => route.path);
|
||||
</script>
|
||||
@@ -0,0 +1,33 @@
|
||||
import { createApp } from 'vue';
|
||||
import { createPinia } from 'pinia';
|
||||
import App from './App.vue';
|
||||
import router from './router';
|
||||
import { setupElementPlus } from './plugins/element-plus';
|
||||
import { permissionDirective } from './directives/permission';
|
||||
import { setHttpPinia } from './api/http';
|
||||
|
||||
import './styles/tailwind.css';
|
||||
import './styles/element-override.css';
|
||||
import './styles/transitions.css';
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
if (import.meta.env.DEV && import.meta.env.VITE_ENABLE_MSW === 'true') {
|
||||
const { worker } = await import('./mocks/browser');
|
||||
await worker.start({ onUnhandledRequest: 'bypass' });
|
||||
}
|
||||
|
||||
const app = createApp(App);
|
||||
const pinia = createPinia();
|
||||
|
||||
app.use(pinia);
|
||||
app.use(router);
|
||||
setupElementPlus(app);
|
||||
|
||||
setHttpPinia(pinia);
|
||||
|
||||
app.directive('permission', permissionDirective);
|
||||
|
||||
app.mount('#app');
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
@@ -0,0 +1,4 @@
|
||||
import { setupWorker } from 'msw/browser';
|
||||
import { handlers } from './handlers';
|
||||
|
||||
export const worker = setupWorker(...handlers);
|
||||
@@ -0,0 +1,70 @@
|
||||
export interface MockOrder {
|
||||
id: number;
|
||||
tenantId: number;
|
||||
deptId: number;
|
||||
orderNo: string;
|
||||
amount: number;
|
||||
status: string;
|
||||
deleted: boolean;
|
||||
createTime: string;
|
||||
updateTime: string;
|
||||
}
|
||||
|
||||
let nextId = 100;
|
||||
const now = () => new Date().toISOString();
|
||||
|
||||
export const orders: MockOrder[] = [
|
||||
{ id: 1, tenantId: 1, deptId: 10, orderNo: 'ORD-001', amount: 99.99, status: 'CREATED', deleted: false, createTime: '2026-02-10T08:00:00Z', updateTime: '2026-02-10T08:00:00Z' },
|
||||
{ id: 2, tenantId: 1, deptId: 10, orderNo: 'ORD-002', amount: 250.00, status: 'PAID', deleted: false, createTime: '2026-02-10T09:00:00Z', updateTime: '2026-02-11T10:00:00Z' },
|
||||
{ id: 3, tenantId: 1, deptId: 20, orderNo: 'ORD-003', amount: 15.50, status: 'CANCELLED', deleted: false, createTime: '2026-02-11T08:00:00Z', updateTime: '2026-02-11T12:00:00Z' },
|
||||
{ id: 4, tenantId: 2, deptId: 30, orderNo: 'ORD-004', amount: 1200.00, status: 'CREATED', deleted: false, createTime: '2026-02-11T14:00:00Z', updateTime: '2026-02-11T14:00:00Z' },
|
||||
{ id: 5, tenantId: 2, deptId: 30, orderNo: 'ORD-005', amount: 88.88, status: 'PAID', deleted: false, createTime: '2026-02-12T06:00:00Z', updateTime: '2026-02-12T07:00:00Z' },
|
||||
{ id: 6, tenantId: 5, deptId: 50, orderNo: 'ORD-006', amount: 500.00, status: 'CREATED', deleted: false, createTime: '2026-02-12T08:00:00Z', updateTime: '2026-02-12T08:00:00Z' },
|
||||
{ id: 7, tenantId: 1, deptId: 10, orderNo: 'ORD-007', amount: 77.77, status: 'CREATED', deleted: false, createTime: '2026-02-12T09:00:00Z', updateTime: '2026-02-12T09:00:00Z' },
|
||||
{ id: 8, tenantId: 1, deptId: 20, orderNo: 'ORD-008', amount: 333.33, status: 'PAID', deleted: false, createTime: '2026-02-12T10:00:00Z', updateTime: '2026-02-12T10:30:00Z' },
|
||||
];
|
||||
|
||||
export function findOrdersByTenant(tenantId: number, pageNum: number, pageSize: number, status?: string) {
|
||||
let filtered = orders.filter((o) => o.tenantId === tenantId && !o.deleted);
|
||||
if (status) filtered = filtered.filter((o) => o.status === status);
|
||||
const total = filtered.length;
|
||||
const start = (pageNum - 1) * pageSize;
|
||||
const records = filtered.slice(start, start + pageSize);
|
||||
return { records, total, pageNum, pageSize };
|
||||
}
|
||||
|
||||
export function findOrderById(tenantId: number, id: number) {
|
||||
return orders.find((o) => o.id === id && o.tenantId === tenantId && !o.deleted) ?? null;
|
||||
}
|
||||
|
||||
export function createOrder(tenantId: number, deptId: number, orderNo: string, amount: number): MockOrder {
|
||||
const order: MockOrder = {
|
||||
id: nextId++,
|
||||
tenantId,
|
||||
deptId,
|
||||
orderNo,
|
||||
amount,
|
||||
status: 'CREATED',
|
||||
deleted: false,
|
||||
createTime: now(),
|
||||
updateTime: now(),
|
||||
};
|
||||
orders.push(order);
|
||||
return order;
|
||||
}
|
||||
|
||||
export function updateOrderStatus(tenantId: number, id: number, status: string): MockOrder | null {
|
||||
const order = findOrderById(tenantId, id);
|
||||
if (!order) return null;
|
||||
order.status = status;
|
||||
order.updateTime = now();
|
||||
return order;
|
||||
}
|
||||
|
||||
export function softDeleteOrder(tenantId: number, id: number): boolean {
|
||||
const order = findOrderById(tenantId, id);
|
||||
if (!order) return false;
|
||||
order.deleted = true;
|
||||
order.updateTime = now();
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import {
|
||||
findOrdersByTenant,
|
||||
findOrderById,
|
||||
createOrder,
|
||||
updateOrderStatus,
|
||||
softDeleteOrder,
|
||||
} from './data/orders';
|
||||
|
||||
function getTenantId(request: Request): number | null {
|
||||
const header = request.headers.get('X-Tenant-Id');
|
||||
return header !== null ? Number(header) : null;
|
||||
}
|
||||
|
||||
function apiOk<T>(data: T) {
|
||||
return HttpResponse.json({ code: 200, message: 'success', data });
|
||||
}
|
||||
|
||||
function apiError(code: number, message: string, status = 400) {
|
||||
return HttpResponse.json({ code, message, data: null }, { status });
|
||||
}
|
||||
|
||||
export const handlers = [
|
||||
// ── 订单 CRUD ──
|
||||
http.get('/api/v1/orders', ({ request }) => {
|
||||
const tenantId = getTenantId(request);
|
||||
if (tenantId === null) return apiError(400, 'Missing X-Tenant-Id header');
|
||||
|
||||
const url = new URL(request.url);
|
||||
const pageNum = Number(url.searchParams.get('pageNum') || '1');
|
||||
const pageSize = Number(url.searchParams.get('pageSize') || '20');
|
||||
const status = url.searchParams.get('status') || undefined;
|
||||
|
||||
return apiOk(findOrdersByTenant(tenantId, pageNum, pageSize, status));
|
||||
}),
|
||||
|
||||
http.get('/api/v1/orders/:id', ({ request, params }) => {
|
||||
const tenantId = getTenantId(request);
|
||||
if (tenantId === null) return apiError(400, 'Missing X-Tenant-Id header');
|
||||
|
||||
const order = findOrderById(tenantId, Number(params.id));
|
||||
if (!order) return apiError(404, 'Order not found', 404);
|
||||
return apiOk(order);
|
||||
}),
|
||||
|
||||
http.post('/api/v1/orders', async ({ request }) => {
|
||||
const tenantId = getTenantId(request);
|
||||
if (tenantId === null) return apiError(400, 'Missing X-Tenant-Id header');
|
||||
|
||||
const body = (await request.json()) as Record<string, unknown>;
|
||||
|
||||
// P2-2: 显式类型校验
|
||||
if (typeof body.deptId !== 'number' || typeof body.orderNo !== 'string' || typeof body.amount !== 'number') {
|
||||
return apiError(400, 'deptId (number), orderNo (string), amount (number) are required');
|
||||
}
|
||||
if (body.orderNo.trim() === '') {
|
||||
return apiError(400, 'orderNo must not be blank');
|
||||
}
|
||||
if (body.amount < 0.01) {
|
||||
return apiError(400, 'amount must be >= 0.01');
|
||||
}
|
||||
|
||||
const order = createOrder(tenantId, body.deptId as number, body.orderNo as string, body.amount as number);
|
||||
return HttpResponse.json({ code: 200, message: 'success', data: order }, { status: 201 });
|
||||
}),
|
||||
|
||||
http.put('/api/v1/orders/:id/status', async ({ request, params }) => {
|
||||
const tenantId = getTenantId(request);
|
||||
if (tenantId === null) return apiError(400, 'Missing X-Tenant-Id header');
|
||||
|
||||
const body = (await request.json()) as Record<string, unknown>;
|
||||
if (typeof body.status !== 'string' || body.status.trim() === '') {
|
||||
return apiError(400, 'status (string) is required');
|
||||
}
|
||||
|
||||
const order = updateOrderStatus(tenantId, Number(params.id), body.status as string);
|
||||
if (!order) return apiError(404, 'Order not found', 404);
|
||||
return apiOk(order);
|
||||
}),
|
||||
|
||||
http.delete('/api/v1/orders/:id', ({ request, params }) => {
|
||||
const tenantId = getTenantId(request);
|
||||
if (tenantId === null) return apiError(400, 'Missing X-Tenant-Id header');
|
||||
|
||||
const ok = softDeleteOrder(tenantId, Number(params.id));
|
||||
if (!ok) return apiError(404, 'Order not found', 404);
|
||||
return apiOk(null);
|
||||
}),
|
||||
|
||||
// ── Actuator ──
|
||||
http.get('/actuator/health', () => {
|
||||
return HttpResponse.json({
|
||||
status: 'UP',
|
||||
components: {
|
||||
db: { status: 'UP', details: { database: 'PostgreSQL', validationQuery: 'isValid()' } },
|
||||
diskSpace: { status: 'UP', details: { total: 107374182400, free: 53687091200, threshold: 10485760 } },
|
||||
ping: { status: 'UP' },
|
||||
redisPing: { status: 'UP', details: { latency: '2ms' } },
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
http.get('/actuator/metrics', () => {
|
||||
return HttpResponse.json({
|
||||
names: [
|
||||
'http.server.requests',
|
||||
'jvm.memory.used',
|
||||
'jvm.memory.max',
|
||||
'jvm.gc.pause',
|
||||
'hikaricp.connections.active',
|
||||
'hikaricp.connections.idle',
|
||||
'hikaricp.connections.pending',
|
||||
'cache.gets',
|
||||
'order.created.total',
|
||||
'process.cpu.usage',
|
||||
'system.cpu.usage',
|
||||
],
|
||||
});
|
||||
}),
|
||||
|
||||
http.get('/actuator/metrics/:metricName', ({ params }) => {
|
||||
const name = params.metricName as string;
|
||||
const mockMetrics: Record<string, object> = {
|
||||
'order.created.total': {
|
||||
name: 'order.created.total',
|
||||
description: 'Total orders created',
|
||||
measurements: [{ statistic: 'COUNT', value: 42 }],
|
||||
availableTags: [{ tag: 'tenantId', values: ['1', '2', '5'] }],
|
||||
},
|
||||
'http.server.requests': {
|
||||
name: 'http.server.requests',
|
||||
description: 'HTTP server requests',
|
||||
measurements: [{ statistic: 'COUNT', value: 1234 }, { statistic: 'TOTAL_TIME', value: 56.78 }],
|
||||
availableTags: [
|
||||
{ tag: 'method', values: ['GET', 'POST', 'PUT', 'DELETE'] },
|
||||
{ tag: 'status', values: ['200', '201', '400', '404', '500'] },
|
||||
{ tag: 'uri', values: ['/api/v1/orders', '/api/v1/orders/{id}'] },
|
||||
],
|
||||
},
|
||||
'jvm.memory.used': {
|
||||
name: 'jvm.memory.used',
|
||||
description: 'JVM memory used',
|
||||
measurements: [{ statistic: 'VALUE', value: 268435456 }],
|
||||
availableTags: [{ tag: 'area', values: ['heap', 'nonheap'] }],
|
||||
},
|
||||
'process.cpu.usage': {
|
||||
name: 'process.cpu.usage',
|
||||
description: 'Process CPU usage',
|
||||
measurements: [{ statistic: 'VALUE', value: 0.023 }],
|
||||
availableTags: [],
|
||||
},
|
||||
};
|
||||
const metric = mockMetrics[name];
|
||||
if (!metric) {
|
||||
return HttpResponse.json({ name, measurements: [{ statistic: 'VALUE', value: 0 }], availableTags: [] });
|
||||
}
|
||||
return HttpResponse.json(metric);
|
||||
}),
|
||||
|
||||
http.get('/actuator/prometheus', () => {
|
||||
const text = `# HELP order_created_total Total orders created
|
||||
# TYPE order_created_total counter
|
||||
order_created_total{tenantId="1"} 25
|
||||
order_created_total{tenantId="2"} 12
|
||||
order_created_total{tenantId="5"} 5
|
||||
# HELP http_server_requests_seconds_count HTTP server requests count
|
||||
# TYPE http_server_requests_seconds_count counter
|
||||
http_server_requests_seconds_count{method="GET",status="200",uri="/api/v1/orders"} 890
|
||||
http_server_requests_seconds_count{method="POST",status="201",uri="/api/v1/orders"} 42
|
||||
http_server_requests_seconds_count{method="GET",status="404",uri="/api/v1/orders/{id}"} 3
|
||||
# HELP jvm_memory_used_bytes JVM memory used
|
||||
# TYPE jvm_memory_used_bytes gauge
|
||||
jvm_memory_used_bytes{area="heap"} 268435456
|
||||
jvm_memory_used_bytes{area="nonheap"} 67108864
|
||||
# HELP hikaricp_connections_active Active DB connections
|
||||
# TYPE hikaricp_connections_active gauge
|
||||
hikaricp_connections_active 3
|
||||
hikaricp_connections_idle 7
|
||||
hikaricp_connections_pending 0
|
||||
`;
|
||||
return HttpResponse.text(text, { headers: { 'Content-Type': 'text/plain; version=0.0.4; charset=utf-8' } });
|
||||
}),
|
||||
|
||||
http.get('/actuator/caches', () => {
|
||||
return HttpResponse.json({
|
||||
cacheManagers: {
|
||||
cacheManager: {
|
||||
caches: {
|
||||
order: { target: 'org.springframework.data.redis.cache.RedisCache' },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
http.get('/api/ping', () => {
|
||||
return HttpResponse.json({ code: 200, message: 'ok', data: 'pong' });
|
||||
}),
|
||||
];
|
||||
@@ -0,0 +1,195 @@
|
||||
<template>
|
||||
<div class="space-y-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold text-gray-800 dark:text-gray-100">概览 Dashboard</h1>
|
||||
<el-button size="small" @click="testPing" :loading="pingLoading">
|
||||
Ping 测试
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- Ping 结果 -->
|
||||
<div v-if="pingResult" class="card-glass">
|
||||
<code class="text-xs font-mono text-gray-600 dark:text-gray-300">{{ pingResult }}</code>
|
||||
</div>
|
||||
|
||||
<!-- KPI 卡片 -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4">
|
||||
<div v-for="kpi in kpiCards" :key="kpi.label" class="kpi-card">
|
||||
<div :class="['flex h-12 w-12 items-center justify-center rounded-xl', kpi.bgClass]">
|
||||
<component :is="kpi.icon" :size="22" class="text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500">{{ kpi.label }}</p>
|
||||
<p class="text-2xl font-bold text-gray-800 dark:text-gray-100">{{ kpi.value }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 图表行 -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div class="card h-80">
|
||||
<h3 class="text-sm font-semibold text-gray-500 dark:text-gray-400 mb-3">订单创建趋势(按租户)</h3>
|
||||
<v-chart :option="orderChartOption" autoresize class="h-64" />
|
||||
</div>
|
||||
<div class="card h-80">
|
||||
<h3 class="text-sm font-semibold text-gray-500 dark:text-gray-400 mb-3">JVM 内存使用</h3>
|
||||
<v-chart :option="memoryChartOption" autoresize class="h-64" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 环境信息 -->
|
||||
<div class="card">
|
||||
<h2 class="text-sm font-semibold text-gray-500 dark:text-gray-400 mb-3">环境信息</h2>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<span class="text-gray-400">当前租户</span>
|
||||
<p class="font-mono font-semibold text-primary-600 dark:text-primary-400">
|
||||
{{ tenantStore.currentTenantId ?? '未选择' }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-400">服务健康</span>
|
||||
<p :class="['font-mono font-semibold', healthStatus === 'UP' ? 'text-emerald-600' : 'text-red-500']">
|
||||
{{ healthStatus }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-400">主题</span>
|
||||
<p class="font-mono text-gray-700 dark:text-gray-300">{{ themeStore.mode }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-400">版本</span>
|
||||
<p class="font-mono text-gray-700 dark:text-gray-300">0.2.0 (Sprint 2)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import VChart from 'vue-echarts';
|
||||
import { use } from 'echarts/core';
|
||||
import { CanvasRenderer } from 'echarts/renderers';
|
||||
import { LineChart, BarChart } from 'echarts/charts';
|
||||
import { GridComponent, TooltipComponent, LegendComponent } from 'echarts/components';
|
||||
import { ShoppingCart, Activity, Heart, Database } from 'lucide-vue-next';
|
||||
import { useTenantStore } from '@/stores/tenant';
|
||||
import { useThemeStore } from '@/stores/theme';
|
||||
import { useTenantRefresh } from '@/composables/useTenantRefresh';
|
||||
import { getHealth, getPrometheusText } from '@/api/actuator';
|
||||
import { listOrders } from '@/api/order';
|
||||
import { request } from '@/api/http';
|
||||
|
||||
use([CanvasRenderer, LineChart, BarChart, GridComponent, TooltipComponent, LegendComponent]);
|
||||
|
||||
const tenantStore = useTenantStore();
|
||||
const themeStore = useThemeStore();
|
||||
|
||||
const healthStatus = ref('--');
|
||||
const orderTotal = ref('--');
|
||||
const todayCreated = ref('--');
|
||||
const redisStatus = ref('--');
|
||||
const tenantOrderCounts = ref<{ tenant: string; count: number }[]>([]);
|
||||
const memoryUsed = ref(0);
|
||||
const memoryNonheap = ref(0);
|
||||
|
||||
// P0-2: Ping 测试按钮(通过 fetch 验证 MSW 是否工作)
|
||||
const pingLoading = ref(false);
|
||||
const pingResult = ref('');
|
||||
|
||||
async function testPing() {
|
||||
pingLoading.value = true;
|
||||
pingResult.value = '';
|
||||
try {
|
||||
const resp = await request<string>({ method: 'GET', url: '/api/ping' });
|
||||
pingResult.value = `✅ Ping 成功: ${JSON.stringify(resp)}`;
|
||||
} catch (e) {
|
||||
pingResult.value = `❌ Ping 失败: ${e}`;
|
||||
} finally {
|
||||
pingLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// P1-2: 从 CSS 变量动态读取品牌色
|
||||
function getPrimaryColor(): string {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue('--el-color-primary').trim() || '#3b82f6';
|
||||
}
|
||||
|
||||
const kpiCards = computed(() => [
|
||||
{ label: '订单总数', value: orderTotal.value, icon: ShoppingCart, bgClass: 'bg-primary-500' },
|
||||
{ label: '今日创建', value: todayCreated.value, icon: Activity, bgClass: 'bg-emerald-500' },
|
||||
{ label: '服务健康', value: healthStatus.value, icon: Heart, bgClass: healthStatus.value === 'UP' ? 'bg-emerald-500' : 'bg-red-500' },
|
||||
{ label: 'Redis 状态', value: redisStatus.value, icon: Database, bgClass: redisStatus.value === 'UP' ? 'bg-violet-500' : 'bg-red-500' },
|
||||
]);
|
||||
|
||||
const orderChartOption = computed(() => ({
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: tenantOrderCounts.value.map((t) => `租户 ${t.tenant}`) },
|
||||
xAxis: { type: 'category', data: ['当前'] },
|
||||
yAxis: { type: 'value' },
|
||||
series: tenantOrderCounts.value.map((t, i) => ({
|
||||
name: `租户 ${t.tenant}`,
|
||||
type: 'bar',
|
||||
data: [t.count],
|
||||
itemStyle: { borderRadius: [4, 4, 0, 0] },
|
||||
})),
|
||||
}));
|
||||
|
||||
const memoryChartOption = computed(() => ({
|
||||
tooltip: { trigger: 'axis' },
|
||||
xAxis: { type: 'category', data: ['Heap', 'Non-Heap'] },
|
||||
yAxis: { type: 'value', axisLabel: { formatter: (v: number) => `${(v / 1024 / 1024).toFixed(0)} MB` } },
|
||||
series: [{
|
||||
type: 'bar',
|
||||
data: [memoryUsed.value, memoryNonheap.value],
|
||||
itemStyle: {
|
||||
borderRadius: [4, 4, 0, 0],
|
||||
color: getPrimaryColor(),
|
||||
},
|
||||
}],
|
||||
}));
|
||||
|
||||
async function loadData() {
|
||||
try {
|
||||
const health = await getHealth();
|
||||
healthStatus.value = health.status;
|
||||
redisStatus.value = health.components?.redisPing?.status ?? '--';
|
||||
} catch {
|
||||
healthStatus.value = 'DOWN';
|
||||
redisStatus.value = 'DOWN';
|
||||
}
|
||||
|
||||
try {
|
||||
const promText = await getPrometheusText();
|
||||
const lines = promText.split('\n');
|
||||
const counts: { tenant: string; count: number }[] = [];
|
||||
for (const line of lines) {
|
||||
const match = line.match(/^order_created_total\{tenantId="(\d+)"\}\s+(\d+)/);
|
||||
if (match) counts.push({ tenant: match[1], count: Number(match[2]) });
|
||||
}
|
||||
tenantOrderCounts.value = counts;
|
||||
|
||||
const heapMatch = promText.match(/jvm_memory_used_bytes\{area="heap"\}\s+(\d+)/);
|
||||
const nonheapMatch = promText.match(/jvm_memory_used_bytes\{area="nonheap"\}\s+(\d+)/);
|
||||
memoryUsed.value = heapMatch ? Number(heapMatch[1]) : 0;
|
||||
memoryNonheap.value = nonheapMatch ? Number(nonheapMatch[1]) : 0;
|
||||
} catch { /* prometheus not available */ }
|
||||
|
||||
if (tenantStore.currentTenantId !== null) {
|
||||
try {
|
||||
const resp = await listOrders(1, 1000);
|
||||
orderTotal.value = String(resp.data.total);
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const todayCount = resp.data.records.filter((o) => o.createTime.startsWith(today)).length;
|
||||
todayCreated.value = String(todayCount);
|
||||
} catch {
|
||||
orderTotal.value = '--';
|
||||
todayCreated.value = '--';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadData);
|
||||
useTenantRefresh(loadData);
|
||||
</script>
|
||||
@@ -0,0 +1,10 @@
|
||||
<template>
|
||||
<div class="flex h-full flex-col items-center justify-center gap-4 text-center">
|
||||
<p class="text-8xl font-black text-gray-200 dark:text-gray-700">404</p>
|
||||
<p class="text-lg text-gray-500 dark:text-gray-400">
|
||||
页面不存在,或该功能尚未注册到
|
||||
<code class="text-primary-600">PAGE_REGISTRY</code>。
|
||||
</p>
|
||||
<el-button type="primary" @click="$router.push('/')">返回首页</el-button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<div class="space-y-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold text-gray-800 dark:text-gray-100">Caches</h1>
|
||||
<el-button @click="loadCaches" :loading="loading">刷新</el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="cacheData" class="space-y-4">
|
||||
<div v-for="(manager, managerName) in cacheData.cacheManagers" :key="managerName" class="card">
|
||||
<h3 class="text-sm font-semibold text-gray-500 mb-3">Cache Manager: {{ managerName }}</h3>
|
||||
<div v-for="(cache, cacheName) in manager.caches" :key="cacheName"
|
||||
class="flex items-center justify-between border-b dark:border-gray-700 pb-3 mb-3">
|
||||
<div>
|
||||
<span class="font-mono text-sm font-semibold">{{ cacheName }}</span>
|
||||
<p class="text-xs text-gray-400">{{ cache.target }}</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-xs text-gray-400">Key 模式</p>
|
||||
<code class="text-xs font-mono text-primary-600">{{ cacheName }}::{tenantId}::{id}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3 class="text-sm font-semibold text-gray-500 mb-3">缓存说明</h3>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300 space-y-2">
|
||||
<p>缓存由 <code class="text-primary-600">app.cache.enabled</code> 属性控制,默认 TTL 为 10 分钟,order 缓存 TTL 为 5 分钟。</p>
|
||||
<p>缓存 Key 格式:<code class="text-primary-600">order::{tenantId}::{orderId}</code>,包含租户 ID 以实现租户隔离。</p>
|
||||
<p>当 Redis 不可用时,<code class="text-primary-600">GracefulCacheErrorHandler</code> 记录 WARN 日志并降级为数据库查询。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { getCaches } from '@/api/actuator';
|
||||
import type { CachesResponse } from '@/api/types';
|
||||
|
||||
const loading = ref(false);
|
||||
const cacheData = ref<CachesResponse | null>(null);
|
||||
|
||||
async function loadCaches() {
|
||||
loading.value = true;
|
||||
try { cacheData.value = await getCaches(); }
|
||||
catch { cacheData.value = null; }
|
||||
finally { loading.value = false; }
|
||||
}
|
||||
|
||||
onMounted(loadCaches);
|
||||
</script>
|
||||
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<div class="space-y-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold text-gray-800 dark:text-gray-100">Health Check</h1>
|
||||
<el-button @click="loadHealth" :loading="loading">刷新</el-button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div :class="['h-4 w-4 rounded-full', health?.status === 'UP' ? 'bg-emerald-500' : 'bg-red-500']" />
|
||||
<span class="text-lg font-semibold">{{ health?.status ?? '--' }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="health" class="space-y-3">
|
||||
<div v-for="(comp, name) in health.components" :key="name"
|
||||
class="flex items-center justify-between border-b dark:border-gray-700 pb-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<div :class="['h-3 w-3 rounded-full', comp.status === 'UP' ? 'bg-emerald-500' : 'bg-red-500']" />
|
||||
<span class="font-mono text-sm">{{ name }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<el-tag :type="comp.status === 'UP' ? 'success' : 'danger'" size="small">{{ comp.status }}</el-tag>
|
||||
<span v-if="comp.details" class="text-xs text-gray-400 font-mono">
|
||||
{{ JSON.stringify(comp.details) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { getHealth } from '@/api/actuator';
|
||||
import type { HealthResponse } from '@/api/types';
|
||||
|
||||
const loading = ref(false);
|
||||
const health = ref<HealthResponse | null>(null);
|
||||
|
||||
async function loadHealth() {
|
||||
loading.value = true;
|
||||
try { health.value = await getHealth(); }
|
||||
catch { health.value = null; }
|
||||
finally { loading.value = false; }
|
||||
}
|
||||
|
||||
onMounted(loadHealth);
|
||||
</script>
|
||||
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<div class="space-y-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold text-gray-800 dark:text-gray-100">Metrics</h1>
|
||||
<el-button @click="loadMetrics" :loading="loading">刷新</el-button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<el-input v-model="search" placeholder="搜索指标名..." clearable class="mb-4" />
|
||||
<div class="space-y-2 max-h-96 overflow-auto">
|
||||
<div v-for="name in filteredNames" :key="name"
|
||||
class="flex items-center justify-between px-3 py-2 rounded-lg cursor-pointer
|
||||
hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
@click="selectMetric(name)">
|
||||
<span class="font-mono text-sm">{{ name }}</span>
|
||||
<el-icon><component :is="ChevronRight" /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedMetric" class="card">
|
||||
<h3 class="text-sm font-semibold text-gray-500 mb-3">{{ selectedMetric.name }}</h3>
|
||||
<p v-if="selectedMetric.description" class="text-xs text-gray-400 mb-3">{{ selectedMetric.description }}</p>
|
||||
<div class="space-y-2">
|
||||
<div v-for="m in selectedMetric.measurements" :key="m.statistic" class="flex justify-between text-sm">
|
||||
<span class="text-gray-500">{{ m.statistic }}</span>
|
||||
<span class="font-mono font-semibold">{{ formatValue(m.value) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMetric.availableTags.length" class="mt-4">
|
||||
<p class="text-xs text-gray-400 mb-2">可用标签</p>
|
||||
<div v-for="tag in selectedMetric.availableTags" :key="tag.tag" class="flex gap-2 flex-wrap mb-1">
|
||||
<span class="text-xs text-gray-500">{{ tag.tag }}:</span>
|
||||
<el-tag v-for="v in tag.values" :key="v" size="small" class="mr-1">{{ v }}</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { ChevronRight } from 'lucide-vue-next';
|
||||
import { getMetricNames, getMetricDetail } from '@/api/actuator';
|
||||
import type { MetricDetailResponse } from '@/api/types';
|
||||
|
||||
const loading = ref(false);
|
||||
const search = ref('');
|
||||
const metricNames = ref<string[]>([]);
|
||||
const selectedMetric = ref<MetricDetailResponse | null>(null);
|
||||
|
||||
const filteredNames = computed(() => {
|
||||
if (!search.value) return metricNames.value;
|
||||
const q = search.value.toLowerCase();
|
||||
return metricNames.value.filter((n) => n.toLowerCase().includes(q));
|
||||
});
|
||||
|
||||
function formatValue(v: number) {
|
||||
if (v > 1_000_000) return `${(v / 1_000_000).toFixed(2)}M`;
|
||||
if (v > 1_000) return `${(v / 1_000).toFixed(2)}K`;
|
||||
return v % 1 === 0 ? String(v) : v.toFixed(4);
|
||||
}
|
||||
|
||||
async function loadMetrics() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const resp = await getMetricNames();
|
||||
metricNames.value = resp.names;
|
||||
} catch { /* handled */ }
|
||||
finally { loading.value = false; }
|
||||
}
|
||||
|
||||
async function selectMetric(name: string) {
|
||||
try { selectedMetric.value = await getMetricDetail(name); }
|
||||
catch { /* handled */ }
|
||||
}
|
||||
|
||||
onMounted(loadMetrics);
|
||||
</script>
|
||||
@@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<div class="space-y-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold text-gray-800 dark:text-gray-100">Prometheus</h1>
|
||||
<div class="flex gap-2">
|
||||
<el-button @click="loadPrometheus" :loading="loading">刷新</el-button>
|
||||
<el-button @click="copyText">复制全文</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<el-input v-model="filter" placeholder="过滤指标(如 order_created_total)" clearable />
|
||||
</div>
|
||||
|
||||
<div v-if="parsedMetrics.length" class="space-y-3">
|
||||
<div v-for="metric in filteredMetrics" :key="metric.name" class="card">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="font-mono text-sm font-semibold text-primary-600">{{ metric.name }}</span>
|
||||
<el-tag size="small" type="info">{{ metric.type }}</el-tag>
|
||||
</div>
|
||||
<p v-if="metric.help" class="text-xs text-gray-400 mb-2">{{ metric.help }}</p>
|
||||
<div class="space-y-1">
|
||||
<div v-for="(line, i) in metric.lines" :key="i" class="font-mono text-xs text-gray-600 dark:text-gray-300">
|
||||
{{ line }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3 class="text-sm font-semibold text-gray-500 mb-3">原始输出</h3>
|
||||
<pre class="text-xs font-mono text-gray-600 dark:text-gray-300 whitespace-pre-wrap max-h-96 overflow-auto bg-gray-50 dark:bg-gray-900 p-4 rounded-lg">{{ rawText }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { getPrometheusText } from '@/api/actuator';
|
||||
|
||||
interface ParsedMetric {
|
||||
name: string;
|
||||
help: string;
|
||||
type: string;
|
||||
lines: string[];
|
||||
}
|
||||
|
||||
const loading = ref(false);
|
||||
const rawText = ref('');
|
||||
const filter = ref('');
|
||||
const parsedMetrics = ref<ParsedMetric[]>([]);
|
||||
|
||||
const filteredMetrics = computed(() => {
|
||||
if (!filter.value) return parsedMetrics.value;
|
||||
const q = filter.value.toLowerCase();
|
||||
return parsedMetrics.value.filter((m) => m.name.toLowerCase().includes(q));
|
||||
});
|
||||
|
||||
function parsePrometheus(text: string): ParsedMetric[] {
|
||||
const metrics: ParsedMetric[] = [];
|
||||
let current: ParsedMetric | null = null;
|
||||
|
||||
for (const line of text.split('\n')) {
|
||||
if (line.startsWith('# HELP ')) {
|
||||
const rest = line.slice(7);
|
||||
const spaceIdx = rest.indexOf(' ');
|
||||
const name = rest.slice(0, spaceIdx);
|
||||
const help = rest.slice(spaceIdx + 1);
|
||||
current = { name, help, type: '', lines: [] };
|
||||
metrics.push(current);
|
||||
} else if (line.startsWith('# TYPE ')) {
|
||||
const rest = line.slice(7);
|
||||
const spaceIdx = rest.indexOf(' ');
|
||||
const type = rest.slice(spaceIdx + 1);
|
||||
if (current) current.type = type;
|
||||
} else if (line.trim() && !line.startsWith('#') && current) {
|
||||
current.lines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
return metrics;
|
||||
}
|
||||
|
||||
async function loadPrometheus() {
|
||||
loading.value = true;
|
||||
try {
|
||||
rawText.value = await getPrometheusText();
|
||||
parsedMetrics.value = parsePrometheus(rawText.value);
|
||||
} catch { rawText.value = '无法获取 Prometheus 数据'; }
|
||||
finally { loading.value = false; }
|
||||
}
|
||||
|
||||
function copyText() {
|
||||
navigator.clipboard.writeText(rawText.value);
|
||||
ElMessage.success('已复制到剪贴板');
|
||||
}
|
||||
|
||||
onMounted(loadPrometheus);
|
||||
</script>
|
||||
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<el-dialog title="新建订单" :model-value="visible" width="480px" @update:model-value="$emit('update:visible', $event)" destroy-on-close>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="80px">
|
||||
<el-form-item label="部门ID" prop="deptId">
|
||||
<el-input-number v-model="form.deptId" :min="1" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="订单号" prop="orderNo">
|
||||
<el-input v-model="form.orderNo" placeholder="如 ORD-009" />
|
||||
</el-form-item>
|
||||
<el-form-item label="金额" prop="amount">
|
||||
<el-input-number v-model="form.amount" :min="0.01" :precision="2" :step="10" style="width: 100%" placeholder="请输入金额" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="$emit('update:visible', false)">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit">创建</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive } from 'vue';
|
||||
import type { FormInstance, FormRules } from 'element-plus';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { createOrder } from '@/api/order';
|
||||
|
||||
defineProps<{ visible: boolean }>();
|
||||
const emit = defineEmits<{ 'update:visible': [boolean]; created: [] }>();
|
||||
|
||||
const formRef = ref<FormInstance>();
|
||||
const submitting = ref(false);
|
||||
const form = reactive({
|
||||
deptId: 10,
|
||||
orderNo: '',
|
||||
amount: undefined as number | undefined,
|
||||
});
|
||||
|
||||
const rules: FormRules = {
|
||||
deptId: [{ required: true, message: '请输入部门ID', trigger: 'blur' }],
|
||||
orderNo: [{ required: true, message: '请输入订单号', trigger: 'blur' }],
|
||||
amount: [
|
||||
{ required: true, message: '请输入金额', trigger: 'blur' },
|
||||
{ type: 'number', min: 0.01, message: '金额必须大于 0', trigger: 'blur' },
|
||||
],
|
||||
};
|
||||
|
||||
async function handleSubmit() {
|
||||
const valid = await formRef.value?.validate().catch(() => false);
|
||||
if (!valid) return;
|
||||
submitting.value = true;
|
||||
try {
|
||||
await createOrder({ deptId: form.deptId, orderNo: form.orderNo, amount: form.amount! });
|
||||
ElMessage.success('创建成功');
|
||||
emit('update:visible', false);
|
||||
emit('created');
|
||||
} catch { /* handled */ }
|
||||
finally { submitting.value = false; }
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<div class="space-y-5">
|
||||
<div class="flex items-center gap-3">
|
||||
<el-button @click="$router.back()" size="small">返回</el-button>
|
||||
<h1 class="text-2xl font-bold text-gray-800 dark:text-gray-100">订单详情</h1>
|
||||
</div>
|
||||
|
||||
<!-- P2-1: 未选租户提示 -->
|
||||
<div v-if="tenantStore.currentTenantId === null" class="card text-center py-10">
|
||||
<p class="text-gray-400 mb-4">请先在顶部选择租户后再查看订单详情</p>
|
||||
<el-button type="primary" @click="$router.push('/orders')">返回订单列表</el-button>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div v-if="order" class="card">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 text-sm">
|
||||
<div><span class="text-gray-400">ID</span><p class="font-mono font-semibold mt-1">{{ order.id }}</p></div>
|
||||
<div><span class="text-gray-400">租户ID</span><p class="font-mono font-semibold mt-1">{{ order.tenantId }}</p></div>
|
||||
<div><span class="text-gray-400">部门ID</span><p class="font-mono font-semibold mt-1">{{ order.deptId }}</p></div>
|
||||
<div><span class="text-gray-400">订单号</span><p class="font-mono font-semibold mt-1">{{ order.orderNo }}</p></div>
|
||||
<div><span class="text-gray-400">金额</span><p class="font-mono font-semibold mt-1 text-primary-600">¥{{ order.amount.toFixed(2) }}</p></div>
|
||||
<div><span class="text-gray-400">状态</span><p class="mt-1"><el-tag :type="statusTagType(order.status)">{{ order.status }}</el-tag></p></div>
|
||||
<div><span class="text-gray-400">创建时间</span><p class="font-mono mt-1">{{ formatTime(order.createTime) }}</p></div>
|
||||
<div><span class="text-gray-400">更新时间</span><p class="font-mono mt-1">{{ formatTime(order.updateTime) }}</p></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="order" class="card">
|
||||
<h3 class="text-sm font-semibold text-gray-500 dark:text-gray-400 mb-3">缓存 Key 预览</h3>
|
||||
<code class="text-sm font-mono text-primary-600 bg-gray-100 dark:bg-gray-700 px-3 py-2 rounded-lg block">
|
||||
order::{{ order.tenantId }}::{{ order.id }}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<div v-if="!order && !loading" class="card text-center text-gray-400 py-10">
|
||||
订单不存在或无权访问
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { getOrder } from '@/api/order';
|
||||
import { useTenantStore } from '@/stores/tenant';
|
||||
import type { OrderResponse } from '@/api/types';
|
||||
|
||||
const route = useRoute();
|
||||
const tenantStore = useTenantStore();
|
||||
const loading = ref(true);
|
||||
const order = ref<OrderResponse | null>(null);
|
||||
|
||||
function statusTagType(status: string) {
|
||||
switch (status) {
|
||||
case 'CREATED': return 'info';
|
||||
case 'PAID': return 'success';
|
||||
case 'CANCELLED': return 'danger';
|
||||
default: return 'info';
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(iso: string) {
|
||||
return new Date(iso).toLocaleString('zh-CN');
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (tenantStore.currentTenantId === null) {
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const resp = await getOrder(Number(route.params.id));
|
||||
order.value = resp.data;
|
||||
} catch { /* handled */ }
|
||||
finally { loading.value = false; }
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,132 @@
|
||||
<template>
|
||||
<div class="space-y-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold text-gray-800 dark:text-gray-100">订单管理</h1>
|
||||
<el-button type="primary" @click="showCreateDialog = true" v-permission="'order:order:create'">
|
||||
新建订单
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 未选租户提示 -->
|
||||
<div v-if="tenantStore.currentTenantId === null" class="card text-center py-10">
|
||||
<p class="text-gray-400 mb-4">请先在顶部选择租户</p>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- 筛选栏 -->
|
||||
<div class="card flex flex-wrap items-center gap-4">
|
||||
<el-select v-model="filterStatus" placeholder="状态筛选" clearable size="default" style="width: 160px">
|
||||
<el-option label="CREATED" value="CREATED" />
|
||||
<el-option label="PAID" value="PAID" />
|
||||
<el-option label="CANCELLED" value="CANCELLED" />
|
||||
</el-select>
|
||||
<el-button @click="loadOrders">查询</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 表格 -->
|
||||
<div class="card p-0 overflow-hidden">
|
||||
<el-table :data="orders" v-loading="loading" stripe>
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="orderNo" label="订单号" min-width="140" />
|
||||
<el-table-column prop="deptId" label="部门ID" width="90" />
|
||||
<el-table-column prop="amount" label="金额" width="120">
|
||||
<template #default="{ row }">¥{{ row.amount.toFixed(2) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusTagType(row.status)" size="small">{{ row.status }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createTime" label="创建时间" min-width="180">
|
||||
<template #default="{ row }">{{ formatTime(row.createTime) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="220" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="$router.push(`/orders/${row.id}`)">详情</el-button>
|
||||
<el-button link type="warning" size="small" @click="openStatusDialog(row)" v-permission="'order:order:update'">状态</el-button>
|
||||
<el-button link type="danger" size="small" @click="handleDelete(row)" v-permission="'order:order:delete'">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="flex justify-end">
|
||||
<el-pagination
|
||||
v-model:current-page="pageNum"
|
||||
v-model:page-size="pageSize"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@size-change="loadOrders" @current-change="loadOrders"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<CreateOrderDialog v-model:visible="showCreateDialog" @created="loadOrders" />
|
||||
<UpdateStatusDialog v-model:visible="showStatusDialog" :order="selectedOrder" @updated="loadOrders" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { ElMessageBox, ElMessage } from 'element-plus';
|
||||
import { listOrders, deleteOrder } from '@/api/order';
|
||||
import { useTenantRefresh } from '@/composables/useTenantRefresh';
|
||||
import { useTenantStore } from '@/stores/tenant';
|
||||
import type { OrderResponse } from '@/api/types';
|
||||
import CreateOrderDialog from './CreateOrderDialog.vue';
|
||||
import UpdateStatusDialog from './UpdateStatusDialog.vue';
|
||||
|
||||
const tenantStore = useTenantStore();
|
||||
const loading = ref(false);
|
||||
const orders = ref<OrderResponse[]>([]);
|
||||
const total = ref(0);
|
||||
const pageNum = ref(1);
|
||||
const pageSize = ref(20);
|
||||
const filterStatus = ref<string>('');
|
||||
const showCreateDialog = ref(false);
|
||||
const showStatusDialog = ref(false);
|
||||
const selectedOrder = ref<OrderResponse | null>(null);
|
||||
|
||||
function statusTagType(status: string) {
|
||||
switch (status) {
|
||||
case 'CREATED': return 'info';
|
||||
case 'PAID': return 'success';
|
||||
case 'CANCELLED': return 'danger';
|
||||
default: return 'info';
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(iso: string) {
|
||||
return new Date(iso).toLocaleString('zh-CN');
|
||||
}
|
||||
|
||||
async function loadOrders() {
|
||||
if (tenantStore.currentTenantId === null) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const resp = await listOrders(pageNum.value, pageSize.value, filterStatus.value || undefined);
|
||||
orders.value = resp.data.records;
|
||||
total.value = resp.data.total;
|
||||
} catch { /* handled by interceptor */ }
|
||||
finally { loading.value = false; }
|
||||
}
|
||||
|
||||
function openStatusDialog(order: OrderResponse) {
|
||||
selectedOrder.value = order;
|
||||
showStatusDialog.value = true;
|
||||
}
|
||||
|
||||
async function handleDelete(order: OrderResponse) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认删除订单 ${order.orderNo}?`, '提示', { type: 'warning' });
|
||||
await deleteOrder(order.id);
|
||||
ElMessage.success('删除成功');
|
||||
await loadOrders();
|
||||
} catch { /* cancelled or error */ }
|
||||
}
|
||||
|
||||
onMounted(loadOrders);
|
||||
useTenantRefresh(loadOrders);
|
||||
</script>
|
||||
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<el-dialog title="更新订单状态" :model-value="visible" width="400px" @update:model-value="$emit('update:visible', $event)" destroy-on-close>
|
||||
<div v-if="order" class="space-y-4">
|
||||
<p class="text-sm text-gray-500">订单号: <span class="font-mono font-semibold">{{ order.orderNo }}</span></p>
|
||||
<p class="text-sm text-gray-500">当前状态: <el-tag size="small">{{ order.status }}</el-tag></p>
|
||||
<el-select v-model="newStatus" placeholder="选择新状态" style="width: 100%">
|
||||
<el-option v-for="s in statuses" :key="s" :label="s" :value="s" :disabled="s === order.status" />
|
||||
</el-select>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="$emit('update:visible', false)">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" :disabled="!newStatus || newStatus === order?.status" @click="handleSubmit">确认</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { updateOrderStatus } from '@/api/order';
|
||||
import type { OrderResponse } from '@/api/types';
|
||||
|
||||
const props = defineProps<{ visible: boolean; order: OrderResponse | null }>();
|
||||
const emit = defineEmits<{ 'update:visible': [boolean]; updated: [] }>();
|
||||
|
||||
const statuses = ['CREATED', 'PAID', 'CANCELLED'];
|
||||
const newStatus = ref('');
|
||||
const submitting = ref(false);
|
||||
|
||||
watch(() => props.visible, (v) => { if (v) newStatus.value = ''; });
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!props.order || !newStatus.value) return;
|
||||
submitting.value = true;
|
||||
try {
|
||||
await updateOrderStatus(props.order.id, { status: newStatus.value });
|
||||
ElMessage.success('状态更新成功');
|
||||
emit('update:visible', false);
|
||||
emit('updated');
|
||||
} catch { /* handled */ }
|
||||
finally { submitting.value = false; }
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { App } from 'vue';
|
||||
import ElementPlus from 'element-plus';
|
||||
import 'element-plus/dist/index.css';
|
||||
import 'element-plus/theme-chalk/dark/css-vars.css';
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn';
|
||||
|
||||
export function setupElementPlus(app: App): void {
|
||||
app.use(ElementPlus, {
|
||||
locale: zhCn,
|
||||
size: 'default',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
import { staticRoutes } from './static-routes';
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: staticRoutes,
|
||||
scrollBehavior: () => ({ top: 0 }),
|
||||
});
|
||||
|
||||
// Sprint 3:在此处添加 router.beforeEach 做权限守卫和动态路由注入
|
||||
// router.beforeEach(async (to, from, next) => { ... });
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* 页面注册表(白名单)。
|
||||
*
|
||||
* 后端 sys_menu.component 字段存储 PageKey 类型的字符串,
|
||||
* 前端通过此 registry 映射到实际的 Vue 组件加载器。
|
||||
*
|
||||
* 新增页面时必须在此处注册,否则 CI 检查脚本将报错。
|
||||
*/
|
||||
export const PAGE_REGISTRY = {
|
||||
Dashboard: () => import('@/pages/dashboard/DashboardPage.vue'),
|
||||
OrderList: () => import('@/pages/order/OrderListPage.vue'),
|
||||
OrderDetail: () => import('@/pages/order/OrderDetailPage.vue'),
|
||||
HealthCheck: () => import('@/pages/observability/HealthCheckPage.vue'),
|
||||
MetricsDashboard: () => import('@/pages/observability/MetricsDashboardPage.vue'),
|
||||
PrometheusViewer: () => import('@/pages/observability/PrometheusViewerPage.vue'),
|
||||
CachesViewer: () => import('@/pages/observability/CachesViewerPage.vue'),
|
||||
|
||||
// ── Sprint 3 ──
|
||||
// MenuManagement: () => import('@/pages/system/MenuManagementPage.vue'),
|
||||
// RoleManagement: () => import('@/pages/system/RoleManagementPage.vue'),
|
||||
// PermissionManagement: () => import('@/pages/system/PermissionManagementPage.vue'),
|
||||
|
||||
// ── Sprint 4 ──
|
||||
// CacheLab: () => import('@/pages/lab/CacheLabPage.vue'),
|
||||
// TenantIsolation: () => import('@/pages/lab/TenantIsolationPage.vue'),
|
||||
// ErrorCodeDemo: () => import('@/pages/lab/ErrorCodeDemoPage.vue'),
|
||||
|
||||
NotFound: () => import('@/pages/error/NotFoundPage.vue'),
|
||||
} as const satisfies Record<string, () => Promise<any>>;
|
||||
|
||||
export type PageKey = keyof typeof PAGE_REGISTRY;
|
||||
|
||||
/**
|
||||
* 根据 PageKey 获取组件加载器,找不到则返回 NotFound。
|
||||
*/
|
||||
export function resolvePageComponent(key: string): () => Promise<any> {
|
||||
return (PAGE_REGISTRY as Record<string, (() => Promise<any>) | undefined>)[key]
|
||||
?? PAGE_REGISTRY.NotFound;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { RouteRecordRaw } from 'vue-router';
|
||||
import AdminLayout from '@/layout/AdminLayout.vue';
|
||||
|
||||
export const staticRoutes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/',
|
||||
component: AdminLayout,
|
||||
redirect: '/dashboard',
|
||||
children: [
|
||||
{
|
||||
path: 'dashboard',
|
||||
name: 'Dashboard',
|
||||
component: () => import('@/pages/dashboard/DashboardPage.vue'),
|
||||
meta: { title: '概览 Dashboard', icon: 'LayoutDashboard' },
|
||||
},
|
||||
{
|
||||
path: 'orders',
|
||||
name: 'OrderList',
|
||||
component: () => import('@/pages/order/OrderListPage.vue'),
|
||||
meta: { title: '订单管理', icon: 'ShoppingCart' },
|
||||
},
|
||||
{
|
||||
path: 'orders/:id',
|
||||
name: 'OrderDetail',
|
||||
component: () => import('@/pages/order/OrderDetailPage.vue'),
|
||||
meta: { title: '订单详情', hidden: true },
|
||||
},
|
||||
{
|
||||
path: 'health',
|
||||
name: 'HealthCheck',
|
||||
component: () => import('@/pages/observability/HealthCheckPage.vue'),
|
||||
meta: { title: 'Health', icon: 'Heart' },
|
||||
},
|
||||
{
|
||||
path: 'metrics',
|
||||
name: 'MetricsDashboard',
|
||||
component: () => import('@/pages/observability/MetricsDashboardPage.vue'),
|
||||
meta: { title: 'Metrics', icon: 'BarChart3' },
|
||||
},
|
||||
{
|
||||
path: 'prometheus',
|
||||
name: 'PrometheusViewer',
|
||||
component: () => import('@/pages/observability/PrometheusViewerPage.vue'),
|
||||
meta: { title: 'Prometheus', icon: 'Activity' },
|
||||
},
|
||||
{
|
||||
path: 'caches',
|
||||
name: 'CachesViewer',
|
||||
component: () => import('@/pages/observability/CachesViewerPage.vue'),
|
||||
meta: { title: 'Caches', icon: 'Database' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'NotFound',
|
||||
component: () => import('@/pages/error/NotFoundPage.vue'),
|
||||
meta: { title: '404', hidden: true },
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,55 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
import { APP_EVENTS } from '@/constants/events';
|
||||
|
||||
const STORAGE_KEY = 'tmpl-admin:current-tenant';
|
||||
const RECENT_KEY = 'tmpl-admin:recent-tenants';
|
||||
const MAX_RECENT = 5;
|
||||
|
||||
export const useTenantStore = defineStore('tenant', () => {
|
||||
const currentTenantId = ref<number | null>(loadFromStorage());
|
||||
const recentTenants = ref<number[]>(loadRecentFromStorage());
|
||||
|
||||
function switchTenant(tenantId: number): void {
|
||||
currentTenantId.value = tenantId;
|
||||
addToRecent(tenantId);
|
||||
localStorage.setItem(STORAGE_KEY, String(tenantId));
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(APP_EVENTS.TENANT_CHANGED, { detail: tenantId }),
|
||||
);
|
||||
}
|
||||
|
||||
function clearTenant(): void {
|
||||
currentTenantId.value = null;
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
|
||||
function addToRecent(tenantId: number): void {
|
||||
const list = recentTenants.value.filter((id) => id !== tenantId);
|
||||
list.unshift(tenantId);
|
||||
if (list.length > MAX_RECENT) list.pop();
|
||||
recentTenants.value = list;
|
||||
localStorage.setItem(RECENT_KEY, JSON.stringify(list));
|
||||
}
|
||||
|
||||
function loadFromStorage(): number | null {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
return raw !== null ? Number(raw) : null;
|
||||
}
|
||||
|
||||
function loadRecentFromStorage(): number[] {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(RECENT_KEY) || '[]');
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
currentTenantId,
|
||||
recentTenants,
|
||||
switchTenant,
|
||||
clearTenant,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
|
||||
type ThemeMode = 'light' | 'dark' | 'system';
|
||||
|
||||
const STORAGE_KEY = 'tmpl-admin:theme';
|
||||
|
||||
export const useThemeStore = defineStore('theme', () => {
|
||||
const mode = ref<ThemeMode>((localStorage.getItem(STORAGE_KEY) as ThemeMode) || 'light');
|
||||
|
||||
function setMode(newMode: ThemeMode): void {
|
||||
mode.value = newMode;
|
||||
localStorage.setItem(STORAGE_KEY, newMode);
|
||||
applyTheme();
|
||||
}
|
||||
|
||||
function toggle(): void {
|
||||
setMode(mode.value === 'dark' ? 'light' : 'dark');
|
||||
}
|
||||
|
||||
function initTheme(): void {
|
||||
applyTheme();
|
||||
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
||||
if (mode.value === 'system') applyTheme();
|
||||
});
|
||||
}
|
||||
|
||||
function applyTheme(): void {
|
||||
const isDark =
|
||||
mode.value === 'dark' ||
|
||||
(mode.value === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
|
||||
document.documentElement.classList.toggle('dark', isDark);
|
||||
}
|
||||
|
||||
return { mode, setMode, toggle, initTheme };
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* Element Plus 主题覆盖。
|
||||
*
|
||||
* 策略:Dark 模式完全依赖 element-plus/theme-chalk/dark/css-vars.css,
|
||||
* 此处仅覆盖 primary 品牌色和圆角。不重复定义 --el-bg-color / --el-text-color 等,
|
||||
* 避免与官方 dark vars 冲突。
|
||||
*/
|
||||
:root {
|
||||
--el-color-primary: #3b82f6;
|
||||
--el-color-primary-light-3: #60a5fa;
|
||||
--el-color-primary-light-5: #93c5fd;
|
||||
--el-color-primary-light-7: #bfdbfe;
|
||||
--el-color-primary-light-9: #eff6ff;
|
||||
--el-color-primary-dark-2: #2563eb;
|
||||
--el-border-radius-base: 8px;
|
||||
--el-border-radius-small: 6px;
|
||||
}
|
||||
|
||||
/* 表格行高更舒展 */
|
||||
.el-table .el-table__row {
|
||||
height: 52px;
|
||||
}
|
||||
|
||||
.el-table--enable-row-hover .el-table__body tr:hover > td {
|
||||
background-color: var(--el-color-primary-light-9) !important;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/*
|
||||
* preflight 已关闭。仅补充 box-sizing 保证 Tailwind 宽高计算正确。
|
||||
* 不覆盖 body 字体/颜色,由 Element Plus 和 Tailwind utilities 各自控制。
|
||||
*/
|
||||
@layer base {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.card {
|
||||
@apply rounded-2xl bg-white shadow-card p-5;
|
||||
@apply dark:bg-gray-800 dark:border dark:border-gray-700;
|
||||
@apply transition-shadow duration-200 hover:shadow-card-hover;
|
||||
}
|
||||
|
||||
.card-glass {
|
||||
@apply rounded-2xl p-5 shadow-glass;
|
||||
@apply bg-white/70 backdrop-blur-[16px];
|
||||
@apply dark:bg-gray-800/60 dark:border dark:border-gray-700/50;
|
||||
}
|
||||
|
||||
.kpi-card {
|
||||
@apply card flex items-center gap-4 min-w-[200px];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/* 路由切换过渡 */
|
||||
.fade-slide-enter-active,
|
||||
.fade-slide-leave-active {
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.fade-slide-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
|
||||
.fade-slide-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { Config } from 'tailwindcss';
|
||||
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{vue,ts,tsx}'],
|
||||
darkMode: 'class',
|
||||
corePlugins: {
|
||||
preflight: false,
|
||||
},
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#eff6ff',
|
||||
100: '#dbeafe',
|
||||
200: '#bfdbfe',
|
||||
300: '#93c5fd',
|
||||
400: '#60a5fa',
|
||||
500: '#3b82f6',
|
||||
600: '#2563eb',
|
||||
700: '#1d4ed8',
|
||||
800: '#1e40af',
|
||||
900: '#1e3a8a',
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
'2xl': '1rem',
|
||||
'3xl': '1.5rem',
|
||||
},
|
||||
boxShadow: {
|
||||
'card': '0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04)',
|
||||
'card-hover': '0 4px 12px rgba(0,0,0,0.08)',
|
||||
'glass': '0 8px 32px rgba(0,0,0,0.06)',
|
||||
},
|
||||
backdropBlur: {
|
||||
'glass': '16px',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
} satisfies Config;
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.d.ts",
|
||||
"src/**/*.vue",
|
||||
"src/auto-imports.d.ts",
|
||||
"src/components.d.ts"
|
||||
],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts", "tailwind.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { defineConfig, loadEnv } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import AutoImport from 'unplugin-auto-import/vite';
|
||||
import Components from 'unplugin-vue-components/vite';
|
||||
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers';
|
||||
import { resolve } from 'path';
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), '');
|
||||
|
||||
const apiTarget = env.VITE_API_BASE_URL || 'http://localhost:8080';
|
||||
const actuatorTarget = env.VITE_ACTUATOR_BASE_URL || 'http://localhost:9090';
|
||||
|
||||
return {
|
||||
plugins: [
|
||||
vue(),
|
||||
AutoImport({
|
||||
resolvers: [ElementPlusResolver()],
|
||||
imports: ['vue', 'vue-router', 'pinia'],
|
||||
dts: 'src/auto-imports.d.ts',
|
||||
}),
|
||||
Components({
|
||||
resolvers: [ElementPlusResolver()],
|
||||
dts: 'src/components.d.ts',
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: apiTarget,
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/actuator': {
|
||||
target: actuatorTarget,
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -25,7 +25,6 @@ import java.util.Optional;
|
||||
*/
|
||||
@Configuration
|
||||
@EnableJpaAuditing(auditorAwareRef = "tenantAuditorAware")
|
||||
@EnableJpaRepositories(basePackages = "com.ljwx.tmpl.infrastructure.persistence")
|
||||
@EntityScan(basePackages = "com.ljwx.tmpl.infrastructure.persistence")
|
||||
public class JpaAuditingConfig {
|
||||
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
spring:
|
||||
datasource:
|
||||
url: jdbc:postgresql://localhost:5432/tmpl_dev
|
||||
username: postgres
|
||||
password: postgres
|
||||
url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:java_template}
|
||||
username: ${DB_USER:postgres}
|
||||
password: ${DB_PASS:postgres}
|
||||
jpa:
|
||||
show-sql: true
|
||||
data:
|
||||
redis:
|
||||
host: localhost
|
||||
port: 6379
|
||||
profiles:
|
||||
include: redis
|
||||
# Redis configuration moved to application-redis.yml and activated via profiles
|
||||
|
||||
# ====== Batch 9 dev 覆盖 ======
|
||||
# dev 环境管理端口合并到业务端口(仅本地开发便利)。
|
||||
|
||||
@@ -19,7 +19,6 @@ import org.springframework.test.web.servlet.MockMvc;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
@@ -28,19 +27,19 @@ import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
/**
|
||||
* Unit tests for { @link OrderController}.
|
||||
* Unit tests for {@link OrderController}.
|
||||
*
|
||||
* <p>Uses { @--- @WebMvcTest} to load only the web layer and
|
||||
* mocks { @link OrderAppService} to isolate controller logic.
|
||||
* <p>Uses {@code @WebMvcTest} to load only the web layer and
|
||||
* mocks {@link OrderAppService} to isolate controller logic.
|
||||
*/
|
||||
@WebMvcTest(OrderController.class)
|
||||
@---(SecurityConfig.class) // Include security config for permitAll
|
||||
@WebMvcTest(OrderController.class)
|
||||
@Import(SecurityConfig.class) // Include security config for permitAll
|
||||
class OrderControllerTest {
|
||||
|
||||
@Autowired
|
||||
@@ -64,7 +63,8 @@ class OrderControllerTest {
|
||||
return order;
|
||||
}
|
||||
|
||||
@--- @DisplayName("POST /api/v1/orders → 200 OK with created order")
|
||||
@Test
|
||||
@DisplayName("POST /api/v1/orders → 201 Created with created order")
|
||||
void create_success() throws Exception {
|
||||
Order sampleOrder = createSampleOrder();
|
||||
CreateOrderRequest req = new CreateOrderRequest();
|
||||
@@ -72,94 +72,92 @@ class OrderControllerTest {
|
||||
req.setOrderNo("ORD-001");
|
||||
req.setAmount(new BigDecimal("99.99"));
|
||||
|
||||
when(appService.create(eq(100L), eq(10L), eq("ORD-001"), eq(new BigDecimal("99.99"))))
|
||||
when(appService.createOrder(eq(100L), eq(10L), eq("ORD-001"), eq(new BigDecimal("99.99"))))
|
||||
.thenReturn(sampleOrder);
|
||||
|
||||
mockMvc.perform(post("/api/v1/orders")
|
||||
.header("X-Tenant-Id", "100")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(req)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value("OK"))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.data.id").value(1))
|
||||
.andExpect(jsonPath("$.data.orderNo").value("ORD-001"))
|
||||
.andExpect(jsonPath("$.data.status").value("CREATED"));
|
||||
|
||||
verify(appService).create(100L, 10L, "ORD-001", new BigDecimal("99.99"));
|
||||
verify(appService).createOrder(100L, 10L, "ORD-001", new BigDecimal("99.99"));
|
||||
}
|
||||
|
||||
@--- @DisplayName("GET /api/v1/orders/{id} → 200 OK with order")
|
||||
@Test
|
||||
@DisplayName("GET /api/v1/orders/{id} → 200 OK with order")
|
||||
void get_found() throws Exception {
|
||||
Order sampleOrder = createSampleOrder();
|
||||
when(appService.get(eq(100L), eq(1L))).thenReturn(Optional.of(sampleOrder));
|
||||
when(appService.getOrder(eq(100L), eq(1L))).thenReturn(sampleOrder);
|
||||
|
||||
mockMvc.perform(get("/api/v1/orders/1")
|
||||
.header("X-Tenant-Id", "100"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value("OK"))
|
||||
.andExpect(jsonPath("$.data.id").value(1))
|
||||
.andExpect(jsonPath("$.data.orderNo").value("ORD-001"));
|
||||
}
|
||||
|
||||
@--- @DisplayName("GET /api/v1/orders/{id} → 200 OK with error when not found")
|
||||
@Test
|
||||
@DisplayName("GET /api/v1/orders/{id} → 404 Not Found when not found")
|
||||
void get_notFound() throws Exception {
|
||||
when(appService.get(eq(100L), eq(999L))).thenReturn(Optional.empty());
|
||||
when(appService.getOrder(eq(100L), eq(999L))).thenThrow(
|
||||
new com.ljwx.tmpl.domain.exception.DomainException(
|
||||
com.ljwx.tmpl.domain.exception.ErrorCode.COMMON_NOT_FOUND));
|
||||
|
||||
mockMvc.perform(get("/api/v1/orders/999")
|
||||
.header("X-Tenant-Id", "100"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value("ORDER_NOT_FOUND"))
|
||||
.andExpect(jsonPath("$.message").value("Order not found"))
|
||||
.andExpect(jsonPath("$.data").doesNotExist());
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
@--- @DisplayName("GET /api/v1/orders → 200 OK with paged list")
|
||||
@Test
|
||||
@DisplayName("GET /api/v1/orders → 200 OK with paged list")
|
||||
void list_success() throws Exception {
|
||||
Order sampleOrder = createSampleOrder();
|
||||
when(appService.list(eq(100L), eq(1), eq(20))).thenReturn(List.of(sampleOrder));
|
||||
when(appService.listOrders(eq(100L), eq(1), eq(20))).thenReturn(List.of(sampleOrder));
|
||||
|
||||
mockMvc.perform(get("/api/v1/orders")
|
||||
.header("X-Tenant-Id", "100")
|
||||
.param("pageNum", "1")
|
||||
.param("pageSize", "20"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value("OK"))
|
||||
.andExpect(jsonPath("$.data").isArray())
|
||||
.andExpect(jsonPath("$.data.length()").value(1))
|
||||
.andExpect(jsonPath("$.data[0].id").value(1));
|
||||
}
|
||||
|
||||
@--- @DisplayName("PUT /api/v1/orders/{id}/status → 200 OK with updated order")
|
||||
@Test
|
||||
@DisplayName("PATCH /api/v1/orders/{id}/status → 200 OK with updated order")
|
||||
void updateStatus_success() throws Exception {
|
||||
Order updatedOrder = createSampleOrder();
|
||||
updatedOrder.setStatus("PAID");
|
||||
|
||||
|
||||
UpdateOrderRequest req = new UpdateOrderRequest();
|
||||
req.setStatus("PAID");
|
||||
|
||||
when(appService.updateStatus(eq(100L), eq(1L), eq("PAID")))
|
||||
.thenReturn(updatedOrder);
|
||||
|
||||
mockMvc.perform(put("/api/v1/orders/1/status")
|
||||
mockMvc.perform(patch("/api/v1/orders/1/status")
|
||||
.header("X-Tenant-Id", "100")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(req)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value("OK"))
|
||||
.andExpect(jsonPath("$.data.status").value("PAID"));
|
||||
|
||||
verify(appService).updateStatus(100L, 1L, "PAID");
|
||||
}
|
||||
|
||||
@--- @DisplayName("DELETE /api/v1/orders/{id} → 200 OK")
|
||||
@Test
|
||||
@DisplayName("DELETE /api/v1/orders/{id} → 204 No Content")
|
||||
void delete_success() throws Exception {
|
||||
doNothing().when(appService).softDelete(100L, 1L);
|
||||
|
||||
mockMvc.perform(delete("/api/v1/orders/1")
|
||||
.header("X-Tenant-Id", "100"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value("OK"))
|
||||
.andExpect(jsonPath("$.data").value(org.hamcrest.Matchers.nullValue()));
|
||||
.andExpect(status().isNoContent());
|
||||
|
||||
verify(appService).softDelete(100L, 1L);
|
||||
}
|
||||
|
||||
@@ -76,15 +76,20 @@ class TenantContextFilterTest {
|
||||
void shouldSetContextAndMdc_onValidHeaders() throws Exception {
|
||||
TenantContextFilter filter = newFilter(false);
|
||||
|
||||
MockFilterChain chain = new MockFilterChain((request, response) -> {
|
||||
assertEquals("T001", TenantContext.getTenantId());
|
||||
assertEquals("100", TenantContext.getDeptId());
|
||||
assertEquals(42L, TenantContext.getUserId());
|
||||
assertEquals(Set.of("ADMIN", "USER"), TenantContext.getRoles());
|
||||
assertEquals("T001", MDC.get("tenantId"));
|
||||
assertEquals("100", MDC.get("deptId"));
|
||||
assertEquals("42", MDC.get("userId"));
|
||||
});
|
||||
MockFilterChain chain = new MockFilterChain() {
|
||||
@Override
|
||||
public void doFilter(jakarta.servlet.ServletRequest request,
|
||||
jakarta.servlet.ServletResponse response)
|
||||
throws jakarta.servlet.ServletException, java.io.IOException {
|
||||
assertEquals("T001", TenantContext.getTenantId());
|
||||
assertEquals("100", TenantContext.getDeptId());
|
||||
assertEquals(42L, TenantContext.getUserId());
|
||||
assertEquals(Set.of("ADMIN", "USER"), TenantContext.getRoles());
|
||||
assertEquals("T001", MDC.get("tenantId"));
|
||||
assertEquals("100", MDC.get("deptId"));
|
||||
assertEquals("42", MDC.get("userId"));
|
||||
}
|
||||
};
|
||||
|
||||
filter.doFilterInternal(validRequest(), new MockHttpServletResponse(), chain);
|
||||
|
||||
@@ -98,8 +103,14 @@ class TenantContextFilterTest {
|
||||
MockHttpServletRequest req = validRequest();
|
||||
req.removeHeader("X-User-Roles");
|
||||
|
||||
MockFilterChain chain = new MockFilterChain((request, response) ->
|
||||
assertTrue(TenantContext.require().getRoles().isEmpty()));
|
||||
MockFilterChain chain = new MockFilterChain() {
|
||||
@Override
|
||||
public void doFilter(jakarta.servlet.ServletRequest request,
|
||||
jakarta.servlet.ServletResponse response)
|
||||
throws jakarta.servlet.ServletException, java.io.IOException {
|
||||
assertTrue(TenantContext.require().getRoles().isEmpty());
|
||||
}
|
||||
};
|
||||
|
||||
filter.doFilterInternal(req, new MockHttpServletResponse(), chain);
|
||||
}
|
||||
@@ -199,11 +210,16 @@ class TenantContextFilterTest {
|
||||
req.addHeader("X-Org-Id", "LEGACY-D");
|
||||
req.addHeader("X-User-Id", "99");
|
||||
|
||||
MockFilterChain chain = new MockFilterChain((request, response) -> {
|
||||
assertEquals("LEGACY-T", TenantContext.getTenantId());
|
||||
assertEquals("LEGACY-D", TenantContext.getDeptId());
|
||||
assertEquals(99L, TenantContext.getUserId());
|
||||
});
|
||||
MockFilterChain chain = new MockFilterChain() {
|
||||
@Override
|
||||
public void doFilter(jakarta.servlet.ServletRequest request,
|
||||
jakarta.servlet.ServletResponse response)
|
||||
throws jakarta.servlet.ServletException, java.io.IOException {
|
||||
assertEquals("LEGACY-T", TenantContext.getTenantId());
|
||||
assertEquals("LEGACY-D", TenantContext.getDeptId());
|
||||
assertEquals(99L, TenantContext.getUserId());
|
||||
}
|
||||
};
|
||||
|
||||
filter.doFilterInternal(req, new MockHttpServletResponse(), chain);
|
||||
}
|
||||
@@ -246,8 +262,14 @@ class TenantContextFilterTest {
|
||||
MockHttpServletRequest req = validRequest();
|
||||
req.addHeader("X-Customer-Id", "T001");
|
||||
|
||||
MockFilterChain chain = new MockFilterChain((request, response) ->
|
||||
assertEquals("T001", TenantContext.getTenantId()));
|
||||
MockFilterChain chain = new MockFilterChain() {
|
||||
@Override
|
||||
public void doFilter(jakarta.servlet.ServletRequest request,
|
||||
jakarta.servlet.ServletResponse response)
|
||||
throws jakarta.servlet.ServletException, java.io.IOException {
|
||||
assertEquals("T001", TenantContext.getTenantId());
|
||||
}
|
||||
};
|
||||
|
||||
filter.doFilterInternal(req, new MockHttpServletResponse(), chain);
|
||||
}
|
||||
@@ -283,8 +305,14 @@ class TenantContextFilterTest {
|
||||
req.addHeader("X-Dept-Id", "0");
|
||||
req.removeHeader("X-User-Roles");
|
||||
|
||||
MockFilterChain chain = new MockFilterChain((request, response) ->
|
||||
assertEquals("0", TenantContext.getDeptId()));
|
||||
MockFilterChain chain = new MockFilterChain() {
|
||||
@Override
|
||||
public void doFilter(jakarta.servlet.ServletRequest request,
|
||||
jakarta.servlet.ServletResponse response)
|
||||
throws jakarta.servlet.ServletException, java.io.IOException {
|
||||
assertEquals("0", TenantContext.getDeptId());
|
||||
}
|
||||
};
|
||||
|
||||
filter.doFilterInternal(req, new MockHttpServletResponse(), chain);
|
||||
}
|
||||
@@ -299,8 +327,14 @@ class TenantContextFilterTest {
|
||||
req.removeHeader("X-User-Roles");
|
||||
req.addHeader("X-User-Roles", "SCOPE_ALL_DEPT");
|
||||
|
||||
MockFilterChain chain = new MockFilterChain((request, response) ->
|
||||
assertEquals("0", TenantContext.getDeptId()));
|
||||
MockFilterChain chain = new MockFilterChain() {
|
||||
@Override
|
||||
public void doFilter(jakarta.servlet.ServletRequest request,
|
||||
jakarta.servlet.ServletResponse response)
|
||||
throws jakarta.servlet.ServletException, java.io.IOException {
|
||||
assertEquals("0", TenantContext.getDeptId());
|
||||
}
|
||||
};
|
||||
|
||||
filter.doFilterInternal(req, new MockHttpServletResponse(), chain);
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ class OrderAuditIntegrationTest {
|
||||
@Test
|
||||
@DisplayName("Insert: createUserId + createTime auto-filled from TenantContext")
|
||||
void insert_auditFieldsAutoFilled() {
|
||||
TenantContext.set(new TenantScope(100L, 10L, 42L, Set.of("ROLE_USER")));
|
||||
TenantContext.set(new TenantScope("100", "10", 42L, Set.of("ROLE_USER")));
|
||||
|
||||
Order saved = orderRepository.save(
|
||||
new Order(100L, 10L, "ORD-001", new BigDecimal("199.99"), "CREATED"));
|
||||
@@ -105,7 +105,7 @@ class OrderAuditIntegrationTest {
|
||||
@DisplayName("Update: updateUserId + updateTime change; create fields unchanged")
|
||||
void update_auditFieldsUpdated() throws InterruptedException {
|
||||
// insert as user 42
|
||||
TenantContext.set(new TenantScope(100L, 10L, 42L, Set.of("ROLE_USER")));
|
||||
TenantContext.set(new TenantScope("100", "10", 42L, Set.of("ROLE_USER")));
|
||||
Order saved = orderRepository.save(
|
||||
new Order(100L, 10L, "ORD-002", new BigDecimal("50.00"), "CREATED"));
|
||||
|
||||
@@ -115,7 +115,7 @@ class OrderAuditIntegrationTest {
|
||||
Thread.sleep(50); // ensure distinct timestamps
|
||||
|
||||
// switch to user 99 and update
|
||||
TenantContext.set(new TenantScope(100L, 10L, 99L, Set.of("ROLE_ADMIN")));
|
||||
TenantContext.set(new TenantScope("100", "10", 99L, Set.of("ROLE_ADMIN")));
|
||||
saved.setStatus("PAID");
|
||||
saved.setAmount(new BigDecimal("55.00"));
|
||||
Order updated = orderRepository.save(saved);
|
||||
@@ -145,7 +145,7 @@ class OrderAuditIntegrationTest {
|
||||
@Test
|
||||
@DisplayName("findByIdAndTenantId: correct tenant returns data, wrong tenant returns empty")
|
||||
void find_tenantIsolation() {
|
||||
TenantContext.set(new TenantScope(300L, 30L, 7L, Set.of("ROLE_USER")));
|
||||
TenantContext.set(new TenantScope("300", "30", 7L, Set.of("ROLE_USER")));
|
||||
Order saved = orderRepository.save(
|
||||
new Order(300L, 30L, "ORD-004", new BigDecimal("100.00"), "CREATED"));
|
||||
|
||||
|
||||
+14
-18
@@ -32,7 +32,7 @@ class OrderCacheIntegrationTest extends AbstractIntegrationIT {
|
||||
private RedisTemplate<String, String> redisTemplate;
|
||||
|
||||
private Order createSample(Long tenantId, String orderNo) {
|
||||
return orderAppService.create(tenantId, 100L, orderNo, new BigDecimal("99.99"));
|
||||
return orderAppService.createOrder(tenantId, 100L, orderNo, new BigDecimal("99.99"));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,9 +59,8 @@ class OrderCacheIntegrationTest extends AbstractIntegrationIT {
|
||||
Order created = createSample(1L, "CACHE-001");
|
||||
|
||||
// get → cache miss → DB → 写 Redis
|
||||
Optional<Order> fetched = orderAppService.get(1L, created.getId());
|
||||
assertTrue(fetched.isPresent());
|
||||
assertEquals("CACHE-001", fetched.get().getOrderNo());
|
||||
Order fetched = orderAppService.getOrder(1L, created.getId());
|
||||
assertEquals("CACHE-001", fetched.getOrderNo());
|
||||
|
||||
// 验证 Redis 中存在对应 key
|
||||
String key = redisCacheKey(1L, created.getId());
|
||||
@@ -75,14 +74,13 @@ class OrderCacheIntegrationTest extends AbstractIntegrationIT {
|
||||
Order created = createSample(2L, "CACHE-002");
|
||||
|
||||
// 预热
|
||||
orderAppService.get(2L, created.getId());
|
||||
orderAppService.getOrder(2L, created.getId());
|
||||
String key = redisCacheKey(2L, created.getId());
|
||||
assertTrue(Boolean.TRUE.equals(redisTemplate.hasKey(key)));
|
||||
|
||||
// 第二次调用 — key 仍存在(命中缓存)
|
||||
Optional<Order> cached = orderAppService.get(2L, created.getId());
|
||||
assertTrue(cached.isPresent());
|
||||
assertEquals("CACHE-002", cached.get().getOrderNo());
|
||||
Order cached = orderAppService.getOrder(2L, created.getId());
|
||||
assertEquals("CACHE-002", cached.getOrderNo());
|
||||
assertTrue(Boolean.TRUE.equals(redisTemplate.hasKey(key)));
|
||||
}
|
||||
|
||||
@@ -93,7 +91,7 @@ class OrderCacheIntegrationTest extends AbstractIntegrationIT {
|
||||
String key = redisCacheKey(3L, created.getId());
|
||||
|
||||
// 预热
|
||||
orderAppService.get(3L, created.getId());
|
||||
orderAppService.getOrder(3L, created.getId());
|
||||
assertTrue(Boolean.TRUE.equals(redisTemplate.hasKey(key)));
|
||||
|
||||
// 更新 → evict
|
||||
@@ -104,9 +102,8 @@ class OrderCacheIntegrationTest extends AbstractIntegrationIT {
|
||||
"updateStatus 后 Redis key 应被清除");
|
||||
|
||||
// 再次 get → 从 DB 加载最新状态 → 重新缓存
|
||||
Optional<Order> refreshed = orderAppService.get(3L, created.getId());
|
||||
assertTrue(refreshed.isPresent());
|
||||
assertEquals("PAID", refreshed.get().getStatus());
|
||||
Order refreshed = orderAppService.getOrder(3L, created.getId());
|
||||
assertEquals("PAID", refreshed.getStatus());
|
||||
assertTrue(Boolean.TRUE.equals(redisTemplate.hasKey(key)));
|
||||
}
|
||||
|
||||
@@ -117,7 +114,7 @@ class OrderCacheIntegrationTest extends AbstractIntegrationIT {
|
||||
String key = redisCacheKey(4L, created.getId());
|
||||
|
||||
// 预热
|
||||
orderAppService.get(4L, created.getId());
|
||||
orderAppService.getOrder(4L, created.getId());
|
||||
assertTrue(Boolean.TRUE.equals(redisTemplate.hasKey(key)));
|
||||
|
||||
// 软删除 → evict
|
||||
@@ -138,8 +135,8 @@ class OrderCacheIntegrationTest extends AbstractIntegrationIT {
|
||||
String keyT20 = redisCacheKey(20L, orderT20.getId());
|
||||
|
||||
// 分别预热
|
||||
orderAppService.get(10L, orderT10.getId());
|
||||
orderAppService.get(20L, orderT20.getId());
|
||||
orderAppService.getOrder(10L, orderT10.getId());
|
||||
orderAppService.getOrder(20L, orderT20.getId());
|
||||
assertTrue(Boolean.TRUE.equals(redisTemplate.hasKey(keyT10)));
|
||||
assertTrue(Boolean.TRUE.equals(redisTemplate.hasKey(keyT20)));
|
||||
|
||||
@@ -152,8 +149,7 @@ class OrderCacheIntegrationTest extends AbstractIntegrationIT {
|
||||
"tenant 20 的 key 应不受影响");
|
||||
|
||||
// tenant 20 缓存仍返回 CREATED
|
||||
Optional<Order> cachedT20 = orderAppService.get(20L, orderT20.getId());
|
||||
assertTrue(cachedT20.isPresent());
|
||||
assertEquals("CREATED", cachedT20.get().getStatus());
|
||||
Order cachedT20 = orderAppService.getOrder(20L, orderT20.getId());
|
||||
assertEquals("CREATED", cachedT20.getStatus());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user