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:
Bruno Gao
2026-02-12 23:25:56 +08:00
co-authored by Qwen-Coder
parent 2fd1eb59ab
commit a977951366
60 changed files with 8576 additions and 86 deletions
+123
View File
@@ -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.
+198
View File
@@ -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
View File
@@ -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
+5
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
# 生产环境:前端与后端同域部署(通过 Nginx/网关转发 /api 和 /actuator),
# 因此 baseURL 为空,所有请求走相对路径。Vite proxy 仅在 dev server 生效。
VITE_API_BASE_URL=
VITE_ACTUATOR_BASE_URL=
VITE_ENABLE_MSW=false
+8
View File
@@ -0,0 +1,8 @@
{
"semi": true,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 120,
"tabWidth": 2,
"endOfLine": "lf"
}
+19
View File
@@ -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'],
},
];
+12
View File
@@ -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>
+5433
View File
File diff suppressed because it is too large Load Diff
+41
View File
@@ -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"
}
}
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+349
View File
@@ -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,
}
}
+65
View File
@@ -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 描述,确认上述三项已完成。
`);
+14
View File
@@ -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>
+35
View File
@@ -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;
}
+95
View File
@@ -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;
+24
View File
@@ -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}` });
}
+59
View File
@@ -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);
});
}
+7
View File
@@ -0,0 +1,7 @@
/**
* 全局自定义事件名常量。
* 避免硬编码字符串导致多处监听时拼写不一致。
*/
export const APP_EVENTS = {
TENANT_CHANGED: 'tmpl:tenant-changed',
} as const;
+36
View File
@@ -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 = '';
}
}
+18
View File
@@ -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;
}
+19
View File
@@ -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>
+11
View File
@@ -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>
+92
View File
@@ -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>
+87
View File
@@ -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>
+33
View File
@@ -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();
+4
View File
@@ -0,0 +1,4 @@
import { setupWorker } from 'msw/browser';
import { handlers } from './handlers';
export const worker = setupWorker(...handlers);
+70
View File
@@ -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;
}
+199
View File
@@ -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>
+12
View File
@@ -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',
});
}
+13
View File
@@ -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;
+39
View File
@@ -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;
}
+60
View File
@@ -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 },
},
];
+55
View File
@@ -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,
};
});
+38
View File
@@ -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;
}
+33
View File
@@ -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];
}
}
+15
View File
@@ -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);
}
+40
View File
@@ -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;
+27
View File
@@ -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" }]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts", "tailwind.config.ts"]
}
+46
View File
@@ -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"));
@@ -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());
}
}